Files
SoulStream/src/mobile/screens/Album.svelte
T
KeelrLeek 4fc6e9c630 android: what a screen reader says — names, roles, and the sheets out of the tree when they are down
Every control on the phone has a name now, in the app's own words, and the
desktop's word where it already has one; the nav is a tab set whose badge
count is part of the tab's name — "You, 3 transfers" rather than a stray 3
read out before it. The two sheets are dialogs with somewhere sensible for
the focus to land, and the half sheet's grab handle is finally a real
Close — the first dismissal a screen reader could reach, because the scrim
never was one. A sheet that is down is inert rather than sitting
off-screen in the accessibility tree with the hidden mini bar and the nav
under it, and one live region is mounted for the life of the app so that
an undo offer is actually announced; switches say on or off, chips say
pressed, and the waveform is a slider where it can seek. Nothing visible
changed. The walk counts the controls with no name, with a junk name, in a
hidden layer, or with unsaid state — all four held at zero on ten screens,
and the nameless count on a room and on both prompts besides, which is
where the six fields named here actually live.
2026-09-14 00:28:28 -05:00

99 lines
5.2 KiB
Svelte

<script>
/* A record. Stacked and centred, the cover as the page's own hero, the
track list under it, the artist's other records at the foot. The
page wears the record's colour (Mobile.svelte reads its art path). */
import Icon from "../../components/Icon.svelte";
import Cover from "../Cover.svelte";
import Release from "../Release.svelte";
import { lib, albumById, albumsBy, tracksOf } from "../lib.svelte.js";
import { player, playAlbum, playAlbumShuffled } from "../player.svelte.js";
import { push, openHalf, openViewer } from "../nav.svelte.js";
import { mmss, longDur, albumFmt, fmtBytes } from "../util.js";
let { id, screenEl = null } = $props();
const album = $derived(albumById(id));
let tracks = $state([]);
$effect(() => {
const a = album;
if (!a) return;
tracksOf(a.id).then((t) => { tracks = t || []; }, (e) => { player.error = String(e); });
});
const discs = $derived.by(() => {
const m = new Map();
for (const t of tracks) {
const d = t.disc_no ?? 1;
if (!m.has(d)) m.set(d, []);
m.get(d).push(t);
}
return [...m.entries()].sort((a, b) => a[0] - b[0]);
});
const fmt = $derived(albumFmt(tracks));
const total = $derived(tracks.reduce((n, t) => n + (t.duration_secs || 0), 0));
const more = $derived(album ? albumsBy(album.artist).filter((x) => x.id !== album.id) : []);
const various = $derived(tracks.some((t) => t.artist && album && t.artist !== album.artist));
function playAt(t) { playAlbum(album, tracks, Math.max(0, tracks.indexOf(t))); }
function isPlaying(t) { return player.track?.path === t.path; }
</script>
{#if album}
<div class="rec-head">
<div class="rec-cover" onclick={() => openViewer(album.art_path)} role="button" tabindex="0" onkeydown={() => {}} aria-label={"View album artwork — " + album.title}><Cover path={album.art_path} eager size={48} /></div>
<div class="rec-meta">
<div class="rec-title">{album.title}</div>
<button class="rec-artist" onclick={() => push("artist", { name: album.artist }, screenEl)}>{album.artist}</button>
<div class="rec-line">{album.year ?? "—"} · {tracks.length || album.track_count} tracks{total ? ` · ${longDur(total)}` : ""}</div>
{#if fmt}
<div class="stats rec-chips">
<span class="s"><Icon name="disc" size={12} strokeWidth={3} />{fmt}</span>
<span class="s"><Icon name="hard-drive" size={12} strokeWidth={3} />{lib.stats[album.id]?.bytes ? fmtBytes(lib.stats[album.id].bytes) : "in your library"}</span>
{#if lib.stats[album.id]?.avg_kbps}<span class="s">{lib.stats[album.id].avg_kbps} kbps</span>{/if}
</div>
{/if}
<div class="rec-acts">
<button class="iconbtn muted" onclick={() => playAlbumShuffled(album, tracks)} aria-label="Shuffle"><Icon name="shuffle" size={22} /></button>
<button class="disc lg" onclick={() => tracks.length && playAt(tracks[0])} aria-label="Play"><Icon name="play" size={26} /></button>
<button class="iconbtn muted" onclick={() => openHalf("album", { album, tracks })} aria-label="More"><Icon name="more" size={22} /></button>
</div>
</div>
</div>
{#each discs as [d, list] (d)}
{#if discs.length > 1}<div class="disc-head">Disc {d}</div>{/if}
<div class="tracks">
{#each list as t, i (t.id)}
<button class="trk" class:playing={isPlaying(t)} onclick={() => playAt(t)} oncontextmenu={(e) => { e.preventDefault(); openHalf("track", { album, track: t }); }} aria-label={(t.track_no ?? i + 1) + ". " + t.title + (various && t.artist ? " · " + t.artist : "")}>
<span class="n">{t.track_no ?? i + 1}</span>
<span class="b">
<span class="t">{t.title}</span>
{#if various && t.artist}<span class="m">{t.artist}</span>{/if}
</span>
<span class="d">{mmss(t.duration_secs)}</span>
<span class="r" role="button" tabindex="0" aria-label={"More for " + t.title} onkeydown={() => {}}
onclick={(e) => { e.stopPropagation(); openHalf("track", { album, track: t }); }}>
<Icon name="more" size={17} />
</span>
</button>
{/each}
</div>
{/each}
{#if more.length}
<div class="sec">
<h2>More by {album.artist}</h2>
<button class="all" onclick={() => push("artist", { name: album.artist }, screenEl)}>All<Icon name="chevron-right" size={14} /></button>
</div>
<div class="rail">
{#each more as x (x.id)}
<Release title={x.title} sub={String(x.year || "")} path={x.art_path} onclick={() => push("album", { id: x.id }, screenEl)} />
{/each}
</div>
{/if}
{:else}
<div class="stub" style="margin-top: calc(var(--bar-h) + 10px)"><b>That record is gone.</b>It is no longer in the library.</div>
{/if}
<div class="endpad"></div>