Kyle picked this off an interactive prototype (research/library/) over a long evening of passes, and the settings he landed on are baked in rather than left as choices: records no catalogue knows about are ordinary citizens with no marks at all, artwork is generated from a record's own name when there is none, the albums you do not have are one collapsed line that opens into full-size dimmed cards, an artist's shelf splits by release type, the toolbar is a floating card, the index rail is always on, the List view's column header is welded to that card, the entrance is a wave of records, and cards are the artist page's own 150px. The artists accordion is gone. In its place: three views (Shelves, Grid, List), six groupings (Artist, Decade, Format, Type, Added, None), thirteen sorts that are independent of the grouping, a live filter that moves every number in the header, and a stat row that is derived rather than written down. A library page has to say what the files ARE, and library_albums only carries id/artist/title/year/art/count/added. So the library crate gained album_stats: one pass over tracks per call, aggregating length, bytes, average bitrate, bit depth, sample rate and the dominant file extension per album, plus the newest play that met a seconds floor the caller passes (the same 45s the recents list uses — under it a play is a skip, and a page that sorts by "never played first" must not count skips as listening). Aggregated in Rust rather than SQL because the dominant format needs the path's extension counted per album, which SQLite cannot do without string gymnastics, and a library is a few thousand rows. Filing a record as an album, an EP, a single, a live set, a compilation, a soundtrack, a remix record or bonus material is not a new opinion: localReleaseType.js carries CatalogArtist's own title heuristics verbatim — the regexes are the tuned artifact — reduced to the branches whose evidence a LOCAL record can carry. A file on disk has no MusicBrainz release group, no editorial lead sentence and usually no record_type, so the MB and description-hint branches are absent because their inputs cannot exist. Under it sits the floor the prototype settled on: when the classifier lands on "other" and the row carries no provider type, the two facts a file always has decide — three tracks or under ten minutes is a single, seven or under twenty-five an EP — with the session/demo check re-run inside it so a five-track practice tape stays bonus material. It is a copy, and it is marked as one at every function; unifying it with the artist page is follow-up work, not something to smuggle into this. Clicking a record opens CatalogArtist's inline expander, class for class, with its numbers unchanged: the 460ms slide whose height is read live per frame and kept monotonic (a captured height animates to a stale number once the tracklist lands), the staggered waterfall that fades each part to its OWN resting opacity, per-row entrances at min(i*16, 180), and a same-row switch that keeps both panels alive while the stage's height eases between them. What differs is the content, because a library knows different facts: where the artist page shows Last.fm plays and listeners, the rail shows size on disk and bitrate, where it offers Save an owned record offers its folder, and each track carries what its file is. Three things about the chrome are measured rather than typed, and each one is a bug the prototype found first. The bar's height is read off the bar, because a typed 50px parks anything pinned below it behind a bar whose controls have wrapped; the bar's own minimum is a constant, because publishing a measurement into the property that decides it is a ratchet. The index rail's 34px gutter is reserved whenever the rail is on, drawn or not, so no click on Group can change the width of the content column. And the table's column template lives on the page rather than on the rows, because a 1fr column floors at min-content and RECORD is wider than an ellipsized title, so identical declarations on two elements resolve to different widths; columns drop by tier from the page's measured width so a record's name is never squeezed out. The entrance is timed by POSITION, not by index. Indexing the document spends the whole stagger on sections nobody is looking at — 38 shelves means every visible one hits the cap and they all arrive in the same frame, which has no relative motion in it and reads as no animation at all. Delay comes from how far down and slightly across the pane a thing sits, normalized to the topmost thing that is both moving and on screen, and the delays are pulled forward over the next few frames as content-visibility geometry lands (only forward, except while a thing is still hidden, where a correction is invisible). The hero, the toolbar and the column header never move: they are the page's furniture, and dealing them in animated the whole window instead of the part that changed. A rail click corrects until its target stops moving. content-visibility makes the height of everything unpainted an estimate, so one scroll to a section overshoots — measured on the prototype at up to 1143px, with the page's own height moving 2260px during the jump. Two bugs fixed on the way, both the same mistake: `>>` on an unsigned 32-bit hash goes negative above 2^31, so a generated track name indexed past its word list and a generated cover's second hue produced an invalid gradient. Both are `>>>`. Deliberately not here. There is no Import button because there is no backend command to import a folder — Rescan re-reads the library and its stats, and a real importer is a backend feature. The "N of M released" count and the missing-albums line appear only for artists you FOLLOW: a follow carries an anchored artist id, and matching a local artist to a provider by name is exactly what the identity rules forbid, so an unfollowed shelf says what it holds and nothing more. Verified against the running app on real files: 7 albums, 72 tracks, 1.8 GB on disk off the new command, and Maladroit opening to 13 tracks, 33:37, 279 MB, 1019k, FLAC 16/44.1 with per-track rates, its notch under the card it belongs to and its row landing 10px under the pinned bar. Verified headless against the mock library at scale: all eighteen view-by-grouping combinations at one content width, the toolbar pinned with zero drift, the joined header's columns matching the rows on both edges, the expander opening from a card and from a row, morphing in place within a row and closing without leaving its reserve behind, and the wave moving 74 things with 34 distinct arrival times across 391ms while the chrome stayed still.
112 lines
4.7 KiB
JavaScript
112 lines
4.7 KiB
JavaScript
// Dev-only mock library data, for exercising the Library UI on a machine
|
|
// without a populated music library. Inert unless explicitly enabled by
|
|
// setting localStorage.mockLibrary = "1" in the webview (then reload);
|
|
// remove the key to go back to real backend data.
|
|
|
|
const ARTISTS = ["Aphex Twin","Boards of Canada","Caribou","Daft Punk","Eno Brian","Four Tet","Grimes","Holly Herndon","Jon Hopkins","Kraftwerk","LCD Soundsystem","Moderat","Nils Frahm","Oneohtrix","Portishead","Radiohead","Squarepusher","Tycho","Underworld","Vangelis","Wilco","Yello","Zero 7","Air","Bonobo","Burial","Clark","Deadmau5","Emancipator","Floating Points","Gold Panda","Helios","Kiasmos","Lorn","Moby","Nujabes","Ott","Plaid","Rival Consoles","Shpongle"];
|
|
|
|
// Titles vary by slot so the Library's classifier has something real to file:
|
|
// a live set, demos, a remix record and a collection appear per artist cycle,
|
|
// and a few rows have NO art_path so the generated artwork can be judged.
|
|
const TITLE_SHAPES = [
|
|
(a, j) => `Album ${j + 1} of ${a}`,
|
|
(a) => `Live at the Roundhouse`,
|
|
(a) => `Four-Track Demos`,
|
|
(a) => `Remixes 2001`,
|
|
(a) => `Singles Collection`,
|
|
];
|
|
const albums = [];
|
|
let aid = 1;
|
|
for (let i = 0; i < ARTISTS.length; i++) {
|
|
const n = 2 + (i % 4); // 2-5 albums per artist
|
|
for (let j = 0; j < n; j++) {
|
|
const shape = j < 2 ? TITLE_SHAPES[0] : TITLE_SHAPES[(i + j) % TITLE_SHAPES.length];
|
|
albums.push({
|
|
id: aid,
|
|
artist: ARTISTS[i],
|
|
title: shape(ARTISTS[i], j),
|
|
year: 1995 + ((i + j) % 30),
|
|
track_count: j === 2 && i % 5 === 0 ? 3 : 8 + ((i + j) % 7),
|
|
art_path: (i + j) % 4 === 3 ? null : `mock://art/${aid}`,
|
|
artist_art_path: `mock://artist/${i}`,
|
|
added_at: 1700000000 + i * 86400 * 9 + j * 86400 * 40,
|
|
path: `C:\\mock\\${i}\\${j}`,
|
|
});
|
|
aid++;
|
|
}
|
|
}
|
|
|
|
// Per-album file aggregates, the same shape library_album_stats returns.
|
|
// Deterministic variety: a third FLAC 16/44.1, a few 24/96, the rest MP3.
|
|
function statsFor(a) {
|
|
const kind = a.id % 7;
|
|
const flac = kind === 0 || kind === 3 || kind === 5;
|
|
const hires = kind === 5;
|
|
const perTrack = flac ? (hires ? 120e6 : 38e6) : 9e6;
|
|
return {
|
|
album_id: a.id,
|
|
secs: a.track_count * (170 + (a.id % 90)),
|
|
bytes: Math.round(a.track_count * perTrack),
|
|
avg_kbps: flac ? (hires ? 2400 : 900) + (a.id % 120) : 192 + (a.id % 4) * 32,
|
|
bit_depth: flac ? (hires ? 24 : 16) : null,
|
|
sample_rate: flac ? (hires ? 96000 : 44100) : null,
|
|
formats: [flac ? "flac" : "mp3"],
|
|
last_played_at: a.id % 3 === 0 ? null : 1723000000 - (a.id % 40) * 86400,
|
|
};
|
|
}
|
|
|
|
function svgArt(hue, label) {
|
|
const s =
|
|
`<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256">` +
|
|
`<rect width="256" height="256" fill="hsl(${hue},45%,35%)"/>` +
|
|
`<text x="128" y="140" font-size="90" text-anchor="middle" fill="rgba(255,255,255,.75)" font-family="sans-serif">${label}</text></svg>`;
|
|
return "data:image/svg+xml;base64," + btoa(s);
|
|
}
|
|
|
|
function tracksFor(albumId) {
|
|
const album = albums.find((a) => a.id === albumId);
|
|
const artist = album?.artist ?? "?";
|
|
const s = album ? statsFor(album) : null;
|
|
const ext = s?.formats[0] ?? "mp3";
|
|
const n = album?.track_count ?? 10;
|
|
return Array.from({ length: n }, (_, k) => ({
|
|
id: albumId * 100 + k,
|
|
album_id: albumId,
|
|
title: `Track ${k + 1}`,
|
|
artist,
|
|
track_no: k + 1,
|
|
duration_secs: 120 + ((albumId + k) % 9) * 23,
|
|
bitrate_kbps: s?.avg_kbps ?? 320,
|
|
bit_depth: s?.bit_depth ?? null,
|
|
sample_rate: s?.sample_rate ?? null,
|
|
size: Math.round((s?.bytes ?? 40e6) / n),
|
|
path: `C:\\mock\\t${albumId}-${k}.${ext}`,
|
|
}));
|
|
}
|
|
|
|
const enabled = (() => {
|
|
try {
|
|
return localStorage.getItem("mockLibrary") === "1";
|
|
} catch {
|
|
return false;
|
|
}
|
|
})();
|
|
|
|
/** Returns a Promise for mocked commands, or undefined to pass through. */
|
|
export function mockInvoke(cmd, args) {
|
|
if (!enabled) return undefined;
|
|
if (cmd === "library_albums") return Promise.resolve(albums);
|
|
if (cmd === "library_album_stats") return Promise.resolve(albums.map(statsFor));
|
|
if (cmd === "followed_artists") return Promise.resolve([]);
|
|
if (cmd === "recent_albums") return Promise.resolve([]);
|
|
if (cmd === "album_tracks")
|
|
return new Promise((r) => setTimeout(() => r(tracksFor(args.albumId)), 60));
|
|
if (cmd === "album_art") {
|
|
const m = /(\d+)$/.exec(args.path || "");
|
|
const num = m ? +m[1] : 0;
|
|
return Promise.resolve(svgArt((num * 47) % 360, String.fromCharCode(65 + (num % 26))));
|
|
}
|
|
if (cmd === "list_playlists") return Promise.resolve([]);
|
|
return undefined;
|
|
}
|