Files
SoulStream/src/mobile/Mobile.svelte
T
KeelrLeek 5738c2d7bf android: the downloads say so in the shade
A Save still running with the app in the background now posts a progress
notice — how many files, the one it is on, a bar, Stop all and Open — and
takes it down when the queue empties. It sits on its own low-importance
channel so it can be silenced without silencing the player's card, and
rides its own dataSync foreground service rather than the media one: a
mediaPlayback service stops itself when the bar empties, a download is not
playback, and nothing else was keeping the process alive for a Save with
nothing playing — which is how the first real Save died, four tracks in.
Stop all goes through the same cancel(id) a row's tap takes, and Transfers
grew the matching button beside Clear finished. The walk pushes two rows
straight into the transfer store and reads the real notification back out
of `dumpsys notification`, without fetching anything.
2026-09-14 00:44:11 -05:00

368 lines
19 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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 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 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 { bindHome } from "./home.svelte.js";
import Stub from "./screens/Stub.svelte";
import { lib, loadLibrary, refreshRecents, albumById, albumsBy, watchLibrary } from "./lib.svelte.js";
import { art, 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 { catalog, artistKey } from "./catalog.svelte.js";
import { bindMedia, syncMedia } from "./media.svelte.js";
import { slsk, bindSlsk, connectStored, 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 * as pls from "./playlists.svelte.js";
import { importer, bindImporter, startFolder } 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 { 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 };
}
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);
const trigger = $derived(meta.hero ? 210 : 52);
const barOn = $derived(!root || scrolled > trigger);
const titled = $derived(scrolled > trigger);
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([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 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);
}
onMount(() => {
loadPrefs();
bootNav();
let unlisten = null;
bindEvents()
.then((u) => { unlisten = u; }, (e) => { player.error = String(e); })
.then(() => restoreSession())
.catch(() => {});
hooks.recentsDirty = refreshRecents;
bindMedia();
loadLibrary();
/* the network: subscribe first, then the saved account signs in on its own */
bindSlsk().then(() => connectStored()).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);
/* 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, slsk, net, resolver, streamSong, streamCatalogAlbum, sources, api, transfers, xfn: xfernote, pls, notice, importer, imp: { startFolder }, wifi: { downloadsAllowed, cellular }, next, prev, toggle, seekSecs, playQueueAt, patch, contacts, loadContacts };
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">
{#key nav.seq}
<div
class="screen tr-{nav.tr} stagger"
class:under-bar={!root && !meta.hero && !meta.flush}
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 === "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>
<!-- the glass bar: GlassBar.svelte's recipe; anonymous until the
page's own large title has scrolled under it -->
<div class="gbar" class:on={barOn} class:titled>
<button class="iconbtn" style:visibility={root ? "hidden" : "visible"} onclick={back} aria-label="Back"><Icon name="chevron-left" size={24} /></button>
<span class="gb-t">{meta.title}{#if meta.kicker && meta.kicker !== meta.title}<small>{meta.kicker}</small>{/if}</span>
<button class="iconbtn muted" onclick={() => openHalf("more")} aria-label="More"><Icon name="more" size={21} /></button>
</div>
</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}
<div class="toast" onclick={() => { player.error = ""; lib.error = ""; }} role="button" tabindex="0" onkeydown={() => {}} aria-label="Dismiss this message">{player.error || lib.error}</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>