diff --git a/ANDROID.md b/ANDROID.md index 6a76c28..2c3583d 100644 --- a/ANDROID.md +++ b/ANDROID.md @@ -208,8 +208,14 @@ Library clears the play history behind a confirm. The now-playing sheet's ⋯ on a streamed track is the desktop's Save menu: Save to library (at once when the cache is byte-complete, else through the download pipeline) and Save this record for the streamed -record the queue holds. Search corrects an obvious misspelling from the -names this phone knows (the library, the searches that worked) — the desktop's rule, +record the queue holds. A moment after boot the phone warms the library's thumbnails the +desktop's way (`library_art_prewarm` at the cards' edge, one decode at a +time behind the tiles' gate). The Library's cards wear the desktop's file stats (`library_album_stats`, +one call for the whole library: FLAC 24/96, MP3 320k, the size on the +list rows and the record page), and the artist page shows this phone's +own listening (`artist_play_stats`: time played as a chip, the most +played track under "On this phone"). Search corrects an obvious +misspelling from the names this phone knows (the library, the searches that worked) — the desktop's rule, a word only when a real name is within two edits. ## Playlists diff --git a/src/lib/mockLibrary.js b/src/lib/mockLibrary.js index 2494a1c..b3d17bc 100644 --- a/src/lib/mockLibrary.js +++ b/src/lib/mockLibrary.js @@ -446,6 +446,7 @@ function mockCatalog(cmd, args) { if (cmd === "catalog_artist_profile") return Promise.resolve({ listeners: 3_100_000, plays: 210_000_000, tags: ["electronic", "house", "french house", "dance"] }); if (cmd === "catalog_artist_bio") return Promise.resolve("Daft Punk were a French electronic music duo formed in Paris in 1993 by Guy-Manuel de Homem-Christo and Thomas Bangalter. They achieved popularity in the late 1990s as part of the French house movement."); if (cmd === "catalog_related") return Promise.resolve(RELATED); + if (cmd === "artist_play_stats") return Promise.resolve({ seconds: 7380, last_played_at: Math.floor(Date.now() / 1000) - 86400, top_title: "Track 1", top_count: 12 }); if (cmd === "followed_artists") return Promise.resolve(follows.slice()); if (cmd === "follow_artist") { follows.push({ artist_id: args?.artistId, name: args?.name, portrait: args?.portrait ?? null, followed_at: Date.now() / 1000 }); later(30, () => mockEmit("soulseek:follows-changed", {})); return Promise.resolve(); } if (cmd === "unfollow_artist") { follows = follows.filter((f) => f.artist_id !== args?.artistId); later(30, () => mockEmit("soulseek:follows-changed", {})); return Promise.resolve(); } @@ -483,6 +484,7 @@ function mockCatalog(cmd, args) { } if (cmd === "cancel_download") return Promise.resolve(); if (cmd === "rescan_library") return Promise.resolve(); + if (cmd === "library_art_prewarm") return Promise.resolve(); if (cmd === "refresh_all_album_art") return new Promise((r) => setTimeout(() => r(140), 300)); if (cmd === "catalog_art") return Promise.resolve(svgArt(200, "D")); if (cmd === "stream_cache_ready") return Promise.resolve(cacheReady.has(args?.path) || (xfer && xfer.path === args?.path && xfer.received >= xfer.total)); diff --git a/src/mobile/Mobile.svelte b/src/mobile/Mobile.svelte index 06740ea..13b859a 100644 --- a/src/mobile/Mobile.svelte +++ b/src/mobile/Mobile.svelte @@ -185,6 +185,10 @@ bindHome(); bindImporter(); bindShare(); + /* the desktop's library art prewarm: every cover cut to the cards' + edge, one decode at a time behind the tiles' gate, a moment after + boot so the first screen and a restored track come first */ + setTimeout(() => { api.libraryArtPrewarm([360]).catch(() => {}); }, 8000); /* the probe handle — the desktop has __soulstream for the same reason: tests/mobile/smoke.mjs drives the real app through it */ window.__ss = { nav, top, go, push, back, openSheet, closeSheet, openHalf, closeHalf, player, lib, art, slsk, net, resolver, streamSong, streamCatalogAlbum, sources, api, transfers, pls, notice, importer, next, prev, toggle, seekSecs, playQueueAt, patch }; diff --git a/src/mobile/lib.svelte.js b/src/mobile/lib.svelte.js index 6a2997f..a7f2307 100644 --- a/src/mobile/lib.svelte.js +++ b/src/mobile/lib.svelte.js @@ -9,6 +9,8 @@ export const lib = $state({ recents: [], // library rows only, with an album_id follows: [], playlists: [], + /** album id → { secs, bytes, avg_kbps, bit_depth, sample_rate, formats, last_played_at } — the desktop's library_album_stats */ + stats: {}, version: "", username: null, loaded: false, @@ -104,6 +106,7 @@ export async function watchLibrary() { timer = setTimeout(async () => { try { lib.albums = await api.libraryAlbums(); } catch { /* keep */ } await refreshRecents(); + void refreshStats(); }, 600); }); /* a playlist mutation (the desktop's commands emit it) re-lists them */ @@ -122,6 +125,24 @@ export async function loadLibrary() { try { lib.follows = (await api.followedArtists()) || []; } catch { lib.follows = []; } try { lib.playlists = (await api.listPlaylists()) || []; } catch { lib.playlists = []; } lib.loaded = true; + void refreshStats(); +} + +/** The files' own aggregates per record — one call for the whole library + * (the desktop's RECENTS_FLOOR_SECS, 45, is the floor a play must reach + * to count as the last one). */ +export async function refreshStats() { + try { + const rows = (await api.libraryAlbumStats(45)) || []; + lib.stats = Object.fromEntries(rows.map((r) => [r.album_id, r])); + } catch { /* the cards stay quiet */ } +} +/** "FLAC 24/96" for a lossless record, "MP3 320k" for a lossy one. */ +export function statLabel(st) { + if (!st || !st.formats?.length) return ""; + const f = String(st.formats[0]).toUpperCase(); + if (st.bit_depth && st.sample_rate) return f + " " + st.bit_depth + "/" + (st.sample_rate % 1000 === 0 ? st.sample_rate / 1000 : (st.sample_rate / 1000).toFixed(1)); + return st.avg_kbps ? f + " " + st.avg_kbps + "k" : f; } export async function refreshPlaylists() { diff --git a/src/mobile/screens/Album.svelte b/src/mobile/screens/Album.svelte index 383f2cb..b559411 100644 --- a/src/mobile/screens/Album.svelte +++ b/src/mobile/screens/Album.svelte @@ -5,10 +5,10 @@ import Icon from "../../components/Icon.svelte"; import Cover from "../Cover.svelte"; import Release from "../Release.svelte"; - import { albumById, albumsBy, tracksOf } from "../lib.svelte.js"; + import { lib, albumById, albumsBy, tracksOf } from "../lib.svelte.js"; import { player, playAlbum, playAlbumShuffled } from "../player.svelte.js"; import { push, openHalf, openViewer } from "../nav.svelte.js"; - import { mmss, longDur, albumFmt } from "../util.js"; + import { mmss, longDur, albumFmt, fmtBytes } from "../util.js"; let { id, screenEl = null } = $props(); @@ -48,7 +48,8 @@ {#if fmt}
{fmt} - in your library + {lib.stats[album.id]?.bytes ? fmtBytes(lib.stats[album.id].bytes) : "in your library"} + {#if lib.stats[album.id]?.avg_kbps}{lib.stats[album.id].avg_kbps} kbps{/if}
{/if}
diff --git a/src/mobile/screens/Artist.svelte b/src/mobile/screens/Artist.svelte index ba30154..a9802c9 100644 --- a/src/mobile/screens/Artist.svelte +++ b/src/mobile/screens/Artist.svelte @@ -22,12 +22,25 @@ import { catalog, lookupArtist, artistKey, fans, yearOf, looseTitle, loadExtras, picture } from "../catalog.svelte.js"; import { resolver, streamSong, streamCatalogAlbum } from "../resolve.svelte.js"; import { discoTabs, activeYears, latestOf, timelineOf, isUpcoming, SOCIAL_LABEL } from "../disco.js"; - import { shuffled, mmss } from "../util.js"; + import { shuffled, mmss, ago } from "../util.js"; /** `seed`: the search's own row (a CatalogArtist), when the page was * opened from one — the identity is then known, no name search. */ let { name, seed = null, screenEl = null } = $props(); + /* the desktop's play-stats chips: this phone's own listening to the artist */ + let plays = $state({ key: "", seconds: 0, last: null, topTitle: null, topCount: 0 }); + $effect(() => { + const n = name; + if (plays.key === n) return; + plays = { key: n, seconds: 0, last: null, topTitle: null, topCount: 0 }; + api.artistPlayStats(n).then((st) => { + if (plays.key !== n) return; + plays = { key: n, seconds: st?.seconds ?? 0, last: st?.last_played_at ?? null, topTitle: st?.top_title ?? null, topCount: st?.top_count ?? 0 }; + }, () => { /* the chips stay hidden */ }); + }); + const playTime = $derived(!plays.seconds || plays.seconds < 60 ? "" : plays.seconds < 3600 ? Math.round(plays.seconds / 60) + " min" : (plays.seconds / 3600).toFixed(1) + " hours"); + /* ── the library's shell ─────────────────────────────────────────── */ const albums = $derived(albumsBy(name)); const portrait = $derived(albums.find((a) => a.artist_art_path)?.artist_art_path || null); @@ -198,6 +211,7 @@ {#if page?.artist?.fans || seed?.fans}{fans(page?.artist?.fans || seed?.fans)}{/if} {#if years}{years}{/if} {#if page?.top_genre}{page.top_genre}{/if} + {#if playTime}{playTime} played{/if} {#if albums.length}{/if} {#each socials as link (link.kind + link.url)} @@ -303,6 +317,7 @@ {#if albums.length}

On this phone · {albums.length} record{albums.length === 1 ? "" : "s"}{tracks ? ` · ${tracks} tracks` : ""}

+ {#if plays.topTitle}
Most played here: {plays.topTitle} · {plays.topCount}×{plays.last ? " · last " + ago(plays.last) : ""}
{/if}
{#each albums as a (a.id)} push("album", { id: a.id }, screenEl)} /> diff --git a/src/mobile/screens/Library.svelte b/src/mobile/screens/Library.svelte index 5f1bbb2..67168ea 100644 --- a/src/mobile/screens/Library.svelte +++ b/src/mobile/screens/Library.svelte @@ -8,7 +8,8 @@ import Tracks from "./Tracks.svelte"; import PlaylistShelf from "../PlaylistShelf.svelte"; import * as api from "../../lib/api.js"; - import { lib, artists, isFollowed, followedEntry } from "../lib.svelte.js"; + import { lib, artists, isFollowed, followedEntry, statLabel } from "../lib.svelte.js"; + import { fmtBytes } from "../util.js"; import { push, go, patch, openHalf } from "../nav.svelte.js"; import { looseTitle, yearOf } from "../catalog.svelte.js"; import { isUpcoming } from "../disco.js"; @@ -110,14 +111,14 @@ {:else if view === "grid"}
{#each list as a (a.id)} - push("album", { id: a.id }, screenEl)} onmore={() => openHalf("album", { album: a })} /> + push("album", { id: a.id }, screenEl)} onmore={() => openHalf("album", { album: a })} /> {/each}
{:else} {#each list as a (a.id)} {/each} diff --git a/tests/mobile/smoke.mjs b/tests/mobile/smoke.mjs index 9690b32..4e9c7d7 100644 --- a/tests/mobile/smoke.mjs +++ b/tests/mobile/smoke.mjs @@ -122,6 +122,8 @@ const PROBE = `(function () { brwDirs: document.querySelectorAll('.brw-dir').length, qrows: document.querySelectorAll('.q-face .row').length, viewer: !!document.querySelector('.viewer img'), + fmtBadges: document.querySelectorAll('.grid2 .release .ed-badge').length, + played: (document.querySelector('.hero-acts .stats .played') || {}).textContent || '', lyrLines: document.querySelectorAll('.lyr .ln').length, sheetTitle: (document.querySelector('.halfsheet .sheet-title') || {}).textContent || '', recents: document.querySelectorAll('.recent-chips .chip').length, @@ -386,7 +388,7 @@ const MOCK_EXPECT = { "search-dym": (i) => i.dym === "shpongle", "search-dym-pick": (i) => i.sq === "shpongle" && !i.dym, search: (i) => i.title.startsWith("Search"), - library: (i) => i.title.startsWith("Your library"), + library: (i) => i.title.startsWith("Your library") && i.fmtBadges >= 10, album: (i) => i.screen.includes("tr-push") && !i.mini, lyrics: (i) => i.sheet.includes("up") && i.lyrLines >= 3, "lyrics-sheet": (i) => i.half.includes("up") && /Lyrics later/.test(i.halfText) && /Search again/.test(i.halfText), @@ -399,7 +401,7 @@ const MOCK_EXPECT = { "track-sheet": (i) => i.half.includes("up"), sheet: (i) => i.sheet.includes("up"), "after-back": (i) => !i.sheet.includes("up"), - artist: (i) => i.screen.includes("has-hero") && i.amb.includes("on:img"), + artist: (i) => i.screen.includes("has-hero") && i.amb.includes("on:img") && /played/.test(i.played), queue: (i) => { globalThis.__qrows = i.qrows; return i.sheet.includes("up") && i.qrows >= 2; }, "queue-row-sheet": (i) => i.half.includes("up") && /Remove from queue/.test(i.halfText) && /Play now/.test(i.halfText), "queue-removed": (i) => !i.half.includes("up") && i.qrows === globalThis.__qrows - 1,