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")}
+
+
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}
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),
};