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.
322 lines
20 KiB
JavaScript
322 lines
20 KiB
JavaScript
// Round twenty-five: the catalog's numbers — Popular asked on its own as
|
||
// well as with the page, a listener count for the rows a record's own
|
||
// artist anchor could not reach, and an artist's hero photo chosen on this
|
||
// phone (set_artist_hero is pick_artist_hero's install step split out and
|
||
// shared, the way round fifteen did it for a playlist's cover).
|
||
const fs = require('fs');
|
||
const root = process.env.SS_ROOT || 'F:/Coding/SoulStream/';
|
||
const DIR = 'C:/Users/Kyle/AppData/Local/Temp/claude/F--Coding-SoulStream/5488514a-11e8-482a-b0a0-20cb511ee110/scratchpad/';
|
||
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); };
|
||
/* the long insertions live beside this script; the file's own last newline
|
||
is not part of the text */
|
||
const ins = (name) => fs.readFileSync(DIR + 'ins-r25-' + name + '.txt', 'utf8').replace(/\r?\n$/, '');
|
||
/* commands.rs is CRLF (and carries a BOM) — anchor and insert in ITS line
|
||
endings, or the file comes back mixed */
|
||
const CRLF = String.fromCharCode(13) + String.fromCharCode(10);
|
||
const crlf = (t) => t.replace(/\r?\n/g, CRLF);
|
||
const repRs = (a, b) => rep(crlf(a), crlf(b));
|
||
|
||
// ── the backend: the desktop's install step, split out and shared ──
|
||
load('src-tauri/src/commands.rs');
|
||
repRs(` let Some(source) = rx.await.unwrap_or(None) else {
|
||
return Ok(None);
|
||
};
|
||
let source = std::path::PathBuf::from(source);`,
|
||
` let Some(source) = rx.await.unwrap_or(None) else {
|
||
return Ok(None);
|
||
};
|
||
install_artist_hero(&app, artist_id, source).map(Some)
|
||
}
|
||
|
||
/// A picked image — the desktop's dialog, or a photo the phone's storage
|
||
/// plugin already copied into the app's cache — validated and written as
|
||
/// this artist's chosen hero.
|
||
fn install_artist_hero(
|
||
app: &tauri::AppHandle,
|
||
artist_id: u64,
|
||
source: String,
|
||
) -> Result<ArtistHeroOverride, String> {
|
||
let source = std::path::PathBuf::from(source);`);
|
||
repRs(` let (ext, _) = artist_image_format(&source)?;
|
||
let dir = artist_hero_dir(&app)?;`,
|
||
` let (ext, _) = artist_image_format(&source)?;
|
||
let dir = artist_hero_dir(app)?;`);
|
||
repRs(` let mut hero = load_artist_hero_override(&app, artist_id)?
|
||
.ok_or_else(|| "The imported hero could not be reopened".to_string())?;
|
||
hero.width = width;
|
||
hero.height = height;
|
||
Ok(Some(hero))
|
||
}`,
|
||
` let mut hero = load_artist_hero_override(app, artist_id)?
|
||
.ok_or_else(|| "The imported hero could not be reopened".to_string())?;
|
||
hero.width = width;
|
||
hero.height = height;
|
||
Ok(hero)
|
||
}
|
||
|
||
/// The phone's door onto the same import: a photo already on disk (the
|
||
/// storage plugin's picker copied it into the app's cache) becomes this
|
||
/// artist's chosen hero. The dialog above stays the desktop's.
|
||
#[tauri::command]
|
||
pub async fn set_artist_hero(
|
||
app: tauri::AppHandle,
|
||
artist_id: u64,
|
||
path: String,
|
||
) -> Result<ArtistHeroOverride, String> {
|
||
install_artist_hero(&app, artist_id, path)
|
||
}`);
|
||
save();
|
||
|
||
load('src-tauri/src/lib.rs');
|
||
rep(` commands::pick_artist_hero,`, ` commands::pick_artist_hero,
|
||
commands::set_artist_hero,`);
|
||
save();
|
||
|
||
// ── the wrapper ──
|
||
load('src/lib/api.js');
|
||
rep(`export const clearArtistHero = (artistId) => invoke("clear_artist_hero", { artistId });`,
|
||
`export const clearArtistHero = (artistId) => invoke("clear_artist_hero", { artistId });
|
||
// The phone's hero door: a photo the storage plugin already copied into the
|
||
// app's cache, installed the way the desktop's dialog pick installs one.
|
||
export const setArtistHero = (artistId, path) => invoke("set_artist_hero", { artistId, path });`);
|
||
save();
|
||
|
||
// ── a data: URL is already the image ──
|
||
// A custom hero comes back inside catalog_artist as a data: URL, and the
|
||
// art gate sent everything that is not https to the thumbnail cache, which
|
||
// cannot open one: the banner, the ground and the accent all went blank.
|
||
load('src/mobile/art.svelte.js');
|
||
rep(` if (art[path] !== undefined) return Promise.resolve(art[path]);`,
|
||
` if (art[path] !== undefined) return Promise.resolve(art[path]);
|
||
/* a data: URL is already the image — an artist's chosen hero comes back
|
||
from Rust as one, and the thumbnail fetch would throw it away */
|
||
if (path.startsWith("data:")) { art[path] = path; return Promise.resolve(path); }`);
|
||
save();
|
||
|
||
// ── the store ──
|
||
load('src/mobile/catalog.svelte.js');
|
||
rep(`import * as api from "../lib/api.js";`, `import * as api from "../lib/api.js";
|
||
import { note } from "./notice.svelte.js";`);
|
||
rep(` /** "artist\\u0000title" → { status, info: { description, genre, label, source } } */
|
||
albumInfo: {},
|
||
});`,
|
||
` /** "artist\\u0000title" → { status, info: { description, genre, label, source } } */
|
||
albumInfo: {},
|
||
/** artist id → { status, tracks } — Popular on its own, ahead of the page */
|
||
popular: {},
|
||
/** catalog album id → { status, counts: { trackId: listeners } } — the
|
||
* deferred Last.fm pass for the rows the record's page had no count for */
|
||
listeners: {},
|
||
});`);
|
||
rep(`/** A portrait for a byline — the same identity rule, search only. */`, ins('popular'));
|
||
rep(`export function lookupAlbum(id) {`, ins('store'));
|
||
save();
|
||
|
||
// ── the artist page: Popular's fast lane ──
|
||
load('src/mobile/screens/Artist.svelte');
|
||
rep(` import { catalog, lookupArtist, artistKey, fans, yearOf, looseTitle, loadExtras, picture } from "../catalog.svelte.js";`,
|
||
` import { catalog, lookupArtist, artistKey, fans, yearOf, looseTitle, loadExtras, loadPopular, picture } from "../catalog.svelte.js";`);
|
||
rep(` $effect(() => {
|
||
const id = page?.artist?.id;
|
||
const nm = page?.artist?.name || name;
|
||
if (id != null) untrack(() => loadExtras(id, nm));
|
||
});`,
|
||
` $effect(() => {
|
||
const id = page?.artist?.id;
|
||
const nm = page?.artist?.name || name;
|
||
if (id != null) untrack(() => loadExtras(id, nm));
|
||
});
|
||
/* Popular's own lane, asked the moment an id is known — from the
|
||
search's own row, or from the identity search — so the section can
|
||
paint before the full page is built */
|
||
$effect(() => { const id = artistId; if (id != null) untrack(() => loadPopular(id)); });`);
|
||
rep(` const popularAll = $derived(page?.popular_tracks ?? []);`,
|
||
` /* the page's own list once it lands (the same Last.fm order, from the
|
||
same inputs); the fast lane's until then, and for an artist whose
|
||
page came back with no Popular at all */
|
||
const fastPopular = $derived(artistId != null ? catalog.popular[artistId]?.tracks ?? [] : []);
|
||
const popularAll = $derived(page?.popular_tracks?.length ? page.popular_tracks : fastPopular);`);
|
||
save();
|
||
|
||
// ── the catalog record: a count for the rows the anchor could not reach ──
|
||
load('src/mobile/screens/CatalogAlbum.svelte');
|
||
rep(` import { catalog, lookupAlbum, lookupArtist, artistKey, seedFor, picture, yearOf, looseTitle, loadAlbumInfo, infoKey } from "../catalog.svelte.js";`,
|
||
` import { catalog, lookupAlbum, lookupArtist, artistKey, seedFor, picture, yearOf, looseTitle, loadAlbumInfo, infoKey, loadTrackListeners } from "../catalog.svelte.js";`);
|
||
rep(` const peak = $derived(rows.reduce((m, t) => Math.max(m, t.listeners || 0), 0));`,
|
||
` /* the rows the record's own artist anchor found no count for — a
|
||
feature's guest, a compilation's stranger — get the deferred pass by
|
||
their OWN artist id; a record's stats are anchored on its artist
|
||
alone and cannot reach them */
|
||
$effect(() => { const rs = rows, k = id; if (rs.length) untrack(() => loadTrackListeners(k, rs)); });
|
||
const extraCounts = $derived(catalog.listeners[id]?.counts ?? {});
|
||
const listenersOf = (t) => t.listeners ?? extraCounts[t.id] ?? null;
|
||
const peak = $derived(rows.reduce((m, t) => Math.max(m, listenersOf(t) || 0), 0));`);
|
||
rep(` {#if showCount}<span class="lbar"><i style="transform: scaleX({(peak ? Math.max(0.04, (t.listeners || 0) / peak) : 0).toFixed(3)})"></i><em>{fmtCount(t.listeners)}</em></span>{/if}`,
|
||
` {#if showCount}<span class="lbar"><i style="transform: scaleX({(peak ? Math.max(0.04, (listenersOf(t) || 0) / peak) : 0).toFixed(3)})"></i><em>{fmtCount(listenersOf(t))}</em></span>{/if}`);
|
||
save();
|
||
|
||
// ── the artist page's own overflow sheet (the bar's ⋯ fell through to home) ──
|
||
load('src/mobile/HalfSheet.svelte');
|
||
rep(` import { catalog, yearOf } from "./catalog.svelte.js";`,
|
||
` import { catalog, yearOf, artistKey, picture, heroFromPhoto, heroClear } from "./catalog.svelte.js";`);
|
||
rep(` if (t.n === "catalogAlbum") return "catalogAlbum-more";`,
|
||
` if (t.n === "catalogAlbum") return "catalogAlbum-more";
|
||
if (t.n === "artist") return "artist-more";`);
|
||
rep(` {:else if kind === "home-more"}`,
|
||
` {:else if kind === "artist-more"}
|
||
{@const nm = top().c.name}
|
||
{@const ent = catalog.artists[artistKey(nm)] ?? null}
|
||
{@const pg = ent?.page ?? null}
|
||
{@const aid = pg?.artist?.id ?? ent?.id ?? null}
|
||
<div class="sheet-head">
|
||
<span class="art">{#if picture(pg?.artist?.picture)}<img src={picture(pg.artist.picture)} alt="" />{:else}<Icon name="users" size={22} />{/if}</span>
|
||
<span class="b"><span class="t">{nm}</span><span class="m">{pg?.albums?.length ? pg.albums.length + " releases on the catalog" : "Artist"}</span></span>
|
||
</div>
|
||
<div class="sheet-rows">
|
||
{#if aid != null}
|
||
{@render row("image", "Choose hero photo…", "a wide picture from this phone, 1920×800 or larger", act(() => heroFromPhoto(aid, nm).catch(oops)))}
|
||
{#if pg?.banner_custom}
|
||
{@render row("x", "Remove custom hero", "the catalog's own photos come back", act(() => heroClear(aid, nm).catch(oops)))}
|
||
{/if}
|
||
<div class="sheet-hair"></div>
|
||
{/if}
|
||
{@render row("settings", "Artwork settings", "covers and portraits, in You", act(() => go("you")))}
|
||
</div>
|
||
{:else if kind === "home-more"}`);
|
||
save();
|
||
|
||
// ── You → Album artwork: the desktop's portrait refresh ──
|
||
load('src/mobile/screens/You.svelte');
|
||
rep(` let refetching = $state(false);`, ` let refetching = $state(false);
|
||
let refreshingArtists = $state(false);`);
|
||
rep(` const ROWS = [
|
||
["ambience", "image", "Ambience", "the artwork in play, blurred, under every screen"],`, ins('you'));
|
||
rep(` <span class="b"><span class="t">{refetching ? "Upgrading covers…" : "Re-fetch all album covers"}</span><span class="m">every record to the canonical high-resolution cover — about one a second</span></span>
|
||
</button>
|
||
</div>`,
|
||
` <span class="b"><span class="t">{refetching ? "Upgrading covers…" : "Re-fetch all album covers"}</span><span class="m">every record to the canonical high-resolution cover — about one a second</span></span>
|
||
</button>
|
||
<button class="row" onclick={refreshPortraits} disabled={refreshingArtists}>
|
||
<span class="lead"><Icon name="users" size={20} /></span>
|
||
<span class="b"><span class="t">{refreshingArtists ? "Refreshing portraits…" : "Refresh artist portraits"}</span><span class="m">a photo for every artist already in your library</span></span>
|
||
</button>
|
||
</div>`);
|
||
save();
|
||
|
||
// ── the mocks ──
|
||
load('src/lib/mockLibrary.js');
|
||
rep(`let follows = [];`, `/* the heroes chosen this session — pick_image → set_artist_hero */
|
||
const heroes = {};
|
||
let follows = [];`);
|
||
rep(` artist: a, banner: null, banners: [], banner_custom: false, logos: [],`,
|
||
` artist: a, banner: heroes[a.id] ?? null, banners: heroes[a.id] ? [heroes[a.id]] : [], banner_custom: !!heroes[a.id], logos: [],`);
|
||
rep(` if (cmd === "catalog_related") return Promise.resolve(RELATED);`,
|
||
` if (cmd === "catalog_related") return Promise.resolve(RELATED);
|
||
/* Popular's own lane: ranked, with counts — and for the artists whose
|
||
page gives none (the related ones), the only Popular there is */
|
||
if (cmd === "catalog_artist_popular") {
|
||
const id = Number(args?.artistId);
|
||
const a = id === CAT_ARTIST.id ? CAT_ARTIST : RELATED.find((r) => r.id === id);
|
||
if (!a) return Promise.resolve([]);
|
||
const rows = id === CAT_ARTIST.id
|
||
? CAT_TRACKS.slice(0, 7)
|
||
: TITLES.slice(0, 4).map((t, i) => ({
|
||
id: 4200000 + id * 10 + i, title: t, artist: a.name, artist_id: id, rank: 700000 - i * 1000,
|
||
album: a.name + " Live", album_id: 900 + id, cover: null, duration_secs: 210 + i * 13, preview: null, track_no: i + 1,
|
||
}));
|
||
return new Promise((r) => setTimeout(() => r(rows.map((t, i) => ({ ...t, listeners: 1_900_000 - i * 240_000 }))), 260));
|
||
}
|
||
/* the deferred pass: a count per row, by that row's own artist id */
|
||
if (cmd === "catalog_track_listeners") {
|
||
const rows = args?.tracks || [];
|
||
return new Promise((r) => setTimeout(() => r(rows.map((t, i) => (t.artistId ? 640_000 - i * 20_000 : null))), 260));
|
||
}
|
||
if (cmd === "set_artist_hero") { const id = Number(args?.artistId); heroes[id] = svgArt(180, "H"); return Promise.resolve({ data_url: heroes[id], width: 3840, height: 2160 }); }
|
||
if (cmd === "clear_artist_hero") { const id = Number(args?.artistId); const had = id in heroes; delete heroes[id]; return Promise.resolve(had); }`);
|
||
rep(` return Promise.resolve({ album: a, tracks: a.id === 302127 ? CAT_TRACKS.map((t, i) => ({ ...t, listeners: 2_400_000 - i * 150_000 })) : [], album_plays: a.id === 302127 ? 310_000_000 : null, album_listeners: a.id === 302127 ? 4_100_000 : null, totals_summed: false });`,
|
||
` /* the last two rows are a guest's: the album's own artist anchor
|
||
has no count for them, which is what the deferred pass is for */
|
||
return Promise.resolve({ album: a, tracks: a.id === 302127 ? CAT_TRACKS.map((t, i) => (i >= CAT_TRACKS.length - 2 ? { ...t, artist: "Daft Punk feat. Todd Edwards", artist_id: 411 } : { ...t, listeners: 2_400_000 - i * 150_000 })) : [], album_plays: a.id === 302127 ? 310_000_000 : null, album_listeners: a.id === 302127 ? 4_100_000 : null, totals_summed: false });`);
|
||
rep(` 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 === "refresh_artist_art") return new Promise((r) => setTimeout(() => r(26), 300));`);
|
||
/* the thumbnail cache READS A FILE (std::fs::read on the path it is given),
|
||
so a data: URL comes back as an error there and the gate remembers "".
|
||
The mock answered every path with a picture, which quietly made the art
|
||
gate's own data: answer untestable — the hero looked fine either way. */
|
||
rep(` if (cmd === "album_art") {
|
||
const m = /(\\d+)$/.exec(args.path || "");`,
|
||
` if (cmd === "album_art") {
|
||
/* not a path: the real one fails to open it, which is why the art
|
||
gate has to answer a data: URL itself */
|
||
if (String(args.path || "").startsWith("data:")) return Promise.reject(new Error("album_art: not a path"));
|
||
const m = /(\\d+)$/.exec(args.path || "");`);
|
||
save();
|
||
|
||
// ── the walks ──
|
||
load('tests/mobile/smoke.mjs');
|
||
/* every row renders an .lbar whether or not it has a number, so counting
|
||
the bars proves nothing — count the <em>s that say something */
|
||
rep(` cardPhs: document.querySelectorAll('.rail .acard svg').length,`,
|
||
` cardPhs: document.querySelectorAll('.rail .acard svg').length,
|
||
popCounts: [].filter.call(document.querySelectorAll('.row.pop .lbar em'), function (e) { return e.textContent.trim(); }).length,
|
||
barCounts: [].filter.call(document.querySelectorAll('.trk .lbar em'), function (e) { return e.textContent.trim(); }).length,
|
||
heroPix: !!document.querySelector('.hero .hero-img.dissolve'),`);
|
||
rep(` { do: \`__click('.disco-tabs button', 'Singles')\`, name: "cat-artist-tab", wait: 700 },`, ins('steps-hero'));
|
||
rep(` { do: \`__click('.rail .release', 'Discovery')\`, name: "cat-album", wait: 1500 },`,
|
||
` { do: \`__click('.rail .release', 'Discovery')\`, name: "cat-album", wait: 1500 },
|
||
/* the two guest rows the record's own anchor had no count for */
|
||
{ name: "cat-album-listeners", wait: 900 },`);
|
||
rep(` { do: \`__click('.prefs[data-mode="albumart"] .chip', '1500 px')\`, wait: 400 },`, ins('steps-you'));
|
||
rep(` "cat-artist-related": (i) => i.title.startsWith("Justice") && i.screen.includes("has-hero"),`,
|
||
` "cat-artist-related": (i) => i.title.startsWith("Justice") && i.screen.includes("has-hero") && i.heroPix && i.pop >= 4 && i.popCounts >= 4,
|
||
"artist-more": (i) => i.half.includes("up") && /Choose hero photo/.test(i.halfText) && !/Remove custom hero/.test(i.halfText),
|
||
"artist-hero-set": (i) => !i.half.includes("up") && /3840×2160/.test(i.notice),
|
||
"artist-more-custom": (i) => i.half.includes("up") && /Remove custom hero/.test(i.halfText) && i.heroPix,
|
||
"artist-hero-cleared": (i) => !i.half.includes("up") && /photos are back/.test(i.notice),`);
|
||
rep(` "cat-album": (i) => i.screen.includes("tr-push") && i.title.startsWith("Discovery") && i.eds >= 2 && i.lede && i.bars >= 10,`,
|
||
` "cat-album": (i) => i.screen.includes("tr-push") && i.title.startsWith("Discovery") && i.eds >= 2 && i.lede && i.bars >= 10,
|
||
"cat-album-listeners": (i) => i.bars === 14 && i.barCounts === 14,`);
|
||
rep(` "you-artedge": (i) => i.artEdge === "1000 px",`,
|
||
` "you-artedge": (i) => i.artEdge === "1000 px",
|
||
"you-portraits": (i) => /portraits refreshed/.test(i.notice),`);
|
||
rep(` { name: "artist", wait: 4500 },`, ins('steps-phone'));
|
||
rep(` artist: (i) => i.screen.includes("has-hero") && i.amb.includes("on:img") && i.tabs >= 1,`,
|
||
` artist: (i) => i.screen.includes("has-hero") && i.amb.includes("on:img") && i.tabs >= 1,
|
||
"artist-more": (i) => i.half.includes("up") && /Choose hero photo/.test(i.halfText),`);
|
||
save();
|
||
|
||
// ── the doc ──
|
||
load('ANDROID.md');
|
||
rep(`played track under "On this phone"). Search corrects an obvious`,
|
||
`played track under "On this phone"). Popular is asked on its own as well as
|
||
with the page (\`catalog_artist_popular\`: the Last.fm ranking and its counts,
|
||
waiting on nothing but the linked MBID, so the section can paint before the
|
||
full page is built — and an artist whose page came back with no Popular at
|
||
all still gets one), and a catalog record's rows that the album's own artist
|
||
anchor found no count for — a feature's guest, a compilation's stranger —
|
||
get the desktop's deferred pass by their OWN Deezer artist id
|
||
(\`catalog_track_listeners\`). An artist's photo is Kyle's to choose: the
|
||
artist page's ⋯ offers **Choose hero photo** (the phone's image picker, then
|
||
\`set_artist_hero\` — \`pick_artist_hero\`'s install step split out and shared,
|
||
so the desktop's dialog keeps its own door; 1920×800 and landscape or it is
|
||
refused) and **Remove custom hero**, which puts the catalog's own photos
|
||
back; the page is asked again either way, so the banner, the ground and the
|
||
accent all follow. The desktop's own chooser has had no home since its pill
|
||
left the glass bar — the phone has one first. You → Album artwork carries
|
||
**Refresh artist portraits** (\`refresh_artist_art\`), the desktop Settings
|
||
row. A \`data:\` URL is the image itself in \`art.svelte.js\` now — a chosen
|
||
hero comes back from Rust as one, and the thumbnail fetch used to throw it
|
||
away. Search corrects an obvious`);
|
||
save();
|
||
console.log('round 25 wired');
|