android: the library's file stats on the cards, the artist's own plays, the thumbnail prewarm

The desktop Library's per-record file stats on the phone (library_album_stats,
one call for the whole library): FLAC 24/96 or MP3 320k badges on the cards,
the size on the list rows and the record page. The artist page's own
listening (artist_play_stats): the time played as a chip, the most played
track under "On this phone". The whole-library thumbnail warm a moment after
boot, at the cards' edge, one decode at a time behind the tiles' gate.
This commit is contained in:
KeelrLeek committed 2026-09-13 20:37:51 -05:00
1 parent 26928b706d
commit 991d52d0cc
8 files changed
+63 -11

No files matched your search

+8 -2
View File
@@ -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 sheet's ⋯ on a streamed track is the desktop's
Save menu: Save to library (at once when the cache is byte-complete, else 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 through the download pipeline) and Save this record for the streamed
record the queue holds. Search corrects an obvious misspelling from the record the queue holds. A moment after boot the phone warms the library's thumbnails the
names this phone knows (the library, the searches that worked) — the desktop's rule, 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. a word only when a real name is within two edits.
## Playlists ## Playlists
+2
View File
@@ -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_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_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 === "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 === "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 === "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(); } 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 === "cancel_download") return Promise.resolve();
if (cmd === "rescan_library") 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 === "refresh_all_album_art") return new Promise((r) => setTimeout(() => r(140), 300));
if (cmd === "catalog_art") return Promise.resolve(svgArt(200, "D")); 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)); if (cmd === "stream_cache_ready") return Promise.resolve(cacheReady.has(args?.path) || (xfer && xfer.path === args?.path && xfer.received >= xfer.total));
+4
View File
@@ -185,6 +185,10 @@
bindHome(); bindHome();
bindImporter(); bindImporter();
bindShare(); 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: /* the probe handle — the desktop has __soulstream for the same reason:
tests/mobile/smoke.mjs drives the real app through it */ 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 }; 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 };
+21
View File
@@ -9,6 +9,8 @@ export const lib = $state({
recents: [], // library rows only, with an album_id recents: [], // library rows only, with an album_id
follows: [], follows: [],
playlists: [], playlists: [],
/** album id → { secs, bytes, avg_kbps, bit_depth, sample_rate, formats, last_played_at } — the desktop's library_album_stats */
stats: {},
version: "", version: "",
username: null, username: null,
loaded: false, loaded: false,
@@ -104,6 +106,7 @@ export async function watchLibrary() {
timer = setTimeout(async () => { timer = setTimeout(async () => {
try { lib.albums = await api.libraryAlbums(); } catch { /* keep */ } try { lib.albums = await api.libraryAlbums(); } catch { /* keep */ }
await refreshRecents(); await refreshRecents();
void refreshStats();
}, 600); }, 600);
}); });
/* a playlist mutation (the desktop's commands emit it) re-lists them */ /* 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.follows = (await api.followedArtists()) || []; } catch { lib.follows = []; }
try { lib.playlists = (await api.listPlaylists()) || []; } catch { lib.playlists = []; } try { lib.playlists = (await api.listPlaylists()) || []; } catch { lib.playlists = []; }
lib.loaded = true; 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() { export async function refreshPlaylists() {
+4 -3
View File
@@ -5,10 +5,10 @@
import Icon from "../../components/Icon.svelte"; import Icon from "../../components/Icon.svelte";
import Cover from "../Cover.svelte"; import Cover from "../Cover.svelte";
import Release from "../Release.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 { player, playAlbum, playAlbumShuffled } from "../player.svelte.js";
import { push, openHalf, openViewer } from "../nav.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(); let { id, screenEl = null } = $props();
@@ -48,7 +48,8 @@
{#if fmt} {#if fmt}
<div class="stats rec-chips"> <div class="stats rec-chips">
<span class="s"><Icon name="disc" size={12} strokeWidth={3} />{fmt}</span> <span class="s"><Icon name="disc" size={12} strokeWidth={3} />{fmt}</span>
<span class="s"><Icon name="hard-drive" size={12} strokeWidth={3} />in your library</span> <span class="s"><Icon name="hard-drive" size={12} strokeWidth={3} />{lib.stats[album.id]?.bytes ? fmtBytes(lib.stats[album.id].bytes) : "in your library"}</span>
{#if lib.stats[album.id]?.avg_kbps}<span class="s">{lib.stats[album.id].avg_kbps} kbps</span>{/if}
</div> </div>
{/if} {/if}
<div class="rec-acts"> <div class="rec-acts">
+16 -1
View File
@@ -22,12 +22,25 @@
import { catalog, lookupArtist, artistKey, fans, yearOf, looseTitle, loadExtras, picture } from "../catalog.svelte.js"; import { catalog, lookupArtist, artistKey, fans, yearOf, looseTitle, loadExtras, picture } from "../catalog.svelte.js";
import { resolver, streamSong, streamCatalogAlbum } from "../resolve.svelte.js"; import { resolver, streamSong, streamCatalogAlbum } from "../resolve.svelte.js";
import { discoTabs, activeYears, latestOf, timelineOf, isUpcoming, SOCIAL_LABEL } from "../disco.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 /** `seed`: the search's own row (a CatalogArtist), when the page was
* opened from one — the identity is then known, no name search. */ * opened from one — the identity is then known, no name search. */
let { name, seed = null, screenEl = null } = $props(); 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 ─────────────────────────────────────────── */ /* ── the library's shell ─────────────────────────────────────────── */
const albums = $derived(albumsBy(name)); const albums = $derived(albumsBy(name));
const portrait = $derived(albums.find((a) => a.artist_art_path)?.artist_art_path || null); const portrait = $derived(albums.find((a) => a.artist_art_path)?.artist_art_path || null);
@@ -198,6 +211,7 @@
{#if page?.artist?.fans || seed?.fans}<span class="s"><Icon name="users" size={12} strokeWidth={3} />{fans(page?.artist?.fans || seed?.fans)}</span>{/if} {#if page?.artist?.fans || seed?.fans}<span class="s"><Icon name="users" size={12} strokeWidth={3} />{fans(page?.artist?.fans || seed?.fans)}</span>{/if}
{#if years}<span class="s"><Icon name="calendar" size={12} strokeWidth={3} />{years}</span>{/if} {#if years}<span class="s"><Icon name="calendar" size={12} strokeWidth={3} />{years}</span>{/if}
{#if page?.top_genre}<span class="s"><Icon name="tag" size={12} strokeWidth={3} />{page.top_genre}</span>{/if} {#if page?.top_genre}<span class="s"><Icon name="tag" size={12} strokeWidth={3} />{page.top_genre}</span>{/if}
{#if playTime}<span class="s played"><Icon name="history" size={12} strokeWidth={3} />{playTime} played</span>{/if}
{#if albums.length}<button class="s quiet" onclick={shuffleAll}><Icon name="shuffle" size={12} strokeWidth={3} />{albums.length} here · shuffle</button>{/if} {#if albums.length}<button class="s quiet" onclick={shuffleAll}><Icon name="shuffle" size={12} strokeWidth={3} />{albums.length} here · shuffle</button>{/if}
{#each socials as link (link.kind + link.url)} {#each socials as link (link.kind + link.url)}
<button class="s quiet soc" onclick={() => openLink(link.url)}><Icon name={link.kind === "website" ? "globe" : "link"} size={12} strokeWidth={3} />{SOCIAL_LABEL[link.kind] || link.kind}</button> <button class="s quiet soc" onclick={() => openLink(link.url)}><Icon name={link.kind === "website" ? "globe" : "link"} size={12} strokeWidth={3} />{SOCIAL_LABEL[link.kind] || link.kind}</button>
@@ -303,6 +317,7 @@
{#if albums.length} {#if albums.length}
<div class="sec"><h2>On this phone <em>· {albums.length} record{albums.length === 1 ? "" : "s"}{tracks ? ` · ${tracks} tracks` : ""}</em></h2></div> <div class="sec"><h2>On this phone <em>· {albums.length} record{albums.length === 1 ? "" : "s"}{tracks ? ` · ${tracks} tracks` : ""}</em></h2></div>
{#if plays.topTitle}<div class="sec-note">Most played here: {plays.topTitle} · {plays.topCount}×{plays.last ? " · last " + ago(plays.last) : ""}</div>{/if}
<div class="grid2"> <div class="grid2">
{#each albums as a (a.id)} {#each albums as a (a.id)}
<Release title={a.title} sub={String(a.year || "")} path={a.art_path} onclick={() => push("album", { id: a.id }, screenEl)} /> <Release title={a.title} sub={String(a.year || "")} path={a.art_path} onclick={() => push("album", { id: a.id }, screenEl)} />
+4 -3
View File
@@ -8,7 +8,8 @@
import Tracks from "./Tracks.svelte"; import Tracks from "./Tracks.svelte";
import PlaylistShelf from "../PlaylistShelf.svelte"; import PlaylistShelf from "../PlaylistShelf.svelte";
import * as api from "../../lib/api.js"; 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 { push, go, patch, openHalf } from "../nav.svelte.js";
import { looseTitle, yearOf } from "../catalog.svelte.js"; import { looseTitle, yearOf } from "../catalog.svelte.js";
import { isUpcoming } from "../disco.js"; import { isUpcoming } from "../disco.js";
@@ -110,14 +111,14 @@
{:else if view === "grid"} {:else if view === "grid"}
<div class="grid2"> <div class="grid2">
{#each list as a (a.id)} {#each list as a (a.id)}
<Release title={a.title} sub={sort === "year" ? String(a.year || "") : a.artist} path={a.art_path} onclick={() => push("album", { id: a.id }, screenEl)} onmore={() => openHalf("album", { album: a })} /> <Release title={a.title} sub={sort === "year" ? String(a.year || "") : a.artist} path={a.art_path} badge={statLabel(lib.stats[a.id]) || null} onclick={() => push("album", { id: a.id }, screenEl)} onmore={() => openHalf("album", { album: a })} />
{/each} {/each}
</div> </div>
{:else} {:else}
{#each list as a (a.id)} {#each list as a (a.id)}
<button class="row" onclick={() => push("album", { id: a.id }, screenEl)} oncontextmenu={(e) => { e.preventDefault(); openHalf("album", { album: a }); }}> <button class="row" onclick={() => push("album", { id: a.id }, screenEl)} oncontextmenu={(e) => { e.preventDefault(); openHalf("album", { album: a }); }}>
<span class="art"><Cover path={a.art_path} size={20} /></span> <span class="art"><Cover path={a.art_path} size={20} /></span>
<span class="b"><span class="t">{a.title}</span><span class="m">{a.artist} · {a.year ?? "—"}</span></span> <span class="b"><span class="t">{a.title}</span><span class="m">{a.artist} · {a.year ?? "—"}{statLabel(lib.stats[a.id]) ? " · " + statLabel(lib.stats[a.id]) : ""}{lib.stats[a.id]?.bytes ? " · " + fmtBytes(lib.stats[a.id].bytes) : ""}</span></span>
<span class="r"><Icon name="chevron-right" size={17} /></span> <span class="r"><Icon name="chevron-right" size={17} /></span>
</button> </button>
{/each} {/each}
+4 -2
View File
@@ -122,6 +122,8 @@ const PROBE = `(function () {
brwDirs: document.querySelectorAll('.brw-dir').length, brwDirs: document.querySelectorAll('.brw-dir').length,
qrows: document.querySelectorAll('.q-face .row').length, qrows: document.querySelectorAll('.q-face .row').length,
viewer: !!document.querySelector('.viewer img'), 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, lyrLines: document.querySelectorAll('.lyr .ln').length,
sheetTitle: (document.querySelector('.halfsheet .sheet-title') || {}).textContent || '', sheetTitle: (document.querySelector('.halfsheet .sheet-title') || {}).textContent || '',
recents: document.querySelectorAll('.recent-chips .chip').length, recents: document.querySelectorAll('.recent-chips .chip').length,
@@ -386,7 +388,7 @@ const MOCK_EXPECT = {
"search-dym": (i) => i.dym === "shpongle", "search-dym": (i) => i.dym === "shpongle",
"search-dym-pick": (i) => i.sq === "shpongle" && !i.dym, "search-dym-pick": (i) => i.sq === "shpongle" && !i.dym,
search: (i) => i.title.startsWith("Search"), 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, album: (i) => i.screen.includes("tr-push") && !i.mini,
lyrics: (i) => i.sheet.includes("up") && i.lyrLines >= 3, 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), "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"), "track-sheet": (i) => i.half.includes("up"),
sheet: (i) => i.sheet.includes("up"), sheet: (i) => i.sheet.includes("up"),
"after-back": (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: (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-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, "queue-removed": (i) => !i.half.includes("up") && i.qrows === globalThis.__qrows - 1,