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}