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.
368 lines
19 KiB
Svelte
368 lines
19 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 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>
|