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
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
+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_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));
+4
View File
@@ -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 };
+21
View File
@@ -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() {
+4 -3
View File
@@ -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}
<div class="stats rec-chips">
<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>
{/if}
<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 { 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}<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 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}
{#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>
@@ -303,6 +317,7 @@
{#if albums.length}
<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">
{#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)} />
+4 -3
View File
@@ -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"}
<div class="grid2">
{#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}
</div>
{:else}
{#each list as a (a.id)}
<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="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>
</button>
{/each}
+4 -2
View File
@@ -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,