diff --git a/ANDROID.md b/ANDROID.md index ef29dd5..b226f4e 100644 --- a/ANDROID.md +++ b/ANDROID.md @@ -24,7 +24,7 @@ implements is `research/mobile-mockup/` (gitignored; see its README). | `crates/tauri-plugin-media-session/` | a Tauri plugin with a Kotlin foreground service (`MediaService`) that owns the `MediaSession`, the MediaStyle notification, audio focus and the becoming-noisy pause. The audio never passes through it; it is what keeps the process alive in the background and what the lock screen shows | | `crates/tauri-plugin-transfer-notice/` | a second Tauri plugin with a second Kotlin foreground service (`TransferService`, `dataSync`) — the downloads' own progress notification on its own low-importance channel, with Stop all and Open on it, and the reason a Save outlives the app going to the background with nothing playing | | `src/mobile/Sheet.svelte`, `HalfSheet.svelte`, `SyncRoom.svelte`, `Lane.svelte`, `Cover.svelte`, `Release.svelte` | the player (art / lyrics / queue faces, container transform from the mini bar, drags), the bottom sheets (track, record, sort, the page overflow, the sleep timer), the tap-to-sync room, the waveform lane, the two card pieces (a long press on a card is its sheet) | -| `src/mobile/screens/` | Home, Search (the library half: records and artists here, tracks through `library_track_search`), Library (albums / artists / tracks / playlists), Album, Artist (the library's shell at once, the catalog dissolving in: portrait, Popular, discography with what is here marked), CatalogAlbum (a catalog record, rows the library has play), Playlist, Community (sign in; rooms, chat, buddies, taste — the Soulseek side; the private layer waits), Room, Thread, You (Appearance switches, real) | +| `src/mobile/screens/` | Home, Search (the library half: records and artists here, tracks through `library_track_search`), Library (albums / artists / tracks / playlists), Album, Artist (the library's shell at once, the catalog dissolving in: portrait, Popular, discography with what is here marked), CatalogAlbum (a catalog record, rows the library has play), Discography (every release, on one page), Playlist, Community (sign in; rooms, chat, buddies, taste — the Soulseek side; the private layer waits), Room, Thread, You (Appearance switches, real) | | `src/mobile/prefs.svelte.js` | Appearance, kept in localStorage: ambience, accent from artwork, motion | | `src/mobile/util.js` | what every screen shares and no screen should write twice: the formatters (`mmss` / `longDur` / `stamp` for a length in seconds, in words and in milliseconds; `ago` / `clock`; `fmtBytes` / `fmtCount`; `fmtOf` / `albumFmt`), the name and folder readers a peer's file needs, the `drag` action, and `devBuild` | | `src-tauri/tauri.android.conf.json` | merged over `tauri.conf.json` on Android: one window at `mobile.html`, no desktop resources, `minSdkVersion` 26 | @@ -163,6 +163,34 @@ shows on the next visit at once instead of behind a whole catalog page — and both lanes are forgotten with the page when a hero is set or removed, or the old banner would answer while the new one was still being fetched. +The **whole discography** is a page of its own +(`src/mobile/screens/Discography.svelte`), reached from the artist page's +Discography head with **All**, and from under its grid with "Show all N +Albums" — which is also where that grid now stops: it shows twelve of the +active tab, because before this an artist with 140 releases rendered 140 +cards. The page is the desktop's `CatalogDiscography.svelte` reduced to the +four things it has that the artist page's tabs do not: **All** in front of +the categories (built by CONCATENATING the per-category families, never by +re-folding the whole set — a single sharing an album's base title would fold +into it as a phantom edition), a **sort** (newest, oldest, A–Z, Z–A), a +**grouping** by year, and the **editions kept whole**, opened in place under +their own record. Three of the desktop's are deliberately left behind: the +per-record tracklists (140 records is 140 `catalog_album` calls over mobile +data, for rows one tap away on the record page), the per-record accent +sampled from each cover, and the scroll-led banner. The year is written into +the list where the desktop has a sticky year rail — a phone list is read top +to bottom. The order and the grouping live on the nav entry and are edited +from the **Sort & group** sheet, Library's own idiom; under a name order By +year is not offered, because the years would repeat out of sequence, and the +sheet says so. The list is capped at sixty rows with a "+ N more releases" +button (Browse's rule), and the cap is taken BEFORE the grouping, so the last +year on screen can be a part of itself. Filing, families and labels all come +from `src/mobile/disco.js` — the artist page's own classifier, called a +second time over the same cached albums. The desktop cannot do that: its +filing depends on a background description harvest and deferred MB type +hints a second page would not read, which is why `CatalogDiscography.svelte` +takes the artist page's computed tab list as the same array object instead. + The **catalog record page** is the desktop's album page: the kind, the artist byline with a portrait, year · tracks · runtime, Last.fm's totals, Play / Save / Shuffle, the record's other editions (the artist's page folded @@ -1183,7 +1211,11 @@ waveform growing; backend events go over the mock's own bus, which `src/lib/events.js` puts in front of Tauri's `listen`), drives Home → a long press → Search → Library and its tabs → a record → its sheets → the player's three faces → the back gesture → an artist page → Community → a room → a -catalog search → a song through the resolver → the lane rising in the sheet → +catalog search → that artist's whole discography on a page of its own (All +in front of six categories, a year header per year, the editions opened in +place, a name order dropping the grouping, and a 140-record artist whose +grid caps at twelve and whose page shows sixty with the rest a button away) +→ a song through the resolver → the lane rising in the sheet → a catalog record, its Save and the Transfers burst (and the shade's notice: posted with a count while rows are in flight, gone when they are not) → Gaps → the playlist @@ -1305,10 +1337,9 @@ symptom. - **Off by design — external runtimes.** yt-dlp and deno, the vocal separator, the lyric alignment models, and the listener door that needs them. -- **Still desktop-only, smaller.** Catalog track previews, the full - discography page, and the fused-artist portrait verdict — that last one - needs the identity merge the phone does not run, so porting it means - porting the merge first. +- **Still desktop-only, smaller.** Catalog track previews and the + fused-artist portrait verdict — that last one needs the identity merge the + phone does not run, so porting it means porting the merge first. - **Per library folder.** Follows and the play history live in the library folder, so the phone's own folder starts empty of both; they arrive with a shared library. diff --git a/src/lib/mockLibrary.js b/src/lib/mockLibrary.js index 0030c29..1fc0ba7 100644 --- a/src/lib/mockLibrary.js +++ b/src/lib/mockLibrary.js @@ -635,13 +635,33 @@ const SLOW_ALBUMS = [ { id: 4155, title: "Long Winter", release_date: "2023-01-20", record_type: "album", track_count: 10 }, { id: 4156, title: "Open Water", release_date: "2024-08-30", record_type: "album", track_count: 11 }, ].map((a) => ({ ...a, artist: CAT_SLOW.name, cover: svgArt((a.id * 17) % 360, a.title[0]), edition_group: null, is_soundtrack: false })); +/* A prolific artist, so the discography page's cap is WALKED rather than + argued about: 140 records, four a year from 1985, each plainly titled for + the same reason SLOW_ALBUMS' are — the classifier must leave every one of + them on the Albums tab, or the counts the walk asserts stop meaning + anything. Deliberately NOT in RELATED: a fifth card in the rail would move + the artist page's own card census (cat-artist-tab counts 2 images and 2 + placeholders there). The walk reaches it with a seed, so no search + answers for it either. */ +const CAT_BIG = { id: 416, name: "Ember Parade", picture: null, album_count: 140, fans: 91000 }; +const BIG_ALBUMS = Array.from({ length: 140 }, (_, i) => ({ + id: 41600 + i, + title: "Field Notes " + (i + 1), + artist: CAT_BIG.name, + cover: svgArt((i * 37) % 360, "F"), + release_date: (1985 + Math.floor(i / 4)) + "-" + String((i % 4) * 3 + 2).padStart(2, "0") + "-15", + record_type: "album", + track_count: 8 + (i % 5), + edition_group: null, + is_soundtrack: false, +})); /* the heroes chosen this session — pick_image → set_artist_hero */ const heroes = {}; let follows = []; function mockCatalog(cmd, args) { if (cmd === "catalog_artist") { const id = Number(args?.artistId); - const a = id === CAT_ARTIST.id ? CAT_ARTIST : id === CAT_SLOW.id ? CAT_SLOW : RELATED.find((r) => r.id === id); + const a = id === CAT_ARTIST.id ? CAT_ARTIST : id === CAT_SLOW.id ? CAT_SLOW : id === CAT_BIG.id ? CAT_BIG : RELATED.find((r) => r.id === id); if (!a) return Promise.reject(new Error("catalog_artist: unknown artist")); const own = a === CAT_ARTIST; /* the exhaustive page, kept four seconds on purpose: whatever is on @@ -650,7 +670,7 @@ function mockCatalog(cmd, args) { return new Promise((r) => setTimeout(() => r({ artist: a, banner: heroes[a.id] ?? null, banners: heroes[a.id] ? [heroes[a.id]] : [], banner_custom: !!heroes[a.id], logos: [], popular_tracks: own ? CAT_TRACKS.slice(0, 7).map((t, i) => ({ ...t, listeners: 2_400_000 - i * 310_000 })) : [], - albums: own ? CAT_DISCO : slow ? SLOW_ALBUMS : [{ id: 900 + id, title: a.name + " Live", artist: a.name, cover: null, release_date: "2015-01-01", record_type: "album", track_count: 9, edition_group: null, is_soundtrack: false }], + albums: own ? CAT_DISCO : slow ? SLOW_ALBUMS : a === CAT_BIG ? BIG_ALBUMS : [{ id: 900 + id, title: a.name + " Live", artist: a.name, cover: null, release_date: "2015-01-01", record_type: "album", track_count: 9, edition_group: null, is_soundtrack: false }], top_genre: "Electronic", socials: [], }), slow ? 4000 : 220)); } @@ -746,9 +766,9 @@ function mockCatalog(cmd, args) { so `artist.id` must equal the argument or the wrapper throws */ if (cmd === "catalog_artist_overview") { const id = Number(args?.artistId); - const a = id === CAT_ARTIST.id ? CAT_ARTIST : id === CAT_SLOW.id ? CAT_SLOW : RELATED.find((r) => r.id === id); + const a = id === CAT_ARTIST.id ? CAT_ARTIST : id === CAT_SLOW.id ? CAT_SLOW : id === CAT_BIG.id ? CAT_BIG : RELATED.find((r) => r.id === id); if (!a) return Promise.reject(new Error("catalog_artist_overview: unknown artist")); - const albums = id === CAT_SLOW.id ? SLOW_ALBUMS.slice(0, 4) : id === CAT_ARTIST.id ? CAT_DISCO.slice(0, 4) : []; + const albums = id === CAT_SLOW.id ? SLOW_ALBUMS.slice(0, 4) : id === CAT_ARTIST.id ? CAT_DISCO.slice(0, 4) : id === CAT_BIG.id ? BIG_ALBUMS.slice(0, 4) : []; return new Promise((r) => setTimeout(() => r({ artist: a, popular_tracks: [], albums }), 300)); } if (cmd === "catalog_art") return Promise.resolve(svgArt(200, "D")); diff --git a/src/mobile/HalfSheet.svelte b/src/mobile/HalfSheet.svelte index 6611b33..a5f686e 100644 --- a/src/mobile/HalfSheet.svelte +++ b/src/mobile/HalfSheet.svelte @@ -62,6 +62,7 @@ if (!h) return null; if (h.kind !== "more") return h.kind; const t = top(); + if (t.n === "disco") return "discosort"; if (t.n === "album") return "album-more"; if (t.n === "library") return "library-more"; if (t.n === "playlist") return "playlist-more"; @@ -722,6 +723,30 @@ {@render row("refresh", "Forget and look again", "", act(() => { void forgetLyrics(); }))} {/if} + {:else if kind === "discosort"} + {@const c = top().c} + {@const ord = c.order || "new"} + {@const byName = ord === "az" || ord === "za"} + {@const grp = byName ? "none" : (c.group ?? "year")} + +
Sort & group
+
Order
+
+ {#each [["new", "Newest first"], ["old", "Oldest first"], ["az", "A–Z"], ["za", "Z–A"]] as [k, l] (k)} + + {/each} +
+
Group
+
+ {#each [["year", "By year"], ["none", "None"]] as [k, l] (k)} + + {/each} +
+ {#if byName}
By year needs a date order — under A–Z the years would repeat out of sequence.
{/if} +
{:else if kind === "librarysort"}
Sort & view
Order
diff --git a/src/mobile/Mobile.svelte b/src/mobile/Mobile.svelte index feddbd7..9a0a5bf 100644 --- a/src/mobile/Mobile.svelte +++ b/src/mobile/Mobile.svelte @@ -22,6 +22,7 @@ import Library from "./screens/Library.svelte"; import Album from "./screens/Album.svelte"; import Artist from "./screens/Artist.svelte"; + import Discography from "./screens/Discography.svelte"; import CatalogAlbum from "./screens/CatalogAlbum.svelte"; import Playlist from "./screens/Playlist.svelte"; import Import from "./screens/Import.svelte"; @@ -86,6 +87,16 @@ const a = albumById(e.c.id); return { title: a?.title ?? "", kicker: a?.artist ?? "", amb: a?.art_path ?? null, flush: true }; } + /* the whole discography wears the artist's own ground, but no + hero of its own: it is a list, and the bar is over it from the + first pixel rather than after 210 of them */ + case "disco": { + const as = albumsBy(e.c.name); + const cat = catalog.artists[artistKey(e.c.name)]?.page; + const p = cat?.banner || cat?.artist?.picture + || as.find((a) => a.artist_art_path)?.artist_art_path || as[0]?.art_path || null; + return { title: e.c.name, kicker: "Discography", amb: p }; + } case "artist": { const as = albumsBy(e.c.name); const cat = catalog.artists[artistKey(e.c.name)]?.page; @@ -339,6 +350,8 @@ {:else if entry.n === "artist"} + {:else if entry.n === "disco"} + {:else if entry.n === "catalogAlbum"} {:else if entry.n === "playlist"} diff --git a/src/mobile/disco.js b/src/mobile/disco.js index 0c39257..e43ec3f 100644 --- a/src/mobile/disco.js +++ b/src/mobile/disco.js @@ -92,3 +92,61 @@ export const SOCIAL_LABEL = { youtube: "YouTube", instagram: "Instagram", twitter: "X", x: "X", facebook: "Facebook", tiktok: "TikTok", spotify: "Spotify", apple: "Apple Music", discogs: "Discogs", lastfm: "Last.fm", musicbrainz: "MusicBrainz", }; + +/* ── the whole discography, on a page of its own ───────────────────────── + The artist page's tabs are one lane per category; the discography page is + the same filing seen whole. Both read ONE classifier, and it is this one. + + The desktop cannot do that: CatalogDiscography.svelte takes the artist + page's already-computed tab list as the same array object, because the + desktop's filing depends on a background description harvest and on + deferred MusicBrainz type hints that a re-run would not read + (CatalogDiscography.svelte:28-46). `discoTabs` here is pure and reads only + what the catalog page carries, so the phone simply calls it twice over the + same cached albums and the two pages can never disagree. */ + +/** "All" in front of the categories — built by CONCATENATING the per-category + * families, never by re-folding the whole set. A single that shares an + * album's base title (Pond released "Terrestrials" in 2026 as an album AND + * as a single) would fold into it as a phantom edition, and one of the two + * would vanish from its own tab. */ +export function allTabs(tabs) { + const ts = tabs || []; + if (!ts.length) return []; + return [{ key: "all", label: "All", items: ts.flatMap((t) => t.items) }, ...ts]; +} + +/** The four orders the page offers, and what each is called. The sort sheet + * in HalfSheet.svelte writes the same four out inline — it is edited by + * every other round and a new import there is a collision waiting to + * happen — so if a label changes it changes in both places. */ +export const ORDER_LABEL = { new: "Newest first", old: "Oldest first", az: "A–Z", za: "Z–A" }; + +/** The desktop's sort (CatalogDiscography.svelte:71-78): dates on + * `release_date`, names on the title, either direction. */ +export function orderFamilies(items, order) { + const list = [...(items || [])]; + const byName = order === "az" || order === "za"; + list.sort((a, b) => (byName + ? (a.title ?? "").localeCompare(b.title ?? "") + : (a.release_date ?? "").localeCompare(b.release_date ?? ""))); + return order === "new" || order === "za" ? list.reverse() : list; +} + +/** [{ key: "2001", items }] — the year headers, cut out of the list in the + * order it is already in rather than re-sorted, which is why a name order + * must not be grouped: the years would repeat out of sequence. `on` false + * gives the whole list back under one null key, so the caller renders one + * loop either way. */ +export function byYear(items, on) { + const list = items || []; + if (!on) return list.length ? [{ key: null, items: list }] : []; + const out = []; + for (const r of list) { + const y = String(r.release_date || "").slice(0, 4) || "—"; + const last = out[out.length - 1]; + if (last && last.key === y) last.items.push(r); + else out.push({ key: y, items: [r] }); + } + return out; +} diff --git a/src/mobile/screens/Artist.svelte b/src/mobile/screens/Artist.svelte index 19d6ece..da140f2 100644 --- a/src/mobile/screens/Artist.svelte +++ b/src/mobile/screens/Artist.svelte @@ -128,7 +128,13 @@ else if (!ts.some((t) => t.key === tab)) tab = albumsFirst(); }); }); - const families = $derived(tabs.find((t) => t.key === tab)?.items ?? []); + /* the grid is a SLICE — the whole tab lives on the discography page, + which the head's "All" and the link under the grid both open. Before + this an artist with 140 releases rendered 140 cards here. */ + const GRID_CAP = 12; + const activeTab = $derived(tabs.find((t) => t.key === tab) ?? null); + const families = $derived((activeTab?.items ?? []).slice(0, GRID_CAP)); + const tabCount = $derived(activeTab?.items.length ?? 0); const inLib = (a) => albums.find((x) => looseTitle(x.title) === looseTitle(a.title)) || null; const familyLib = (f) => (f.editions || [f]).map(inLib).find(Boolean) || null; const familyKeyOf = (f) => f.familyKey ?? f.key ?? f.id; @@ -284,7 +290,10 @@ {/if} {#if tabs.length} -

Discography · {releasedCount} on the catalog

+
+

Discography · {releasedCount} on the catalog

+ +
{#if tabs.length > 1}
{#each tabs as t (t.key)} @@ -306,6 +315,9 @@ /> {/each}
+ {#if tabCount > GRID_CAP} + + {/if} {/if} {#if timeline} diff --git a/src/mobile/screens/Discography.svelte b/src/mobile/screens/Discography.svelte new file mode 100644 index 0000000..ff876b5 --- /dev/null +++ b/src/mobile/screens/Discography.svelte @@ -0,0 +1,196 @@ + + +
+ + {#if porSrc}{:else}{/if} + +
+
{shownName}
+
{items.length} release{items.length === 1 ? "" : "s"} · {someUnknown ? "≥" : ""}{known} tracks
+
+
+ +{#if tabs.length > 1} +
+ {#each tabs as t (t.key)} + + {/each} +
+{/if} + +{#if items.length} +
+

{active?.label ?? "All"} · {items.length} release{items.length === 1 ? "" : "s"}

+ +
+ {#each groups as g (g.key ?? "_all")} + {#if g.key} +
{g.key} · {g.items.length} release{g.items.length === 1 ? "" : "s"}
+ {/if} + {#each g.items as f (familyKeyOf(f))} + {@const l = familyLib(f)} + {@const key = familyKeyOf(f)} + {@const eds = (f.editions?.length ?? 1) > 1 ? f.editions : null} +
+ + {#if eds} + + {#if openEds.has(key)} +
+ {#each eds as e, i (e.id)} + + {/each} +
+ {/if} + {/if} +
+ {/each} + {/each} + {#if items.length > cap} +
+ {/if} +{:else if !cat || cat.status === "loading"} +
Asking the catalog…
+{:else} +
Nothing to list.The catalog has no releases for {shownName}{cat.status === "error" ? " — it did not answer" : ""}.
+{/if} + +
diff --git a/src/mobile/shell.css b/src/mobile/shell.css index bc37189..b7adf7e 100644 --- a/src/mobile/shell.css +++ b/src/mobile/shell.css @@ -1613,3 +1613,51 @@ html[data-motion="off"] *, html[data-motion="off"] *::before, html[data-motion=" } /* why the rows will not move while the filter has something in it */ .pl-note { padding: 2px var(--pad) 8px; font-size: 12px; color: var(--text-muted); } + +/* ── the whole discography ─────────────────────────────────────────────── + The artist page's "All". Nearly all of it is the app's own parts — .sec, + .segtabs, .row, .eds/.ed, .more-link, .btn — so what is here is the head, + the year written into the list where the desktop has a sticky rail, the + editions line under a record, and the editions themselves widened out of + the record page's 210px rail into the width this list already has. */ +.dg-head { display: flex; align-items: center; gap: 12px; padding: 12px var(--pad) 6px; } +.dg-head .b { flex: 1; min-width: 0; } +.dg-head .t { font-size: 18px; font-weight: 800; letter-spacing: -0.2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.dg-head .m { font-size: 12.5px; color: var(--text-muted); margin-top: 2px; } +.dg-por { + width: 44px; height: 44px; + border-radius: 50%; + overflow: hidden; + flex: none; + background: var(--surface); + box-shadow: var(--ring); + display: grid; + place-items: center; + color: var(--text-muted); +} +.dg-por img { width: 100%; height: 100%; object-fit: cover; } +.dg-sec { padding-top: 12px; } +/* the year. A phone list is read top to bottom, so the span the desktop's + rail jumps to is simply written into it. */ +.dg-group { + padding: 16px var(--pad) 5px; + font-size: 12px; + font-weight: 800; + letter-spacing: 0.4px; + color: var(--text-secondary); + font-variant-numeric: tabular-nums; +} +.dg-group em { font-style: normal; font-weight: 650; letter-spacing: 0; color: var(--text-muted); } +/* both of these line up under the row's TITLE, not under its cover: the + pad, the 46px cover and the row's 12px gap */ +.dg-eline { + display: flex; + align-items: center; + gap: 6px; + padding: 0 var(--pad) 9px calc(var(--pad) + 58px); + font-size: 12px; + font-weight: 750; + color: var(--accent-hover); +} +.dg-eds { display: flex; flex-direction: column; gap: 6px; padding: 0 var(--pad) 12px calc(var(--pad) + 58px); } +.dg-eds .ed { width: 100%; } diff --git a/tests/mobile/smoke.mjs b/tests/mobile/smoke.mjs index bd57675..3c5f9cf 100644 --- a/tests/mobile/smoke.mjs +++ b/tests/mobile/smoke.mjs @@ -355,6 +355,19 @@ const PROBE = `(function () { syEst: (document.querySelector('.sy-est') || {}).textContent || '', syHint: (document.querySelector('.sy-hint') || {}).textContent || '', disco: document.querySelectorAll('.grid2 .release').length, + /* the whole-discography page: its rows, its tabs (All in front), the + year headers, an opened edition rail, the cap's own button, the + first row's title, the order the head is showing and the summary + line — plus, back on the artist page, the link down to it */ + dgRows: document.querySelectorAll('.dg-row').length, + dgTabs: document.querySelectorAll('.dg-tabs button').length, + dgGroups: document.querySelectorAll('.dg-group').length, + dgEds: document.querySelectorAll('.dg-eds .ed').length, + dgMore: (document.querySelector('.dg-more') || {}).textContent || '', + dgFirst: (document.querySelector('.dg-row .t') || {}).textContent || '', + dgOrder: (document.querySelector('.dg-sec .all') || {}).textContent || '', + dgSum: (document.querySelector('.dg-head .m') || {}).textContent || '', + dgAll: (document.querySelector('.more-link.dg-all') || {}).textContent || '', /* the artist page's three lanes at once — the page, the hero seed and the overview — so a step can say what painted and what had not landed yet: ":[+art]::" */ @@ -587,6 +600,27 @@ const MOCK_STEPS = [ { do: `__click('.top button', 'Open')`, name: "cat-artist", wait: 2200 }, { do: `__click('.stats .fb', 'Follow')`, name: "cat-artist-follow", wait: 900 }, { do: `__click('.disco-tabs button', 'Singles')`, name: "cat-artist-tab", wait: 700 }, + /* the whole discography, the page the artist page's "All" opens: the + eleven mock rows folded to ten families across six categories, the + year headers, a category tab, the one linked family opened in place, + and the sort sheet taking it to a name order — which is where the + grouping has to fall away */ + { do: `__click('.sec .all', 'All')`, name: "disco-all", wait: 1400 }, + { do: `__click('.dg-tabs button', 'Albums')`, name: "disco-albums", wait: 900 }, + { do: `__click('.dg-tabs button', 'All')`, wait: 800 }, + { do: `__click('.dg-eline', null)`, name: "disco-editions", wait: 800 }, + { do: `__click('.dg-sec .all', null)`, name: "disco-sheet", wait: 900 }, + { do: `__click('.halfsheet .chip', 'A–Z')`, name: "disco-az", wait: 900 }, + { do: `document.querySelector('.scrim').click()`, wait: 700 }, + { do: `history.back()`, wait: 800 }, + /* and the cap, on an artist with 140 records: the grid on the artist + page is a slice with the way down under it, the page itself opens on + the tab that link was showing, and sixty rows extend to all of them */ + { do: `__ss.push('artist', { name: 'Ember Parade', seed: { id: 416, name: 'Ember Parade', picture: null, album_count: 140, fans: 91000 } })`, name: "disco-many-artist", wait: 1800 }, + { do: `__click('.more-link.dg-all', 'Show all')`, name: "disco-many", wait: 1500 }, + { do: `__click('.dg-more', null)`, name: "disco-many-all", wait: 1400 }, + { do: `history.back()`, wait: 800 }, + { do: `history.back()`, wait: 900 }, /* the artist's photo: the page's ⋯ → a picture from this phone becomes the hero, the page is asked again, then the catalog's own come back */ { do: `document.querySelectorAll('.gbar .iconbtn')[1].click()`, name: "artist-more", wait: 700 }, @@ -1171,6 +1205,26 @@ const MOCK_EXPECT = { "cat-artist": (i) => i.screen.includes("has-hero") && i.title.startsWith("Daft Punk") && i.tabs >= 4 && i.pop >= 5 && i.socs === 2, "cat-artist-follow": (i) => i.fb.includes("Following"), "cat-artist-tab": (i) => i.title.startsWith("Daft Punk") && i.cardImgs === 2 && i.cardPhs === 2, + /* the whole discography: All in front of the six categories the eleven + mock rows file into, the ten families they fold to, a year header per + year, the desktop's own summary line and the order the head shows. + Two of those rows are dated relative to today (dayIso(-9), dayIso(21)), + so a run across a New Year splits their year in two — hence the range + rather than a number. */ + "disco-all": (i) => i.dgTabs === 7 && i.dgRows === 10 && i.dgGroups >= 8 && i.dgGroups <= 9 + && /^10 releases/.test(i.dgSum) && /Newest/.test(i.dgOrder) + && !i.a11yAnon && !i.a11yBad && !i.a11yGhost && !i.a11yState, + "disco-albums": (i) => i.dgRows === 4 && i.dgGroups === 4, + /* Discovery and its Deluxe — the one edition_group in the mock, and the + thing a flat list of releases loses */ + "disco-editions": (i) => i.dgEds === 2 && i.dgRows === 10, + "disco-sheet": (i) => i.half.includes("up") && /By year/.test(i.halfText), + /* the rule this page has to get right: a name order cannot be grouped by + year, so the grouping falls away with it */ + "disco-az": (i) => i.dgFirst === "Alive 1997" && i.dgGroups === 0 && /A–Z/.test(i.dgOrder), + "disco-many-artist": (i) => i.title.startsWith("Ember Parade") && i.disco === 12 && /Show all 140/.test(i.dgAll), + "disco-many": (i) => i.dgRows === 60 && /\+ 80 more/.test(i.dgMore), + "disco-many-all": (i) => i.dgRows === 140 && !i.dgMore, "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), @@ -1536,6 +1590,20 @@ const PHONE_STEPS = [ same no-op as the button. Nothing is typed and nothing is picked. */ { do: `__ss.openSetup('preview')`, name: "setup-preview", wait: 1300 }, { do: `document.querySelector('.scrim').click()`, name: "setup-closed", wait: 900 }, + /* the whole discography on the real core, on the same artist the walk + already opened above — so the catalog store is warm and the page has + its releases the moment it is pushed. It reads and it sorts; nothing + is downloaded, played, joined, sent, signed in to or written. */ + { do: `document.querySelectorAll('.nav button')[2].click()`, wait: 600 }, + { do: `document.querySelectorAll('.segtabs button')[1].click()`, wait: 600 }, + { do: `__click('.screen button.row', null)`, wait: 500 }, + { wait: 2500 }, + { do: `__click('.sec .all', 'All')`, name: "dg-page", wait: 1600 }, + { do: `document.querySelectorAll('.gbar .iconbtn')[1].click()`, name: "dg-sheet", wait: 1000 }, + { do: `__click('.halfsheet .chip', 'A–Z')`, name: "dg-az", wait: 1000 }, + { do: `document.querySelector('.scrim').click()`, wait: 700 }, + { do: `history.back()`, wait: 800 }, + { do: `history.back()`, wait: 900 }, { do: `document.querySelectorAll('.nav button')[0].click()`, name: "end", wait: 800 }, ]; const PHONE_EXPECT = { @@ -1661,6 +1729,14 @@ const PHONE_EXPECT = { "export-door": (i) => i.half.includes("up") && /M3U8/.test(i.halfText) && /Backup \(JSON\)/.test(i.halfText), "export-picker": (i) => !i.half.includes("up") && !i.toast, "export-dismissed": (i) => i.title.startsWith("Walk test") && !i.half.includes("up") && !i.toast && !i.notice, + /* the real core answers faster than the mock does, and it answers with + Kyle's own artists, so none of this asserts a count, a title or a + state with a window shorter than the still: only that the page is + there, listing something, in the order the sheet was just told to + use, and that nothing went wrong saying so */ + "dg-page": (i) => i.dgTabs >= 1 && i.dgRows >= 1 && !i.toast && !i.a11yAnon && !i.a11yBad && !i.a11yGhost && !i.a11yState, + "dg-sheet": (i) => i.half.includes("up") && /By year/.test(i.halfText) && !i.toast, + "dg-az": (i) => i.dgRows >= 1 && /A–Z/.test(i.dgOrder) && !i.toast, end: (i) => /^(Good |Still)/.test(i.title), };