Files
SoulStream/handoff/rounds/patch-round11.cjs
T
KeelrLeek 9f0601027c android: the handoff — the pending rounds, the pipeline, and what is unverified
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.
2026-09-14 07:57:41 -05:00

151 lines
9.0 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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');