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.
197 lines
11 KiB
Plaintext
197 lines
11 KiB
Plaintext
<script>
|
|
/* The whole discography — the desktop's CatalogDiscography.svelte, which
|
|
the artist page reaches with "All" on its Discography head.
|
|
|
|
The desktop's page is a stack of album-page heroes — the album page's
|
|
own hero at `--cover-scale: 0.45`, a 108px cover, with the record's
|
|
whole tracklist under it, fetched as the section scrolls near and its
|
|
accent sampled from its own cover (CatalogDiscography.svelte:120-175,
|
|
1555-1560). On a 411px screen that is one record per screenful and one
|
|
`catalog_album` per row over mobile data, for rows that are a tap away
|
|
on the record page — so none of those three comes over. What does is
|
|
the four things the page has that the artist page's tabs do not: All,
|
|
a sort, a grouping, and the editions kept whole.
|
|
|
|
Filed by the same classifier the artist page uses. The state — tab,
|
|
order, grouping — lives on the nav entry, so it survives a push and
|
|
the sort sheet's `patch()` reaches it the way Library's does. */
|
|
import { untrack } from "svelte";
|
|
import Icon from "../../components/Icon.svelte";
|
|
import Cover from "../Cover.svelte";
|
|
import { art, wantArt } from "../art.svelte.js";
|
|
import { albumsBy } from "../lib.svelte.js";
|
|
import { push, patch, openHalf } from "../nav.svelte.js";
|
|
import { catalog, lookupArtist, artistKey, yearOf, looseTitle, picture } from "../catalog.svelte.js";
|
|
import { discoTabs, allTabs, orderFamilies, byYear, ORDER_LABEL } from "../disco.js";
|
|
import { CAT_ONE } from "../../lib/localReleaseType.js";
|
|
import { editionLabel } from "../../lib/editions.js";
|
|
|
|
let { entry, screenEl = null } = $props();
|
|
|
|
const name = $derived(entry.c.name);
|
|
/* normally warm — the artist page that linked here looked the name up
|
|
already — but a page that only works warm is a page that breaks the
|
|
day something else links to it. `lookupArtist` is its own guard. */
|
|
$effect(() => { const n = name, sd = entry.c.seed ?? null; untrack(() => lookupArtist(n, sd)); });
|
|
|
|
const cat = $derived(catalog.artists[artistKey(name)] ?? null);
|
|
const page = $derived(cat?.page ?? null);
|
|
const artistId = $derived(page?.artist?.id ?? cat?.id ?? null);
|
|
const overview = $derived(artistId != null ? catalog.overviews[artistId] ?? null : null);
|
|
const shownName = $derived(page?.artist?.name || overview?.artist?.name || name);
|
|
/* the exhaustive page when it is here, the overview's first round until
|
|
then — never the other way round, the artist page's own rule */
|
|
const discoAll = $derived(page?.albums ?? overview?.albums ?? []);
|
|
|
|
/* ── the tabs, the sort, the grouping ────────────────────────────── */
|
|
const catTabs = $derived(discoTabs(discoAll));
|
|
const tabs = $derived(allTabs(catTabs));
|
|
const tab = $derived(tabs.some((t) => t.key === entry.c.tab) ? entry.c.tab : (tabs[0]?.key ?? "all"));
|
|
const order = $derived(entry.c.order || "new");
|
|
/* Under a name sort the years would repeat out of order, so By year is
|
|
not offered there and the grouping falls to none. The sheet says why. */
|
|
const byName = $derived(order === "az" || order === "za");
|
|
const grouped = $derived(!byName && (entry.c.group ?? "year") === "year");
|
|
|
|
const active = $derived(tabs.find((t) => t.key === tab) ?? null);
|
|
const items = $derived(orderFamilies(active?.items ?? [], order));
|
|
/* the desktop's header line: a record with no track count makes the
|
|
whole figure a floor, not a number */
|
|
const known = $derived(items.reduce((n, r) => n + (r.track_count || 0), 0));
|
|
const someUnknown = $derived(items.some((r) => r.track_count == null));
|
|
/* which category each family was filed under, read off the per-category
|
|
tabs rather than by asking the classifier again once per row */
|
|
const kinds = $derived.by(() => {
|
|
const m = new Map();
|
|
for (const t of catTabs) for (const f of t.items) m.set(f.familyKey ?? f.key ?? f.id, CAT_ONE[t.key] ?? t.label);
|
|
return m;
|
|
});
|
|
|
|
/* the cap. A prolific artist is hundreds of rows and the phone renders
|
|
every one of them if it is asked to — Browse's rule (Browse.svelte:51):
|
|
a slice that extends on demand, reset whenever the list underneath it
|
|
changes. It is taken BEFORE the grouping, so the last year on screen
|
|
can be a part of itself and the button says how much is left. */
|
|
const CAP = 60;
|
|
let cap = $state(CAP);
|
|
$effect(() => { void tab; void order; void grouped; cap = CAP; });
|
|
const groups = $derived(byYear(items.slice(0, cap), grouped));
|
|
|
|
/* ── what this phone already holds ───────────────────────────────── */
|
|
const albums = $derived(albumsBy(name));
|
|
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;
|
|
const subOf = (f, l) => [
|
|
yearOf(f.release_date),
|
|
kinds.get(familyKeyOf(f)) || "",
|
|
f.track_count ? `${f.track_count} tracks` : "",
|
|
l ? "in your library" : "",
|
|
].filter(Boolean).join(" · ");
|
|
|
|
const picUrl = $derived(picture(page?.artist?.picture) || picture(cat?.seed?.picture) || null);
|
|
const seedPath = $derived(albums.find((a) => a.artist_art_path)?.artist_art_path || albums[0]?.art_path || null);
|
|
$effect(() => { if (seedPath) wantArt(seedPath); });
|
|
const porSrc = $derived(picUrl || (seedPath ? art[seedPath] : "") || "");
|
|
|
|
/* ── opening things ──────────────────────────────────────────────── */
|
|
function openRow(r) {
|
|
const l = inLib(r);
|
|
if (l) push("album", { id: l.id }, screenEl);
|
|
else push("catalogAlbum", { id: r.id, artist: shownName, title: r.title }, screenEl);
|
|
}
|
|
function openFamily(f) {
|
|
const l = familyLib(f);
|
|
if (l) push("album", { id: l.id }, screenEl);
|
|
else push("catalogAlbum", { id: f.id, artist: shownName, title: f.title }, screenEl);
|
|
}
|
|
/* a long press is the record sheet the grid's already opens — Play, Save,
|
|
Add to playlist, Go to record, Go to artist. No new actions here. */
|
|
const moreFor = (f) => openHalf("catalogAlbum", { album: { ...(f.editions?.[0] ?? f), artist: shownName } });
|
|
|
|
/* the editions opened in place, under their own record — Browse's folder
|
|
and its files, rather than a rail that scrolls sideways inside a list
|
|
that scrolls down */
|
|
let openEds = $state(new Set());
|
|
function toggleEds(key) {
|
|
const s = new Set(openEds);
|
|
if (s.has(key)) s.delete(key); else s.add(key);
|
|
openEds = s;
|
|
}
|
|
</script>
|
|
|
|
<div class="dg-head">
|
|
<span class="dg-por">
|
|
{#if porSrc}<img src={porSrc} alt="" />{:else}<Icon name="users" size={22} />{/if}
|
|
</span>
|
|
<div class="b">
|
|
<div class="t">{shownName}</div>
|
|
<div class="m">{items.length} release{items.length === 1 ? "" : "s"} · {someUnknown ? "≥" : ""}{known} tracks</div>
|
|
</div>
|
|
</div>
|
|
|
|
{#if tabs.length > 1}
|
|
<div class="segtabs dg-tabs" role="tablist" aria-label="Discography">
|
|
{#each tabs as t (t.key)}
|
|
<button class:on={tab === t.key} onclick={() => patch({ tab: t.key })} role="tab" aria-selected={tab === t.key} aria-label={t.label + ", " + t.items.length}>{t.label}<span class="count" aria-hidden="true">{t.items.length}</span></button>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
|
|
{#if items.length}
|
|
<div class="sec dg-sec">
|
|
<h2>{active?.label ?? "All"} <em>· {items.length} release{items.length === 1 ? "" : "s"}</em></h2>
|
|
<button class="all" onclick={() => openHalf("discosort")} aria-label="Sort and group">{ORDER_LABEL[order] ?? ORDER_LABEL.new}<Icon name="chevron-down" size={14} /></button>
|
|
</div>
|
|
{#each groups as g (g.key ?? "_all")}
|
|
{#if g.key}
|
|
<div class="dg-group">{g.key} <em>· {g.items.length} release{g.items.length === 1 ? "" : "s"}</em></div>
|
|
{/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}
|
|
<div class="dg-item">
|
|
<button class="row dg-row" onclick={() => openFamily(f)} oncontextmenu={(e) => { e.preventDefault(); moreFor(f); }}>
|
|
<span class="art">
|
|
{#if f.cover}<img src={f.cover} alt="" loading="lazy" />
|
|
{:else if l?.art_path}<Cover path={l.art_path} size={20} />
|
|
{:else}<Icon name="disc" size={20} />{/if}
|
|
</span>
|
|
<span class="b">
|
|
<span class="t">{f.title}</span>
|
|
<span class="m">{subOf(f, l)}</span>
|
|
</span>
|
|
</button>
|
|
{#if eds}
|
|
<button class="dg-eline" onclick={() => toggleEds(key)} aria-expanded={openEds.has(key)}>
|
|
<Icon name={openEds.has(key) ? "chevron-up" : "chevron-down"} size={13} />{openEds.has(key) ? "Hide editions" : `${eds.length} editions`}
|
|
</button>
|
|
{#if openEds.has(key)}
|
|
<div class="eds dg-eds">
|
|
{#each eds as e, i (e.id)}
|
|
<button class="ed" onclick={() => openRow(e)}>
|
|
<span class="ed-art">{#if e.cover}<img src={e.cover} alt="" loading="lazy" />{:else}<Icon name="disc" size={16} />{/if}</span>
|
|
<span class="ed-copy">
|
|
<span class="n">{i === 0 ? "Original" : editionLabel(e, f)}</span>
|
|
<span class="y">{[yearOf(e.release_date), e.track_count ? `${e.track_count} tracks` : ""].filter(Boolean).join(" · ")}</span>
|
|
</span>
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
{/if}
|
|
</div>
|
|
{/each}
|
|
{/each}
|
|
{#if items.length > cap}
|
|
<div style="padding: 10px var(--pad) 4px"><button class="btn btn-ghost wide dg-more" onclick={() => (cap += 120)}>+ {items.length - cap} more releases</button></div>
|
|
{/if}
|
|
{:else if !cat || cat.status === "loading"}
|
|
<div class="sec-note" style="padding-top: 16px">Asking the catalog…</div>
|
|
{:else}
|
|
<div class="stub"><b>Nothing to list.</b>The catalog has no releases for {shownName}{cat.status === "error" ? " — it did not answer" : ""}.</div>
|
|
{/if}
|
|
|
|
<div class="endpad"></div>
|