// Round eleven: the desktop Library's per-record file stats (format, depth // or bitrate, size — one call for the whole library) on the phone's cards, // rows and record page; the artist page's own play stats (time played, the // most-played track). Mocks, walks, doc. const fs = require('fs'); const root = 'F:/Coding/SoulStream/'; let p, s; const load = (file) => { p = file; s = fs.readFileSync(root + p, 'utf8'); }; const save = () => fs.writeFileSync(root + p, s); const once = (a) => { const i = s.indexOf(a); if (i < 0) throw new Error('missing in ' + p + ': ' + a.slice(0, 90)); if (s.indexOf(a, i + 1) >= 0) throw new Error('ambiguous in ' + p + ': ' + a.slice(0, 90)); return i; }; const rep = (a, b) => { once(a); s = s.replace(a, () => b); }; const NL = String.fromCharCode(10); const afterLine = (anchor, text) => { const i = once(anchor); const nl = s.indexOf(NL, i); s = s.slice(0, nl + 1) + text + s.slice(nl + 1); }; // ── the store: per-record stats, read with the library ── load('src/mobile/lib.svelte.js'); rep(` playlists: [], version: "",`, ` playlists: [], /** album id → { secs, bytes, avg_kbps, bit_depth, sample_rate, formats, last_played_at } — the desktop's library_album_stats */ stats: {}, version: "",`); rep(` try { lib.playlists = (await api.listPlaylists()) || []; } catch { lib.playlists = []; } lib.loaded = true; }`, ` 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; }`); // a rescan or a save re-reads them with the albums rep(` try { lib.albums = await api.libraryAlbums(); } catch { /* keep */ } await refreshRecents();`, ` try { lib.albums = await api.libraryAlbums(); } catch { /* keep */ } await refreshRecents(); void refreshStats();`); save(); // ── the Library's cards and rows ── load('src/mobile/screens/Library.svelte'); rep(` import { lib, artists, isFollowed, followedEntry } from "../lib.svelte.js";`, ` import { lib, artists, isFollowed, followedEntry, statLabel } from "../lib.svelte.js"; import { fmtBytes } from "../util.js";`); rep(` push("album", { id: a.id }, screenEl)} onmore={() => openHalf("album", { album: a })} />`, ` push("album", { id: a.id }, screenEl)} onmore={() => openHalf("album", { album: a })} />`); rep(` {a.title}{a.artist} · {a.year ?? "—"} {/each} {/if} {:else if tab === "artists"}`, ` {a.title}{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) : ""} {/each} {/if} {:else if tab === "artists"}`); save(); // ── the record page: size and bitrate beside the format ── load('src/mobile/screens/Album.svelte'); rep(` import { albumById, albumsBy, tracksOf } from "../lib.svelte.js";`, ` import { lib, albumById, albumsBy, tracksOf } from "../lib.svelte.js";`); rep(` import { mmss, longDur, albumFmt } from "../util.js";`, ` import { mmss, longDur, albumFmt, fmtBytes } from "../util.js";`); rep(` 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}`); save(); // ── the artist page: the phone's own plays ── load('src/mobile/screens/Artist.svelte'); rep(` {#if page?.top_genre}{page.top_genre}{/if}`, ` {#if page?.top_genre}{page.top_genre}{/if} {#if playTime}{playTime} played{/if}`); rep(`

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

`, `

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}`); { // the loader, after the props line const a = ` let { name, seed = null, screenEl = null } = $props();`; once(a); s = s.replace(a, () => a + ` /* 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");`); if (!/import \* as api from "\.\.\/\.\.\/lib\/api\.js";/.test(s)) throw new Error('Artist.svelte has no api import'); if (!/\bago\b/.test(s.split('')[0]) || !/import \{[^}]*\bago\b[^}]*\} from "\.\.\/util\.js"/.test(s)) { s = s.replace(/import \{ ([^}]*) \} from "\.\.\/util\.js";/, (m, names) => names.split(',').map((x) => x.trim()).includes('ago') ? m : `import { ${names}, ago } from "../util.js";`); } } save(); // ── mocks ── load('src/lib/mockLibrary.js'); rep(` 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 });`); save(); // ── the walks ── load('tests/mobile/smoke.mjs'); rep(` 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 || '',`); rep(` library: (i) => i.title.startsWith("Your library"),`, ` library: (i) => i.title.startsWith("Your library") && i.fmtBadges >= 10,`); rep(` 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),`); save(); // ── the doc ── load('ANDROID.md'); rep(`Search corrects an obvious misspelling from the names this phone knows`, `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`); save(); console.log('round eleven wired');