Everything written this session that has not been through the device: rounds 43 to 48 (each patch adversarially verified, 43's transient-notice expectation fixed), a partial round 49, the pipeline scripts, the Android Auto design and the icon comparison mock. Also the two harness changes that were never A/B'd — adaptive waits, which may only shorten a declared wait and never lengthen it, and a ship that runs the mock walk and the deploy at the same time. The A/B and its 255-step baseline are in handoff/tools; main stays at what the phone has actually walked.
151 lines
9.0 KiB
JavaScript
151 lines
9.0 KiB
JavaScript
// 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(` <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 })} />`);
|
||
rep(` <span class="b"><span class="t">{a.title}</span><span class="m">{a.artist} · {a.year ?? "—"}</span></span>
|
||
<span class="r"><Icon name="chevron-right" size={17} /></span>
|
||
</button>
|
||
{/each}
|
||
{/if}
|
||
{:else if tab === "artists"}`,
|
||
` <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}
|
||
{/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(` <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}`);
|
||
save();
|
||
|
||
// ── the artist page: the phone's own plays ──
|
||
load('src/mobile/screens/Artist.svelte');
|
||
rep(` {#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}`);
|
||
rep(` <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}`);
|
||
{
|
||
// 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('</script>')[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');
|