The bar was anonymous until you scrolled, then faded in carrying the title the page already had in 27px type two centimetres below it. Kyle's rule for what replaces it: the page is cut off where it meets the Android status bar and dissolves there, and — the part that decides the whole shape of this — **backgrounds may pass under the clock, words may not**. A mask on the scroller cannot tell a photograph from a sentence. It takes both. So the artist hero is painted twice: the page's own copy inside the masked scroller, cut away in that band with everything else, and a second copy behind the scroller (heroart.svelte.js), unmasked, showing through exactly where the mask took the first one away. Same image, same box, same focal point, same scale, offset by the same scroll — one picture to the eye, and only the name dissolves. The second copy carries `overflow: hidden` for the same reason .hero does: without it the 1.04 scale renders 268px in a 258px box and five pixels of photograph appear under the hero's foot, below the black the scrim fades to. That also retires an attempt that did not survive contact. The mask first stepped aside for the hero on a scroll-driven `--fade-on`, which meant rebuilding a mask-image on the scroller every frame; measured at 60 Hz median and p90 either way, but it is work for nothing, and the flip that replaced it put the artist's name out of existence in one step. With the photograph protected by being OUTSIDE the mask, neither is needed: the mask is a constant and nothing recomputes it. No page has a bar now. A tab page's ⋯ — which is the app's own menu, the queue and the sleep timer and Settings — moved beside that page's own large title in `.phead`; Search and You grew the row they did not have, Community kept the one it had. A pushed page floats a back chevron and a ⋯ over the page instead, on flat discs with no backdrop blur, because the bar's own note records what three blurred bands did to a 60 Hz scroll on the Pixel 6a and these would have been a fourth. Three numbers now come from one place. `--chrome-d` and `--chrome-gap` are the disc and its margin; `.screen.has-chrome` reserves exactly that much at the top of a pushed page, and `--fade-top` reaches exactly the chrome's foot. They were independent for one build and it showed immediately: the page began underneath its own buttons, so "Transfers" sat behind the back chevron before you had scrolled at all, and the fade stopped 17px short of the discs so they stood on crisp text and a record's floating ⋯ landed on a track row's own ⋯. The walk followed. Nineteen steps reached a page's overflow through `.gbar .iconbtn`, which now matches nothing: the four on a tab page go to `.phead .iconbtn.muted`, the rest to `.fchrome.more`, and the album's back chevron to `.fchrome.back`. The `title` field read the bar's own text, which is a worse source than it looks — it only existed when chrome happened to be drawn. The screen carries its meta as data attributes now and the three probes read those, spelled exactly as `.gb-t`'s textContent was (the title, then the kicker when it differs) so every expectation written against it still holds. Checked on the device rather than argued: no `.gbar` and no `.gb-t` anywhere, `.phead .iconbtn.muted` present on all four tab pages and opening "SoulStream · Queue · Sleep timer · Settings", both `.fchrome` controls present on a record, and the title probe answering "Random Access MemoriesDaft Punk". One thing this leaves, and it is a real cost rather than an oversight: a tab page's ⋯ scrolls away with its header. The bar used to carry it the whole way down, so on Home the sleep timer now wants a scroll back to the top.
479 lines
26 KiB
Svelte
479 lines
26 KiB
Svelte
<script>
|
||
/* SoulStream on a phone — the shell.
|
||
|
||
research/mobile-mockup's phone layout, wired to the real Rust core
|
||
over the same IPC the desktop uses: the library from library.db,
|
||
covers from the thumbnail cache, playback on the audio thread, the
|
||
lane from the app's own waveform decode. The page ground is the
|
||
artwork in play, blurred; the accent follows it, per screen (a record
|
||
page wears the record's colour). Five destinations; Home and Library
|
||
are real, Search and Community say what they will be. */
|
||
import { onMount, tick, untrack } from "svelte";
|
||
import * as api from "../lib/api.js";
|
||
import Icon from "../components/Icon.svelte";
|
||
import Lane from "./Lane.svelte";
|
||
import Cover from "./Cover.svelte";
|
||
import Sheet from "./Sheet.svelte";
|
||
import HalfSheet from "./HalfSheet.svelte";
|
||
import Viewer from "./Viewer.svelte";
|
||
import { heroArt } from "./heroart.svelte.js";
|
||
import SyncRoom from "./SyncRoom.svelte";
|
||
import Home from "./screens/Home.svelte";
|
||
import Search from "./screens/Search.svelte";
|
||
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";
|
||
import Browse from "./screens/Browse.svelte";
|
||
import Peer from "./screens/Peer.svelte";
|
||
import Community from "./screens/Community.svelte";
|
||
import Room from "./screens/Room.svelte";
|
||
import Thread from "./screens/Thread.svelte";
|
||
import You from "./screens/You.svelte";
|
||
import Transfers from "./screens/Transfers.svelte";
|
||
import Diagnostics from "./screens/Diagnostics.svelte";
|
||
import { transfers, bindTransfers, active as activeTransfers } from "./transfers.svelte.js";
|
||
import { xfernote, syncShade, bindXferNote } from "./xfernote.svelte.js";
|
||
import { home, bindHome, loadHome } from "./home.svelte.js";
|
||
import { lyr, loadLyrics } from "./lyrics.svelte.js";
|
||
import Stub from "./screens/Stub.svelte";
|
||
import { lib, loadLibrary, refreshRecents, albumById, albumsBy, watchLibrary } from "./lib.svelte.js";
|
||
import { art, artTally, wantArt, accentFor, applyAccent } from "./art.svelte.js";
|
||
import { player, hooks, bindEvents, restoreSession, next, prev, toggle, seekSecs, playQueueAt } from "./player.svelte.js";
|
||
import { nav, TABS, top, isRoot, bootNav, go, push, back, patch, openSheet, closeSheet, openHalf, closeHalf } from "./nav.svelte.js";
|
||
import { loadPrefs } from "./prefs.svelte.js";
|
||
import { setup, loadSetup, maybeOpenFirstRun, openSetup, finishSetup, setupState } from "./setup.svelte.js";
|
||
import { catalog, artistKey } from "./catalog.svelte.js";
|
||
import { bindMedia, syncMedia } from "./media.svelte.js";
|
||
import { slsk, bindSlsk, connectStored, refreshRooms, unreadTotal } from "./slsk.svelte.js";
|
||
import { net, bindNet } from "./net.svelte.js";
|
||
import { resolver, streamSong, streamCatalogAlbum } from "./resolve.svelte.js";
|
||
import { notice, clearNote } from "./notice.svelte.js";
|
||
import { say, signedOut } from "./trouble.js";
|
||
import * as pls from "./playlists.svelte.js";
|
||
import { importer, bindImporter, startFolder, startFile } from "./importer.svelte.js";
|
||
import { bindShare } from "./share.svelte.js";
|
||
import { downloadsAllowed, cellular } from "./network.svelte.js";
|
||
import { contacts, loadContacts, display } from "./contacts.svelte.js";
|
||
import * as sources from "../lib/sources.js";
|
||
import { boot, sealBoot } from "../lib/boot.js";
|
||
import { greeting, drag } from "./util.js";
|
||
|
||
let screenEl = $state(null);
|
||
let miniArt = $state(null);
|
||
let scrolled = $state(0);
|
||
|
||
const entry = $derived(top());
|
||
const root = $derived(isRoot());
|
||
|
||
/* what the glass bar says, and which artwork the page wears */
|
||
const meta = $derived.by(() => {
|
||
const e = entry;
|
||
switch (e.n) {
|
||
case "home": return { title: greeting(), kicker: "Home" };
|
||
case "search": return { title: "Search", kicker: "Search" };
|
||
case "library": return { title: "Your library", kicker: "Library" };
|
||
case "community": return { title: "Community", kicker: slsk.connection === "online" ? "online as " + slsk.username : "Soulseek" };
|
||
case "room": return { title: "#" + e.c.name, kicker: "Room", flush: true };
|
||
case "thread": return { title: display(e.c.username), kicker: "Chat", flush: true };
|
||
case "you": return { title: "You", kicker: "You" };
|
||
case "transfers": return { title: "Transfers", kicker: "Downloads" };
|
||
case "diag": return { title: "Diagnostics", kicker: "Dev build" };
|
||
case "import": return { title: "Import", kicker: importer.draft?.source_name || "Playlist" };
|
||
case "browse": return { title: e.c.username, kicker: "Their files" };
|
||
case "peer": return { title: e.c.username, kicker: "On Soulseek" };
|
||
case "album": {
|
||
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;
|
||
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: "Artist", amb: p, hero: true };
|
||
}
|
||
case "catalogAlbum": {
|
||
const p = catalog.albums[e.c.id]?.page;
|
||
return { title: p?.album?.title ?? e.c.title ?? "", kicker: p?.album?.artist ?? e.c.artist ?? "", amb: p?.album?.cover ?? null, flush: true };
|
||
}
|
||
case "playlist": {
|
||
const p = lib.playlists.find((x) => x.id === e.c.id);
|
||
return { title: p?.name ?? "Playlist", kicker: p?.kind === "setlist" ? "Setlist" : "Playlist", flush: true };
|
||
}
|
||
default: return { title: e.n, kicker: "" };
|
||
}
|
||
});
|
||
const groundPath = $derived(meta.amb ?? player.album?.art_path ?? null);
|
||
/* A tab page has no bar at all: nothing fades in over what you are
|
||
reading. A pushed page has one because it carries the back chevron,
|
||
and it is static — there and titled from the first frame. */
|
||
const showBar = $derived(!root);
|
||
/* A hero page paints its picture a second time behind the scroller
|
||
(heroart.svelte.js): the mask cuts the page where it meets the clock,
|
||
and that copy fills the cut back in — so the photograph passes under
|
||
the status bar and the name does not. The mask itself never moves. */
|
||
const playing = $derived(player.status === "playing" || player.status === "buffering");
|
||
const played = $derived(player.duration ? Math.min(1, player.position / player.duration) : 0);
|
||
|
||
/* ── what the machine says ───────────────────────────────────────── */
|
||
/* A tab's own name: the section, and when the dot is up, what the
|
||
number counts. "You, 3 transfers" — not a stray 3 read out before
|
||
the word it belongs to. */
|
||
function tabCount(id) {
|
||
if (id === "you") return activeTransfers().length;
|
||
if (id === "community") return unreadTotal();
|
||
return 0;
|
||
}
|
||
function tabName(id, label) {
|
||
const n = tabCount(id);
|
||
if (!n) return label;
|
||
return label + ", " + n + (id === "you" ? " transfer" + (n === 1 ? "" : "s") : " unread");
|
||
}
|
||
|
||
/* The one live region. It is mounted for the life of the app on
|
||
purpose: a region that appears with its words already inside it is
|
||
announced reliably by neither TalkBack nor VoiceOver, so an
|
||
announcement here is a TEXT CHANGE in a node that was always there.
|
||
The desktop does the same (FollowToast's always-mounted .layer,
|
||
PreviewOverlay's .sr-announce). Never drawn — shell.css clips it to
|
||
a pixel. */
|
||
const announce = $derived([say(player.error || lib.error), notice.text, notice.action?.label].filter(Boolean).join(". "));
|
||
|
||
/* ── the ground: two layers, the incoming one fades up over the old ── */
|
||
let ambA = $state(null);
|
||
let ambB = $state(null);
|
||
let lit = $state("b");
|
||
let ambCur = null;
|
||
$effect(() => {
|
||
const p = groundPath;
|
||
const url = p ? art[p] : null;
|
||
if (p && url === undefined) { wantArt(p); return; }
|
||
if (!url || url === ambCur) return;
|
||
ambCur = url;
|
||
untrack(() => {
|
||
if (lit === "a") { ambB = url; requestAnimationFrame(() => { lit = "b"; }); }
|
||
else { ambA = url; requestAnimationFrame(() => { lit = "a"; }); }
|
||
});
|
||
});
|
||
|
||
/* ── the shade's download notice follows the transfer store ───────
|
||
An effect, not a hook off the download event: the effect tracks the
|
||
store itself, progress ticks and all, so a row written straight into
|
||
it — the walk, with nothing fetched — drives the notice exactly as a
|
||
real one does. It reads the store and never the notice's own record,
|
||
which is why that record is a plain object. */
|
||
$effect(() => { syncShade(); });
|
||
|
||
/* ── the first run stamps when it goes away, whatever closed it ────
|
||
The foot's button calls finishSetup itself; the scrim, the back
|
||
gesture and the Soulseek row (which navigates, and navigating clears
|
||
the half sheet) do not — and a tour that ran is a tour that ran.
|
||
finishSetup writes only in boot mode and only once, so all four
|
||
doors land in the same place, and a PREVIEW opened from You writes
|
||
nothing at all.
|
||
|
||
untrack is not optional here: finishSetup calls closeHalf, which
|
||
reads nav.half, which is exactly what this effect tracks. Read your
|
||
own write in a Svelte 5 effect and the subtree dies painted. */
|
||
let setupWasUp = false;
|
||
$effect(() => {
|
||
const up = nav.half?.kind === "setup";
|
||
if (setupWasUp && !up) untrack(() => { void finishSetup(); });
|
||
setupWasUp = up;
|
||
});
|
||
|
||
/* ── a library read that failed gets a Try again ──────────────────
|
||
The library is the one thing every screen stands on, and a rejected
|
||
read left the app empty with a Rust string over it. The retry is wired
|
||
HERE, where the toast is drawn, rather than in lib.svelte.js: that
|
||
store reaching for the player store would close an import loop —
|
||
player.svelte.js already reads lib. */
|
||
$effect(() => {
|
||
const e = lib.error;
|
||
if (!e) return;
|
||
untrack(() => {
|
||
player.retry = { for: e, fn: () => { player.error = ""; lib.error = ""; player.retry = null; void loadLibrary(); } };
|
||
});
|
||
});
|
||
|
||
/* ── the card gets the cover once the thumbnail has landed ───────── */
|
||
$effect(() => {
|
||
const k = player.album?.art_path;
|
||
const u = k ? art[k] : "";
|
||
if (u) syncMedia();
|
||
});
|
||
|
||
/* ── the accent follows the same artwork ─────────────────────────── */
|
||
let accentReq = 0;
|
||
$effect(() => {
|
||
const p = groundPath;
|
||
/* a streamed cover lands late: read it here so the accent follows */
|
||
void (p ? art[p] : null);
|
||
const req = ++accentReq;
|
||
accentFor(p).then((c) => { if (req === accentReq) applyAccent(c); });
|
||
});
|
||
|
||
/* ── a new screen: scroll to where it was (a pop) or the top (a push) ── */
|
||
$effect(() => {
|
||
void nav.seq;
|
||
scrolled = 0;
|
||
tick().then(() => {
|
||
if (!screenEl) return;
|
||
const s = untrack(() => top().scroll) || 0;
|
||
screenEl.scrollTop = s;
|
||
scrolled = s;
|
||
});
|
||
});
|
||
|
||
/* ── the mini bar's gestures: swipe to skip, drag up to open ─────── */
|
||
let miniMoved = false;
|
||
const miniDrag = {
|
||
start: () => { miniMoved = false; },
|
||
move: (dx, dy, el) => {
|
||
if (Math.abs(dx) > 8 || Math.abs(dy) > 8) miniMoved = true;
|
||
if (Math.abs(dx) > Math.abs(dy)) el.style.transform = `translateX(${dx * 0.6}px)`;
|
||
else if (dy < 0) el.style.transform = `translateY(${Math.max(dy, -44) * 0.6}px)`;
|
||
},
|
||
end: (dx, dy, el) => {
|
||
el.style.transform = "";
|
||
if (Math.abs(dx) > 56 && Math.abs(dx) > Math.abs(dy)) { if (dx < 0) next(); else prev(); }
|
||
else if (dy < -34) openSheet(miniArt);
|
||
},
|
||
};
|
||
function onMiniTap() {
|
||
if (miniMoved) { miniMoved = false; return; }
|
||
openSheet(miniArt);
|
||
}
|
||
|
||
/* ── when the library landed, for the record ──────────────────────
|
||
Home gates its landing screen on lib.loaded, so this is the number a
|
||
launch is actually felt as, and it is the one the phone leg prints.
|
||
`boot` is a plain object and never a rune, so the write cannot feed
|
||
the effect back into itself; untrack says so out loud. */
|
||
$effect(() => {
|
||
if (!lib.loaded) return;
|
||
untrack(() => { if (boot.libMs == null) boot.libMs = Math.round(performance.now() - boot.t0); });
|
||
});
|
||
|
||
onMount(() => {
|
||
/* the boot census starts here. Every command the app issues between
|
||
this line and the first frame is counted, and the mock walk holds
|
||
a budget against the count — the point is to catch a round that
|
||
loads the world at boot, not to police milliseconds. */
|
||
boot.t0 = performance.now();
|
||
try { performance.mark("ss:boot"); } catch { /* a webview without the entry types */ }
|
||
loadPrefs();
|
||
bootNav();
|
||
let unlisten = null;
|
||
bindEvents()
|
||
.then((u) => { unlisten = u; }, (e) => { player.error = String(e); })
|
||
.then(() => restoreSession())
|
||
.catch(() => {});
|
||
hooks.recentsDirty = refreshRecents;
|
||
bindMedia();
|
||
/* the first run. The config is read at boot because the notes on
|
||
the screens that are empty BECAUSE of it read this store — and
|
||
the tour itself is offered only once the library and the saved
|
||
account have both answered. Asked any earlier they are `[]` and
|
||
`""` on every phone in existence, Kyle's included, and the sheet
|
||
that must never open on his would open on his. */
|
||
const configRead = loadSetup();
|
||
const libRead = loadLibrary();
|
||
/* the network: subscribe first, then the saved account signs in on its own */
|
||
const netRead = bindSlsk().then(() => connectStored()).catch(() => {});
|
||
Promise.all([configRead, libRead, netRead]).then(() => maybeOpenFirstRun()).catch(() => {});
|
||
bindNet();
|
||
watchLibrary();
|
||
bindTransfers();
|
||
bindXferNote();
|
||
bindHome();
|
||
bindImporter();
|
||
bindShare();
|
||
loadContacts();
|
||
/* the desktop's library art prewarm: every cover cut to the cards'
|
||
edge, one decode at a time behind the tiles' gate, a moment after
|
||
boot so the first screen and a restored track come first */
|
||
setTimeout(() => { api.libraryArtPrewarm([360]).catch(() => {}); }, 8000);
|
||
/* everything above went out in this one turn. A call that waits on
|
||
the one before it can only have issued its first here, so this is
|
||
the number that says the boot chain is still going out in one
|
||
round — 10 before this round, 23 after it — and the walk holds a
|
||
floor against it. */
|
||
boot.sync = boot.calls;
|
||
/* two frames: the first is the shell, the second is the first one
|
||
the app could have drawn anything into. A phone producing no
|
||
frames at all (launched straight into the background) seals on
|
||
the timer instead, well before the walk's first probe — the
|
||
census must never depend on something that might not happen. */
|
||
const sealTimer = setTimeout(sealBoot, 1200);
|
||
requestAnimationFrame(() => requestAnimationFrame(() => { clearTimeout(sealTimer); sealBoot(); }));
|
||
/* the probe handle — the desktop has __soulstream for the same reason:
|
||
tests/mobile/smoke.mjs drives the real app through it */
|
||
window.__ss = { nav, top, go, push, back, openSheet, closeSheet, openHalf, closeHalf, player, lib, art, catalog, home, loadHome, lyr, loadLyrics, slsk, boot, net, resolver, streamSong, streamCatalogAlbum, sources, api, transfers, xfn: xfernote, pls, notice, importer, artTally, imp: { startFolder, startFile }, setup, openSetup, setupState, wifi: { downloadsAllowed, cellular }, next, prev, toggle, seekSecs, playQueueAt, patch, contacts, loadContacts, say, refreshRooms };
|
||
return () => { if (unlisten) unlisten(); };
|
||
});
|
||
</script>
|
||
|
||
<div class="app" class:has-mini={!!player.track}>
|
||
<!-- the ground: the artwork in play, blurred, under everything -->
|
||
<div class="app-bg" aria-hidden="true">
|
||
<div class="amb" class:on={lit === "a"} style:background-image={ambA ? `url("${ambA}")` : null}></div>
|
||
<div class="amb" class:on={lit === "b"} style:background-image={ambB ? `url("${ambB}")` : null}></div>
|
||
<div class="amb-tint"></div>
|
||
<div class="grain"></div>
|
||
</div>
|
||
|
||
<div class="pane">
|
||
<div class="viewport">
|
||
{#if meta.hero && heroArt.src}
|
||
<div class="hero-behind" aria-hidden="true" style:--hero-shift="{-scrolled}px">
|
||
<i style:background-image={`url("${heroArt.src}")`}></i>
|
||
</div>
|
||
<div class="hero-wash" aria-hidden="true"></div>
|
||
{/if}
|
||
{#key nav.seq}
|
||
<div
|
||
class="screen tr-{nav.tr} stagger"
|
||
class:has-chrome={!root && !meta.hero && !meta.flush}
|
||
data-title={meta.title}
|
||
data-kicker={meta.kicker && meta.kicker !== meta.title ? meta.kicker : ""}
|
||
class:has-hero={meta.hero}
|
||
bind:this={screenEl}
|
||
onscroll={(e) => { scrolled = e.currentTarget.scrollTop; }}
|
||
>
|
||
{#if entry.n === "home"}
|
||
<Home {screenEl} />
|
||
{:else if entry.n === "search"}
|
||
<Search {entry} {screenEl} />
|
||
{:else if entry.n === "library"}
|
||
<Library {entry} {screenEl} />
|
||
{:else if entry.n === "album"}
|
||
<Album id={entry.c.id} {screenEl} />
|
||
{:else if entry.n === "artist"}
|
||
<Artist name={entry.c.name} seed={entry.c.seed ?? null} {screenEl} />
|
||
{:else if entry.n === "disco"}
|
||
<Discography {entry} {screenEl} />
|
||
{:else if entry.n === "catalogAlbum"}
|
||
<CatalogAlbum id={entry.c.id} artist={entry.c.artist} title={entry.c.title} {screenEl} />
|
||
{:else if entry.n === "playlist"}
|
||
<Playlist id={entry.c.id} />
|
||
{:else if entry.n === "import"}
|
||
<Import />
|
||
{:else if entry.n === "browse"}
|
||
<Browse username={entry.c.username} />
|
||
{:else if entry.n === "peer"}
|
||
<Peer username={entry.c.username} {screenEl} />
|
||
{:else if entry.n === "community"}
|
||
<Community {entry} {screenEl} />
|
||
{:else if entry.n === "room"}
|
||
<Room name={entry.c.name} {screenEl} />
|
||
{:else if entry.n === "thread"}
|
||
<Thread username={entry.c.username} {screenEl} />
|
||
{:else if entry.n === "you"}
|
||
<You />
|
||
{:else if entry.n === "transfers"}
|
||
<Transfers />
|
||
{:else if entry.n === "diag"}
|
||
<Diagnostics />
|
||
{:else}
|
||
<Stub kind={entry.n} />
|
||
{/if}
|
||
</div>
|
||
{/key}
|
||
</div>
|
||
|
||
<!-- no bar on any screen. A pushed page floats its two controls over
|
||
the page instead; the title is the page's own, in 27px type. -->
|
||
{#if showBar}
|
||
<button class="fchrome back" onclick={back} aria-label="Back"><Icon name="chevron-left" size={24} /></button>
|
||
<button class="fchrome more" onclick={() => openHalf("more")} aria-label="More"><Icon name="more" size={21} /></button>
|
||
{/if}
|
||
</div>
|
||
|
||
{#if player.track}
|
||
<div class="mini" class:hidden={nav.sheet} use:drag={miniDrag} onclick={onMiniTap} oncontextmenu={(e) => { e.preventDefault(); openHalf("track", { album: player.album, track: player.track }); }} role="button" tabindex="0" onkeydown={() => {}} aria-hidden={nav.sheet ? "true" : undefined} aria-label={"Now playing: " + (player.track.title || "") + (player.track.artist || player.album?.artist ? " · " + (player.track.artist || player.album?.artist) : "")}>
|
||
<span class="art" bind:this={miniArt}><Cover path={player.album?.art_path} eager size={20} /></span>
|
||
<span class="b">
|
||
<span class="t">{player.track.title}</span>
|
||
{#if resolver.active && !player.track.stream}
|
||
<span class="m live">Finding the best source…</span>
|
||
{:else if player.track.stream && (player.phase === "hold" || player.phase === "loading")}
|
||
<span class="m live">Finding {player.track.stream.username}’s copy…</span>
|
||
{:else if player.track.stream}
|
||
<span class="m live">{player.track.artist || player.album?.artist || ""} · streaming from {player.track.stream.username}</span>
|
||
{:else}
|
||
<span class="m">{player.track.artist || player.album?.artist || ""}</span>
|
||
{/if}
|
||
</span>
|
||
<button class="iconbtn sm" onclick={(e) => { e.stopPropagation(); prev(); }} aria-label="Previous"><Icon name="skip-back" size={19} /></button>
|
||
<button class="iconbtn sm" onclick={(e) => { e.stopPropagation(); toggle(); }} aria-label={playing ? "Pause" : "Play"}><Icon name={playing ? "pause" : "play"} size={21} /></button>
|
||
<button class="iconbtn sm" onclick={(e) => { e.stopPropagation(); next(); }} aria-label="Next"><Icon name="skip-forward" size={19} /></button>
|
||
<Lane class="mini-lane" height={16} peaks={player.peaks} heldPeaks={player.heldPeaks} phase={player.phase} coverage={player.coverage} {played} />
|
||
</div>
|
||
{/if}
|
||
|
||
<!-- svelte-ignore a11y_no_noninteractive_element_to_interactive_role -- the
|
||
five destinations ARE a tab set, and the desktop's own segmented
|
||
control says so too; wrapping them in an inner div to keep <nav>'s
|
||
implicit role would change the bar's flex layout -->
|
||
<nav class="nav" class:gone={nav.sheet} role="tablist" aria-label="Sections" aria-hidden={nav.sheet ? "true" : undefined}>
|
||
{#each TABS as [id, icon, label] (id)}
|
||
<button class:on={nav.tab === id} onclick={() => go(id)} role="tab" aria-selected={nav.tab === id} aria-label={tabName(id, label)}>
|
||
<span class="pill"></span>
|
||
<span class="gl">
|
||
<Icon name={icon} size={22} />
|
||
{#if id === "you" && activeTransfers().length}<span class="nb" aria-hidden="true">{activeTransfers().length}</span>{/if}
|
||
{#if id === "community" && unreadTotal()}<span class="nb" aria-hidden="true">{unreadTotal()}</span>{/if}
|
||
</span>
|
||
<span class="lb">{label}</span>
|
||
</button>
|
||
{/each}
|
||
</nav>
|
||
|
||
<div class="scrim" class:on={!!nav.half} onclick={closeHalf} aria-hidden="true"></div>
|
||
<HalfSheet />
|
||
<Sheet />
|
||
<Viewer />
|
||
<SyncRoom />
|
||
|
||
<!-- the announcement: one node, always here, so a change of its words
|
||
is what gets read out. The toasts below stay where they were and
|
||
say nothing on their own — two announcers say everything twice -->
|
||
<div class="sr-live" data-live aria-live="polite" aria-atomic="true">{announce}</div>
|
||
{#if player.error || lib.error}
|
||
{@const msg = player.error || lib.error}
|
||
<!-- the notice's shape, in the red: the sentence trouble.js makes of
|
||
whatever Rust said, and one action beside it where there is one to
|
||
offer — Sign in when nobody is, else the Try again that was armed
|
||
for THIS message and no other -->
|
||
<div class="toast" onclick={() => { player.error = ""; lib.error = ""; player.retry = null; }} role="button" tabindex="0" onkeydown={() => {}} aria-label="Dismiss this message">
|
||
<span class="nt">{say(msg)}</span>
|
||
{#if signedOut(msg)}
|
||
<button class="na" onclick={(e) => { e.stopPropagation(); player.error = ""; lib.error = ""; player.retry = null; go("community"); }}>Sign in</button>
|
||
{:else if player.retry?.for === msg}
|
||
<button class="na" onclick={(e) => { e.stopPropagation(); const r = player.retry; player.error = ""; lib.error = ""; player.retry = null; r.fn(); }}>Try again</button>
|
||
{/if}
|
||
</div>
|
||
{/if}
|
||
{#if notice.text}
|
||
<div class="toast notice">
|
||
<span class="nt" aria-hidden="true">{notice.text}</span>
|
||
{#if notice.action}<button class="na" onclick={() => { const f = notice.action.fn; clearNote(); f?.(); }}>{notice.action.label}</button>{/if}
|
||
</div>
|
||
{/if}
|
||
</div>
|