android: the phone, on the real core — the mockup shell, playback, the media card, the catalog, the resolver, Save, the dev flavour

The same Tauri 2 project builds for Android: mobile.html as a third Vite
entry (src/mobile/, the mockup on real data), tauri.android.conf.json, the
mobile stubs (updater, lyric alignment, vocal separation), rustls reqwest
on Android, the AAudio buffer, and a hand-driven detour build
(src-tauri/android/{env,build,deploy,dev}.sh) while Developer Mode is off.
ANDROID.md is the recipe.

Playback: the media-session plugin crate (lock-screen card, MediaStyle
notification, audio focus, headset buttons, a foreground service that
promotes itself first — Android 14 kills the app otherwise), session
restore across reload and restart, solid chrome after measuring the
backdrop-filter lag.

The catalog: Search is the desktop's top bar (the top-result rule, artist
cards), the artist page and the record page are the desktop's, Home is the
desktop's landing page, Gaps per followed shelf. Playing goes through the
desktop's own source rules, moved verbatim from state.svelte.js into
src/lib/sources.js so both apps share one resolver; the phone readies the
next three tracks and crosses boundaries gapless. Save fetches a record or
a track into the library by the download rules; Transfers shows the
burst. The raw Soulseek search stays, offered on request, a peer's copy
streaming with the lane rising as the bytes arrive. Community is the
Soulseek side (rooms, chat, buddies); the private layer waits.

The opener and storage plugins (social links, the picked library folder,
all-files access), the volume normalization switch, and the harness:
tests/mobile/smoke.mjs walks the mock (a fake network behind
src/lib/events.js) and the real phone over CDP, perf.mjs measures frames.
This commit is contained in:
KeelrLeek committed 2026-09-13 09:30:06 -05:00
1 parent 972dd103cb
commit bb828ff2dc
102 files changed
+12258 -830

No files matched your search

+26
View File
@@ -0,0 +1,26 @@
<script>
/* A cover from the thumbnail cache, or the resting disc while it is not
here yet. `eager` asks for it at once (a record page, the mini bar);
otherwise it is requested when the element is about to be seen. */
import Icon from "../components/Icon.svelte";
import { art, visible, wantArt } from "./art.svelte.js";
let { path = null, eager = false, size = 26 } = $props();
$effect(() => {
if (eager && path) wantArt(path);
});
const url = $derived(path ? art[path] : "");
</script>
{#if eager}
{#if url}<img src={url} alt="" />{:else}<Icon name="disc" size={size} />{/if}
{:else}
<span class="cv" use:visible={path}>
{#if url}<img src={url} alt="" />{:else}<Icon name="disc" size={size} />{/if}
</span>
{/if}
<style>
.cv { display: contents; }
</style>
+211
View File
@@ -0,0 +1,211 @@
<script>
/* Bottom sheets — the phone's answer to the desktop's right-click menus
and toolbar popovers. Only rows that do something are here; what a
row cannot do yet is not a dead row, it is absent. Facts about a file
are key/value rows, not buttons. */
import Icon from "../components/Icon.svelte";
import Cover from "./Cover.svelte";
import * as api from "../lib/api.js";
import { nav, closeHalf, push, top, patch, go, back, openHalf, openSheet } from "./nav.svelte.js";
import { player, playNext, enqueue, setSleep, sleepLabel } from "./player.svelte.js";
import { streamSong, saveSong } from "./resolve.svelte.js";
import { slsk, refreshRooms, disconnect, openThread, addBuddy, removeBuddy, leaveRoom } from "./slsk.svelte.js";
import { albumById, albumsBy, tracksOf, loadLibrary } from "./lib.svelte.js";
import { fmtOf, albumFmt, fmtBytes, basename, dirname, longDur } from "./util.js";
const SORTS = [["added", "Recently added"], ["az", "A–Z"], ["artist", "By artist"], ["year", "By year"]];
/* the last sheet is kept through its slide down */
let shown = $state(null);
$effect(() => { if (nav.half) shown = nav.half; });
const kind = $derived.by(() => {
const h = shown;
if (!h) return null;
if (h.kind !== "more") return h.kind;
const t = top();
if (t.n === "album") return "album-more";
if (t.n === "library") return "library-more";
return "home-more";
});
/* an album sheet that arrived without its tracks (the page's overflow, a
long press on a card) reads them itself */
let resolved = $state(null);
$effect(() => {
const want = kind === "album-more" ? albumById(top().c.id)
: kind === "album" && shown?.arg?.album && !shown.arg.tracks && shown.arg.album.id != null ? shown.arg.album
: null;
if (!want) { resolved = null; return; }
let live = true;
resolved = { album: want, tracks: [] };
tracksOf(want.id).then((t) => { if (live) resolved = { album: want, tracks: t || [] }; }, () => {});
return () => { live = false; };
});
const albumArg = $derived(
kind === "album" ? (shown?.arg?.tracks ? shown.arg : resolved)
: kind === "album-more" ? resolved
: null,
);
const trackArg = $derived(kind === "track" ? shown?.arg : null);
const onThisAlbum = $derived((id) => top().n === "album" && top().c.id === id);
const after = $derived(Math.max(0, player.queue.length - player.qi - 1));
/* a catalog track (no file on this phone): the resolver plays it, Save
fetches it — the desktop's row buttons, on the sheet */
const isCatalog = (t) => !!t && !t.path && !!t.catalog;
let saveNote = $state("");
async function saveTrack(track, album) {
saveNote = "…";
try {
const r = await saveSong(track.artist || album?.artist, track.title);
saveNote = r ? `Saving from ${r.username}` : "No sources";
} catch (e) { saveNote = "Failed"; player.error = String(e); }
setTimeout(() => { saveNote = ""; closeHalf(); }, 1400);
}
function playCatalog(track, album) {
closeHalf();
streamSong(track.artist || album?.artist, track.title, {
trackId: track.catalog?.trackId ?? null, albumId: track.catalog?.albumId ?? album?.catalogId ?? null,
album: album?.title || null, cover: track.catalog?.cover ?? album?.art_path ?? null, expectSecs: track.duration_secs ?? null,
}).catch((e) => { player.error = String(e); });
}
function act(fn) {
return () => { closeHalf(); if (fn) fn(); };
}
async function rescan() {
try { await api.rescanLibrary(); await loadLibrary(); } catch (e) { player.error = String(e); }
}
</script>
{#snippet row(icon, t, m, fn)}
<button class="row" onclick={fn}>
<span class="lead"><Icon name={icon} size={20} /></span>
<span class="b"><span class="t">{t}</span>{#if m}<span class="m">{m}</span>{/if}</span>
</button>
{/snippet}
<div class="halfsheet" class:up={!!nav.half}>
<div class="grab"></div>
<div class="hs-body">
{#if kind === "track" && trackArg}
{@const album = trackArg.album}
{@const track = trackArg.track}
<div class="sheet-head">
<span class="art">{#if isCatalog(track) && (track.catalog?.cover || album?.art_path)}<img src={track.catalog?.cover || album?.art_path} alt="" />{:else}<Cover path={album?.art_path} eager size={22} />{/if}</span>
<span class="b"><span class="t">{track.title}</span><span class="m">{track.artist || album?.artist} · {album?.title}</span></span>
</div>
<div class="sheet-rows">
{#if isCatalog(track)}
{@render row("play", "Play", "streams from the best source", () => playCatalog(track, album))}
{@render row(saveNote.startsWith("Saving") ? "check" : "download", saveNote || "Save to library", saveNote ? "" : "the best copy by your download rules", () => { if (!saveNote) saveTrack(track, album); })}
<div class="sheet-hair"></div>
{#if album?.catalogId != null}
{@render row("disc", "Go to record", album.title, act(() => push("catalogAlbum", { id: album.catalogId, artist: album.artist, title: album.title })))}
{/if}
{#if album?.artist || track.artist}
{@render row("users", "Go to artist", track.artist || album.artist, act(() => push("artist", { name: track.artist || album.artist })))}
{/if}
{:else}
{@render row("play", "Play next", "", act(() => playNext([{ t: track, album }])))}
{@render row("list-plus", "Add to queue", after ? after + " after this" : "", act(() => enqueue([{ t: track, album }])))}
<div class="sheet-hair"></div>
{#if album?.id != null && !onThisAlbum(album.id)}
{@render row("disc", "Go to album", album.title, act(() => push("album", { id: album.id })))}
{/if}
{#if album?.artist || track.artist}
{@render row("users", "Go to artist", track.artist || album.artist, act(() => push("artist", { name: track.artist || album.artist })))}
{/if}
{/if}
</div>
{#if !isCatalog(track)}
<div class="sheet-info">
<div class="kv"><span class="k">Format</span><span class="v">{fmtOf(track) || "—"}</span></div>
{#if track.size}<div class="kv"><span class="k">Size</span><span class="v">{fmtBytes(track.size)}</span></div>{/if}
<div class="kv"><span class="k">File</span><span class="v mono">{basename(track.path)}</span></div>
<div class="kv"><span class="k">Folder</span><span class="v mono dim">{dirname(track.path)}</span></div>
</div>
{/if}
{:else if (kind === "album" || kind === "album-more") && albumArg}
{@const album = albumArg.album}
{@const tracks = albumArg.tracks || []}
<div class="sheet-head">
<span class="art"><Cover path={album.art_path} eager size={22} /></span>
<span class="b"><span class="t">{album.title}</span><span class="m">{album.artist} · {album.year ?? "—"} · {tracks.length || album.track_count} tracks</span></span>
</div>
<div class="sheet-rows">
{@render row("play", "Play next", "", act(() => playNext(tracks.map((t) => ({ t, album })))))}
{@render row("list-plus", "Add to queue", tracks.length + " tracks", act(() => enqueue(tracks.map((t) => ({ t, album })))))}
<div class="sheet-hair"></div>
{@render row("users", "Go to artist", album.artist, act(() => push("artist", { name: album.artist })))}
</div>
<div class="sheet-info">
<div class="kv"><span class="k">Format</span><span class="v">{albumFmt(tracks) || "—"}</span></div>
<div class="kv"><span class="k">Length</span><span class="v">{longDur(tracks.reduce((n, t) => n + (t.duration_secs || 0), 0))}</span></div>
{#if tracks[0]?.path}<div class="kv"><span class="k">Folder</span><span class="v mono dim">{dirname(tracks[0].path)}</span></div>{/if}
</div>
{:else if kind === "librarysort"}
<div class="sheet-title">Sort &amp; view</div>
<div class="sheet-sec">Order</div>
<div class="chips" style="padding-bottom:12px">
{#each SORTS as [k, l] (k)}
<button class="chip" class:on={(top().c.sort || "added") === k} class:quiet={(top().c.sort || "added") !== k} onclick={() => patch({ sort: k, tab: "albums" })}>{l}</button>
{/each}
</div>
<div class="sheet-sec">View</div>
<div class="chips" style="padding-bottom:12px">
{#each [["grid", "Grid"], ["list", "List"]] as [k, l] (k)}
<button class="chip" class:on={(top().c.view || "grid") === k} class:quiet={(top().c.view || "grid") !== k} onclick={() => patch({ view: k, tab: "albums" })}>{l}</button>
{/each}
</div>
<div style="padding: 4px var(--pad) 16px"><button class="btn btn-fill wide" onclick={closeHalf}>Done</button></div>
{:else if kind === "library-more"}
<div class="sheet-title">Library</div>
<div class="sheet-rows">
{@render row("layers", "Sort and view", "", () => openHalf("librarysort"))}
{@render row("playlist", "Playlists", "", act(() => patch({ tab: "playlists" })))}
<div class="sheet-hair"></div>
{@render row("refresh", "Rescan library", "looks for new files in the folder", act(rescan))}
</div>
{:else if kind === "home-more"}
<div class="sheet-title">SoulStream</div>
<div class="sheet-rows">
{#if player.track}
{@render row("list-plus", "Queue", after + " after this", act(() => { nav.face = "queue"; openSheet(null); }))}
{/if}
{@render row("clock", "Sleep timer", sleepLabel(), () => openHalf("sleep"))}
{@render row("settings", "Settings", "", act(() => go("you")))}
</div>
{:else if kind === "community"}
<div class="sheet-title">Community</div>
<div class="sheet-rows">
{@render row("refresh", "Refresh the rooms", slsk.rooms.length + " on the server", act(refreshRooms))}
{@render row("x", "Sign out", slsk.username, act(disconnect))}
</div>
<div class="sec-note" style="padding-bottom:16px">Spaces, holdings and the sealed inbox wait for the private layer on the phone.</div>
{:else if kind === "roomusers" && shown?.arg?.name}
{@const room = slsk.joined[shown.arg.name]}
<div class="sheet-title">#{shown.arg.name} <span class="t-sub">· {room?.users.length ?? 0} here</span></div>
<div class="sheet-rows">
{#each (room?.users ?? []).slice(0, 120) as u (u.username)}
{@const buddy = slsk.buddies.includes(u.username)}
<button class="row" onclick={act(() => { openThread(u.username); push("thread", { username: u.username }); })} oncontextmenu={(e) => { e.preventDefault(); (buddy ? removeBuddy : addBuddy)(u.username).catch(() => {}); }}>
<span class="lead"><span class="dot" class:off={u.status === 0} class:warn={u.status === 1}></span></span>
<span class="b"><span class="t">{u.username}{#if buddy} <span class="tag hi">BUDDY</span>{/if}</span><span class="m">{u.file_count ? u.file_count.toLocaleString() + " files" : ""}</span></span>
</button>
{/each}
</div>
<div style="padding: 6px var(--pad) 16px"><button class="btn btn-ghost wide" onclick={act(() => { leaveRoom(shown.arg.name).catch(() => {}); back(); })}>Leave #{shown.arg.name}</button></div>
{:else if kind === "sleep"}
<div class="sheet-title">Sleep timer</div>
<div class="sheet-sec">Pause after</div>
<div class="chips" style="padding-bottom:16px">
{#each [[0, "Off"], [15, "15 min"], [30, "30 min"], [45, "45 min"], [60, "1 hour"], [90, "1½ hours"]] as [m, l] (m)}
<button class="chip" class:on={m === 0 ? !player.sleepAt : Math.abs((player.sleepAt - Date.now()) / 60000 - m) < 1} class:quiet={!(m === 0 ? !player.sleepAt : Math.abs((player.sleepAt - Date.now()) / 60000 - m) < 1)} onclick={act(() => setSleep(m))}>{l}</button>
{/each}
</div>
{#if player.sleepAt}<div class="sec-note" style="padding-bottom:14px">Playback pauses in {sleepLabel().replace(" left", "")}.</div>{/if}
{/if}
</div>
</div>
+109
View File
@@ -0,0 +1,109 @@
<script>
/* The waveform lane — the app's one position control, at whatever height
a surface can spare. One <i> per bar on the desktop's 2-on-6 pitch,
shaped by src/lib/waveshape.js, the played part mixed with the accent.
The bars are updated in place: the count only changes with the width,
so a phase change lands as a CSS transition on nodes that exist.
The stream's RISE: a partial waveform folds only the peaks that have
arrived across only the covered bars (the rail's rule), and the covered
bars are released one per RISE_CADENCE_MS, never behind the playhead,
so a fast peer cannot machine-gun the lane. */
import { waveLevels, BAR_PITCH, BAR_W, PENDING_ALPHA, RISE_CADENCE_MS } from "../lib/waveshape.js";
let {
height = 16,
peaks = [],
heldPeaks = [],
/** hold | loading | rising | arriving | ready */
phase = "ready",
/** how much of the track the peaks describe, 0..1 (a stream) */
coverage = 1,
/** 0..1 */
played = 0,
/** nothing to draw at all (no track) */
pending = false,
/** fn(fraction) — makes the lane a scrubber */
onseek = null,
class: cls = "",
} = $props();
let width = $state(0);
let el;
const bars = $derived(Math.max(10, Math.floor((width + BAR_PITCH - BAR_W) / BAR_PITCH)));
const covered = $derived(phase === "rising" ? Math.max(1, Math.min(bars, Math.floor(bars * coverage))) : bars);
const levels = $derived.by(() => {
if (pending) return null;
if (phase === "hold") return heldPeaks?.length ? waveLevels(heldPeaks, bars) : null;
if (phase === "loading") return null;
if (!peaks?.length) return null;
if (phase === "rising") {
const lv = waveLevels(peaks, covered);
while (lv.length < bars) lv.push(null);
return lv;
}
return waveLevels(peaks, bars);
});
const loading = $derived(!pending && phase === "loading");
const dimAll = $derived(pending || loading || !levels);
const playedBars = $derived(played * bars);
/* the release cadence, only while rising */
let revealed = $state(0);
let riseTimer = null;
$effect(() => {
if (phase !== "rising") { clearInterval(riseTimer); riseTimer = null; revealed = Infinity; return; }
revealed = 0;
riseTimer = setInterval(() => { revealed += 1; }, RISE_CADENCE_MS);
return () => { clearInterval(riseTimer); riseTimer = null; };
});
const shown = $derived(phase === "rising" ? Math.min(covered, Math.max(revealed, Math.ceil(playedBars))) : Infinity);
function heightOf(i) {
const lv = levels ? levels[i] : null;
if (lv == null || i >= shown) return 0;
return lv * 100;
}
function dimOf(i) {
return dimAll || (levels ? levels[i] == null : true) || i >= shown;
}
function fillOf(i) {
if (dimOf(i) || phase === "hold") return 0;
return Math.max(0, Math.min(1, playedBars - i));
}
/* drag-to-seek: the lane measures itself */
let down = false;
function at(e) {
const b = el.getBoundingClientRect();
if (!b.width) return;
onseek?.(Math.max(0, Math.min(1, (e.clientX - b.left) / b.width)));
}
function pd(e) {
if (!onseek) return;
down = true;
try { el.setPointerCapture(e.pointerId); } catch { /* fine */ }
at(e);
}
function pm(e) { if (down) at(e); }
function pu() { down = false; }
</script>
<span
class="lane {cls}"
class:pending
class:loading
class:rising={phase === "rising"}
class:arriving={phase === "arriving"}
style="height:{height}px; --bars:{bars}"
bind:this={el}
bind:clientWidth={width}
onpointerdown={pd}
onpointermove={pm}
onpointerup={pu}
onpointercancel={pu}
>
{#each Array(bars) as _, i (i)}
<i style="--i:{i}; height:{heightOf(i)}%; --fill:{fillOf(i).toFixed(3)}; opacity:{dimOf(i) ? PENDING_ALPHA : 1}"></i>
{/each}
</span>
+282
View File
@@ -0,0 +1,282 @@
<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 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 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 { transfers, bindTransfers } from "./transfers.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 } from "./slsk.svelte.js";
import { net, bindNet } from "./net.svelte.js";
import { resolver, streamSong, streamCatalogAlbum } from "./resolve.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: e.c.username, kicker: "Chat", flush: true };
case "you": return { title: "You", kicker: "You" };
case "transfers": return { title: "Transfers", kicker: "Downloads" };
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: "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);
/* ── 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 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();
bindHome();
/* 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, next, prev, toggle, seekSecs, playQueueAt, patch };
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 === "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}
<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-label="Now playing">
<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}
<nav class="nav" class:gone={nav.sheet}>
{#each TABS as [id, icon, label] (id)}
<button class:on={nav.tab === id} onclick={() => go(id)}>
<span class="pill"></span>
<span class="gl"><Icon name={icon} size={22} /></span>
<span class="lb">{label}</span>
</button>
{/each}
</nav>
<div class="scrim" class:on={!!nav.half} onclick={closeHalf} role="presentation"></div>
<HalfSheet />
<Sheet />
{#if player.error || lib.error}
<div class="toast" onclick={() => { player.error = ""; lib.error = ""; }} role="status">{player.error || lib.error}</div>
{/if}
</div>
+25
View File
@@ -0,0 +1,25 @@
<script>
/* The release card — Home.svelte's .release at phone scale. A tap opens
the record; a long press (the WebView's contextmenu) offers its sheet. */
import Icon from "../components/Icon.svelte";
import { art, visible } from "./art.svelte.js";
/* `path` is a library cover (through the thumbnail cache); `url` a
catalog cover, shown straight from the network */
let { title, sub = "", path = null, url = null, badge = null, w2 = false, onclick, onmore = null } = $props();
const src = $derived(url || (path ? art[path] : ""));
function hold(e) {
if (!onmore) return;
e.preventDefault();
onmore();
}
</script>
<button class="release" class:w2 {onclick} oncontextmenu={hold} use:visible={path}>
<span class="cover-wrap">
{#if badge}<span class="ed-badge">{badge}</span>{/if}
{#if src}<img src={src} alt="" loading="lazy" />{:else}<span class="ph"><Icon name="disc" size={26} /></span>{/if}
</span>
<span class="title">{title}</span>
<span class="sub">{sub}</span>
</button>
+275
View File
@@ -0,0 +1,275 @@
<script>
/* The player sheet. Three FACES — art, lyrics, queue — of one screen
rather than three destinations, because on a phone the thing you are
listening to should never be somewhere you navigate away to.
Two arrivals. Tapping the mini bar is a container transform: the
artwork you tapped IS the big artwork, so it flies there and the sheet
materialises around it (FLIP on the spatial spring). Anything else
slides up. Drag the head or the art down to put it away; swipe them
sideways to skip; drag the lane to seek. */
import { tick } from "svelte";
import Icon from "../components/Icon.svelte";
import Lane from "./Lane.svelte";
import Cover from "./Cover.svelte";
import * as api from "../lib/api.js";
import { art } from "./art.svelte.js";
import { albumsBy } from "./lib.svelte.js";
import { player, next, prev, toggle, seekFrac, seekSecs, playQueueAt, toggleShuffle, cycleRepeat } from "./player.svelte.js";
import { nav, closeSheet, morphSource, openHalf, push } from "./nav.svelte.js";
import { mmss, drag } from "./util.js";
let frameEl = $state(null);
let faceEl = $state(null);
let shown = $state(false);
let morphing = $state(false);
let morphTimer = null;
const cover = $derived(player.album?.art_path ? art[player.album.art_path] : "");
const face = $derived(nav.face);
const small = $derived(face !== "art");
const nextEntry = $derived(player.queue[player.qi + 1] ?? null);
const crossing = $derived(!!nextEntry && nextEntry.album?.title !== player.album?.title);
const played = $derived(player.duration ? Math.min(1, player.position / player.duration) : 0);
const remaining = $derived(Math.max(0, (player.duration ?? 0) - player.position));
const playing = $derived(player.status === "playing" || player.status === "buffering");
const artistName = $derived(player.track?.artist || player.album?.artist || "");
/* the artist page stands on the library and fills in from the catalog,
so any name opens it */
const artistInLibrary = $derived(!!artistName);
/* ── arrival ─────────────────────────────────────────────────────── */
$effect(() => {
if (!nav.sheet) {
shown = false;
morphing = false;
clearTimeout(morphTimer);
return;
}
if (nav.morph && morphSource.rect) {
morphing = true;
shown = false;
tick().then(() => requestAnimationFrame(() => {
shown = true;
const dur = flip(morphSource.rect);
clearTimeout(morphTimer);
/* once the art has landed the sheet is an ordinary sheet
again: drags spring back and a close slides down */
morphTimer = setTimeout(() => { morphing = false; }, dur + 40);
}));
} else {
morphing = false;
shown = true;
}
});
function flip(a) {
const to = frameEl;
const css = getComputedStyle(document.documentElement);
const dur = parseFloat(css.getPropertyValue("--spd-spatial-default")) || 480;
if (!a || !to || typeof to.animate !== "function") return 0;
const b = to.getBoundingClientRect();
if (!a.width || !b.width) return 0;
const s = a.width / b.width;
const dx = a.left + a.width / 2 - (b.left + b.width / 2);
const dy = a.top + a.height / 2 - (b.top + b.height / 2);
const ease = css.getPropertyValue("--sp-spatial-default").trim() || "ease-out";
to.animate(
[{ transform: `translate(${dx}px,${dy}px) scale(${s})` }, { transform: "none" }],
{ duration: dur, easing: ease, fill: "both" },
);
return dur;
}
const sheetDrag = {
from: (t) => !!(t.closest(".np-head") || t.closest(".np-art")),
move: (dx, dy, el) => { if (dy > 0) el.style.transform = `translateY(${dy}px)`; },
end: (dx, dy, el) => {
el.style.transform = "";
if (dy > 110) closeSheet();
else if (Math.abs(dx) > 70 && Math.abs(dx) > Math.abs(dy)) { if (dx < 0) next(); else prev(); }
},
};
function goArtist() {
if (!artistInLibrary) return;
closeSheet();
push("artist", { name: artistName });
}
function goAlbum() {
const a = player.album;
if (!a) return;
if (a.id != null) { closeSheet(); push("album", { id: a.id }); return; }
if (a.catalogId != null) { closeSheet(); push("catalogAlbum", { id: a.catalogId, artist: a.artist, title: a.title }); }
}
function more() {
if (player.track) openHalf("track", { album: player.album, track: player.track });
}
/* ── lyrics: the app's own pipeline, per track ───────────────────── */
let lyr = $state({ key: null, status: "none", lines: [], plain: "", offset: 0 });
$effect(() => {
if (face !== "lyrics" || !player.track) return;
const t = player.track;
const key = t.path;
if (lyr.key === key) return;
lyr = { key, status: "loading", lines: [], plain: "", offset: 0 };
api.lyricsGet({
title: t.title,
artist: t.artist || player.album?.artist || "",
album: player.album?.title ?? null,
durationSecs: t.duration_secs ?? null,
path: t.path,
}).then(
(p) => {
if (lyr.key !== key) return;
lyr = {
key,
status: p?.status || "none",
lines: p?.synced || [],
plain: p?.plain || "",
offset: (p?.offset_ms || 0) + (p?.auto_offset_ms || 0),
};
},
() => { if (lyr.key === key) lyr = { key, status: "none", lines: [], plain: "", offset: 0 }; },
);
});
const nowMs = $derived(player.position * 1000);
const activeIdx = $derived.by(() => {
let i = -1;
for (let k = 0; k < lyr.lines.length; k++) {
if (lyr.lines[k].time_ms + lyr.offset <= nowMs) i = k;
else break;
}
return i;
});
/* the active line stays in view */
$effect(() => {
const i = activeIdx;
const host = faceEl;
if (i < 0 || !host || face !== "lyrics") return;
const el = host.querySelector(`[data-ln="${i}"]`);
if (el && typeof el.scrollIntoView === "function") el.scrollIntoView({ block: "center", behavior: "smooth" });
});
function lineSeek(l) { seekSecs(Math.max(0, (l.time_ms + lyr.offset) / 1000)); }
</script>
<div class="sheet" class:up={shown} class:morphing use:drag={sheetDrag}>
{#if player.album}
<div class="sheet-bg" aria-hidden="true">
<div class="amb on" style:background-image={cover ? `url("${cover}")` : null}></div>
<div class="amb-tint"></div>
<div class="grain"></div>
</div>
<div class="np">
<div class="np-head">
<button class="iconbtn" onclick={closeSheet} aria-label="Close"><Icon name="chevron-down" size={24} /></button>
<button class="np-ctx" onclick={goAlbum} aria-label="Go to the record">
<div class="k">{player.track?.stream ? "Streaming from " + player.track.stream.username : "Playing from " + (player.from && player.from !== player.album.title ? "playlist" : "album")}</div>
<div class="v">{player.from || player.album.title}</div>
</button>
<button class="iconbtn" onclick={more} aria-label="More"><Icon name="more" size={22} /></button>
</div>
<div class="np-art" class:small>
<div class="np-frame" bind:this={frameEl}><Cover path={player.album.art_path} eager size={64} /></div>
</div>
{#if !small}
<div class="np-spacer">
{#if nextEntry}
<button class="np-next" onclick={() => playQueueAt(player.qi + 1)}>
<Icon name="skip-forward" size={13} />
<span><b>Next</b> {nextEntry.t.title}{#if crossing}<i> · {nextEntry.album?.artist}</i>{/if}</span>
<Icon name="chevron-right" size={14} />
</button>
{/if}
</div>
{:else}
<div class="np-face" bind:this={faceEl}>
{#if face === "lyrics"}
<div class="lyr">
{#if lyr.status === "loading"}
<div class="lyr-note">Looking for the words…</div>
{:else if lyr.lines.length}
{#each lyr.lines as l, i (i)}
{#if !l.text.trim()}
<div class="lyr-gap"></div>
{:else}
<button class="ln" class:past={i < activeIdx} class:now={i === activeIdx} class:words={i === activeIdx && l.words?.length} data-ln={i} onclick={() => lineSeek(l)}>
{#if i === activeIdx && l.words?.length}
{#each l.words as w, k (k)}<span class:fill={w.time_ms + lyr.offset <= nowMs}>{w.text}</span>{k < l.words.length - 1 ? " " : ""}{/each}
{:else}
{l.text}
{/if}
</button>
{/if}
{/each}
{:else if lyr.plain}
<div class="lyr-plain">{lyr.plain}</div>
<div class="lyr-note">Plain lyrics — nothing timed for this recording.</div>
{:else if lyr.status === "instrumental"}
<div class="lyr-note">Instrumental.</div>
{:else}
<div class="lyr-note">No lyrics found for this track.</div>
{/if}
</div>
{:else}
<div class="q-face">
<div class="sec" style="padding-top:8px">
<h2>Up next <em>· from {player.from || player.album.title}</em></h2>
<button class="all" class:on={player.shuffle} onclick={toggleShuffle} aria-label="Shuffle"><Icon name="shuffle" size={14} /></button>
</div>
{#each player.queue.slice(player.qi + 1, player.qi + 41) as e, k (player.qi + 1 + k)}
{@const prevAlbum = k === 0 ? player.album?.title : player.queue[player.qi + k]?.album?.title}
{#if e.album?.title !== prevAlbum}
<div class="q-break">{e.album?.artist} · {e.album?.title}</div>
{/if}
<button class="row" onclick={() => playQueueAt(player.qi + 1 + k)} oncontextmenu={(ev) => { ev.preventDefault(); openHalf("track", { album: e.album, track: e.t }); }}>
<span class="art"><Cover path={e.album?.art_path} size={18} /></span>
<span class="b"><span class="t">{e.t.title}</span><span class="m">{e.t.artist || e.album?.artist}</span></span>
<span class="r">{mmss(e.t.duration_secs)}</span>
</button>
{/each}
{#if player.qi + 1 >= player.queue.length}
<div class="stub"><b>End of the queue</b>Nothing after this.</div>
{/if}
</div>
{/if}
</div>
{/if}
<div class="np-meta">
<div class="b">
<div class="np-t">{player.track?.title}</div>
{#if artistInLibrary}
<button class="np-a" onclick={goArtist}>{artistName}</button>
{:else}
<span class="np-a">{artistName}</span>
{/if}
</div>
</div>
<div class="np-scrub">
<Lane height={38} peaks={player.peaks} heldPeaks={player.heldPeaks} phase={player.phase} coverage={player.coverage} played={played} onseek={seekFrac} />
<div class="times"><span>{mmss(player.position)}</span><span>-{mmss(remaining)}</span></div>
</div>
<div class="np-transport">
<button class="iconbtn" class:on={player.shuffle} class:muted={!player.shuffle} onclick={toggleShuffle} aria-label="Shuffle"><Icon name="shuffle" size={21} /></button>
<button class="iconbtn" onclick={prev} aria-label="Previous"><Icon name="skip-back" size={30} /></button>
<button class="disc lg" onclick={toggle} aria-label={playing ? "Pause" : "Play"}><Icon name={playing ? "pause" : "play"} size={27} /></button>
<button class="iconbtn" onclick={next} aria-label="Next"><Icon name="skip-forward" size={30} /></button>
<button class="iconbtn" class:on={player.repeat > 0} class:muted={!player.repeat} onclick={cycleRepeat} aria-label="Repeat"><Icon name={player.repeat === 2 ? "repeat-one" : "repeat"} size={21} /></button>
</div>
<div class="np-foot">
<div class="np-faces">
{#each [["art", "disc", "Art"], ["lyrics", "lyrics", "Lyrics"], ["queue", "playlist", "Queue"]] as [f, ic, lb] (f)}
<button class="fbtn" class:on={face === f} onclick={() => (nav.face = f)}><Icon name={ic} size={17} /><span>{lb}</span></button>
{/each}
</div>
</div>
</div>
{/if}
</div>
+124
View File
@@ -0,0 +1,124 @@
/* Artwork on the phone: the thumbnail cache behind a gate, and the accent
each cover yields.
Three decodes in flight at most, newest request first — the cover that
just scrolled into view is the one the eye is on (the desktop's library
art gate, LIFO, same reason). A cover is asked for when its card is about
to be seen (the `visible` action) or at once when something needs it now
(the ground, the accent, the mini bar). */
import * as api from "../lib/api.js";
import { accentFromImage } from "../lib/color.js";
/** art_path -> data URL; "" once a fetch failed, so it is never retried */
export const art = $state({});
export const DEFAULT_ACCENT = { accent: "#1f9ad6", hover: "#41b7ee" };
const pending = new Map(); // path -> { promise, resolve }
const waiting = []; // LIFO
let inFlight = 0;
/** The data URL for a cover, fetching it if needed. `urgent` moves it to
* the front of the line. Resolves "" when there is nothing to show. */
export function loadArt(path, urgent = false) {
if (!path) return Promise.resolve("");
if (art[path] !== undefined) return Promise.resolve(art[path]);
/* a streamed record's cover comes out of the file's own tags once enough
of it is here (player.svelte.js sets it); nothing to fetch, and a miss
must not be remembered */
if (isStreamKey(path)) return Promise.resolve("");
const p = pending.get(path);
if (p) {
if (urgent) {
const i = waiting.indexOf(path);
if (i >= 0) { waiting.splice(i, 1); waiting.push(path); }
}
return p.promise;
}
let resolve;
const promise = new Promise((r) => { resolve = r; });
pending.set(path, { promise, resolve });
waiting.push(path);
pump();
return promise;
}
/* a library cover comes from the thumbnail cache; a catalog picture (an
https URL) comes through the desktop's art proxy, which caches it and
hands back a data URL — so the ground and the accent can read it too */
function fetchArt(p) {
return /^https?:\/\//.test(p) ? api.catalogArt(p) : api.albumArt(p, 360);
}
function pump() {
while (inFlight < 3 && waiting.length) {
const p = waiting.pop();
inFlight++;
fetchArt(p)
.then((u) => u || "", () => "")
.then((u) => {
art[p] = u;
const w = pending.get(p);
pending.delete(p);
if (w) w.resolve(u);
})
.finally(() => { inFlight--; pump(); });
}
}
export function wantArt(path) { void loadArt(path); }
/** use:visible={path} — ask for a cover when its card is about to be seen. */
export function visible(node, path) {
let io = null;
const arm = (p) => {
if (io) { io.disconnect(); io = null; }
if (!p || art[p] !== undefined) return;
io = new IntersectionObserver(
(entries) => {
if (entries.some((e) => e.isIntersecting)) {
wantArt(p);
io.disconnect();
io = null;
}
},
{ rootMargin: "320px" },
);
io.observe(node);
};
arm(path);
return {
update(p) { arm(p); },
destroy() { if (io) io.disconnect(); },
};
}
/* ── the accent ─────────────────────────────────────────────────────────
src/lib/color.js, the desktop's own extraction, on the same 360px thumb
the cards show. Cached per cover: a record's colour never changes. */
const accents = new Map(); // path -> Promise<{ accent, hover }>
export const isStreamKey = (path) => typeof path === "string" && path.startsWith("stream:");
export function accentFor(path) {
if (!path) return Promise.resolve(DEFAULT_ACCENT);
if (isStreamKey(path) && !art[path]) return Promise.resolve(DEFAULT_ACCENT);
if (!accents.has(path)) {
accents.set(
path,
loadArt(path, true)
.then((u) => (u ? accentFromImage(u) : null))
.then((c) => (c ? { accent: c.accent, hover: c.hover } : DEFAULT_ACCENT))
.catch(() => DEFAULT_ACCENT),
);
}
return accents.get(path);
}
/** Set the app's accent. The registered property + the transition on html
* (mobile.css) crossfade it on the compositor. */
export function applyAccent(c) {
const s = document.documentElement.style;
s.setProperty("--accent", c.accent);
s.setProperty("--accent-hover", c.hover);
}
+160
View File
@@ -0,0 +1,160 @@
/* The catalog on the phone: the desktop's lookups (Deezer through the
metadata crate, the art proxy for pictures), cached for the session.
An artist page opens cold — the library's own shell, instantly — and the
lookup fills the rest in: the picture, Popular, the discography. The
identity rule is the desktop's (`openArtistByName`): among the search's
exact-name matches, the most-followed wins, so a shared name resolves to
the canonical artist and never to a namesake the search listed first. */
import * as api from "../lib/api.js";
export const catalog = $state({
/** lowercase name → { status: loading | ready | none | error, id, seed, page, error } */
artists: {},
/** catalog album id → { status, page, error } */
albums: {},
/** artist id → { status, related, profile, bio } — fans also like, the
* Last.fm profile (listeners, plays, tags), the bio; once per artist */
extras: {},
/** artist id → { status, listeners, plays, tags } — the profile alone,
* for the search hero */
profiles: {},
/** lowercase name → CatalogArtist | null — a portrait for a byline */
seeds: {},
/** "artist\u0000title" → { status, info: { description, genre, label, source } } */
albumInfo: {},
});
export function artistKey(name) {
return String(name || "").trim().toLowerCase();
}
/** The most-followed exact match for a name, or the best the search has. */
async function bestArtistFor(name) {
const k = artistKey(name);
const res = await api.catalogSearch(name);
const artists = res?.artists ?? [];
const exact = artists.filter((a) => artistKey(a.name) === k);
const pool = exact.length ? exact : artists;
return pool.reduce((top, a) => ((a.fans ?? 0) > (top?.fans ?? -1) ? a : top), null);
}
/** Open an artist by name — or by the search's own row (`seed`, a
* CatalogArtist with its id), which skips the identity search: the row
* IS the identity, namesake-safe, and its portrait is on screen at once. */
export function lookupArtist(name, seed = null) {
const k = artistKey(name);
if (!k) return;
const cur = catalog.artists[k];
if (cur && cur.status !== "error") return;
catalog.artists[k] = { status: "loading", id: seed?.id ?? null, seed: seed ?? null, page: null };
(async () => {
try {
const best = seed?.id != null ? seed : await bestArtistFor(name);
if (!best) { catalog.artists[k] = { status: "none", id: null, seed: null, page: null }; return; }
catalog.artists[k] = { status: "loading", id: best.id, seed: best, page: null };
const page = await api.catalogArtist(best.id);
catalog.artists[k] = { status: "ready", id: best.id, seed: best, page };
} catch (e) {
/* transport failures stay retryable — the next visit asks again */
catalog.artists[k] = { status: "error", id: null, seed: null, page: null, error: String(e) };
}
})();
}
/** Fans also like, the Last.fm profile and the bio — the desktop's
* ArtistExtras, asked once per artist after the page is on screen. */
export function loadExtras(id, name) {
if (id == null || catalog.extras[id]) return;
catalog.extras[id] = { status: "loading", related: [], profile: null, bio: "", socials: [] };
Promise.all([
api.catalogRelated(id).catch(() => []),
api.catalogArtistProfile(id).catch(() => null),
api.catalogArtistBio(id, name).catch(() => null),
/* the socials are a deferred MusicBrainz pass on the desktop too:
rate-limited upstream, asked after the page is on screen */
api.catalogArtistSocials(id).catch(() => []),
]).then(([r, p, b, l]) => {
const text = typeof b === "string" ? b : (b?.text ?? b?.bio ?? "");
catalog.extras[id] = { status: "ready", related: r ?? [], profile: p ?? null, bio: text ?? "", socials: Array.isArray(l) ? l : [] };
if (p) catalog.profiles[id] = { status: "ready", ...p };
});
}
/** The profile alone: the search hero's tags and totals. */
export function loadProfile(id) {
if (id == null || catalog.profiles[id]) return;
catalog.profiles[id] = { status: "loading" };
api.catalogArtistProfile(id).then(
(p) => { catalog.profiles[id] = { status: "ready", ...(p || {}) }; },
() => { catalog.profiles[id] = { status: "error" }; },
);
}
/** A portrait for a byline — the same identity rule, search only. */
export function seedFor(name) {
const k = artistKey(name);
if (!k || k in catalog.seeds) return;
catalog.seeds[k] = null;
const known = catalog.artists[k]?.seed;
if (known) { catalog.seeds[k] = known; return; }
bestArtistFor(name).then((a) => { catalog.seeds[k] = a || null; }, () => {});
}
export const infoKey = (artist, title) => String(artist || "").toLowerCase() + "\u0000" + String(title || "").toLowerCase();
/** About this record: Wikipedia's words, the genre and the label, through
* the desktop's own lookup. Once per record. */
export function loadAlbumInfo(artist, title) {
const k = infoKey(artist, title);
if (!artist || !title || catalog.albumInfo[k]) return;
catalog.albumInfo[k] = { status: "loading", info: null };
api.catalogAlbumInfo(null, artist, title).then(
(info) => { catalog.albumInfo[k] = { status: "ready", info: info || null }; },
() => { catalog.albumInfo[k] = { status: "error", info: null }; },
);
}
export function lookupAlbum(id) {
if (id == null) return;
const cur = catalog.albums[id];
if (cur && cur.status !== "error") return;
catalog.albums[id] = { status: "loading", page: null };
api.catalogAlbum(id).then(
(page) => { catalog.albums[id] = { status: "ready", page }; },
(e) => { catalog.albums[id] = { status: "error", page: null, error: String(e) }; },
);
}
/** Deezer answers a missing artist photo with a grey silhouette IMAGE under
* the hash of the empty string (the metadata crate's
* DEEZER_EMPTY_PICTURE_HASH), which reads as a light disc against the dark
* UI. Treat it as no picture, so the app's own placeholder shows. */
const DEEZER_EMPTY_PICTURE_HASH = "d41d8cd98f00b204e9800998ecf8427e";
export function picture(url) {
if (!url) return null;
const u = String(url);
return u.includes(DEEZER_EMPTY_PICTURE_HASH) || /\/images\/[a-z]+\/\//.test(u) ? null : u;
}
/** "1.2M fans", the desktop's stat chip */
export function fans(n) {
if (!n) return "";
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1).replace(/\.0$/, "") + "M fans";
if (n >= 1000) return Math.round(n / 1000) + "k fans";
return n + " fans";
}
export function yearOf(date) {
const m = /^(\d{4})/.exec(String(date || ""));
return m ? m[1] : "";
}
/** Titles compared loosely: case, punctuation and bracketed suffixes
* ("(Remastered)") do not make a different record. */
export function looseTitle(s) {
return String(s || "")
.toLowerCase()
.replace(/\s*[([].*?[)\]]\s*/g, " ")
.replace(/[^\p{L}\p{N}]+/gu, " ")
.trim();
}
+94
View File
@@ -0,0 +1,94 @@
/* The discography as the desktop files it (CatalogArtist.svelte's tabs):
one category per release, the categories' order and labels from the
shared TAB_DEFS, editions folded into families by editions.js. The
desktop also weighs MusicBrainz type hints and each record's own
description before the title heuristics; the phone files on what the
catalog page carries — the edition link, the announced date, the
mixtape flag, then the same title rules the library uses. */
import { categoryForLocal, TAB_DEFS } from "../lib/localReleaseType.js";
import { releaseFamilies } from "../lib/editions.js";
const today = () => new Date().toISOString().slice(0, 10);
/** An announced full-length: dated, and the date is still ahead. */
export const isUpcoming = (a) =>
(a.record_type || "").toLowerCase() === "album"
&& typeof a.release_date === "string" && a.release_date.length >= 10
&& a.release_date.slice(0, 10) > today();
function ownCategory(album) {
if (isUpcoming(album)) return "upcoming";
if (album.is_mixtape) return "mixtape";
return categoryForLocal(album);
}
/** A linked edition files WITH its base release (one hop). */
export function categoryFor(album, albums) {
if (album.edition_group != null && album.id !== album.edition_group) {
const base = (albums || []).find((o) => o.id === album.edition_group);
if (base) return ownCategory(base);
}
return ownCategory(album);
}
/** [{ key, label, items: families }] — only the tabs with something in them. */
export function discoTabs(albums) {
const list = albums || [];
const by = {};
for (const def of TAB_DEFS) by[def.key] = [];
for (const a of list) (by[categoryFor(a, list)] ?? by.other).push(a);
return TAB_DEFS
.map((def) => ({ key: def.key, label: def.label, items: releaseFamilies(def.cats.flatMap((c) => by[c] || []), def.key) }))
.filter((t) => t.items.length > 0);
}
/** "1997–present" / "1997–2013": the released records' span. */
export function activeYears(albums) {
const years = (albums || [])
.filter((a) => !isUpcoming(a))
.map((a) => Number(String(a.release_date || "").slice(0, 4)))
.filter((y) => Number.isFinite(y) && y > 1900);
if (!years.length) return null;
const first = Math.min(...years);
const last = Math.max(...years);
const nowYear = new Date().getFullYear();
return last >= nowYear - 1 ? `${first}–present` : `${first}–${last}`;
}
/** The newest dated record. */
export function latestOf(albums) {
let best = null;
for (const a of albums || []) {
if (!a.release_date) continue;
if (!best || a.release_date > best.release_date) best = a;
}
return best;
}
/** One column per year across the whole span, gaps included — null under
* three years, when it would say nothing. */
export function timelineOf(albums) {
const byYear = new Map();
for (const a of albums || []) {
const y = String(a.release_date || "").slice(0, 4);
if (y) byYear.set(y, (byYear.get(y) ?? 0) + 1);
}
const years = [...byYear.keys()].sort();
if (years.length < 3) return null;
const first = Number(years[0]);
const last = Number(years[years.length - 1]);
const peak = Math.max(...byYear.values());
const cols = [];
for (let y = first; y <= last; y++) {
const n = byYear.get(String(y)) ?? 0;
cols.push({ year: y, n, h: n ? Math.max(8, Math.round((n / peak) * 44)) : 2 });
}
return { cols, first, last, total: (albums || []).length };
}
/** The desktop's social chips: a label per link kind. */
export const SOCIAL_LABEL = {
website: "Website", wikipedia: "Wikipedia", bandcamp: "Bandcamp", soundcloud: "SoundCloud",
youtube: "YouTube", instagram: "Instagram", twitter: "X", x: "X", facebook: "Facebook", tiktok: "TikTok",
spotify: "Spotify", apple: "Apple Music", discogs: "Discogs", lastfm: "Last.fm", musicbrainz: "MusicBrainz",
};
+148
View File
@@ -0,0 +1,148 @@
/* Home's material on the phone — the desktop's homeStore: one slot per
followed artist (their catalog page's records, the MusicBrainz-only
ones, six related artists), the news across all of them, warmed the
same way (home_warm fills the backend caches this walk reads). The rows
themselves — the feed, the back catalogue, the recommendations, the
set to complete — are derived in Home.svelte from these slots with the
desktop's own rules, copied. */
import * as api from "../lib/api.js";
import { listen } from "../lib/events.js";
import { lib, refreshFollows } from "./lib.svelte.js";
export const home = $state({
started: false,
/** artistId → { id, name, picture, albums, mb, related, error } */
slots: {},
order: [],
/** [{ artist, title, url, source, date (unix secs) }] newest first */
news: [],
newsLoaded: false,
loading: false,
});
async function loadArtist(f) {
const slot = { id: f.artist_id, name: f.name, picture: f.portrait ?? null, albums: [], mb: [], related: [] };
try {
const page = await api.catalogArtist(f.artist_id);
slot.name = page?.artist?.name ?? f.name;
slot.picture = page?.artist?.picture ?? slot.picture;
slot.albums = (page?.albums ?? []).filter((a) => !a.appears_on);
} catch { slot.error = true; }
try {
const identity = await api.catalogArtistIdentity(f.artist_id, slot.name);
slot.mb = identity?.albums ?? [];
} catch { /* the Deezer page stands alone */ }
try {
slot.related = ((await api.catalogRelated(f.artist_id)) ?? []).slice(0, 6);
} catch { /* fine */ }
home.slots[f.artist_id] = slot;
}
let loadSeq = 0;
/** Bring the landing page's material in. Safe to call again: warm caches
* make a re-walk cost milliseconds. `force` re-reads every slot. */
export async function loadHome(force = false) {
if (home.started && !force) return;
home.started = true;
const my = ++loadSeq;
home.loading = true;
void api.homeWarm().catch(() => {});
await refreshFollows();
const follows = lib.follows;
home.order = follows.map((f) => f.artist_id);
for (const f of follows) {
if (my !== loadSeq) return;
if (force || !home.slots[f.artist_id]) await loadArtist(f);
}
if ((!home.newsLoaded || force) && follows.length) {
try {
const byArtist = await api.homeArtistNews(follows.map((f) => f.name));
if (my !== loadSeq) return;
home.news = Object.entries(byArtist || {})
.flatMap(([artist, items]) => (items ?? []).map((n) => ({ ...n, artist })))
.sort((a, b) => b.date - a.date);
home.newsLoaded = true;
} catch { /* the page reads fine without news */ }
}
if (my === loadSeq) home.loading = false;
}
let bound = false;
/** A follow made anywhere (the artist page, the desktop's copy of the
* library) re-walks the slots. */
export async function bindHome() {
if (bound) return;
bound = true;
try { await listen("soulseek:follows-changed", () => { void loadHome(true); }); } catch { /* not a phone */ }
}
/* ── the desktop's date words ─────────────────────────────────────── */
const DAY = 86400000;
export const daysFrom = (d) => Math.floor((Date.now() - new Date(d).getTime()) / DAY);
export const future = (d) => !!d && new Date(d).getTime() > Date.now();
export function age(d) {
if (!d) return "";
const a = daysFrom(d);
if (a <= 0) return "today";
if (a === 1) return "yesterday";
if (a < 7) return a + "d ago";
if (a < 31) return Math.round(a / 7) + "w ago";
if (a < 365) return Math.round(a / 30.4) + "mo ago";
return (a / 365).toFixed(a < 730 ? 1 : 0) + "y ago";
}
export function ageShort(ts) {
const h = (Date.now() - ts * 1000) / 3600000;
if (h < 1) return "just now";
if (h < 24) return Math.round(h) + "h ago";
const a = Math.floor(h / 24);
if (a === 1) return "yesterday";
if (a < 7) return a + "d ago";
return Math.round(a / 7) + "w ago";
}
export function inDays(d) {
const n = -daysFrom(d);
return n === 1 ? "tomorrow" : "in " + n + " days";
}
export function upcomingLabel(album) {
const value = album.release_date;
if (!value) return "Upcoming · Date TBA";
if (value.length === 4) return `Expected ${value} · Date TBA`;
if (value.length < 10) {
const month = new Date(`${value.slice(0, 7)}-01T00:00:00`).toLocaleDateString(undefined, { month: "short", year: "numeric" });
return `Expected ${month} · Date TBA`;
}
const date = new Date(`${value.slice(0, 10)}T00:00:00`);
return `Releases ${date.toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" })}`;
}
const KIND = { album: "Album", ep: "EP", single: "Single" };
export const kindLabel = (t) => KIND[t] || "Album";
export const RANK = { album: 3, ep: 2, single: 1 };
export const baseTitle = (t) => String(t).replace(/\s*[([].*$/, "").trim().toLowerCase();
/** The feed: every follow's records inside the window, upcoming first, the
* editions of one work folded to the best-ranked / newest / longer-titled
* one — the desktop's assembleFeed, verbatim. */
export function assembleFeed(slots, windowDays) {
const out = [];
for (const slot of slots) {
const kept = [];
const seen = [...(slot.albums ?? []), ...((slot.mb ?? []).map((a) => ({ ...a, mbOnly: true })))];
for (const r of seen) {
if (!r.release_date || !r.cover) continue;
const upcoming = future(r.release_date);
if (!upcoming && daysFrom(r.release_date) > windowDays) continue;
const cand = { ...r, artistName: slot.name, artistId: slot.id, upcoming };
const rival = kept.find((k) => k.cover === cand.cover || baseTitle(k.title) === baseTitle(cand.title));
if (!rival) { kept.push(cand); continue; }
const rr = RANK[rival.record_type] || 0;
const cr = RANK[cand.record_type] || 0;
if (cr > rr || (cr === rr && String(cand.release_date) > String(rival.release_date))) {
kept[kept.indexOf(rival)] = cand;
} else if (cr === rr && cand.release_date === rival.release_date && cand.title.length > rival.title.length) {
rival.title = cand.title;
}
}
out.push(...kept);
}
return out.sort((x, y) => (y.upcoming ? 1 : 0) - (x.upcoming ? 1 : 0) || String(y.release_date).localeCompare(String(x.release_date)));
}
+123
View File
@@ -0,0 +1,123 @@
/* The library, as the phone reads it: albums from library.db, the recents
from the play history, follows and playlists. One store, loaded once at
launch; the pieces that change (recents) are re-read when they do. */
import * as api from "../lib/api.js";
import { listen } from "../lib/events.js";
export const lib = $state({
albums: [],
recents: [], // library rows only, with an album_id
follows: [],
playlists: [],
version: "",
username: null,
loaded: false,
error: "",
});
const tracksCache = new Map(); // album id -> Promise<Track[]>
/** An album's tracks, cached for the session (a record's rows do not
* change under the phone). */
export function tracksOf(id) {
if (!tracksCache.has(id)) {
tracksCache.set(
id,
api.albumTracks(id).catch((e) => { tracksCache.delete(id); throw e; }),
);
}
return tracksCache.get(id);
}
export function albumById(id) {
return lib.albums.find((a) => a.id === id) || null;
}
/** Newest first — "recently added" everywhere. */
export function newest() {
return lib.albums.slice().sort((a, b) => b.added_at - a.added_at);
}
/** An artist's records, newest year first. */
export function albumsBy(artist) {
const k = String(artist || "").toLowerCase();
return lib.albums
.filter((a) => a.artist.toLowerCase() === k)
.sort((x, y) => (y.year || 0) - (x.year || 0) || y.added_at - x.added_at);
}
/** Every artist in the library, biggest first, with the portrait the
* library already holds (artist_art_path) when it has one. Casing splits in
* the tags ("King Gizzard & The/the Lizard Wizard") merge here. */
export function artists() {
const m = new Map();
for (const a of lib.albums) {
const k = a.artist.toLowerCase();
let e = m.get(k);
if (!e) { e = { name: a.artist, albums: 0, tracks: 0, art: null, cover: null }; m.set(k, e); }
e.albums++;
e.tracks += a.track_count || 0;
if (!e.art && a.artist_art_path) e.art = a.artist_art_path;
if (!e.cover && a.art_path) e.cover = a.art_path;
}
return [...m.values()].sort((x, y) => y.albums - x.albums || y.tracks - x.tracks || x.name.localeCompare(y.name));
}
export function isFollowed(name) {
const k = String(name || "").toLowerCase();
return lib.follows.some((f) => f.name.toLowerCase() === k);
}
export function followedEntry(name) {
const k = String(name || "").toLowerCase();
return lib.follows.find((f) => f.name.toLowerCase() === k) || null;
}
export async function refreshFollows() {
try { lib.follows = (await api.followedArtists()) || []; } catch { /* keep what we had */ }
}
/** Follow or unfollow, by the catalog identity; the list is re-read. */
export async function toggleFollow(artistId, name, portrait = null) {
const cur = followedEntry(name);
if (cur) await api.unfollowArtist(cur.artist_id);
else await api.followArtist(artistId, name, portrait ?? null);
await refreshFollows();
}
export async function refreshRecents() {
try {
const rows = await api.recentAlbums(14);
lib.recents = (rows || []).filter((r) => r.kind === "library" && r.album_id != null);
} catch {
/* the rail simply stays as it was */
}
}
/** A download the pipeline just filed, a rescan, an import: the backend
* says so (soulseek:library-changed) and the albums are re-read. */
let watching = false;
export async function watchLibrary() {
if (watching) return;
watching = true;
let timer = null;
try {
await listen("soulseek:library-changed", () => {
clearTimeout(timer);
timer = setTimeout(async () => {
try { lib.albums = await api.libraryAlbums(); } catch { /* keep */ }
await refreshRecents();
}, 600);
});
} catch { /* not a phone */ }
}
export async function loadLibrary() {
try {
const st = await api.appStatus();
lib.version = st?.version ?? "";
lib.username = st?.connected_as ?? null;
} catch (e) { lib.error = String(e); }
try { lib.albums = await api.libraryAlbums(); } catch (e) { lib.error = String(e); }
await refreshRecents();
try { lib.follows = (await api.followedArtists()) || []; } catch { lib.follows = []; }
try { lib.playlists = (await api.listPlaylists()) || []; } catch { lib.playlists = []; }
lib.loaded = true;
}
+75
View File
@@ -0,0 +1,75 @@
/* The phone's media session: the lock-screen card, the shade's transport,
the headset buttons, audio focus. The card is a mirror of the bar — fed
on every change of track or transport, topped up now and then so its
progress does not drift — and its buttons come back as actions the
player carries out. Everything real lives in the plugin
(crates/tauri-plugin-media-session); this is the wiring. */
import { invoke, addPluginListener } from "@tauri-apps/api/core";
import { player, hooks, toggle, next, prev, seekSecs } from "./player.svelte.js";
import { art } from "./art.svelte.js";
let lastArtKey = null;
let timer = null;
let bound = false;
async function push() {
timer = null;
const t = player.track;
if (!t) {
lastArtKey = null;
await invoke("plugin:media-session|clear").catch(() => {});
return;
}
const key = player.album?.art_path || null;
const url = key ? art[key] : "";
const state = {
title: t.title,
artist: t.artist || player.album?.artist || "",
album: player.album?.title || "",
playing: player.status === "playing" || player.status === "buffering",
position: player.position,
duration: player.duration,
artKey: key,
art: null,
hasNext: player.qi + 1 < player.queue.length || player.repeat > 0,
hasPrev: true,
};
/* the cover's bytes ride along once per record, and only once they exist */
if (key && key !== lastArtKey && url && url.startsWith("data:image")) {
state.art = url.slice(url.indexOf(",") + 1);
lastArtKey = key;
}
await invoke("plugin:media-session|update", { state }).catch(() => {});
}
/** Coalesce a burst of changes (track, then status, then position) into one card update. */
export function syncMedia() {
if (timer) clearTimeout(timer);
timer = setTimeout(push, 60);
}
/** Wire the card to the player. Harmless where there is no plugin. */
export async function bindMedia() {
if (bound) return;
bound = true;
hooks.mediaSync = syncMedia;
try {
await addPluginListener("media-session", "action", (p) => {
const playing = player.status === "playing" || player.status === "buffering";
switch (p?.action) {
case "play": if (!playing) toggle(); break;
case "pause": if (playing) toggle(); break;
case "toggle": toggle(); break;
case "next": next(); break;
case "prev": prev(); break;
case "seek": if (typeof p.position === "number" && p.position >= 0) seekSecs(p.position); break;
case "stop": if (playing) toggle(); break;
}
});
} catch {
/* not a phone */
}
/* the card interpolates its progress from the last update; a top-up
every half minute keeps a long track honest */
setInterval(() => { if (player.track) syncMedia(); }, 30_000);
}
+142
View File
@@ -0,0 +1,142 @@
/* ═══════════════════════════════════════════════════════════════════════════
SoulStream on Android — foundations.
The palette is the desktop palette, unchanged (global.css / the mockup's
tokens.css): same greys, same accent, same surface ladder. What is new is
spatial — radii open up, type goes up a step, every tappable thing is 48dp
— and the material: the page ground is the artwork in play, blurred.
Text is authored in px; the Android WebView applies the system font scale
to CSS px on its own, so there is no sp arithmetic here.
═══════════════════════════════════════════════════════════════════════════ */
/* the desktop's face, from the same files */
@font-face { font-family: "Nunito"; font-style: normal; font-weight: 400; font-display: swap; src: url("/assets/fonts/nunito-400.woff2") format("woff2"); }
@font-face { font-family: "Nunito"; font-style: normal; font-weight: 600; font-display: swap; src: url("/assets/fonts/nunito-600.woff2") format("woff2"); }
@font-face { font-family: "Nunito"; font-style: normal; font-weight: 700; font-display: swap; src: url("/assets/fonts/nunito-700.woff2") format("woff2"); }
/* Typed registration so the per-artwork accent can TRANSITION. The desktop
crossfades its accent on a rAF loop (state.svelte.js); on the phone the
compositor does it — see the transition on html below. */
@property --accent { syntax: "<color>"; inherits: true; initial-value: #1f9ad6; }
@property --accent-hover { syntax: "<color>"; inherits: true; initial-value: #41b7ee; }
:root {
--bg-base: #121212;
--bg-elevated: #181818;
--bg-highlight: #242424;
--bg-press: #2a2a2a;
--text-primary: #ffffff;
--text-secondary: #b3b3b3;
--text-muted: #7a7a7a;
--accent: #1f9ad6;
--accent-hover: #41b7ee;
--accent-soft: color-mix(in srgb, var(--accent) 15%, transparent);
--border: #2a2a2a;
/* the surface ladder: the same three alphas the desktop's glass mode
uses, because the ground under a surface is the ambience here */
--surface: rgba(255, 255, 255, 0.03);
--hover: rgba(255, 255, 255, 0.08);
--press: rgba(255, 255, 255, 0.11);
--hairline: rgba(255, 255, 255, 0.1);
--ring: inset 0 0 0 1px rgba(255, 255, 255, 0.06);
--well: rgba(0, 0, 0, 0.18);
--r-art: 10px;
--r-card: 14px;
--r-sheet: 26px;
--r-pill: 999px;
--pad: 18px;
--gap: 10px;
--nav-h: 60px;
--mini-h: 70px; /* a 62dp row over a 16dp lane strip */
--bar-h: 52px; /* the glass bar */
--tap: 48px;
/* Android's own insets: the status bar above, the gesture handle below. */
--inset-top: env(safe-area-inset-top, 0px);
--inset-bottom: env(safe-area-inset-bottom, 0px);
--top-h: calc(var(--bar-h) + var(--inset-top));
/* ── motion: real springs, sampled ────────────────────────────────────
Material 3 Expressive specifies motion as springs; CSS takes a sampled
curve through linear(). These are the mockup's generated tokens
(research/mobile-mockup/tools/springs.mjs): the spatial ones overshoot
because real objects do, the effects ones never do. */
--sp-spatial-fast: linear(0, 0.0512, 0.176, 0.3375, 0.5079, 0.6679, 0.8057, 0.9155, 0.9963, 1.0502, 1.081, 1.0937, 1.0931, 1.0838, 1.0696, 1.0535, 1.0378, 1.0237, 1.0121, 1.0033, 0.9971, 0.9933, 0.9915, 0.991, 0.9916, 0.9928, 0.9943, 0.9958, 0.9972, 0.9984, 0.9994, 1, 1.0005, 1.0008, 1);
--spd-spatial-fast: 413ms;
--sp-spatial-default: linear(0, 0.0329, 0.1132, 0.2191, 0.335, 0.4503, 0.558, 0.6543, 0.7374, 0.8069, 0.8634, 0.9081, 0.9425, 0.9681, 0.9865, 0.9992, 1.0074, 1.0122, 1.0145, 1.0152, 1.0146, 1.0134, 1.0118, 1.01, 1.0083, 1.0067, 1.0052, 1.0039, 1.0029, 1.002, 1.0014, 1.0009, 1.0005, 1.0002, 1);
--spd-spatial-default: 482ms;
--sp-spatial-slow: linear(0, 0.0309, 0.1068, 0.2078, 0.3194, 0.4314, 0.5372, 0.6329, 0.7165, 0.7874, 0.8459, 0.893, 0.9298, 0.9579, 0.9787, 0.9935, 1.0035, 1.0098, 1.0134, 1.0149, 1.0151, 1.0143, 1.013, 1.0114, 1.0097, 1.008, 1.0065, 1.0051, 1.0039, 1.0029, 1.002, 1.0014, 1.0009, 1.0005, 1);
--spd-spatial-slow: 641ms;
--sp-effects-fast: linear(0, 0.0527, 0.1672, 0.3005, 0.4304, 0.5464, 0.6448, 0.7255, 0.7901, 0.8409, 0.8803, 0.9105, 0.9334, 0.9507, 0.9637, 0.9733, 0.9804, 0.9857, 0.9896, 0.9924, 0.9945, 0.996, 0.9971, 0.9979, 0.9985, 0.9989, 0.9992, 0.9994, 0.9996, 0.9997, 0.9998, 0.9999, 0.9999, 0.9999, 1);
--spd-effects-fast: 202ms;
--sp-effects-default: linear(0, 0.0436, 0.1412, 0.2593, 0.3786, 0.4892, 0.5866, 0.6696, 0.7385, 0.7947, 0.84, 0.876, 0.9044, 0.9266, 0.9439, 0.9572, 0.9675, 0.9754, 0.9814, 0.986, 0.9894, 0.9921, 0.9941, 0.9956, 0.9967, 0.9975, 0.9982, 0.9986, 0.999, 0.9992, 0.9994, 0.9996, 0.9997, 0.9998, 1);
--spd-effects-default: 280ms;
--sp-effects-slow: linear(0, 0.0388, 0.1273, 0.2365, 0.3492, 0.4558, 0.5517, 0.635, 0.7056, 0.7644, 0.8128, 0.8521, 0.8837, 0.9089, 0.929, 0.9448, 0.9572, 0.9669, 0.9745, 0.9804, 0.9849, 0.9885, 0.9912, 0.9933, 0.9949, 0.9961, 0.997, 0.9977, 0.9983, 0.9987, 0.999, 0.9993, 0.9994, 0.9996, 1);
--spd-effects-slow: 371ms;
--m-fast: var(--spd-effects-fast) var(--sp-effects-fast);
--m-base: var(--spd-spatial-fast) var(--sp-spatial-fast);
--m-sheet: var(--spd-spatial-default) var(--sp-spatial-default);
--m-spring: var(--spd-spatial-fast) var(--sp-spatial-fast);
--art-grow: 1.035;
--ambience: 0.55; /* how much of the artwork the page ground admits */
--grain: 0.05;
color-scheme: dark;
}
/* The registered property only makes --accent animatable; this is the
crossfade. Slow on purpose — it is the app changing colour, and it should
read as a change rather than a flicker. */
html {
transition:
--accent var(--spd-effects-slow) linear,
--accent-hover var(--spd-effects-slow) linear;
}
/* Appearance → Accent from artwork OFF: the brand blue everywhere */
html[data-accent="brand"] { --accent: #1f9ad6 !important; --accent-hover: #41b7ee !important; }
/* Appearance → Ambience OFF: the honest floor, the desktop's measured greys */
html[data-material="solid"] {
--ambience: 0;
--surface: var(--bg-elevated);
--hover: var(--bg-highlight);
--press: var(--bg-press);
--hairline: rgba(255, 255, 255, 0.07);
}
* { box-sizing: border-box; }
html, body {
margin: 0;
padding: 0;
height: 100%;
background: var(--bg-base);
color: var(--text-primary);
font-family: "Nunito", -apple-system, "Segoe UI", Roboto, sans-serif;
font-size: 15px;
-webkit-font-smoothing: antialiased;
-webkit-tap-highlight-color: transparent;
overscroll-behavior: none;
user-select: none;
-webkit-user-select: none;
}
#mobile { height: 100%; }
/* :where() so the reset carries zero specificity and never beats a
component rule (the mockup lost every accent-filled control to this once) */
:where(button) {
font: inherit;
color: inherit;
background: none;
border: 0;
padding: 0;
margin: 0;
cursor: default;
touch-action: manipulation;
}
:where(input) { font: inherit; color: inherit; }
img { display: block; }
+171
View File
@@ -0,0 +1,171 @@
/* Navigation: a stack per tab, the way Android does it, plus the two
sheets. The state here is the truth; the browser history is a MIRROR of
it, kept one entry per back-able layer, so the system back gesture — which
reaches a Tauri WebView as WebView.goBack() — peels exactly one layer:
the player sheet, then a half sheet, then the pushed screen, then the
tab (back to Home). With nothing left to peel there is no history entry
left either, and the activity goes to the background as Android expects. */
export const TABS = [
["home", "home", "Home"],
["search", "search", "Search"],
["library", "library", "Library"],
["community", "users", "Community"],
["you", "settings", "You"],
];
const root = (n) => ({ n, c: {}, scroll: 0 });
export const nav = $state({
tab: "home",
stacks: {
home: [root("home")],
search: [root("search")],
library: [root("library")],
community: [root("community")],
you: [root("you")],
},
/* the transition the next render plays, and a counter that forces it */
tr: "tab",
seq: 0,
/* the player sheet; `morph` = it opened from the mini bar's artwork */
sheet: false,
morph: false,
face: "art",
/* the half sheet: { kind, arg } */
half: null,
});
/** Where the container transform starts — the mini bar's art, measured the
* moment it was tapped (the bar starts sliding away at once, so a later
* measurement would be short). Plain, not reactive: a rect is not state. */
export const morphSource = { rect: null };
let depth = 0; // history entries this module pushed
let suppress = 0; // popstates to ignore (our own history.go)
let booted = false;
export function top() {
const s = nav.stacks[nav.tab];
return s[s.length - 1];
}
export function isRoot() {
return nav.stacks[nav.tab].length === 1;
}
function backables() {
return (nav.sheet ? 1 : 0) + (nav.half ? 1 : 0)
+ (nav.stacks[nav.tab].length - 1) + (nav.tab !== "home" ? 1 : 0);
}
/* make the history depth equal the number of layers the back gesture can peel */
function sync() {
if (!booted) return;
const want = backables();
while (depth < want) {
depth++;
try { history.pushState({ d: depth }, ""); } catch { /* file:// previews */ }
}
if (want < depth) {
const k = depth - want;
depth = want;
suppress++;
try { history.go(-k); } catch { suppress--; }
}
}
export function bootNav() {
booted = true;
try { history.replaceState({ d: 0 }, ""); } catch { /* ignore */ }
window.addEventListener("popstate", () => {
if (suppress > 0) { suppress--; return; }
depth = Math.max(0, depth - 1);
if (nav.sheet) nav.sheet = false;
else if (nav.half) nav.half = null;
else if (!popNow() && nav.tab !== "home") switchNow("home");
sync();
});
}
function switchNow(tab) {
nav.half = null;
nav.tab = tab;
nav.tr = "tab";
nav.seq++;
}
function popNow() {
const s = nav.stacks[nav.tab];
if (s.length <= 1) return false;
s.pop();
nav.tr = "pop";
nav.seq++;
return true;
}
/** A tab. Tapping the tab you are on pops it to its root. */
export function go(tab) {
if (tab === nav.tab) {
if (nav.stacks[tab].length > 1) {
nav.stacks[tab] = [nav.stacks[tab][0]];
nav.tr = "pop";
nav.seq++;
}
nav.half = null;
sync();
return;
}
switchNow(tab);
sync();
}
/** Push a screen onto the current tab. `screenEl` is the scroller of the
* screen being left, so a pop can put it back where it was. */
export function push(n, c = {}, screenEl = null) {
nav.half = null;
const t = top();
if (screenEl) t.scroll = screenEl.scrollTop;
nav.stacks[nav.tab].push({ n, c, scroll: 0 });
nav.tr = "push";
nav.seq++;
sync();
}
/** The in-app back chevron. Same order as the system gesture. */
export function back() {
if (nav.sheet) nav.sheet = false;
else if (nav.half) nav.half = null;
else if (!popNow() && nav.tab !== "home") switchNow("home");
sync();
}
/** Same screen, different arguments — a swap, not a journey. */
export function patch(c) {
Object.assign(top().c, c);
}
export function openSheet(fromEl = null) {
if (nav.sheet) return;
let rect = null;
if (fromEl && typeof fromEl.getBoundingClientRect === "function") {
const r = fromEl.getBoundingClientRect();
if (r.width) rect = { left: r.left, top: r.top, width: r.width, height: r.height };
}
morphSource.rect = rect;
nav.morph = !!rect;
nav.sheet = true;
sync();
}
export function closeSheet() {
if (!nav.sheet) return;
nav.sheet = false;
sync();
}
export function openHalf(kind, arg = null) {
nav.half = { kind, arg };
sync();
}
export function closeHalf() {
if (!nav.half) return;
nav.half = null;
sync();
}
+148
View File
@@ -0,0 +1,148 @@
/* The network half of Search: one Soulseek search at a time, its answers
grouped by PEER AND FOLDER — on this network the choice is whose copy to
take, not which file — and ranked the way the desktop's source rules
rank them: lossless first, then a free slot, then speed. The file lists
stay out of the reactive state until a batch is folded in; the screen
sees a sorted snapshot every quarter second. */
import * as api from "../lib/api.js";
import { listen } from "../lib/events.js";
export const AUDIO = new Set(["flac", "mp3", "mp2", "m4a", "m4b", "aac", "ogg", "oga", "wav", "aiff", "aif", "opus", "wma", "ape", "wv", "mpc", "tta", "dsf", "dff", "alac"]);
export const LOSSLESS = new Set(["flac", "wav", "aiff", "aif", "ape", "wv", "alac", "dsf", "dff", "tta"]);
export const net = $state({
query: "",
token: null,
searching: false,
responses: 0,
files: 0,
/** [{ key, username, folder, folderName, slot, speed, queue, files, size, lossless, topExt, maxBitrate, maxDepth }] */
groups: [],
error: "",
});
const groups = new Map(); // key -> group (plain; published as a snapshot)
let publishTimer = null;
let stopTimer = null;
let bound = false;
const GROUPS_MAX = 150;
const FILES_PER_GROUP_MAX = 200;
const SEARCH_MS = 15_000;
function fold(batch) {
let changed = false;
for (const r of batch || []) {
if (r.token !== net.token) continue;
net.responses += 1;
for (const f of r.files || []) {
if (!AUDIO.has(f.extension)) continue;
const key = r.username + "|" + f.folder;
let g = groups.get(key);
if (!g) {
if (groups.size >= GROUPS_MAX) continue;
g = {
key,
username: r.username,
folder: f.folder,
folderName: f.folder.split("\\").filter(Boolean).pop() || r.username,
slot: !!r.has_free_upload_slot,
speed: r.upload_speed || 0,
queue: r.queue_length || 0,
files: [],
exts: new Map(),
size: 0,
lossless: 0,
topExt: "",
maxBitrate: 0,
maxDepth: 0,
};
groups.set(key, g);
}
if (g.files.length >= FILES_PER_GROUP_MAX) continue;
g.files.push(f);
g.size += f.size || 0;
if (LOSSLESS.has(f.extension)) g.lossless += 1;
if ((f.bitrate || 0) > g.maxBitrate) g.maxBitrate = f.bitrate;
if ((f.bit_depth || 0) > g.maxDepth) g.maxDepth = f.bit_depth;
const n = (g.exts.get(f.extension) || 0) + 1;
g.exts.set(f.extension, n);
if (n >= (g.exts.get(g.topExt) || 0)) g.topExt = f.extension;
net.files += 1;
changed = true;
}
}
if (changed) schedulePublish();
}
function schedulePublish() {
if (publishTimer) return;
publishTimer = setTimeout(publish, 250);
}
/** The screen's copy: sorted, files in name order (which is track order). */
function publish() {
publishTimer = null;
const list = [...groups.values()].map((g) => ({
...g,
exts: undefined,
losslessShare: g.files.length ? g.lossless / g.files.length : 0,
files: g.files.slice().sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true })),
}));
list.sort((a, b) =>
b.losslessShare - a.losslessShare
|| (b.slot ? 1 : 0) - (a.slot ? 1 : 0)
|| b.speed - a.speed
|| b.files.length - a.files.length);
net.groups = list;
}
export async function bindNet() {
if (bound) return;
bound = true;
try { await listen("soulseek:search-results", (ev) => fold(ev.payload)); } catch { /* not a phone */ }
}
/** Ask the network. One live search at a time; the last one is stopped. */
export async function search(query) {
const q = String(query || "").trim();
if (!q) return;
await stop();
net.query = q;
net.responses = 0;
net.files = 0;
net.error = "";
groups.clear();
net.groups = [];
net.searching = true;
try {
net.token = await api.startSearch(q);
} catch (e) {
net.searching = false;
net.error = String(e);
return;
}
clearTimeout(stopTimer);
stopTimer = setTimeout(() => stop(true), SEARCH_MS);
}
/** Stop listening; keep what came in unless told otherwise. */
export async function stop(keep = false) {
clearTimeout(stopTimer);
const t = net.token;
net.token = null;
net.searching = false;
if (t != null) api.stopSearch(t).catch(() => {});
if (!keep) { groups.clear(); net.groups = []; }
}
export function speedLabel(kbps) {
if (!kbps) return "speed unknown";
return kbps >= 1000 ? (kbps / 1000).toFixed(1) + " MB/s" : Math.round(kbps) + " kB/s";
}
export function groupFormat(g) {
const ext = (g.topExt || "").toUpperCase();
if (!ext) return "";
if (LOSSLESS.has(g.topExt)) return ext + (g.maxDepth ? " " + g.maxDepth + "-bit" : "");
return ext + (g.maxBitrate ? " " + g.maxBitrate : "");
}
+695
View File
@@ -0,0 +1,695 @@
/* The player, on the phone: one queue, the audio thread behind the same
IPC the desktop uses, and the lane's story.
THE QUEUE IS A LIST. Entries are { t: Track, album: Album } — the album
is carried because the mini bar, the ground and the accent all read the
artwork off it, and a queue can leave the record it started from.
GAPLESS, the desktop's way: when a track starts, the next one is decoded
now and appended behind it in the same sink (`preload_local`); the engine
then reports the boundary as `track-ended { gapless: true }` and the
phone only moves its index. If the queue changed under an armed preload,
the boundary is re-sourced properly instead — a gap, never a wrong track.
SHUFFLE is a predetermined order: the rest of the queue is shuffled once,
so the track the sheet promises next is the one the engine has armed.
The unshuffled queue is kept so shuffle off restores the order.
THE SESSION survives a relaunch (localStorage): the queue, the position,
the modes. A reload finds the audio thread still playing and follows it;
a restart restores the track paused with `needsResource`, and play
re-sources it from where it was — the desktop's rule, kept.
THE LANE'S STORY (src/lib/waveshape.js's constants): on a track change the
outgoing shape is HELD for ARRIVE_HOLD_MS; a decode that lands inside the
hold morphs shape → shape (ARRIVING, one motion over ARRIVE_MS); one that
takes longer drops to the LOADING swell first. */
import * as api from "../lib/api.js";
import { listen } from "../lib/events.js";
import { ARRIVE_HOLD_MS, ARRIVE_MS } from "../lib/waveshape.js";
import { loadArt, art } from "./art.svelte.js";
import { lib, tracksOf } from "./lib.svelte.js";
import { shuffled } from "./util.js";
export const player = $state({
status: "idle", // idle | buffering | playing | paused
position: 0,
duration: null,
queue: [],
qi: -1,
from: "", // what the queue was built from, for the sheet's kicker
track: null,
album: null,
peaks: [],
heldPeaks: [],
phase: "ready", // hold | loading | rising | arriving | ready
/** a stream: how much of the track the peaks describe */
coverage: 1,
/** the transfer behind a streamed track */
stream: { received: 0, total: 0, done: false, failed: null },
/** how the last boundary was crossed: "gapless" (the engine had the next
* appended) or "sourced" (the next was started from scratch) */
advance: "",
shuffle: false,
repeat: 0, // 0 off · 1 all · 2 one
/* restored from the last session with nothing loaded behind it: play
has to re-source the track rather than resume */
needsResource: false,
/* the sleep timer: when it pauses, as a timestamp; 0 = off */
sleepAt: 0,
error: "",
});
/** Things the shell plugs in: `recentsDirty` runs when a play has been
* written and the recents rail should re-read; `mediaSync` mirrors the
* bar to the phone's media session (media.svelte.js). */
export const hooks = { recentsDirty: null, mediaSync: null, resolveEntry: null };
function mediaSync() {
if (typeof hooks.mediaSync === "function") hooks.mediaSync();
}
let gen = 0;
let holdTimer = null;
let arriveTimer = null;
let original = null; // the unshuffled queue while shuffle is on
let armedPath = null; // the file the engine has appended for the next boundary
export function current() {
return player.queue[player.qi] ?? null;
}
function setErr(e) {
player.error = String(e);
}
/* ── the lane's story ────────────────────────────────────────────────── */
function beginStory() {
player.heldPeaks = player.peaks;
player.peaks = [];
player.phase = "hold";
clearTimeout(holdTimer);
clearTimeout(arriveTimer);
const g = gen;
holdTimer = setTimeout(() => {
if (g === gen && player.phase === "hold") player.phase = "loading";
}, ARRIVE_HOLD_MS);
}
function landPeaks(g, peaks) {
if (g !== gen) return;
clearTimeout(holdTimer);
player.peaks = peaks;
player.phase = "arriving";
arriveTimer = setTimeout(() => { if (g === gen) player.phase = "ready"; }, ARRIVE_MS + 80);
}
async function loadWave(g, path) {
try {
const w = await api.waveform(path, 900);
landPeaks(g, Array.isArray(w) ? w : []);
} catch {
if (g === gen) player.phase = "ready";
}
}
/* ── starting a track ────────────────────────────────────────────────── */
async function startEntry(e, startSecs = null) {
if (!e) return;
const g = ++gen;
clearTimeout(watchdog);
++preloadGen;
clearTimeout(preloadPoll);
listenFlush();
armedPath = null;
player.track = e.t;
player.album = e.album;
player.position = startSecs || 0;
player.duration = e.t.duration_secs ?? null;
player.status = "buffering";
player.needsResource = false;
player.error = "";
beginStory();
player.coverage = 1;
player.stream = { received: 0, total: 0, done: false, failed: null };
void loadArt(e.album?.art_path, true);
writeSession();
mediaSync();
if (e.t.catalog && !e.t.path) {
/* a record's later track: it finds its own copy now (resolve.svelte.js) */
let ok = false;
try { ok = await hooks.resolveEntry?.(e); } catch { ok = false; }
if (g !== gen) return;
if (!ok) {
setErr("No source for “" + e.t.title + "”");
ended(false);
return;
}
player.duration = e.t.duration_secs ?? null;
writeSession();
mediaSync();
}
if (e.t.stream) {
/* a peer's copy: the engine plays it as it arrives; the lane tells
the story from the transfer's own bytes (streamStory) */
try {
await api.playStream(e.t.stream.username, e.t.path, e.t.stream.size, e.t.duration_secs ?? null);
} catch (err) {
if (g === gen) { setErr(err); player.status = "idle"; player.phase = "ready"; }
return;
}
if (g !== gen) return;
streamStory(g, e);
armWatchdog(g, e);
armPreload();
return;
}
try {
await api.playLocal(e.t.path, e.t.duration_secs ?? null, startSecs);
} catch (err) {
if (g === gen) { setErr(err); player.status = "idle"; player.phase = "ready"; }
return;
}
if (g !== gen) return;
armPreload();
loadWave(g, e.t.path);
}
/* ── a stream's lane ─────────────────────────────────────────────────────
The partial waveform is asked for every few seconds while the bytes
arrive: the backend folds what it has and says how much of the track that
covers, and the lane RISES to that frontier. The last request, once the
transfer is done, is the whole shape — the arrival. The cover comes out
of the file's own tags once enough of it is here. */
/* A peer that has not sent a byte in time is not about to: the entry's
runner-ups (`t.alts`, the resolver's next candidates) take over one by
one — 12s for the first peer (an indirect handshake honestly takes ~10),
8s for the rest — and when they are spent the queue moves on. */
let watchdog = null;
function armWatchdog(g, e) {
clearTimeout(watchdog);
const first = !e.t.tried;
watchdog = setTimeout(() => {
if (g !== gen) return;
if (player.status === "playing" || player.stream.received > 0) return;
const n = e.t.alts?.shift();
if (n) {
e.t.tried = (e.t.tried || 0) + 1;
e.t.path = n.path;
e.t.stream = { username: n.username, size: n.size };
if (n.duration) e.t.duration_secs = n.duration;
startEntry(e);
return;
}
setErr("Sources for “" + e.t.title + "” aren't responding");
ended(false);
}, first ? 12000 : 8000);
}
let streamTimer = null;
function streamStory(g, e) {
clearTimeout(streamTimer);
const { username, size } = e.t.stream;
const path = e.t.path;
let artTried = false;
const tick = async () => {
if (g !== gen) return;
const done = player.stream.done;
try {
const slice = await api.streamWaveform(username, path, size, 900);
if (g !== gen) return;
const peaks = slice?.peaks || [];
const coverage = Math.max(0, Math.min(1, slice?.coverage ?? 0));
if (peaks.length) {
clearTimeout(holdTimer);
player.peaks = peaks;
player.coverage = coverage;
if (done || coverage >= 0.999) {
player.phase = "arriving";
clearTimeout(arriveTimer);
arriveTimer = setTimeout(() => { if (g === gen) player.phase = "ready"; }, ARRIVE_MS + 80);
} else if (player.phase !== "rising") {
player.phase = "rising";
}
}
} catch { /* nothing to fold yet */ }
if (!artTried && (player.stream.received > 200_000 || done)) {
artTried = true;
const key = e.album?.art_path;
if (key && art[key] === undefined) {
api.streamEmbeddedArt(username, path).then((u) => { art[key] = u || ""; }, () => { art[key] = ""; });
}
}
if (g === gen && !done) streamTimer = setTimeout(tick, 2500);
};
streamTimer = setTimeout(tick, 900);
}
/** The index the engine will play next, in this order, with these modes. */
function nextIndex() {
if (!player.queue.length) return -1;
if (player.repeat === 2) return player.qi;
if (player.qi + 1 < player.queue.length) return player.qi + 1;
return player.repeat === 1 ? 0 : -1;
}
/** Ready the next track now, whatever it is — the desktop's album fill and
* stream preload, folded into one place:
* - a library file: decoded and appended behind the current one;
* - a peer's copy: fetched into the cache ahead of time, and appended the
* moment it is byte-complete, so the boundary is gapless;
* - a record's later track that has no copy yet: found first, QUIETLY (the
* resolver's line never shows for a background search), then the above.
* Streams wait ALBUM_FILL_DELAY_MS after a start, so the search and the
* fetch never compete with the track that is starting. */
const ALBUM_FILL_DELAY_MS = 4000;
/** THREE ahead — the desktop's STREAM_PRELOAD_DEPTH. One track of headroom
* is not enough when a peer is slow or a search misses. */
const PRELOAD_DEPTH = 3;
let preloadGen = 0;
let preloadPoll = null;
/** The next `count` queue indices in play order: none past the end unless
* repeat-all wraps them; repeat-one has no next. The queue IS the play
* order on the phone (shuffle reorders it). */
function peekNextIndices(count = PRELOAD_DEPTH) {
const n = player.queue.length;
if (!n || player.repeat === 2) return [];
const out = [];
let i = player.qi;
for (let k = 0; k < count; k++) {
i += 1;
if (i >= n) { if (player.repeat !== 1) break; i = 0; }
if (i === player.qi) break;
out.push(i);
}
return out;
}
function armPreload() {
++preloadGen;
clearTimeout(preloadPoll);
armedPath = null;
const ahead = player.status === "idle" ? [] : peekNextIndices();
if (!ahead.length) return;
const first = player.queue[ahead[0]];
if (first?.t?.path && !first.t.stream && !first.t.catalog) {
/* a library file: decoded and appended now; the rest are files too or
get their fetch below */
const path = first.t.path;
armedPath = path;
api.preloadLocal(path, first.t.duration_secs ?? null).catch(() => {
if (armedPath === path) armedPath = null;
});
}
if (ahead.some((i) => player.queue[i]?.t?.stream || player.queue[i]?.t?.catalog)) armStreamPreload(ahead);
}
/** Ready the peers' copies among the next three: found QUIETLY where a
* record's later track has none yet (a background search never shows the
* resolver's line), fetched into the cache ahead of time — one after the
* other, so the search pacing holds — and the FIRST appended the moment it
* is byte-complete, so the boundary is gapless. Waits after a start so
* nothing competes with the track that is starting. */
function armStreamPreload(indices) {
const g = preloadGen;
preloadPoll = setTimeout(async () => {
for (const i of indices) {
if (g !== preloadGen) return;
const e = player.queue[i];
if (!e?.t) continue;
if (e.t.catalog && !e.t.path) {
let ok = false;
try { ok = await hooks.resolveEntry?.(e, true); } catch { ok = false; }
if (g !== preloadGen) return;
if (!ok) continue;
}
if (!e.t.stream) continue; // the library's, or nothing to fetch
try { await api.preloadStream(e.t.stream.username, e.t.path, e.t.stream.size); } catch { /* next one */ }
}
/* the immediate next, once it is whole */
const e = player.queue[indices[0]];
if (!e?.t?.stream || g !== preloadGen) return;
const { username, size } = e.t.stream;
const path = e.t.path;
const poll = async () => {
if (g !== preloadGen) return;
let ready = false;
try { ready = await api.streamCacheReady(username, path, size); } catch { ready = false; }
if (g !== preloadGen) return;
if (!ready) { preloadPoll = setTimeout(poll, 3000); return; }
try {
const ok = await api.preloadCachedStream(username, path, size, e.t.duration_secs ?? null);
if (ok !== false && g === preloadGen) armedPath = path;
} catch { /* a gap at the boundary, never the wrong track */ }
};
poll();
}, ALBUM_FILL_DELAY_MS);
}
/** The engine crossed into the appended track on its own. */
function advanceGaplessly(i) {
const e = player.queue[i];
const g = ++gen;
player.advance = "gapless";
listenFlush();
player.qi = i;
player.track = e.t;
player.album = e.album;
player.position = 0;
player.duration = e.t.duration_secs ?? null;
player.status = "playing";
player.needsResource = false;
beginStory();
void loadArt(e.album?.art_path, true);
armedPath = null;
armPreload();
if (e.t.stream) {
/* the appended copy was byte-complete: the lane gets the whole shape */
player.coverage = 1;
player.stream = { received: e.t.stream.size, total: e.t.stream.size, done: true, failed: null };
streamStory(g, e);
} else {
loadWave(g, e.t.path);
}
writeSession();
mediaSync();
}
/* ── the queue ───────────────────────────────────────────────────────── */
export function playEntries(entries, at = 0, from = "") {
original = null;
if (player.shuffle && entries.length > 1) {
/* a predetermined order: the chosen track first, the rest shuffled */
original = entries;
const first = entries[at];
entries = [first, ...shuffled(entries.filter((_, i) => i !== at))];
at = 0;
}
player.queue = entries;
player.qi = at;
player.from = from;
return startEntry(entries[at]);
}
export function playAlbum(album, tracks, at = 0) {
return playEntries(tracks.map((t) => ({ t, album })), at, album.title);
}
export function playAlbumShuffled(album, tracks) {
if (!tracks.length) return;
player.shuffle = true;
return playEntries(tracks.map((t) => ({ t, album })), Math.floor(Math.random() * tracks.length), album.title);
}
export function playQueueAt(i) {
if (i < 0 || i >= player.queue.length) return;
player.qi = i;
return startEntry(player.queue[i]);
}
/** After the current track. */
export function playNext(entries) {
if (player.qi < 0) return playEntries(entries, 0, player.from);
player.queue.splice(player.qi + 1, 0, ...entries);
if (original) {
const cur = player.queue[player.qi];
const k = original.findIndex((x) => x.t.path === cur?.t?.path);
original.splice(k >= 0 ? k + 1 : original.length, 0, ...entries);
}
armPreload();
writeSession();
}
/** At the end of the queue. */
export function enqueue(entries) {
if (player.qi < 0) return playEntries(entries, 0, player.from);
player.queue.push(...entries);
if (original) original.push(...entries);
if (player.qi + 1 === player.queue.length - entries.length) armPreload();
writeSession();
}
/* ── a track from the index (Search, the Tracks tab) ─────────────────── */
/** The library record a TrackIndexRow belongs to, when the library has it. */
export function albumOfRow(row) {
const k = String(row.artist || "").toLowerCase();
return lib.albums.find((a) => a.title === row.album && a.artist.toLowerCase() === k)
|| (row.art_path ? lib.albums.find((a) => a.art_path === row.art_path) : null)
|| null;
}
export function entryFromRow(row, album = albumOfRow(row)) {
return {
t: { id: row.path, title: row.title, artist: row.artist, path: row.path, duration_secs: row.duration_secs ?? null },
album: album || { id: null, title: row.album, artist: row.artist, art_path: row.art_path ?? null },
};
}
/** The track, then the rest of its record. */
export async function playIndexRow(row) {
try {
const album = albumOfRow(row);
if (album) {
const tr = await tracksOf(album.id);
const i = tr.findIndex((t) => t.path === row.path);
if (i >= 0) return await playAlbum(album, tr, i);
}
await playEntries([entryFromRow(row, album)], 0, row.album);
} catch (e) { setErr(e); }
}
/* ── the sleep timer ─────────────────────────────────────────────────── */
let sleepTimer = null;
export function setSleep(minutes) {
clearTimeout(sleepTimer);
sleepTimer = null;
player.sleepAt = minutes > 0 ? Date.now() + minutes * 60_000 : 0;
if (minutes > 0) {
sleepTimer = setTimeout(() => {
player.sleepAt = 0;
sleepTimer = null;
if (player.status === "playing" || player.status === "buffering") api.playerControl("pause").catch(() => {});
}, minutes * 60_000);
}
}
export function sleepLabel() {
if (!player.sleepAt) return "off";
const m = Math.max(1, Math.round((player.sleepAt - Date.now()) / 60_000));
return m + " min left";
}
/* ── the transport ───────────────────────────────────────────────────── */
export function next() {
if (!player.queue.length) return;
if (player.repeat === 2) { seekSecs(0); return; }
const i = player.qi + 1 < player.queue.length ? player.qi + 1 : (player.repeat === 1 ? 0 : -1);
if (i < 0) {
/* the end of the queue: stop, keep the record on the bar */
api.playerControl("stop").catch(() => {});
player.status = "idle";
armedPath = null;
mediaSync();
return;
}
playQueueAt(i);
}
export function prev() {
/* the universal transport rule: restart first, go back on a second press */
if (player.position > 5 || player.qi <= 0) { seekSecs(0); return; }
playQueueAt(player.qi - 1);
}
export function toggle() {
if (player.status === "playing" || player.status === "buffering") {
api.playerControl("pause").catch(setErr);
} else if (player.status === "paused" && !player.needsResource) {
api.playerControl("resume").catch(setErr);
} else if (current()) {
/* nothing loaded behind the bar — a restored session, or a queue
that ran out: source it again, from where it was if it was somewhere */
const at = player.needsResource && player.position > 1 && player.duration
&& player.position < player.duration - 1 ? player.position : null;
startEntry(current(), at);
}
}
export function seekSecs(s) {
if (player.duration == null) return;
s = Math.max(0, Math.min(player.duration, s));
player.position = s;
if (player.needsResource) { startEntry(current(), s > 1 ? s : null); return; }
api.playerControl("seek", s).catch(setErr);
mediaSync();
}
export function seekFrac(f) {
if (player.duration) seekSecs(f * player.duration);
}
export function cycleRepeat() {
player.repeat = (player.repeat + 1) % 3;
armPreload();
writeSession();
}
export function toggleShuffle() {
if (!player.shuffle) {
original = player.queue.slice();
const rest = shuffled(player.queue.slice(player.qi + 1));
player.queue = player.queue.slice(0, player.qi + 1).concat(rest);
player.shuffle = true;
} else {
if (original) {
const cur = player.queue[player.qi];
const k = original.findIndex((x) => x.t.path === cur?.t?.path);
if (k >= 0) { player.queue = original; player.qi = k; }
original = null;
}
player.shuffle = false;
}
armPreload();
writeSession();
}
function ended(gapless) {
listenFlush();
const i = nextIndex();
const e = i >= 0 ? player.queue[i] : null;
if (gapless && e && armedPath && e.t.path === armedPath) { advanceGaplessly(i); return; }
if (!e) {
player.status = "idle";
if (player.duration != null) player.position = player.duration;
armedPath = null;
writeSession();
mediaSync();
return;
}
/* not armed, or armed with a track the queue no longer has next: source
the right one (a gap at worst, never the wrong track) */
player.advance = "sourced";
playQueueAt(i);
}
/** Subscribe to the audio thread. Returns the unlisten. */
export async function bindEvents() {
document.addEventListener("visibilitychange", () => { if (document.hidden) writeSession(); });
window.addEventListener("pagehide", writeSession);
setInterval(() => { if (player.status === "playing") writeSession(); }, 10_000);
try {
await listen("soulseek:stream", (ev) => {
const p = ev.payload || {};
if (!current()?.t?.stream) return;
player.stream = { received: p.received || 0, total: p.total || 0, done: !!p.done, failed: p.failed || null };
if (p.failed) setErr("The stream from " + current().t.stream.username + " failed: " + p.failed);
});
} catch { /* not a phone */ }
return await listen("soulseek:player", (ev) => {
const p = ev.payload || {};
switch (p.kind) {
case "playing": player.status = "playing"; player.needsResource = false; mediaSync(); break;
case "paused": player.status = "paused"; mediaSync(); break;
case "buffering": player.status = "buffering"; break;
case "stopped": player.status = "idle"; mediaSync(); break;
case "position":
if (typeof p.secs === "number") {
player.position = p.secs;
listenTick(p.secs);
}
if (p.duration != null) player.duration = p.duration;
break;
case "track-ended": ended(!!p.gapless); break;
case "error":
player.error = p.message || "playback error";
player.status = "idle";
break;
}
});
}
/* ── the session ─────────────────────────────────────────────────────── */
const SESSION_KEY = "ss.mobile.session.v1";
function packEntry(e) {
const a = e.album;
return {
t: e.t,
album: a ? { id: a.id ?? null, title: a.title, artist: a.artist, year: a.year ?? null, art_path: a.art_path ?? null, artist_art_path: a.artist_art_path ?? null } : null,
};
}
export function writeSession() {
try {
if (!player.track) { localStorage.removeItem(SESSION_KEY); return; }
localStorage.setItem(SESSION_KEY, JSON.stringify({
v: 1,
queue: player.queue.map(packEntry),
qi: player.qi,
from: player.from,
position: player.position,
duration: player.duration,
shuffle: player.shuffle,
repeat: player.repeat,
original: original ? original.map(packEntry) : null,
}));
} catch { /* quota, or storage off: the session is a convenience */ }
}
/** Put back what the last launch left behind. The audio thread is the
* authority: a reload finds it playing and follows it; a restart finds it
* idle and the track comes back paused, to be re-sourced by play. */
export async function restoreSession() {
let saved = null;
try { saved = JSON.parse(localStorage.getItem(SESSION_KEY) || "null"); } catch { saved = null; }
if (!saved || saved.v !== 1 || !Array.isArray(saved.queue) || !saved.queue[saved.qi]?.t?.path) return false;
let transport = null;
try { transport = await api.playerState(); } catch { transport = null; }
const g = ++gen;
player.queue = saved.queue;
player.qi = saved.qi;
player.from = saved.from || "";
player.shuffle = !!saved.shuffle;
player.repeat = saved.repeat || 0;
original = Array.isArray(saved.original) ? saved.original : null;
const e = player.queue[player.qi];
player.track = e.t;
player.album = e.album;
const live = !!transport && transport.status !== "idle";
player.status = live ? transport.status : "paused";
player.position = live ? (transport.position ?? 0) : (saved.position ?? 0);
player.duration = live && transport.duration != null ? transport.duration : (saved.duration ?? e.t.duration_secs ?? null);
player.needsResource = !live;
player.phase = "ready";
void loadArt(e.album?.art_path, true);
loadWave(g, e.t.path);
if (live) armPreload();
mediaSync();
return true;
}
/* ── play history ────────────────────────────────────────────────────────
The desktop's rule (state.svelte.js): listened time is accumulated from
the position ticks, a row opens once a track has really accumulated a
second, top-ups land every 15s and at the track change, seeks are ignored
by the delta guard. The phone writes to the same table, so Jump back in
is one history across both. */
const LISTEN_FLUSH_SECS = 15;
const RECENTS_FLOOR_SECS = 45;
let session = null;
let lastPos = null;
function listenFlush(s = session) {
if (!s?.playIdP) return;
const secs = Math.floor(s.pending);
if (secs < 1) return;
s.pending -= secs;
s.playIdP.then((id) => api.addPlaySeconds(id, secs)).catch(() => {});
}
function scheduleRecents(ms) {
setTimeout(() => { if (typeof hooks.recentsDirty === "function") hooks.recentsDirty(); }, ms);
}
function listenTick(pos) {
const t = player.track;
if (!t) return;
const title = t.title;
const artist = (t.artist || player.album?.artist || "").trim();
if (!title || !artist) return;
const key = artist + "" + title;
if (session?.key !== key) {
listenFlush();
scheduleRecents(1500);
session = { key, pending: 0, playIdP: null };
lastPos = null;
}
const d = lastPos == null ? 0 : pos - lastPos;
lastPos = pos;
if (d <= 0 || d > 3) return;
session.pending += d;
if (!session.playIdP && session.pending >= 1) {
session.playIdP = api.recordPlay(artist, title, t.stream ? null : (t.path ?? null));
session.playIdP.catch(() => {});
session.playIdP.then(() => scheduleRecents(RECENTS_FLOOR_SECS * 1000 + 2000)).catch(() => {});
}
if (session.pending >= LISTEN_FLUSH_SECS) listenFlush();
}
+31
View File
@@ -0,0 +1,31 @@
/* Appearance, kept on this device: the three switches the mockup's
Appearance page had. Each one is a data attribute on <html> that the
foundations (mobile.css) read — no component has to know. */
/* `glass` is the desktop's frosted chrome (a backdrop blur behind the bars).
Off by default on the phone: tests/mobile/perf.mjs measured it at half the
frames on a Home scroll and on the player's opening. */
export const settings = $state({ ambience: true, accent: true, motion: true, glass: false });
const KEY = "ss.mobile.prefs";
function apply() {
const d = document.documentElement.dataset;
d.material = settings.ambience ? "ambience" : "solid";
d.accent = settings.accent ? "art" : "brand";
d.motion = settings.motion ? "on" : "off";
d.glass = settings.glass ? "blur" : "solid";
}
export function loadPrefs() {
try {
const saved = JSON.parse(localStorage.getItem(KEY) || "{}");
for (const k of Object.keys(settings)) if (typeof saved[k] === "boolean") settings[k] = saved[k];
} catch { /* a fresh phone */ }
apply();
}
export function setSetting(k, v) {
settings[k] = v;
try { localStorage.setItem(KEY, JSON.stringify({ ...settings })); } catch { /* fine */ }
apply();
}
+279
View File
@@ -0,0 +1,279 @@
/* The phone's resolver: a catalog track or record → a peer's copy, through
the desktop's own source rules (src/lib/sources.js — one copy, shared).
The order is the desktop's: the library first, then the remembered source
if its bytes are still complete in the cache, then the network — the
query ladder, the candidates, and up to four peers tried in turn.
A record queues every track from the ONE folder that wins. A song played
off a record queues the rest of the record behind it as UNRESOLVED
entries, each finding its own copy when its turn comes (the player calls
hooks.resolveEntry). Failover lives in the player: an entry carries its
runner-up peers in `t.alts`, and a stream that has not sent a byte in
time moves to the next one. */
import * as api from "../lib/api.js";
import {
resolveSources, songCandidates, albumCandidates, simplifyTitle, albumQueryTitle,
cachedStreamSourceFor, rememberStreamSource, catalogTrackFor, cleanTrackName,
hqCover, qualityOf, refreshSourcePrefs, libraryAlbumIn, libraryTrackIn,
} from "../lib/sources.js";
import { player, hooks, playEntries, playAlbum } from "./player.svelte.js";
import { lib, tracksOf } from "./lib.svelte.js";
import { slsk } from "./slsk.svelte.js";
import { yearOf } from "./catalog.svelte.js";
export const resolver = $state({ active: false, label: "", error: "" });
void refreshSourcePrefs();
const online = () => slsk.connection === "online";
const OFFLINE = "Sign in on Community to play from the network.";
/* ── shapes ────────────────────────────────────────────────────────── */
/** The record a streamed entry belongs to, as the player's `album`. No
* library id: the catalog id rides in `catalogId`, and the cover URL is the
* art key (art.svelte.js routes https through the catalog art proxy). */
function albumMeta(o, artist) {
return {
id: null,
catalogId: o.albumId ?? null,
title: o.album ?? null,
artist: o.albumArtist ?? artist,
year: o.year ?? null,
art_path: hqCover(o.cover) ?? null,
};
}
function streamEntry(title, artist, src, o, album) {
return {
t: {
id: o.trackId != null ? "cat:" + o.trackId : "stream:" + src.username + "|" + src.path,
title,
artist,
path: src.path,
duration_secs: src.duration ?? o.expectSecs ?? null,
stream: { username: src.username, size: src.size },
catalog: { trackId: o.trackId ?? null, albumId: o.albumId ?? null, expectSecs: o.expectSecs ?? null },
alts: [],
},
album,
};
}
/** The rest of a record, unresolved: a path-less entry the player resolves
* on arrival. */
function lazyEntries(rest, artist, o, album) {
return (rest || []).map((ct) => ({
t: {
id: "cat:" + ct.id,
title: ct.title,
artist: ct.artist || artist,
path: null,
duration_secs: ct.duration_secs ?? null,
stream: null,
catalog: { trackId: ct.id ?? null, albumId: o.albumId ?? null, expectSecs: ct.duration_secs ?? null },
alts: [],
},
album,
}));
}
const alt = (c) => ({ username: c.username, path: c.file.path, size: c.file.size, duration: c.file.duration ?? null });
function remember(e, quality) {
rememberStreamSource({ stream: e.t.stream, path: e.t.path, title: e.t.title, artist: e.t.artist, trackId: e.t.catalog?.trackId ?? null, duration: e.t.duration_secs, quality });
}
/* the bar says what is being looked for while the ladder runs */
function placeholder(title, artist, album) {
player.track = { id: "resolving", title, artist, path: null, stream: null };
player.album = album;
player.position = 0;
player.duration = null;
player.status = "buffering";
player.phase = "loading";
player.error = "";
}
function fail(msg) {
resolver.error = msg;
player.error = msg;
if (player.track?.id === "resolving") { player.track = null; player.status = "idle"; player.phase = "ready"; }
}
/* ── a song ────────────────────────────────────────────────────────── */
/** Play a catalog track: `o` carries what the row knows — trackId, albumId,
* album (title), cover, expectSecs, year, and `rest` (the record's later
* tracks, queued behind it). */
export async function streamSong(artist, title, o = {}) {
if (resolver.active) return;
const local = await libraryTrackIn(lib.albums, (id) => tracksOf(id), artist, title);
if (local) {
await playAlbum(local.album, local.tracks, local.index);
return;
}
const album = albumMeta(o, artist);
const from = album.title || artist;
const cached = await cachedStreamSourceFor(o.trackId ?? null, artist, title);
if (cached) {
await playEntries([streamEntry(title, artist, cached, o, album), ...lazyEntries(o.rest, artist, o, album)], 0, from);
return;
}
if (!online()) { fail(OFFLINE); return; }
placeholder(title, artist, album);
const { responses, artistIncluded } = await resolveSources(
`${artist} — ${title}`,
[`${artist} ${simplifyTitle(title)}`, simplifyTitle(title)],
(rs) => songCandidates(rs, title, true, o.album, o.expectSecs, artist).length,
artist,
resolver,
);
const cands = songCandidates(responses, title, true, o.album, o.expectSecs, artist, !artistIncluded);
if (!cands.length) { fail(`No streamable sources found for “${title}”`); return; }
const [best, ...alts] = cands.slice(0, 4);
const e = streamEntry(title, artist, alt(best), o, album);
e.t.alts = alts.map(alt);
remember(e, qualityOf(best.file));
await playEntries([e, ...lazyEntries(o.rest, artist, o, album)], 0, from);
}
/* ── a record ──────────────────────────────────────────────────────── */
/** Play a catalog record from track `at`: the library's copy if it holds
* one, else the best folder on the network, every track from that one
* peer, the runner-up folders as the first track's failover. */
export async function streamCatalogAlbum(album, at = 0) {
if (resolver.active) return;
const local = libraryAlbumIn(lib.albums, album.artist, album.title, album.release_date ?? null);
if (local) {
const tracks = await tracksOf(local.id).catch(() => []);
if (tracks.length) { await playAlbum(local, tracks, Math.min(at, tracks.length - 1)); return; }
}
if (!online()) { fail(OFFLINE); return; }
const meta = albumMeta({ albumId: album.id ?? null, album: album.title, cover: album.cover, year: yearOf(album.release_date) }, album.artist);
placeholder(album.title, album.artist, meta);
const catalogTracks = album.id
? await api.catalogAlbum(album.id).then((p) => p?.tracks ?? null, () => null)
: null;
const trackCount = catalogTracks?.length || album.track_count || null;
if (trackCount === 1) {
const t = catalogTracks?.[0];
player.track = null;
await streamSong(album.artist, t?.title ?? album.title, {
cover: t?.cover ?? album.cover, trackId: t?.id ?? null, albumId: album.id ?? null,
album: album.title || null, expectSecs: t?.duration_secs ?? null, year: meta.year,
});
return;
}
const { responses, artistIncluded } = await resolveSources(
`${album.artist} — ${album.title}`,
[`${album.artist} ${albumQueryTitle(album)}`, albumQueryTitle(album)],
(rs) => albumCandidates(rs, album, true, trackCount).length,
album.artist,
resolver,
);
const folders = albumCandidates(responses, album, true, trackCount, !artistIncluded);
if (!folders.length) { fail(`No streamable sources found for “${album.title}”`); return; }
const c = folders[0];
const entries = c.audio.map((f, i) => {
const ct = catalogTrackFor(f, i, c.audio, catalogTracks);
const o = { trackId: ct?.id ?? null, albumId: album.id ?? null, expectSecs: ct?.duration_secs ?? null };
const e = streamEntry(ct?.title ?? cleanTrackName(f.name), album.artist, { username: c.username, path: f.path, size: f.size, duration: f.duration ?? null }, o, meta);
remember(e, qualityOf(f));
return e;
});
const start = Math.min(Math.max(0, at), entries.length - 1);
entries[start].t.alts = folders.slice(1, 4).map((g) => {
const f = g.audio[start] || g.audio[0];
return { username: g.username, path: f.path, size: f.size, duration: f.duration ?? null };
});
await playEntries(entries, start, album.title);
}
/* ── saving ────────────────────────────────────────────────────────── */
/** Fetch a record into the library: the best folder by the DOWNLOAD rules
* (quality first, a queue is fine), every file in it — the pipeline files
* art and logs beside the finished album and says soulseek:library-changed.
* Returns { username, files } or false. */
export async function saveCatalogAlbum(album) {
if (resolver.active) return false;
if (!online()) { resolver.error = OFFLINE; player.error = OFFLINE; return false; }
const catalogTracks = album.id
? await api.catalogAlbum(album.id).then((p) => p?.tracks ?? null, () => null)
: null;
const trackCount = catalogTracks?.length || album.track_count || null;
if (trackCount === 1) {
const t = catalogTracks?.[0];
return saveSong(album.artist, t?.title ?? album.title);
}
const { responses, artistIncluded } = await resolveSources(
`${album.artist} — ${album.title}`,
[`${album.artist} ${albumQueryTitle(album)}`, albumQueryTitle(album)],
(rs) => albumCandidates(rs, album, false, trackCount).length,
album.artist,
resolver,
);
const best = albumCandidates(responses, album, false, trackCount, !artistIncluded)[0];
if (!best) {
resolver.error = `No album sources found for “${album.title}”`;
player.error = resolver.error;
return false;
}
for (const f of best.files) await api.downloadFile(best.username, f.path, f.size);
return { username: best.username, files: best.files.length };
}
/** One song into the library, by the download rules. */
export async function saveSong(artist, title) {
if (resolver.active) return false;
if (!online()) { resolver.error = OFFLINE; player.error = OFFLINE; return false; }
const { responses, artistIncluded } = await resolveSources(
`${artist} — ${title}`,
[`${artist} ${simplifyTitle(title)}`, simplifyTitle(title)],
(rs) => songCandidates(rs, title, false, null, null, artist).length,
artist,
resolver,
);
const best = songCandidates(responses, title, false, null, null, artist, !artistIncluded)[0];
if (!best) {
resolver.error = `No sources found for “${title}”`;
player.error = resolver.error;
return false;
}
await api.downloadFile(best.username, best.file.path, best.file.size);
return { username: best.username, files: 1 };
}
/* ── an entry's turn ───────────────────────────────────────────────── */
/** Fill in a path-less catalog entry when the queue reaches it: the
* library, the cache, then a search of its own. Returns whether it can
* play. Mutates the entry (a queue proxy) in place. */
async function resolveEntry(e, quiet = false) {
const { title, artist, catalog } = e.t;
const local = await libraryTrackIn(lib.albums, (id) => tracksOf(id), artist, title);
if (local) {
const t = local.tracks[local.index];
e.t.path = t.path;
e.t.duration_secs = t.duration_secs ?? e.t.duration_secs;
e.t.stream = null;
return true;
}
const cached = await cachedStreamSourceFor(catalog?.trackId ?? null, artist, title);
if (cached) {
e.t.path = cached.path;
e.t.stream = { username: cached.username, size: cached.size };
e.t.duration_secs = cached.duration ?? e.t.duration_secs;
return true;
}
if (!online()) return false;
const { responses, artistIncluded } = await resolveSources(
`${artist} — ${title}`,
[`${artist} ${simplifyTitle(title)}`, simplifyTitle(title)],
(rs) => songCandidates(rs, title, true, e.album?.title, catalog?.expectSecs, artist).length,
artist,
quiet ? null : resolver,
);
const cands = songCandidates(responses, title, true, e.album?.title, catalog?.expectSecs, artist, !artistIncluded);
if (!cands.length) return false;
const [best, ...alts] = cands.slice(0, 4);
const src = alt(best);
e.t.path = src.path;
e.t.stream = { username: src.username, size: src.size };
e.t.duration_secs = src.duration ?? e.t.duration_secs;
e.t.alts = alts.map(alt);
remember(e, qualityOf(best.file));
return true;
}
hooks.resolveEntry = resolveEntry;
+97
View File
@@ -0,0 +1,97 @@
<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 { albumById, albumsBy, tracksOf } from "../lib.svelte.js";
import { player, playAlbum, playAlbumShuffled } from "../player.svelte.js";
import { push, openHalf } from "../nav.svelte.js";
import { mmss, longDur, albumFmt } 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"><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} />in your library</span>
</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 }); }}>
<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" 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>
+334
View File
@@ -0,0 +1,334 @@
<script>
/* The artist page, the desktop's (CatalogArtist.svelte + ArtistExtras)
at phone scale: the banner with the wordmark or the name on it, the
identity row — Play popular, Follow, releases, fans, years active,
the genre, the socials — Popular with its listener bars, the latest
release, the discography filed the desktop's way (tabs, editions
folded into families, what the library holds marked), releases by
year, fans also like, what is on this phone, and About.
Opens cold: the library's own shell at once (its portrait blurred as
a seed when it is only a cover), the catalog lookup dissolving the
rest in — dissolve, never pop. */
import { untrack } from "svelte";
import Icon from "../../components/Icon.svelte";
import Release from "../Release.svelte";
import * as api from "../../lib/api.js";
import { openUrl } from "@tauri-apps/plugin-opener";
import { albumsBy, tracksOf, isFollowed, toggleFollow } from "../lib.svelte.js";
import { art, wantArt } from "../art.svelte.js";
import { player, playEntries, playAlbum } from "../player.svelte.js";
import { push, openHalf } from "../nav.svelte.js";
import { catalog, lookupArtist, artistKey, fans, yearOf, looseTitle, loadExtras, picture } from "../catalog.svelte.js";
import { resolver, streamSong, streamCatalogAlbum } from "../resolve.svelte.js";
import { discoTabs, activeYears, latestOf, timelineOf, isUpcoming, SOCIAL_LABEL } from "../disco.js";
import { shuffled, mmss } from "../util.js";
/** `seed`: the search's own row (a CatalogArtist), when the page was
* opened from one — the identity is then known, no name search. */
let { name, seed = null, screenEl = null } = $props();
/* ── the library's shell ─────────────────────────────────────────── */
const albums = $derived(albumsBy(name));
const portrait = $derived(albums.find((a) => a.artist_art_path)?.artist_art_path || null);
const seedPath = $derived(portrait || albums[0]?.art_path || null);
const tracks = $derived(albums.reduce((n, a) => n + (a.track_count || 0), 0));
/* ── the catalog ─────────────────────────────────────────────────── */
const entry = $derived(catalog.artists[artistKey(name)] ?? null);
const page = $derived(entry?.page ?? null);
const artistId = $derived(page?.artist?.id ?? entry?.id ?? seed?.id ?? null);
const shownName = $derived(page?.artist?.name || seed?.name || name);
const heroUrl = $derived(page?.banner || picture(page?.artist?.picture) || picture(entry?.seed?.picture) || picture(seed?.picture) || null);
const logoUrl = $derived(page?.logos?.[0] ?? null);
/* once per name — untracked, or the entry the lookup writes would re-arm
this effect, and a lookup that fails would fail forever, hot */
$effect(() => { const n = name, sd = seed; untrack(() => lookupArtist(n, sd)); });
$effect(() => { if (seedPath) wantArt(seedPath); });
$effect(() => { if (heroUrl) wantArt(heroUrl); });
$effect(() => {
const id = page?.artist?.id;
const nm = page?.artist?.name || name;
if (id != null) untrack(() => loadExtras(id, nm));
});
const seedSrc = $derived(seedPath ? art[seedPath] : "");
const heroSrc = $derived(heroUrl ? art[heroUrl] : "");
const extras = $derived(artistId != null ? catalog.extras[artistId] ?? null : null);
const socials = $derived(page?.socials?.length ? page.socials : (extras?.socials ?? []));
/* the library's rows for this artist, by loose title, so Popular and the
discography can say what is already here */
let libByTitle = $state(new Map());
$effect(() => {
const as = albums;
let live = true;
(async () => {
const m = new Map();
for (const a of as) {
let tr = [];
try { tr = await tracksOf(a.id); } catch { tr = []; }
for (const t of tr) if (!m.has(looseTitle(t.title))) m.set(looseTitle(t.title), { album: a, track: t });
}
if (live) libByTitle = m;
})();
return () => { live = false; };
});
/* ── Popular ─────────────────────────────────────────────────────── */
const popularAll = $derived(page?.popular_tracks ?? []);
let showAllPopular = $state(false);
const popular = $derived((showAllPopular ? popularAll : popularAll.slice(0, 5)).map((t) => ({ t, hit: libByTitle.get(looseTitle(t.title)) || null })));
const popPeak = $derived(popularAll.reduce((m, t) => Math.max(m, t.listeners || 0), 0));
const share = (t) => (popPeak ? Math.max(0.04, (t.listeners || 0) / popPeak) : 0);
const fmtCount = (n) => (!n ? "" : n >= 1e6 ? (n / 1e6).toFixed(1).replace(/\.0$/, "") + "M" : n >= 1e3 ? Math.round(n / 1e3) + "k" : String(n));
/* ── the discography, filed ──────────────────────────────────────── */
const discoAll = $derived(page?.albums ?? []);
const tabs = $derived(discoTabs(discoAll));
let tab = $state("album");
let tabFor = null;
$effect(() => {
const id = artistId;
const ts = tabs;
if (!ts.length) return;
untrack(() => {
const albumsFirst = () => ts.find((t) => t.key === "album")?.key ?? ts[0].key;
if (tabFor !== id) { tabFor = id; tab = albumsFirst(); }
else if (!ts.some((t) => t.key === tab)) tab = albumsFirst();
});
});
const families = $derived(tabs.find((t) => t.key === tab)?.items ?? []);
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 latest = $derived(latestOf(discoAll));
const timeline = $derived(timelineOf(discoAll));
const years = $derived(activeYears(discoAll));
const releasedCount = $derived(discoAll.filter((a) => !isUpcoming(a)).length);
/* ── the identity row ────────────────────────────────────────────── */
const followed = $derived(isFollowed(shownName));
let followBusy = $state(false);
async function follow() {
if (artistId == null || followBusy) return;
followBusy = true;
try { await toggleFollow(artistId, shownName, page?.artist?.picture ?? seed?.picture ?? null); } catch (e) { player.error = String(e); }
followBusy = false;
}
/** The opener plugin (an Android intent); the desktop command as the
* fallback for a build without it. */
function openLink(url) {
openUrl(url).catch(() => api.openExternal(url).catch((e) => { player.error = String(e); }));
}
/* ── playing ─────────────────────────────────────────────────────── */
const current = (t) => player.track?.id === "cat:" + t.id;
async function playPopular(p) {
try {
if (p.hit) {
const tr = await tracksOf(p.hit.album.id);
await playAlbum(p.hit.album, tr, Math.max(0, tr.findIndex((x) => x.path === p.hit.track.path)));
return;
}
await streamSong(shownName, p.t.title, {
trackId: p.t.id, albumId: p.t.album_id ?? null, album: p.t.album || null,
cover: p.t.cover ?? null, expectSecs: p.t.duration_secs ?? null,
});
} catch (e) { player.error = String(e); }
}
function playTop() { const p = popular[0]; if (p) playPopular(p); }
function morePopular(p) {
if (p.hit) { openHalf("track", { album: p.hit.album, track: p.hit.track }); return; }
const t = p.t;
openHalf("track", {
album: { id: null, catalogId: t.album_id ?? null, title: t.album || null, artist: shownName, art_path: t.cover ?? null },
track: { id: "cat:" + t.id, title: t.title, artist: shownName, path: null, duration_secs: t.duration_secs ?? null, catalog: { trackId: t.id, albumId: t.album_id ?? null, cover: t.cover ?? null } },
});
}
async function playRecord(a) {
try {
const l = inLib(a);
if (l) { const tr = await tracksOf(l.id); await playAlbum(l, tr, 0); return; }
await streamCatalogAlbum({ ...a, artist: shownName }, 0);
} catch (e) { player.error = String(e); }
}
async function shuffleAll() {
try {
const es = [];
for (const a of albums) for (const t of await tracksOf(a.id)) es.push({ t, album: a });
if (es.length) await playEntries(shuffled(es), 0, shownName);
} catch (e) { player.error = String(e); }
}
function openRecord(a) {
const l = inLib(a);
if (l) push("album", { id: l.id }, screenEl);
else push("catalogAlbum", { id: a.id, artist: shownName, title: a.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);
}
function openRelated(a) { push("artist", { name: a.name, seed: a }, screenEl); }
let bioOpen = $state(false);
</script>
<div class="hero">
<div class="hero-img seed" style:background-image={seedSrc ? `url("${seedSrc}")` : null}></div>
{#if heroSrc}
<div class="hero-img dissolve" style:background-image={`url("${heroSrc}")`}></div>
{/if}
<div class="hero-scrim"></div>
<div class="hero-in">
{#if logoUrl}
<img class="hero-logo" src={logoUrl} alt={shownName} />
{:else}
<div class="hero-name">{shownName}</div>
{/if}
</div>
</div>
<div class="hero-acts">
<button class="disc lg" onclick={playTop} disabled={!popular.length} aria-label="Play popular songs"><Icon name="play" size={26} /></button>
<div class="stats">
<button class="s fb" class:on={followed} onclick={follow} disabled={artistId == null || followBusy} aria-pressed={followed}>
<Icon name={followed ? "check" : "plus"} size={12} strokeWidth={3} />{followed ? "Following" : "Follow"}
</button>
{#if releasedCount}<span class="s"><Icon name="disc" size={12} strokeWidth={3} />{releasedCount} releases</span>{/if}
{#if page?.artist?.fans || seed?.fans}<span class="s"><Icon name="users" size={12} strokeWidth={3} />{fans(page?.artist?.fans || seed?.fans)}</span>{/if}
{#if years}<span class="s"><Icon name="calendar" size={12} strokeWidth={3} />{years}</span>{/if}
{#if page?.top_genre}<span class="s"><Icon name="tag" size={12} strokeWidth={3} />{page.top_genre}</span>{/if}
{#if albums.length}<button class="s quiet" onclick={shuffleAll}><Icon name="shuffle" size={12} strokeWidth={3} />{albums.length} here · shuffle</button>{/if}
{#each socials as link (link.kind + link.url)}
<button class="s quiet soc" onclick={() => openLink(link.url)}><Icon name={link.kind === "website" ? "globe" : "link"} size={12} strokeWidth={3} />{SOCIAL_LABEL[link.kind] || link.kind}</button>
{/each}
</div>
</div>
{#if entry?.status === "loading"}
<div class="sec-note" style="padding-top: 12px">Looking up the rest on the catalog…</div>
{:else if entry?.status === "error"}
<div class="sec-note" style="padding-top: 12px">The catalog did not answer — only what this phone holds is here.</div>
{/if}
{#if popular.length}
<div class="sec"><h2>Popular</h2></div>
{#each popular as p, i (p.t.id)}
<button class="row pop" class:playing={p.hit ? player.track?.path === p.hit.track.path : current(p.t)} onclick={() => playPopular(p)} oncontextmenu={(e) => { e.preventDefault(); morePopular(p); }}>
<span class="rank">{i + 1}</span>
<span class="art">{#if p.t.cover}<img src={p.t.cover} alt="" loading="lazy" />{:else}<Icon name="disc" size={20} />{/if}</span>
<span class="b">
<span class="t">{p.t.title}</span>
<span class="m">{p.t.album}{p.hit ? " · in your library" : ""}{current(p.t) && resolver.active ? " · finding the best source…" : ""}</span>
{#if popPeak}<span class="lbar"><i style="transform: scaleX({share(p.t).toFixed(3)})"></i><em>{fmtCount(p.t.listeners)}</em></span>{/if}
</span>
<span class="r">{mmss(p.t.duration_secs)}</span>
</button>
{/each}
{#if popularAll.length > 5}
<button class="more-link" onclick={() => (showAllPopular = !showAllPopular)}>{showAllPopular ? "Show less" : "Show more"}</button>
{/if}
{/if}
{#if latest}
<div class="sec"><h2>Latest release</h2></div>
<div class="feat" style="margin: 0 var(--pad)">
<div class="feat-bg" style:background-image={latest.cover ? `url("${latest.cover}")` : null}></div>
<div class="feat-in">
<button class="feat-art" onclick={() => openRecord(latest)} aria-label="Open {latest.title}">
{#if latest.cover}<img src={latest.cover} alt="" />{:else}<span class="ph"><Icon name="disc" size={28} /></span>{/if}
</button>
<div class="feat-copy">
<div class="k">Latest · {(latest.record_type || "album").toUpperCase()}</div>
<div class="feat-name">{latest.title}</div>
<div class="m">{yearOf(latest.release_date)}{latest.track_count ? ` · ${latest.track_count} tracks` : ""}{inLib(latest) ? " · in your library" : ""}</div>
<div class="feat-acts">
<button class="btn btn-fill" onclick={() => playRecord(latest)}><Icon name="play" size={14} />Play</button>
<button class="btn btn-ghost" onclick={() => openRecord(latest)}>Open</button>
</div>
</div>
</div>
</div>
{/if}
{#if tabs.length}
<div class="sec"><h2>Discography <em>· {releasedCount} on the catalog</em></h2></div>
{#if tabs.length > 1}
<div class="segtabs disco-tabs">
{#each tabs as t (t.key)}
<button class:on={tab === t.key} onclick={() => (tab = t.key)}>{t.label}<span class="count">{t.items.length}</span></button>
{/each}
</div>
{/if}
<div class="grid2">
{#each families as f (familyKeyOf(f))}
{@const l = familyLib(f)}
<Release
title={f.title}
sub={tab === "upcoming" ? "Upcoming" : [yearOf(f.release_date), f.track_count ? `${f.track_count} tracks` : ""].filter(Boolean).join(" · ")}
url={f.cover}
path={l?.art_path ?? null}
badge={(f.editions?.length ?? 1) > 1 ? `${f.editions.length} editions` : (l ? "in your library" : null)}
onclick={() => openFamily(f)}
/>
{/each}
</div>
{/if}
{#if timeline}
<div class="sec"><h2>Releases by year <em>· {timeline.last - timeline.first + 1} years · {timeline.total} releases</em></h2></div>
<div class="tl">
{#each timeline.cols as c (c.year)}
<div class="tl-col" class:empty={!c.n} title="{c.year} · {c.n ? c.n + (c.n === 1 ? ' release' : ' releases') : 'nothing released'}">
<span class="tl-bar" style="height:{c.h}px"></span>
<span class="tl-yr">{String(c.year).slice(2)}</span>
</div>
{/each}
</div>
{/if}
{#if extras?.related?.length}
<div class="sec"><h2>Fans also like</h2></div>
<div class="rail">
{#each extras.related.slice(0, 12) as a (a.id)}
<button class="acard" onclick={() => openRelated(a)}>
<span class="por">{#if picture(a.picture)}<img src={picture(a.picture)} alt="" loading="lazy" />{:else}<Icon name="users" size={36} />{/if}</span>
<span class="nm">{a.name}</span>
<span class="kd">{fans(a.fans) || "Artist"}</span>
</button>
{/each}
</div>
{/if}
{#if albums.length}
<div class="sec"><h2>On this phone <em>· {albums.length} record{albums.length === 1 ? "" : "s"}{tracks ? ` · ${tracks} tracks` : ""}</em></h2></div>
<div class="grid2">
{#each albums as a (a.id)}
<Release title={a.title} sub={String(a.year || "")} path={a.art_path} onclick={() => push("album", { id: a.id }, screenEl)} />
{/each}
</div>
{/if}
{#if extras?.bio || extras?.profile?.listeners || extras?.profile?.tags?.length}
<div class="sec"><h2>About</h2></div>
<div class="about">
{#if extras.profile?.listeners || extras.profile?.plays}
<div class="stats">
{#if extras.profile.listeners}<span class="s"><Icon name="users" size={12} strokeWidth={3} />{fmtCount(extras.profile.listeners)} listeners</span>{/if}
{#if extras.profile.plays}<span class="s"><Icon name="play" size={12} strokeWidth={3} />{fmtCount(extras.profile.plays)} plays</span>{/if}
</div>
{/if}
{#if extras.bio}
<p class="bio" class:open={bioOpen} onclick={() => (bioOpen = !bioOpen)} role="button" tabindex="0" onkeydown={() => {}}>{extras.bio}</p>
{/if}
{#if extras.profile?.tags?.length}
<div class="tags">{#each extras.profile.tags.slice(0, 6) as t (t)}<span class="chip quiet">{t}</span>{/each}</div>
{/if}
</div>
{/if}
{#if !albums.length && !popular.length && !tabs.length && entry?.status !== "loading"}
<div class="stub"><b>Nothing here yet.</b>No records by {shownName} on this phone, and the catalog had nothing to add.</div>
{/if}
<div class="endpad"></div>
+221
View File
@@ -0,0 +1,221 @@
<script>
/* A record from the catalog — the desktop's album page (CatalogAlbum
.svelte) at phone scale: the cover and the kind, the artist byline
with a portrait, year · tracks · runtime, Last.fm's totals, Play /
Save / Shuffle, the record's other editions, the tracklist with its
listener bars and disc heads, then About this record (Wikipedia's
words, the lede promoted) and the release pills with who said so.
Rows the library holds play from it; the rest play through the
resolver, the rest of the record queued behind. */
import { untrack } from "svelte";
import Icon from "../../components/Icon.svelte";
import { albumsBy, tracksOf } from "../lib.svelte.js";
import { player, playAlbum, playAlbumShuffled, toggleShuffle } from "../player.svelte.js";
import { push, openHalf } from "../nav.svelte.js";
import { catalog, lookupAlbum, lookupArtist, artistKey, seedFor, picture, yearOf, looseTitle, loadAlbumInfo, infoKey } from "../catalog.svelte.js";
import { resolver, streamSong, streamCatalogAlbum, saveCatalogAlbum } from "../resolve.svelte.js";
import { releaseFamilies, editionLabel, splitDescription } from "../../lib/editions.js";
import { mmss, longDur } from "../util.js";
let { id, artist = "", title = "", screenEl = null } = $props();
const entry = $derived(catalog.albums[id] ?? null);
const page = $derived(entry?.page ?? null);
const album = $derived(page?.album ?? null);
const rows = $derived(page?.tracks ?? []);
$effect(() => { const i = id; untrack(() => lookupAlbum(i)); });
/* the artist: a portrait for the byline, and their page for the editions */
const artistName = $derived(album?.artist || artist);
$effect(() => { const n = artistName; if (n) untrack(() => { seedFor(n); lookupArtist(n); }); });
const byline = $derived(artistName ? catalog.seeds[artistKey(artistName)] ?? null : null);
/* the words: Wikipedia through the desktop's lookup, the lede promoted */
$effect(() => { const a = album; if (a) untrack(() => loadAlbumInfo(a.artist, a.title)); });
const info = $derived(album ? catalog.albumInfo[infoKey(album.artist, album.title)]?.info ?? null : null);
const desc = $derived(splitDescription(info?.description));
let descOpen = $state(false);
/* the record's other pressings: the desktop's rule — a sibling counts
only when the backend linked it (edition_group) or it declares the
same record type, then editions.js folds the family */
const siblings = $derived(artistName ? catalog.artists[artistKey(artistName)]?.page?.albums ?? [] : []);
const editions = $derived.by(() => {
if (!album || !siblings.length) return [];
const type = (album.record_type ?? "album").toLowerCase();
const linked = (o) => album.edition_group != null && o.edition_group === album.edition_group;
const pool = siblings.filter((o) => o.id === album.id || linked(o) || (o.record_type ?? "album").toLowerCase() === type);
if (!pool.some((o) => o.id === album.id)) pool.push(album);
const family = releaseFamilies(pool, type).find((f) => f.editions.some((e) => e.id === album.id));
if (!family || family.editions.length < 2) return [];
return family.editions.map((e) => ({ ...e, label: editionLabel(e, family), on: e.id === album.id }));
});
/* the library's copy, when its title matches */
const libAlbum = $derived(albumsBy(artistName).find((a) => looseTitle(a.title) === looseTitle(album?.title || title)) || null);
let libTracks = $state([]);
$effect(() => {
const a = libAlbum;
if (!a) { libTracks = []; return; }
let live = true;
tracksOf(a.id).then((t) => { if (live) libTracks = t || []; }, () => {});
return () => { live = false; };
});
const hits = $derived(new Map(libTracks.map((t) => [looseTitle(t.title), t])));
/* a PARTIAL copy (a Save cut short, a rip missing tracks) still wants
the rest: Save stays, and says how many are missing */
const missing = $derived(libAlbum ? rows.filter((t) => !t.unreleased && !hits.has(looseTitle(t.title))).length : 0);
/* the numbers */
const total = $derived(rows.reduce((n, t) => n + (t.duration_secs || 0), 0));
const runtime = $derived(total ? longDur(total) : "");
const multiDisc = $derived(new Set(rows.map((t) => t.disk_number ?? 1)).size > 1);
const peak = $derived(rows.reduce((m, t) => Math.max(m, t.listeners || 0), 0));
const showCount = $derived(peak > 0 && rows.length >= 3);
const hasUnreleased = $derived(rows.some((t) => t.unreleased));
const fmtCount = (n) => (!n ? "" : n >= 1e6 ? (n / 1e6).toFixed(1).replace(/\.0$/, "") + "M" : n >= 1e3 ? Math.round(n / 1e3) + "k" : String(n));
const fullDate = (d) => {
const v = String(d || "");
if (v.length < 10) return yearOf(v);
const dt = new Date(v.slice(0, 10) + "T00:00:00");
return Number.isNaN(dt.getTime()) ? yearOf(v) : dt.toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" });
};
/* playing, saving */
const current = (t) => player.track?.id === "cat:" + t.id;
async function playRow(t, i) {
if (t.unreleased) return;
const hit = hits.get(looseTitle(t.title));
try {
if (hit && libAlbum) { await playAlbum(libAlbum, libTracks, Math.max(0, libTracks.indexOf(hit))); return; }
await streamSong(artistName, t.title, {
trackId: t.id, albumId: album?.id ?? id, album: album?.title || title, albumArtist: artistName,
cover: album?.cover ?? t.cover ?? null, expectSecs: t.duration_secs ?? null, year: yearOf(album?.release_date) || null,
rest: rows.slice(i + 1).filter((x) => !x.unreleased),
});
} catch (e) { player.error = String(e); }
}
async function playRecord() {
try {
if (libAlbum) { await playAlbum(libAlbum, libTracks, 0); return; }
if (album) await streamCatalogAlbum(album, 0);
} catch (e) { player.error = String(e); }
}
async function shuffleRecord() {
try {
if (libAlbum) { await playAlbumShuffled(libAlbum, libTracks); return; }
if (!album) return;
if (!player.shuffle) toggleShuffle();
await streamCatalogAlbum(album, 0);
} catch (e) { player.error = String(e); }
}
let saving = $state("");
async function save() {
if (!album || saving === "…") return;
saving = "…";
try {
const r = await saveCatalogAlbum(album);
saving = r ? `Saving ${r.files} from ${r.username}` : "No sources";
} catch (e) { saving = "Failed"; player.error = String(e); }
}
function more(t) {
openHalf("track", {
album: { id: null, catalogId: album?.id ?? id, title: album?.title || title, artist: artistName, art_path: album?.cover ?? null },
track: { id: "cat:" + t.id, title: t.title, artist: artistName, path: null, duration_secs: t.duration_secs ?? null, catalog: { trackId: t.id, albumId: album?.id ?? id, cover: album?.cover ?? t.cover ?? null } },
});
}
function switchEdition(ed) {
if (ed.on) return;
push("catalogAlbum", { id: ed.id, artist: artistName, title: ed.title }, screenEl);
}
</script>
<div class="rec-head">
<div class="rec-cover">{#if album?.cover}<img src={album.cover} alt="" />{:else}<Icon name="disc" size={48} />{/if}</div>
<div class="rec-meta">
<div class="kind">{(album?.record_type || "album").toUpperCase()}</div>
<div class="rec-title">{album?.title || title}</div>
<button class="rec-artist who" onclick={() => push("artist", { name: artistName, seed: byline }, screenEl)}>
{#if picture(byline?.picture)}<img src={picture(byline.picture)} alt="" />{/if}{artistName}
</button>
<div class="rec-line">{[yearOf(album?.release_date), rows.length ? `${rows.length} tracks` : "", runtime, libAlbum ? "in your library" : ""].filter(Boolean).join(" · ") || "—"}</div>
{#if page?.album_plays || page?.album_listeners}
<div class="stats rec-chips">
{#if page.album_plays}<span class="s"><Icon name="play" size={12} strokeWidth={3} />{fmtCount(page.album_plays)} plays</span>{/if}
{#if page.album_listeners}<span class="s"><Icon name="users" size={12} strokeWidth={3} />{fmtCount(page.album_listeners)} listeners</span>{/if}
{#if page.totals_summed}<span class="s quiet">summed</span>{/if}
</div>
{/if}
{#if libAlbum || rows.length}
<div class="rec-acts">
<button class="disc lg" onclick={playRecord} disabled={hasUnreleased} aria-label={libAlbum ? "Play your copy" : "Play from the network"}><Icon name="play" size={26} /></button>
{#if !libAlbum || missing > 0}
<button class="btn btn-ghost save" onclick={save} disabled={hasUnreleased || saving === "…"}><Icon name={saving.startsWith("Saving") ? "check" : "download"} size={14} />{saving || (libAlbum ? `Complete · ${missing} missing` : "Save")}</button>
{/if}
<button class="btn btn-ghost" class:on={player.shuffle} onclick={shuffleRecord} disabled={hasUnreleased} aria-label="Shuffle this record"><Icon name="shuffle" size={14} /></button>
</div>
{/if}
</div>
</div>
{#if editions.length}
<div class="sec"><h2>Editions</h2></div>
<div class="rail eds">
{#each editions as ed (ed.id)}
<button class="ed" class:on={ed.on} onclick={() => switchEdition(ed)}>
<span class="ed-art">{#if ed.cover}<img src={ed.cover} alt="" loading="lazy" />{:else}<Icon name="disc" size={16} />{/if}</span>
<span class="ed-copy"><span class="n">{ed.label}</span><span class="y">{[yearOf(ed.release_date), ed.track_count ? `${ed.track_count} tracks` : ""].filter(Boolean).join(" · ")}</span></span>
</button>
{/each}
</div>
{/if}
{#if entry?.status === "loading"}
<div class="sec-note" style="padding-top: 16px">Asking the catalog…</div>
{:else if entry?.status === "error"}
<div class="stub"><b>The catalog did not answer.</b>{entry.error}</div>
{/if}
<div class="tracks" style="padding-top: 10px">
{#each rows as t, i (t.id)}
{@const hit = hits.get(looseTitle(t.title))}
{#if multiDisc && (t.disk_number ?? 1) !== (rows[i - 1]?.disk_number ?? (i === 0 ? null : 1))}
<div class="disc-head">Disc {t.disk_number ?? 1}</div>
{/if}
<button class="trk" class:playing={hit ? player.track?.path === hit.path : current(t)} class:locked={t.unreleased} disabled={t.unreleased} onclick={() => playRow(t, i)} oncontextmenu={(e) => { e.preventDefault(); if (!t.unreleased && !hit) more(t); }}>
<span class="n">{t.track_no ?? i + 1}</span>
<span class="b">
<span class="t">{t.title}{#if hit}<span class="tick" title="In your library"><Icon name="check" size={11} strokeWidth={3} /></span>{/if}</span>
{#if !hit && !libAlbum && current(t) && resolver.active}<span class="m">finding the best source…</span>{/if}
{#if showCount}<span class="lbar"><i style="transform: scaleX({(peak ? Math.max(0.04, (t.listeners || 0) / peak) : 0).toFixed(3)})"></i><em>{fmtCount(t.listeners)}</em></span>{/if}
</span>
<span class="d">{t.unreleased ? "Unreleased" : mmss(t.duration_secs)}</span>
</button>
{/each}
</div>
{#if info?.description}
<div class="sec"><h2>About this record</h2></div>
<div class="about">
{#if desc.lede}<p class="lede">{desc.lede}</p>{/if}
{#if desc.rest}
<p class="bio" class:open={descOpen} onclick={() => (descOpen = !descOpen)} role="button" tabindex="0" onkeydown={() => {}}>{desc.rest}</p>
{/if}
</div>
{/if}
{#if album && (album.release_date || info?.label || info?.genre)}
<div class="sec"><h2>Release</h2></div>
<div class="about">
<div class="tags">
{#if info?.genre}<span class="chip on">{info.genre}</span>{/if}
{#if info?.label}<span class="chip quiet">{info.label}</span>{/if}
{#if album.release_date}<span class="chip quiet">{fullDate(album.release_date)}</span>{/if}
{#if rows.length}<span class="chip quiet">{rows.length} tracks</span>{/if}
{#if runtime}<span class="chip quiet">{runtime}</span>{/if}
</div>
<p class="srcs">{#if info?.description}<b>Words</b> Wikipedia · {/if}{#if page?.album_plays || page?.album_listeners}<b>Counts</b> Last.fm · {/if}<b>Catalogue</b> {libAlbum ? "your library + " : ""}Deezer + MusicBrainz</p>
</div>
{/if}
<div class="endpad"></div>
+160
View File
@@ -0,0 +1,160 @@
<script>
/* Community — the Soulseek side: rooms, chat, buddies. Signed out it is
the sign-in card; signed in it is the hub, the desktop's seats drawn
with the phone's components. The private layer (Spaces, holdings, the
sealed inbox) waits for the Freenet story on the phone. */
import Icon from "../../components/Icon.svelte";
import Identicon from "../../components/Identicon.svelte";
import { slsk, connect, connectStored, disconnect, refreshRooms, joinRoom, leaveRoom, openThread, unreadTotal } from "../slsk.svelte.js";
import { push, patch, openHalf } from "../nav.svelte.js";
import { player } from "../player.svelte.js";
import { ago } from "../util.js";
let { entry, screenEl = null } = $props();
const tab = $derived(entry.c.tab || "rooms");
let user = $state("");
let pass = $state("");
let showPass = $state(false);
$effect(() => { if (!user && slsk.savedUser) user = slsk.savedUser; });
const online = $derived(slsk.connection === "online");
const joinedNames = $derived(Object.keys(slsk.joined));
const otherRooms = $derived(slsk.rooms.filter((r) => !slsk.joined[r.name]).slice(0, 80));
const threads = $derived(
Object.entries(slsk.chats)
.map(([u, c]) => ({ u, c, last: c.messages[c.messages.length - 1] }))
.sort((a, b) => (b.last?.ts || 0) - (a.last?.ts || 0)),
);
const buddies = $derived(slsk.buddies.map((u) => ({ u, st: slsk.buddyState[u] || {} })));
async function signIn() {
if (!user.trim() || !pass) return;
await connect(user, pass);
if (slsk.connection !== "offline") pass = "";
}
async function open(room) {
if (!slsk.joined[room]) {
try { await joinRoom(room); } catch (e) { player.error = String(e); return; }
}
push("room", { name: room }, screenEl);
}
function thread(username) {
openThread(username);
push("thread", { username }, screenEl);
}
function status(st) {
if (st.status === 2 || st.status === "online") return "online";
if (st.status === 1 || st.status === "away") return "away";
return "offline";
}
</script>
<div class="phead">
<div class="row">
<h1>Community</h1>
{#if online}
<div class="acts">
<button class="iconbtn" onclick={refreshRooms} aria-label="Refresh rooms"><Icon name="refresh" size={21} /></button>
<button class="iconbtn muted" onclick={() => openHalf("community")} aria-label="More"><Icon name="more" size={21} /></button>
</div>
{/if}
</div>
{#if online}
<div class="online-line"><span class="dot"></span><span class="t-sub" style="font-weight:650">online as <b>{slsk.username}</b> · Soulseek</span></div>
{:else if slsk.connection === "connecting"}
<div class="online-line"><span class="dot warn"></span><span class="t-sub" style="font-weight:650">signing in…</span></div>
{/if}
</div>
{#if !online}
<div class="signin">
<div class="signin-t">Sign in to Soulseek</div>
<div class="signin-m">The network's rooms, its people and, next, its music. Your account is kept on this phone only.</div>
<input class="field" bind:value={user} placeholder="Username" autocomplete="username" autocapitalize="off" spellcheck="false" enterkeyhint="next" />
<div class="field-row">
<input class="field" type={showPass ? "text" : "password"} bind:value={pass} placeholder="Password" autocomplete="current-password" enterkeyhint="go" onkeydown={(e) => { if (e.key === "Enter") signIn(); }} />
<button class="iconbtn sm muted" onclick={() => (showPass = !showPass)} aria-label="Show password"><Icon name="eye" size={18} /></button>
</div>
{#if slsk.loginError}<div class="signin-err">{slsk.loginError}</div>{/if}
<button class="btn btn-fill wide" style="height:46px" disabled={slsk.connection === "connecting" || !user.trim() || !pass} onclick={signIn}>
{slsk.connection === "connecting" ? "Signing in…" : "Sign in"}
</button>
{#if slsk.savedUser && slsk.hasPassword && slsk.connection === "offline"}
<button class="btn btn-ghost wide" style="margin-top:8px" onclick={connectStored}>Reconnect as {slsk.savedUser}</button>
{/if}
<div class="sec-note" style="padding: 12px 0 0">No account? Soulseek makes one from the first sign-in with a new name.</div>
</div>
<div class="stub">
<b>Spaces, holdings and the sealed inbox come later on the phone.</b>
They live on the private layer; the network side is here first.
</div>
{:else}
<div class="segtabs" style="padding-top:12px">
{#each [["rooms", "Rooms"], ["chat", "Chat"], ["buddies", "Buddies"]] as [k, l] (k)}
<button class:on={tab === k} onclick={() => patch({ tab: k })}>
{l}{#if k === "chat" && unreadTotal()} <span class="count">{unreadTotal()}</span>{/if}
</button>
{/each}
</div>
{#if tab === "rooms"}
{#if joinedNames.length}
<div class="sec" style="padding-top:8px"><h2>Joined <em>· {joinedNames.length}</em></h2></div>
{#each joinedNames as name (name)}
{@const r = slsk.joined[name]}
{@const last = r.messages[r.messages.length - 1]}
<button class="row" onclick={() => open(name)} oncontextmenu={(e) => { e.preventDefault(); leaveRoom(name); }}>
<span class="art hash"><Icon name="hash" size={20} /></span>
<span class="b">
<span class="t">{name}</span>
<span class="m">{last ? `${last.username}: ${last.message}` : `${r.users.length} here`}</span>
</span>
<span class="r"><span class="n">{r.users.length}</span><Icon name="chevron-right" size={17} /></span>
</button>
{/each}
{/if}
<div class="sec" style="padding-top:{joinedNames.length ? 18 : 8}px"><h2>Rooms <em>· {slsk.rooms.length} on the server</em></h2></div>
{#if !slsk.rooms.length}
<div class="sec-note">Asking the server for its rooms…</div>
{/if}
{#each otherRooms as r (r.name)}
<button class="row" onclick={() => open(r.name)}>
<span class="art hash"><Icon name="hash" size={20} /></span>
<span class="b"><span class="t">{r.name}</span><span class="m">{r.users} in the room</span></span>
<span class="r"><Icon name="plus" size={18} /></span>
</button>
{/each}
{:else if tab === "chat"}
{#if !threads.length}
<div class="stub"><b>No conversations yet.</b>A name in a room, or a buddy, opens one.</div>
{/if}
{#each threads as t (t.u)}
<button class="row" onclick={() => thread(t.u)}>
<span class="art round idc"><Identicon seed={t.u} size={46} /></span>
<span class="b">
<span class="t">{t.u}{#if t.c.unread} <span class="unread">{t.c.unread}</span>{/if}</span>
<span class="m">{t.last ? (t.last.me ? "you: " : "") + t.last.text : ""}</span>
</span>
<span class="r">{t.last ? ago(t.last.ts / 1000) : ""}</span>
</button>
{/each}
{:else if tab === "buddies"}
{#if !buddies.length}
<div class="stub"><b>No buddies on this account yet.</b>Hold a name in a room to add one.</div>
{/if}
{#each buddies as b (b.u)}
<button class="row" onclick={() => thread(b.u)}>
<span class="art round idc"><Identicon seed={b.u} size={46} /></span>
<span class="b">
<span class="t">{b.u}</span>
<span class="m">{status(b.st)}{b.st.file_count ? ` · ${b.st.file_count.toLocaleString()} files` : ""}</span>
</span>
<span class="r"><span class="dot" class:off={status(b.st) === "offline"} class:warn={status(b.st) === "away"}></span></span>
</button>
{/each}
{/if}
<div style="padding: 18px var(--pad) 0"><button class="btn btn-ghost wide" onclick={disconnect}>Sign out</button></div>
{/if}
<div class="endpad"></div>
+320
View File
@@ -0,0 +1,320 @@
<script>
/* Home — the desktop's landing page (Home.svelte) at phone scale, on
top of the library it already showed: the greeting; the feed's lead
on the featured surface (a new or coming record from someone you
follow — the newest record in the library when there is none); New
from your artists; the news; Jump back in from the play history; From
the back catalogue; Recommended for you (each card says which follow
earned it); Recently added; Out now; Complete the set (gaps on your
shelf); Following. Every rule is the desktop's, copied. */
import { untrack } from "svelte";
import Icon from "../../components/Icon.svelte";
import Identicon from "../../components/Identicon.svelte";
import Release from "../Release.svelte";
import * as api from "../../lib/api.js";
import { lib, newest, tracksOf } from "../lib.svelte.js";
import { art, visible } from "../art.svelte.js";
import { player, playAlbum } from "../player.svelte.js";
import { push, go, openHalf } from "../nav.svelte.js";
import { streamCatalogAlbum } from "../resolve.svelte.js";
import { picture } from "../catalog.svelte.js";
import { home, loadHome, assembleFeed, age, ageShort, future, inDays, upcomingLabel, kindLabel, RANK, baseTitle, daysFrom } from "../home.svelte.js";
import { greeting, dateKick, ago } from "../util.js";
let { screenEl = null } = $props();
/* the library's own rows, as before */
const sorted = $derived(newest());
const newestHere = $derived(sorted[0] ?? null);
const thisYear = new Date().getFullYear();
const fresh = $derived(
lib.albums
.filter((a) => (a.year ?? 0) >= thisYear - 1)
.sort((x, y) => (y.year || 0) - (x.year || 0) || y.added_at - x.added_at)
.slice(0, 10),
);
const fromThisYear = $derived(lib.albums.filter((a) => a.year === thisYear).length);
const recents = $derived(lib.recents.slice(0, 12));
/* the follows' material: walked once, and again when the follows change */
$effect(() => {
const n = lib.follows.length;
const ids = lib.follows.map((f) => f.artist_id).join(",");
untrack(() => { if (n || home.started) void loadHome(ids !== loadedIds); loadedIds = ids; });
});
let loadedIds = "";
const slots = $derived(home.order.map((id) => home.slots[id]).filter(Boolean));
const WINDOW_DAYS = 90;
const ROW_CAP = 10;
const feed = $derived(assembleFeed(slots, WINDOW_DAYS));
const lead = $derived(feed[0] ?? null);
const feedRest = $derived(feed.slice(1, 1 + ROW_CAP));
const backCatalogue = $derived.by(() => {
const rows = [];
for (const slot of slots) {
for (const r of [...(slot.mb ?? []), ...(slot.albums ?? [])]) {
if (!r.cover || !r.release_date || future(r.release_date)) continue;
if (daysFrom(r.release_date) <= WINDOW_DAYS) continue;
if ((RANK[r.record_type] || 0) < 3) continue;
rows.push({ ...r, artistName: slot.name, artistId: slot.id });
}
}
const seen = new Set();
const picks = [];
for (const pass of [0, 1]) {
for (const r of rows) {
if (picks.length >= ROW_CAP) break;
const once = picks.some((p) => p.artistName === r.artistName);
if (pass === 0 && once) continue;
const key = r.artistName + "|" + baseTitle(r.title);
if (seen.has(key)) continue;
seen.add(key);
picks.push(r);
}
}
return picks;
});
/** Every follow contributes; each card says which one earned it. */
const because = $derived.by(() => {
const followed = new Set(slots.map((s) => s.name));
const seen = new Set();
const pool = [];
for (let round = 0; pool.length < ROW_CAP; round++) {
let added = 0;
for (const slot of slots) {
const pick = (slot.related ?? []).filter((r) => !followed.has(r.name) && !seen.has(r.name))[round];
if (pick) {
seen.add(pick.name);
pool.push({ ...pick, because: slot.name });
added++;
if (pool.length >= ROW_CAP) break;
}
}
if (!added) break;
}
return pool;
});
/** Own some of an artist's albums with a new one coming: the shelf gap. */
const completeSet = $derived.by(() => {
const owned = {};
for (const a of lib.albums) owned[a.artist] = (owned[a.artist] || 0) + 1;
return slots
.filter((s) => owned[s.name])
.map((s) => {
const albums = (s.mb ?? []).filter((r) => (RANK[r.record_type] || 0) === 3).length || (s.albums ?? []).length;
const up = [...(s.albums ?? []), ...(s.mb ?? [])].find((r) => future(r.release_date));
return { slot: s, own: owned[s.name], albums, up };
});
});
const setNote = (row) => `You own ${row.own} of ${row.albums} albums` + (row.up ? ` · new one ${inDays(row.up.release_date)}` : "");
const following = $derived(
slots.map((s) => {
const latest = [...(s.albums ?? []), ...(s.mb ?? [])]
.filter((r) => r.release_date)
.sort((x, y) => String(y.release_date).localeCompare(String(x.release_date)))[0];
return { slot: s, latest };
}),
);
const followNote = (row) => (row.latest ? (future(row.latest.release_date) ? "next " + inDays(row.latest.release_date) : "latest " + age(row.latest.release_date)) : "no releases known");
const newsItems = $derived(home.news.slice(0, 8));
/* the lead: the feed's, else the newest record here */
const leadUrl = $derived(newestHere?.art_path ? art[newestHere.art_path] : "");
function open(id) { push("album", { id }, screenEl); }
function openRelease(r) {
if (r.mbOnly || r.id == null) { push("artist", { name: r.artistName || r.artist, seed: r.artistId != null ? { id: r.artistId, name: r.artistName } : null }, screenEl); return; }
push("catalogAlbum", { id: r.id, artist: r.artistName || r.artist, title: r.title }, screenEl);
}
function openArtist(s) { push("artist", { name: s.name, seed: { id: s.id, name: s.name, picture: s.picture } }, screenEl); }
async function play(a) {
try { await playAlbum(a, await tracksOf(a.id), 0); } catch (e) { player.error = String(e); }
}
function playRelease(r) {
streamCatalogAlbum({ ...r, artist: r.artistName || r.artist }).catch((e) => { player.error = String(e); });
}
function openNews(n) {
api.openExternal(n.url).catch(() => { try { window.open(n.url, "_blank"); } catch { /* fine */ } });
}
</script>
<div class="phead">
<div class="row">
<div>
<div class="kick">{dateKick()}</div>
<h1>{greeting()}</h1>
</div>
<button class="iconbtn" onclick={() => go("you")} aria-label="You">
<Identicon seed={lib.username || "SoulStream"} size={34} />
</button>
</div>
</div>
{#if lead}
<!-- the feed's lead on the featured surface: a new or coming record from
someone you follow, standing on a blurred blow-up of its own cover -->
<div class="feat">
<div class="bg" style:background-image={`url("${lead.cover}")`}></div>
<div class="tint"></div>
<div class="grain"></div>
<div class="in">
<div class="feat-top">
<button class="feat-art" onclick={() => openRelease(lead)} aria-label="Open {lead.title}"><img src={lead.cover} alt="" /></button>
<div style="min-width:0;flex:1">
<div class="kick">{lead.upcoming ? "Coming" : "New"} · {kindLabel(lead.record_type).toUpperCase()}</div>
<div class="nm">{lead.title}</div>
<div class="feat-sub">{lead.artistName} · {lead.upcoming ? upcomingLabel(lead) : age(lead.release_date)}{lead.track_count ? ` · ${lead.track_count} tracks` : ""}</div>
</div>
</div>
<div class="stats">
<span class="s"><Icon name="users" size={12} strokeWidth={3} />{lib.follows.length} followed</span>
<span class="s"><Icon name="library" size={12} strokeWidth={3} />{lib.albums.length} albums</span>
</div>
<div class="acts">
{#if lead.upcoming}
<button class="btn btn-ghost" disabled><Icon name="clock" size={14} />Out {inDays(lead.release_date)}</button>
{:else if !lead.mbOnly}
<button class="btn btn-fill" onclick={() => playRelease(lead)}><Icon name="play" size={14} />Play</button>
{/if}
<button class="btn btn-ghost" onclick={() => openArtist({ id: lead.artistId, name: lead.artistName })}><Icon name="users" size={14} />Go to artist</button>
</div>
</div>
</div>
{:else if newestHere}
<div class="feat" use:visible={newestHere.art_path}>
<div class="bg" style:background-image={leadUrl ? `url("${leadUrl}")` : null}></div>
<div class="tint"></div>
<div class="grain"></div>
<div class="in">
<div class="feat-top">
<button class="feat-art" onclick={() => open(newestHere.id)} aria-label="Open">
{#if leadUrl}<img src={leadUrl} alt="" />{/if}
</button>
<div style="min-width:0;flex:1">
<div class="kick">Newest in your library</div>
<div class="nm">{newestHere.title}</div>
<div class="feat-sub">{newestHere.artist} · {newestHere.year ?? "—"} · {newestHere.track_count} tracks</div>
</div>
</div>
<div class="stats">
<span class="s"><Icon name="disc" size={12} strokeWidth={3} />{fromThisYear} from this year</span>
<span class="s"><Icon name="users" size={12} strokeWidth={3} />{lib.follows.length} followed</span>
<span class="s"><Icon name="library" size={12} strokeWidth={3} />{lib.albums.length} albums</span>
</div>
<div class="acts">
<button class="btn btn-fill" onclick={() => play(newestHere)}><Icon name="play" size={14} />Play</button>
<button class="btn btn-ghost" onclick={() => open(newestHere.id)}>Open</button>
</div>
</div>
</div>
{:else if lib.loaded}
<div class="stub">
<b>Nothing in the library on this phone yet.</b>
Music the phone can play lives in the app's own Library folder for now; follow artists and Save records from the catalog, and this page fills up.
</div>
{/if}
{#if feedRest.length}
<div class="sec"><h2>New from your artists <em>· {lib.follows.length} followed</em></h2></div>
<div class="rail">
{#each feedRest as r (r.artistName + "|" + r.title)}
<Release title={r.title} sub={kindLabel(r.record_type) + " · " + r.artistName} url={r.cover} badge={r.upcoming ? inDays(r.release_date) : age(r.release_date)} onclick={() => openRelease(r)} />
{/each}
</div>
{/if}
{#if newsItems.length}
<div class="sec"><h2>News <em>· from your artists</em></h2></div>
{#each newsItems as n (n.url)}
<button class="row news" onclick={() => openNews(n)}>
<span class="art round">{#if picture(home.slots[home.order.find((id) => home.slots[id]?.name === n.artist)]?.picture)}<img src={picture(home.slots[home.order.find((id) => home.slots[id]?.name === n.artist)].picture)} alt="" loading="lazy" />{:else}<Icon name="users" size={18} />{/if}</span>
<span class="b"><span class="t">{n.title}</span><span class="m">{n.artist} · {n.source} · {ageShort(n.date)}</span></span>
<span class="r"><Icon name="link" size={16} /></span>
</button>
{/each}
{/if}
{#if recents.length}
<div class="sec">
<h2>Jump back in <em>· picks up where you left off</em></h2>
<button class="all" onclick={() => go("library")}>All<Icon name="chevron-right" size={14} /></button>
</div>
<div class="rail">
{#each recents as r (r.album_id)}
<Release title={r.title} sub="{r.artist} · {ago(r.last_played_at)}" path={r.art_path} onclick={() => open(r.album_id)} />
{/each}
</div>
{/if}
{#if backCatalogue.length}
<div class="sec"><h2>From the back catalogue <em>· albums you may have missed</em></h2></div>
<div class="rail">
{#each backCatalogue as r (r.artistName + "|" + r.title)}
<Release title={r.title} sub={r.artistName} url={r.cover} badge={age(r.release_date)} onclick={() => openRelease(r)} />
{/each}
</div>
{/if}
{#if because.length}
<div class="sec"><h2>Recommended for you <em>· from everyone you follow</em></h2></div>
<div class="rail">
{#each because as r (r.id)}
<button class="acard" onclick={() => push("artist", { name: r.name, seed: r }, screenEl)}>
<span class="por">{#if picture(r.picture)}<img src={picture(r.picture)} alt="" loading="lazy" />{:else}<Icon name="users" size={36} />{/if}</span>
<span class="nm">{r.name}</span>
<span class="kd">Because you follow {r.because}</span>
</button>
{/each}
</div>
{/if}
{#if sorted.length}
<div class="sec">
<h2>Recently added <em>· {lib.albums.length} albums</em></h2>
<button class="all" onclick={() => go("library")}>Library<Icon name="chevron-right" size={14} /></button>
</div>
<div class="grid2">
{#each sorted.slice(0, 6) as a (a.id)}
<Release title={a.title} sub={a.artist} path={a.art_path} onclick={() => open(a.id)} onmore={() => openHalf("album", { album: a })} />
{/each}
</div>
{/if}
{#if fresh.length}
<div class="sec">
<h2>Out now <em>· released this year</em></h2>
<button class="all" onclick={() => go("library")}>All<Icon name="chevron-right" size={14} /></button>
</div>
<div class="rail">
{#each fresh as a (a.id)}
<Release title={a.title} sub={a.artist} path={a.art_path} badge={(a.year ?? 0) >= thisYear ? String(a.year) : null} onclick={() => open(a.id)} onmore={() => openHalf("album", { album: a })} />
{/each}
</div>
{/if}
{#if completeSet.length}
<div class="sec"><h2>Complete the set <em>· gaps on your shelf</em></h2></div>
{#each completeSet as row (row.slot.id)}
<button class="row" onclick={() => openArtist(row.slot)}>
<span class="art round">{#if picture(row.slot.picture)}<img src={picture(row.slot.picture)} alt="" loading="lazy" />{:else}<Icon name="users" size={18} />{/if}</span>
<span class="b"><span class="t">{row.slot.name}</span><span class="m">{setNote(row)}</span></span>
<span class="r"><Icon name="chevron-right" size={17} /></span>
</button>
{/each}
{/if}
{#if following.length}
<div class="sec"><h2>Following <em>· {lib.follows.length} artists</em></h2></div>
<div class="rail">
{#each following as row (row.slot.id)}
<button class="acard" onclick={() => openArtist(row.slot)}>
<span class="por">{#if picture(row.slot.picture)}<img src={picture(row.slot.picture)} alt="" loading="lazy" />{:else}<Icon name="users" size={36} />{/if}</span>
<span class="nm">{row.slot.name}</span>
<span class="kd">{followNote(row)}</span>
</button>
{/each}
</div>
{/if}
<div class="endpad"></div>
+187
View File
@@ -0,0 +1,187 @@
<script>
/* Your library — five second-tier tabs; Albums, Artists and Playlists
are real. The sort and the view live on the tab's root entry, so they
survive a push and a pop. */
import Icon from "../../components/Icon.svelte";
import Release from "../Release.svelte";
import Cover from "../Cover.svelte";
import Tracks from "./Tracks.svelte";
import * as api from "../../lib/api.js";
import { lib, artists, isFollowed, followedEntry } from "../lib.svelte.js";
import { push, go, patch, openHalf } from "../nav.svelte.js";
import { looseTitle, yearOf } from "../catalog.svelte.js";
import { isUpcoming } from "../disco.js";
import { longDur } from "../util.js";
let { entry, screenEl = null } = $props();
const TABS = [["albums", "Albums"], ["artists", "Artists"], ["tracks", "Tracks"], ["playlists", "Playlists"], ["gaps", "Gaps"]];
const SORTS = [["added", "Recently added"], ["az", "A–Z"], ["artist", "By artist"], ["year", "By year"]];
const tab = $derived(entry.c.tab || "albums");
const sort = $derived(entry.c.sort || "added");
const view = $derived(entry.c.view || "grid");
const list = $derived.by(() => {
const l = lib.albums.slice();
l.sort((x, y) => {
if (sort === "az") return x.title.localeCompare(y.title);
if (sort === "artist") return x.artist.localeCompare(y.artist) || (x.year || 0) - (y.year || 0);
if (sort === "year") return (y.year || 0) - (x.year || 0) || y.added_at - x.added_at;
return y.added_at - x.added_at;
});
return l;
});
const totalTracks = $derived(lib.albums.reduce((n, a) => n + (a.track_count || 0), 0));
const arts = $derived(artists());
/* ── Gaps — the desktop's (Library.svelte's gapSection): only a shelf
with an ANCHORED identity — a follow — is asked, so a name can never
be guessed onto a namesake; the catalog's released records minus the
ones on the shelf, by loose title; the totals remembered so the line
can say its number before the lookup. */
const GAPS_KEY = "library.gaps.v1";
const foldKey = (x) => String(x ?? "").toLowerCase();
let gapTotals = $state((() => { try { return JSON.parse(localStorage.getItem(GAPS_KEY)) ?? {}; } catch { return {}; } })());
let gapRows = $state({}); // foldKey(artist) → catalog rows not owned
let gapOpen = $state({});
let gapLoading = $state({});
const shelves = $derived(arts.filter((ar) => followedEntry(ar.name)));
const ownedTitles = (name) => new Set(lib.albums.filter((a) => foldKey(a.artist) === foldKey(name)).map((a) => looseTitle(a.title)));
async function toggleGaps(name) {
const key = foldKey(name);
if (gapOpen[key]) { gapOpen = { ...gapOpen, [key]: false }; return; }
const follow = followedEntry(name);
if (!follow) return;
if (!gapRows[key] && !gapLoading[key]) {
gapLoading = { ...gapLoading, [key]: true };
try {
const page = await api.catalogArtist(follow.artist_id);
const released = (page?.albums ?? []).filter((a) => !isUpcoming(a) && !a.appears_on);
const owned = ownedTitles(name);
gapRows = { ...gapRows, [key]: released.filter((r) => !owned.has(looseTitle(r.title))) };
gapTotals = { ...gapTotals, [key]: { total: released.length } };
try { localStorage.setItem(GAPS_KEY, JSON.stringify(gapTotals)); } catch { /* ignore */ }
} catch { /* the line stays collapsed; nothing is invented */ }
gapLoading = { ...gapLoading, [key]: false };
}
if (gapRows[key]) gapOpen = { ...gapOpen, [key]: true };
}
const gapCount = (ar) => {
const key = foldKey(ar.name);
const missing = gapRows[key];
const total = gapTotals[key]?.total;
return missing ? missing.length : (total ? Math.max(0, total - ar.albums) : null);
};
</script>
<div class="phead">
<div class="row">
<h1>Your library</h1>
<div class="acts">
<button class="iconbtn" onclick={() => go("search")} aria-label="Search"><Icon name="search" size={21} /></button>
<button class="iconbtn" onclick={() => patch({ tab: "playlists" })} aria-label="Playlists"><Icon name="playlist" size={21} /></button>
<button class="iconbtn" onclick={() => openHalf("librarysort")} aria-label="Sort and filter"><Icon name="layers" size={21} /></button>
</div>
</div>
</div>
<div class="segtabs">
{#each TABS as [k, l] (k)}
<button class:on={tab === k} onclick={() => patch({ tab: k })}>{l}</button>
{/each}
</div>
{#if tab === "albums"}
<div class="chips" style="padding-bottom:12px">
{#each SORTS as [k, l] (k)}
<button class="chip" class:on={sort === k} class:quiet={sort !== k} onclick={() => patch({ sort: k })}>{l}</button>
{/each}
</div>
<div class="countline">
<span class="t-sub">{list.length} album{list.length === 1 ? "" : "s"} · {totalTracks} tracks</span>
<button class="iconbtn sm muted" onclick={() => patch({ view: view === "grid" ? "list" : "grid" })} aria-label="Grid or list">
<Icon name={view === "grid" ? "playlist" : "layers"} size={18} />
</button>
</div>
{#if !list.length}
<div class="stub"><b>Nothing here yet</b>The library on this phone is empty.</div>
{:else if view === "grid"}
<div class="grid2">
{#each list as a (a.id)}
<Release title={a.title} sub={sort === "year" ? String(a.year || "") : a.artist} path={a.art_path} onclick={() => push("album", { id: a.id }, screenEl)} onmore={() => openHalf("album", { album: a })} />
{/each}
</div>
{:else}
{#each list as a (a.id)}
<button class="row" onclick={() => push("album", { id: a.id }, screenEl)} oncontextmenu={(e) => { e.preventDefault(); openHalf("album", { album: a }); }}>
<span class="art"><Cover path={a.art_path} size={20} /></span>
<span class="b"><span class="t">{a.title}</span><span class="m">{a.artist} · {a.year ?? "—"}</span></span>
<span class="r"><Icon name="chevron-right" size={17} /></span>
</button>
{/each}
{/if}
{:else if tab === "artists"}
<div class="sec-note">{arts.length} artists · {lib.follows.length} followed</div>
{#each arts as ar (ar.name)}
<button class="row" onclick={() => push("artist", { name: ar.name }, screenEl)}>
<span class="art round"><Cover path={ar.art || ar.cover} size={20} /></span>
<span class="b">
<span class="t">{ar.name}</span>
<span class="m">{ar.albums} album{ar.albums === 1 ? "" : "s"} · {ar.tracks} tracks{isFollowed(ar.name) ? " · following" : ""}</span>
</span>
<span class="r"><Icon name="chevron-right" size={17} /></span>
</button>
{/each}
{:else if tab === "tracks"}
<Tracks {entry} />
{:else if tab === "playlists"}
{#if !lib.playlists.length}
<div class="stub"><b>No playlists on this phone yet.</b>Playlists are part of the library folder, so the ones the desktop keeps come over with it.</div>
{/if}
{#each lib.playlists as p (p.id)}
<button class="row" onclick={() => push("playlist", { id: p.id }, screenEl)}>
<span class="art"><Icon name="playlist" size={20} /></span>
<span class="b">
<span class="t">{p.name}</span>
<span class="m">{p.description ? p.description + " · " : ""}{p.track_count} track{p.track_count === 1 ? "" : "s"}</span>
</span>
<span class="r"><Icon name="chevron-right" size={17} /></span>
</button>
{/each}
{:else if tab === "gaps"}
{#if !shelves.length}
<div class="stub"><b>Follow the artists you collect.</b>The records they released that this library does not have show up here, shelf by shelf — the desktop's discography lookup, only ever for an artist you followed, so a name is never guessed onto a namesake.</div>
{:else}
<div class="sec-note">{shelves.length} followed {shelves.length === 1 ? "artist" : "artists"} on this phone</div>
{#each shelves as ar (ar.name)}
{@const key = foldKey(ar.name)}
{@const count = gapCount(ar)}
<button class="row" onclick={() => push("artist", { name: ar.name }, screenEl)}>
<span class="art round"><Cover path={ar.art || ar.cover} size={20} /></span>
<span class="b">
<span class="t">{ar.name}</span>
<span class="m">{ar.albums} album{ar.albums === 1 ? "" : "s"} here · following</span>
</span>
<span class="r"><Icon name="chevron-right" size={17} /></span>
</button>
{#if count !== 0}
<button class="gapline" onclick={() => toggleGaps(ar.name)}>
{#if gapLoading[key]}Checking the catalogue…{:else if count != null}<b>{count}</b> more released{:else}See what's missing{/if}
<span class="cta">{gapOpen[key] ? "hide" : "show"}<Icon name={gapOpen[key] ? "chevron-down" : "chevron-right"} size={12} /></span>
</button>
{:else}
<div class="gapline done"><b>Complete</b> — every released record is here</div>
{/if}
{#if gapOpen[key] && gapRows[key]?.length}
<div class="rail gaps">
{#each gapRows[key] as r (r.id)}
<Release title={r.title} sub={[yearOf(r.release_date), r.record_type && r.record_type !== "album" ? r.record_type.toUpperCase() : ""].filter(Boolean).join(" · ")} url={r.cover} onclick={() => push("catalogAlbum", { id: r.id, artist: ar.name, title: r.title }, screenEl)} />
{/each}
</div>
{/if}
{/each}
{/if}
{/if}
<div class="endpad"></div>
+70
View File
@@ -0,0 +1,70 @@
<script>
/* A playlist, in the record page's shape. Rows without a local file
(streams, unresolved imports) keep their place and say so. */
import Icon from "../../components/Icon.svelte";
import * as api from "../../lib/api.js";
import { lib } from "../lib.svelte.js";
import { player, playEntries } from "../player.svelte.js";
import { openHalf } from "../nav.svelte.js";
import { mmss, longDur, shuffled } from "../util.js";
let { id } = $props();
const pl = $derived(lib.playlists.find((p) => p.id === id) || null);
let items = $state([]);
let cover = $state("");
$effect(() => {
const p = pl;
if (!p) return;
api.playlistItems(p.id).then((r) => { items = r || []; }, (e) => { player.error = String(e); });
api.playlistCover(p.id, 360).then((u) => { cover = u || ""; }, () => {});
});
const total = $derived(items.reduce((n, i) => n + (i.duration_secs || 0), 0));
const playable = $derived(items.filter((i) => i.path).length);
function entryOf(x) {
return {
t: { id: "pl" + x.id, title: x.title, artist: x.artist, path: x.path, duration_secs: x.duration_secs ?? null },
album: { id: null, title: x.album || pl.name, artist: x.artist, art_path: x.art_path || null },
};
}
function playFrom(i) {
const es = items.slice(i).filter((x) => x.path).map(entryOf);
if (es.length) playEntries(es, 0, pl.name);
}
function shuffle() {
const es = shuffled(items.filter((x) => x.path).map(entryOf));
if (es.length) { player.shuffle = true; playEntries(es, 0, pl.name); }
}
</script>
{#if pl}
<div class="rec-head">
<div class="rec-cover">{#if cover}<img src={cover} alt="" />{:else}<Icon name="playlist" size={48} />{/if}</div>
<div class="rec-meta">
<div class="rec-title">{pl.name}</div>
<div class="rec-line">{items.length} track{items.length === 1 ? "" : "s"}{total ? ` · ${longDur(total)}` : ""}{playable < items.length ? ` · ${playable} on this phone` : ""}</div>
{#if pl.description}<div class="rec-line" style="margin-top: 8px; color: var(--text-secondary)">{pl.description}</div>{/if}
<div class="rec-acts">
<button class="iconbtn muted" onclick={shuffle} aria-label="Shuffle"><Icon name="shuffle" size={22} /></button>
<button class="disc lg" onclick={() => playFrom(0)} aria-label="Play"><Icon name="play" size={26} /></button>
</div>
</div>
</div>
<div class="tracks">
{#each items as x, i (x.id)}
<button class="trk" class:playing={x.path && player.track?.path === x.path} onclick={() => x.path && playFrom(i)} oncontextmenu={(e) => { e.preventDefault(); if (x.path) openHalf("track", entryOf(x)); }} style:opacity={x.path ? 1 : 0.5}>
<span class="n">{i + 1}</span>
<span class="b">
<span class="t">{x.title}</span>
<span class="m">{x.artist}{x.album ? " · " + x.album : ""}{x.path ? "" : " · not on this phone"}</span>
</span>
<span class="d">{mmss(x.duration_secs)}</span>
</button>
{/each}
</div>
{:else}
<div class="stub" style="margin-top: calc(var(--bar-h) + 10px)"><b>That playlist is gone.</b></div>
{/if}
<div class="endpad"></div>
+69
View File
@@ -0,0 +1,69 @@
<script>
/* A Soulseek room. The conversation shape the mockup settled — theirs
left on the surface, ours right in the accent, the name on a line
opens that person — and a composer that stays above the mini bar. */
import { tick } from "svelte";
import Icon from "../../components/Icon.svelte";
import { slsk, sayRoom, leaveRoom, openThread, addBuddy } from "../slsk.svelte.js";
import { push, back, openHalf } from "../nav.svelte.js";
import { player } from "../player.svelte.js";
import { clock } from "../util.js";
let { name, screenEl = null } = $props();
const room = $derived(slsk.joined[name] ?? null);
const lines = $derived(room?.messages ?? []);
let text = $state("");
let inputEl = $state(null);
let sending = $state(false);
/* new lines keep the end in view, unless the reader has scrolled up */
$effect(() => {
void lines.length;
const el = screenEl;
if (!el) return;
const nearEnd = el.scrollHeight - el.scrollTop - el.clientHeight < 240;
tick().then(() => { if (nearEnd || lines.length <= 2) el.scrollTop = el.scrollHeight; });
});
async function send() {
const t = text.trim();
if (!t || sending) return;
sending = true;
try { await sayRoom(name, t); text = ""; } catch (e) { player.error = String(e); }
sending = false;
inputEl?.focus();
}
function person(u) {
if (u === slsk.username) return;
openThread(u);
push("thread", { username: u }, screenEl);
}
</script>
{#if room}
<div class="room-head">
<span class="b"><span class="t-sub">{room.users.length} here now</span></span>
<button class="btn btn-ghost" style="height:32px;padding:0 12px" onclick={() => openHalf("roomusers", { name })}>{room.users.length} <Icon name="users" size={14} /></button>
</div>
<div class="chat">
{#if !lines.length}
<div class="sec-note" style="padding: 20px 0">Nothing said since you joined.</div>
{/if}
{#each lines as l, i (i)}
{@const me = l.username === slsk.username}
<div class="msg" class:me>
{#if !me}<button class="who" onclick={() => person(l.username)}>{l.username}</button>{/if}
<div class="bub">{l.message}</div>
<div class="at">{clock(l.ts)}</div>
</div>
{/each}
</div>
<div class="composer">
<input class="field" bind:this={inputEl} bind:value={text} placeholder="Message #{name}…" autocomplete="off" enterkeyhint="send" onkeydown={(e) => { if (e.key === "Enter") { e.preventDefault(); send(); } }} />
<button class="disc sm" onclick={send} aria-label="Send" disabled={!text.trim()}><Icon name="send" size={16} /></button>
</div>
{:else}
<div class="stub" style="margin-top: calc(var(--bar-h) + 10px)"><b>Not in this room.</b>Join it from Community.</div>
{/if}
<div class="endpad"></div>
+383
View File
@@ -0,0 +1,383 @@
<script>
/* Search — the desktop's top bar, on the phone: the CATALOG first
(Deezer, MusicBrainz and Apple behind one field), led by the desktop's
TOP RESULT — the most popular exact match of its kind, an artist, a
record or a song, scored as a share of its own kind's leader — then
artists, records and songs, then what the library holds, then the raw
network, offered rather than run. A song row plays through the
resolver (the source rules pick a peer's copy); a record opens its
page; an artist theirs. */
import { untrack } from "svelte";
import Icon from "../../components/Icon.svelte";
import Release from "../Release.svelte";
import Cover from "../Cover.svelte";
import Identicon from "../../components/Identicon.svelte";
import * as api from "../../lib/api.js";
import { lib, artists } from "../lib.svelte.js";
import { push, openHalf, patch } from "../nav.svelte.js";
import { player, playIndexRow, albumOfRow, playEntries } from "../player.svelte.js";
import { slsk } from "../slsk.svelte.js";
import { net, search as netSearch, speedLabel, groupFormat } from "../net.svelte.js";
import { resolver, streamSong, streamCatalogAlbum } from "../resolve.svelte.js";
import { catalog, artistKey, loadProfile, seedFor, lookupArtist, yearOf, fans, picture } from "../catalog.svelte.js";
import { latestOf } from "../disco.js";
import { mmss, fmtBytes, titleFromName, folderMeta } from "../util.js";
let { entry, screenEl = null } = $props();
let q = $state(entry.c.q || "");
let inputEl = $state(null);
/** the screen remembers its query across the tab switch (nav's entry) */
function setQ(v) { q = v; patch({ q: v }); }
const query = $derived(q.trim());
const asking = $derived(query.length >= 2);
/* ── the catalog, asked once the typing pauses ─────────────────── */
let cat = $state(null);
let catBusy = $state(false);
let catErr = $state("");
let catSeq = 0;
let catTimer = null;
$effect(() => {
const qq = query;
clearTimeout(catTimer);
if (qq.length < 2) { cat = null; catBusy = false; catErr = ""; return; }
catBusy = true;
catErr = "";
const my = ++catSeq;
catTimer = setTimeout(() => {
api.catalogSearch(qq).then(
(r) => { if (my === catSeq) { cat = r || { artists: [], albums: [], tracks: [] }; catBusy = false; } },
(e) => { if (my === catSeq) { cat = null; catErr = String(e); catBusy = false; } },
);
}, 350);
return () => clearTimeout(catTimer);
});
const allArtists = $derived(cat?.artists ?? []);
const allAlbums = $derived(cat?.albums ?? []);
const allTracks = $derived(cat?.tracks ?? []);
const catArtists = $derived(allArtists.slice(0, 8));
const catAlbums = $derived(allAlbums.slice(0, 12));
const catTracks = $derived(allTracks.slice(0, 10));
const catEmpty = $derived(!!cat && !allArtists.length && !allAlbums.length && !allTracks.length);
/* ── the top result: the desktop's rule, verbatim ──────────────────
Each candidate scores as a SHARE OF ITS OWN KIND'S LEADER, never as a
raw number — fans and rank measure different things on different
scales. An outright tie falls back to artist → album → song, the
order of how much a page can say about the thing you named; nothing
named outright falls back to each list's own most popular entry. */
const norm = (s) => String(s ?? "").toLowerCase().replace(/[^a-z0-9]/g, "");
const rankOf = (t) => t?.rank ?? 0;
const top = $derived.by(() => {
const qn = norm(query);
if (!qn || !cat) return null;
const exact = (s) => norm(s) === qn;
const artist = allArtists.filter((a) => exact(a.name)).sort((a, b) => (b.fans ?? 0) - (a.fans ?? 0))[0] ?? null;
const song = allTracks.filter((t) => exact(t.title)).sort((a, b) => rankOf(b) - rankOf(a))[0] ?? null;
const albumRank = (al) => Math.max(0, ...allTracks.filter((t) => t.album_id === al.id).map(rankOf));
const album = allAlbums.filter((al) => exact(al.title)).sort((a, b) => albumRank(b) - albumRank(a))[0] ?? null;
const maxFans = Math.max(1, ...allArtists.map((a) => a.fans ?? 0));
const maxRank = Math.max(1, ...allTracks.map(rankOf));
const candidates = [
artist && { kind: "artist", data: artist, score: (artist.fans ?? 0) / maxFans, tie: 3 },
album && { kind: "album", data: album, score: albumRank(album) / maxRank, tie: 2 },
song && { kind: "song", data: song, score: rankOf(song) / maxRank, tie: 1 },
].filter(Boolean);
if (candidates.length) {
candidates.sort((a, b) => b.score - a.score || b.tie - a.tie);
return candidates[0];
}
if (allArtists.length) return { kind: "artist", data: allArtists[0] };
if (allAlbums.length) return { kind: "album", data: allAlbums[0] };
if (allTracks.length) return { kind: "song", data: [...allTracks].sort((a, b) => rankOf(b) - rankOf(a))[0] };
return null;
});
const topArt = $derived(top?.kind === "artist" ? picture(top.data.picture) : (top?.data.cover ?? null));
/* only the kind that won pays for its supplement: an artist's profile
(tags, totals) and its page — warmed while the results are read, as
the desktop does, so "Open artist" is instant and the latest release
is the real one (a Deezer SEARCH carries no dates) — or a record's or
song's artist portrait for the byline */
$effect(() => {
const t = top;
if (!t) return;
untrack(() => {
if (t.kind === "artist") { loadProfile(t.data.id); lookupArtist(t.data.name, t.data); }
else seedFor(t.data.artist);
});
});
const profile = $derived(top?.kind === "artist" ? catalog.profiles[top.data.id] ?? null : null);
const heroTags = $derived((profile?.tags ?? []).slice(0, 4));
const byline = $derived(top && top.kind !== "artist" ? catalog.seeds[artistKey(top.data.artist)] ?? null : null);
const topReleases = $derived(top?.kind === "artist" ? allAlbums.filter((al) => norm(al.artist) === norm(top.data.name)) : []);
/** The newest record by the hero artist — the other thing a search about
* an artist is asking. From the artist's own page once it has landed
* (dated); until then nothing, rather than a guess. */
const latestRelease = $derived.by(() => {
if (top?.kind !== "artist") return null;
const pg = catalog.artists[artistKey(top.data.name)]?.page;
return pg ? latestOf(pg.albums) : null;
});
function openArtist(name, seed = null) { push("artist", { name, seed }, screenEl); }
function openAlbum(a) { push("catalogAlbum", { id: a.id, artist: a.artist, title: a.title }, screenEl); }
function openTop() {
if (!top) return;
if (top.kind === "artist") openArtist(top.data.name, top.data);
else if (top.kind === "album") openAlbum(top.data);
else if (top.data.album_id) push("catalogAlbum", { id: top.data.album_id, artist: top.data.artist, title: top.data.album }, screenEl);
}
function playTop() {
if (!top) return;
if (top.kind === "artist") {
const t = allTracks.find((x) => norm(x.artist) === norm(top.data.name)) ?? allTracks[0];
if (t) playSong(t);
} else if (top.kind === "album") {
streamCatalogAlbum(top.data).catch((e) => { player.error = String(e); });
} else {
playSong(top.data);
}
}
function playSong(t) {
streamSong(t.artist, t.title, {
trackId: t.id, albumId: t.album_id ?? null, album: t.album || null,
cover: t.cover ?? null, expectSecs: t.duration_secs ?? null,
}).catch((e) => { player.error = String(e); });
}
const current = (t) => player.track?.id === "cat:" + t.id;
/* ── the library ───────────────────────────────────────────────── */
const lnorm = (s) => String(s || "").toLowerCase();
const words = $derived(lnorm(q).split(/\s+/).filter(Boolean));
const albumHits = $derived(
words.length
? lib.albums.filter((a) => { const hay = lnorm(a.title + " " + a.artist); return words.every((w) => hay.includes(w)); }).slice(0, 12)
: [],
);
const artistHits = $derived(
words.length
? artists().filter((ar) => { const hay = lnorm(ar.name); return words.every((w) => hay.includes(w)); }).slice(0, 6)
: [],
);
let tracks = $state([]);
let searching = $state(false);
let timer = null;
let seq = 0;
$effect(() => {
const qq = query;
clearTimeout(timer);
if (qq.length < 2) { tracks = []; searching = false; return; }
searching = true;
const my = ++seq;
timer = setTimeout(() => {
api.libraryTrackSearch(qq).then(
(rows) => { if (my === seq) { tracks = (rows || []).slice(0, 20); searching = false; } },
() => { if (my === seq) { tracks = []; searching = false; } },
);
}, 160);
});
const libAny = $derived(artistHits.length || albumHits.length || tracks.length);
const playHit = playIndexRow;
function moreHit(row) {
const album = albumOfRow(row) || { id: null, title: row.album, artist: row.artist, art_path: row.art_path ?? null };
openHalf("track", { album, track: { id: row.path, title: row.title, artist: row.artist, path: row.path, duration_secs: row.duration_secs ?? null } });
}
/* ── the raw network, on request ───────────────────────────────── */
const online = $derived(slsk.connection === "online");
const netHere = $derived(online && net.query === query && (net.searching || net.groups.length > 0));
let expanded = $state(new Set());
function askNetwork() {
if (!online || query.length < 2) return;
netSearch(query).catch(() => {});
inputEl?.blur();
}
function streamEntries(g, from) {
const meta = folderMeta(g.folder);
const artKey = "stream:" + g.username + "|" + g.folder;
const album = { id: null, title: meta.album || g.folderName, artist: meta.artist || g.username, year: meta.year || null, art_path: artKey };
return g.files.slice(from).map((f) => ({
t: { id: "stream:" + g.username + "|" + f.path, title: titleFromName(f.name), artist: meta.artist || g.username, path: f.path, duration_secs: f.duration ?? null, stream: { username: g.username, size: f.size } },
album,
}));
}
function playPeer(g, i) {
const es = streamEntries(g, i);
if (es.length) playEntries(es, 0, g.folderName).catch((e) => { player.error = String(e); });
}
function expand(key) { expanded = new Set([...expanded, key]); }
function enter(e) {
if (e.key !== "Enter") return;
e.preventDefault();
inputEl?.blur();
}
</script>
<div class="phead"><div class="row"><h1>Search</h1></div></div>
<div class="sfield">
<Icon name="search" size={18} />
<input bind:this={inputEl} value={q} oninput={(e) => setQ(e.currentTarget.value)} type="search" placeholder="Artists, records, songs" autocomplete="off" autocapitalize="off" spellcheck="false" enterkeyhint="search" aria-label="Search" onkeydown={enter} />
{#if q}
<button class="iconbtn sm" onclick={() => { setQ(""); inputEl?.focus(); }} aria-label="Clear"><Icon name="x" size={18} /></button>
{/if}
</div>
{#if !asking}
<div class="stub">
<b>The catalog, your library{online ? " and the network" : ""}.</b>
Artists, records and songs from the catalog; a song plays from the best copy the network has, a record opens its page.
{#if !online}Sign in on Community to play what the library does not hold.{/if}
</div>
{:else}
{#if catErr}<div class="sec-note">{catErr}</div>{/if}
{#if catBusy && !cat}<div class="sec-note">Asking the catalog…</div>{/if}
{#if catEmpty && !catBusy}<div class="sec-note">Nothing on the catalog for “{query}”.</div>{/if}
{#if top}
<div class="top" class:round={top.kind === "artist"}>
<div class="top-bg" style:background-image={topArt ? `url("${topArt}")` : null}></div>
<div class="top-in">
<button class="top-art" onclick={openTop} aria-label={top.kind === "artist" ? "Open artist" : "Open record"}>
{#if topArt}<img src={topArt} alt="" />{:else}<span class="ph"><Icon name={top.kind === "artist" ? "users" : "disc"} size={30} /></span>{/if}
</button>
<div class="top-text">
<div class="k">Top result · {top.kind === "artist" ? "Artist" : top.kind === "album" ? "Record" : "Song"}</div>
<div class="top-name">{top.kind === "artist" ? top.data.name : top.data.title}</div>
{#if top.kind === "artist"}
<div class="m">{[fans(top.data.fans), topReleases.length ? topReleases.length + " release" + (topReleases.length === 1 ? "" : "s") : "", profile?.listeners ? fans(profile.listeners).replace("fans", "listeners") : ""].filter(Boolean).join(" · ")}</div>
{#if heroTags.length}<div class="tags">{#each heroTags as t (t)}<span class="chip quiet">{t}</span>{/each}</div>{/if}
{:else if top.kind === "album"}
<div class="m">
<button class="top-who" onclick={() => openArtist(top.data.artist, byline)}>{#if picture(byline?.picture)}<img src={picture(byline.picture)} alt="" />{/if}{top.data.artist}</button>
{[yearOf(top.data.release_date), top.data.track_count ? `${top.data.track_count} tracks` : ""].filter(Boolean).map((x) => " · " + x).join("")}
</div>
{:else}
<div class="m">
<button class="top-who" onclick={() => openArtist(top.data.artist, byline)}>{#if picture(byline?.picture)}<img src={picture(byline.picture)} alt="" />{/if}{top.data.artist}</button>
{top.data.album ? " · " + top.data.album : ""}{top.data.duration_secs ? " · " + mmss(top.data.duration_secs) : ""}
</div>
{/if}
<div class="top-acts">
<button class="btn btn-fill" onclick={playTop}><Icon name="play" size={14} />{top.kind === "song" && current(top.data) && resolver.active ? "Finding…" : "Play"}</button>
<button class="btn btn-ghost" onclick={openTop}>{top.kind === "artist" ? "Open artist" : top.kind === "album" ? "Open record" : "Go to record"}</button>
</div>
</div>
</div>
</div>
{#if latestRelease}
<button class="row" onclick={() => openAlbum(latestRelease)}>
<span class="art">{#if latestRelease.cover}<img src={latestRelease.cover} alt="" loading="lazy" />{:else}<Icon name="disc" size={20} />{/if}</span>
<span class="b"><span class="t">{latestRelease.title}</span><span class="m">Latest release{yearOf(latestRelease.release_date) ? " · " + yearOf(latestRelease.release_date) : ""}{latestRelease.record_type && latestRelease.record_type !== "album" ? " · " + latestRelease.record_type.toUpperCase() : ""}</span></span>
<span class="r"><Icon name="chevron-right" size={17} /></span>
</button>
{/if}
{/if}
{#if catArtists.length}
<div class="sec"><h2>Artists</h2></div>
<div class="rail">
{#each catArtists as ar (ar.id)}
<button class="acard cat-artist" onclick={() => openArtist(ar.name, ar)}>
<span class="por">{#if picture(ar.picture)}<img src={picture(ar.picture)} alt="" loading="lazy" />{:else}<Icon name="users" size={36} />{/if}</span>
<span class="nm">{ar.name}</span>
<span class="kd">{fans(ar.fans) || "Artist"}</span>
</button>
{/each}
</div>
{/if}
{#if catAlbums.length}
<div class="sec"><h2>Records</h2></div>
<div class="rail">
{#each catAlbums as a (a.id)}
<Release title={a.title} sub={[a.artist, yearOf(a.release_date)].filter(Boolean).join(" · ")} url={a.cover} onclick={() => openAlbum(a)} />
{/each}
</div>
{/if}
{#if catTracks.length}
<div class="sec"><h2>Songs</h2></div>
{#each catTracks as t (t.id)}
<button class="row cat-song" class:playing={current(t)} onclick={() => playSong(t)}>
<span class="art">{#if t.cover}<img src={t.cover} alt="" loading="lazy" />{:else}<Icon name="disc" size={20} />{/if}</span>
<span class="b"><span class="t">{t.title}</span><span class="m">{t.artist}{t.album ? " · " + t.album : ""}</span></span>
<span class="r">{current(t) && resolver.active ? "finding…" : mmss(t.duration_secs)}</span>
</button>
{/each}
{/if}
{#if libAny}
<div class="sec"><h2>In your library{#if searching} <em>· searching…</em>{/if}</h2></div>
{#each artistHits as ar (ar.name)}
<button class="row" onclick={() => openArtist(ar.name)}>
<span class="art round"><Cover path={ar.art || ar.cover} size={20} /></span>
<span class="b"><span class="t">{ar.name}</span><span class="m">{ar.albums} record{ar.albums === 1 ? "" : "s"} · {ar.tracks} tracks</span></span>
<span class="r"><Icon name="chevron-right" size={17} /></span>
</button>
{/each}
{#if albumHits.length}
<div class="rail">
{#each albumHits as a (a.id)}
<Release title={a.title} sub={a.artist} path={a.art_path} onclick={() => push("album", { id: a.id }, screenEl)} onmore={() => openHalf("album", { album: a })} />
{/each}
</div>
{/if}
{#each tracks as row (row.path)}
<button class="trk" class:playing={player.track?.path === row.path} onclick={() => playHit(row)} oncontextmenu={(e) => { e.preventDefault(); moreHit(row); }}>
<span class="n"><Icon name="play" size={12} /></span>
<span class="b"><span class="t">{row.title}</span><span class="m">{row.artist} · {row.album}</span></span>
<span class="d">{mmss(row.duration_secs)}</span>
<span class="r" role="button" tabindex="0" aria-label="More" onkeydown={() => {}} onclick={(e) => { e.stopPropagation(); moreHit(row); }}><Icon name="more" size={17} /></span>
</button>
{/each}
{/if}
{#if online}
{#if !netHere}
<div style="padding: 18px var(--pad) 0">
<button class="btn btn-ghost wide net-ask" onclick={askNetwork}><Icon name="globe" size={16} />Search the network for “{query}”</button>
</div>
{:else}
<div class="sec">
<h2>The network <em>· {net.searching ? "searching…" : net.responses ? net.responses + " people · " + net.files + " files" : "nothing yet"}</em></h2>
{#if !net.searching}<button class="all net-ask" onclick={askNetwork}>Again<Icon name="refresh" size={14} /></button>{/if}
</div>
{#if net.error}<div class="sec-note">{net.error}</div>{/if}
{#if net.searching && !net.groups.length}
<div class="sec-note">Asking the network as {slsk.username}…</div>
{/if}
{#each net.groups.slice(0, 40) as g (g.key)}
<div class="peer">
<div class="peer-head">
<span class="peer-who">
<Identicon seed={g.username} size={30} />
<span><b>{g.username}</b><i>{g.slot ? "slot free" : "queue " + g.queue} · {speedLabel(g.speed)}</i></span>
</span>
<span class="dot" class:warn={!g.slot}></span>
</div>
<button class="peer-folder" onclick={() => playPeer(g, 0)}>
<span class="peer-path">{g.folderName}</span>
<span class="peer-meta">{groupFormat(g)} · {g.files.length} file{g.files.length === 1 ? "" : "s"} · {fmtBytes(g.size)}</span>
<span class="peer-get"><Icon name="play" size={14} />Play</span>
</button>
<div class="peer-files">
{#each g.files.slice(0, expanded.has(g.key) ? 200 : 3) as f, i (f.path)}
<button class="peer-file" class:playing={player.track?.path === f.path} onclick={() => playPeer(g, i)}>
<span>{i + 1}</span>
<span class="pf-t">{titleFromName(f.name)}</span>
<span class="pf-m">{f.bitrate ? f.bitrate + " kbps" : (f.extension || "").toUpperCase()}{f.duration ? " · " + mmss(f.duration) : ""}</span>
</button>
{/each}
{#if g.files.length > 3 && !expanded.has(g.key)}
<button class="peer-more" onclick={() => expand(g.key)}>+ {g.files.length - 3} more</button>
{/if}
</div>
</div>
{/each}
{/if}
{/if}
{/if}
<div class="endpad"></div>
+13
View File
@@ -0,0 +1,13 @@
<script>
/* The destinations the phone does not have yet, said plainly. */
let { kind } = $props();
const TEXT = {
search: ["Search", "Your library and the Soulseek network in one field, results grouped by peer, streaming a peer's copy straight from the row — the desktop's search, on the phone next."],
community: ["Community", "Rooms, friends, Spaces and the shared holdings — the desktop's Community area, on the phone next."],
};
const [title, text] = $derived(TEXT[kind] || [kind, ""]);
</script>
<div class="phead"><div class="row"><h1>{title}</h1></div></div>
<div class="stub"><b>{title} is next.</b>{text}</div>
<div class="endpad"></div>
+57
View File
@@ -0,0 +1,57 @@
<script>
/* A direct conversation with one person on the network. */
import { tick } from "svelte";
import Icon from "../../components/Icon.svelte";
import Identicon from "../../components/Identicon.svelte";
import { slsk, sendDm, openThread } from "../slsk.svelte.js";
import { player } from "../player.svelte.js";
import { clock } from "../util.js";
let { username, screenEl = null } = $props();
$effect(() => { openThread(username); slsk.openChat = username; return () => { if (slsk.openChat === username) slsk.openChat = null; }; });
const chat = $derived(slsk.chats[username] ?? { messages: [], unread: 0 });
const lines = $derived(chat.messages);
const st = $derived(slsk.buddyState[username] || {});
let text = $state("");
let inputEl = $state(null);
let sending = $state(false);
$effect(() => {
void lines.length;
const el = screenEl;
if (!el) return;
const nearEnd = el.scrollHeight - el.scrollTop - el.clientHeight < 240;
tick().then(() => { if (nearEnd || lines.length <= 2) el.scrollTop = el.scrollHeight; });
});
async function send() {
const t = text.trim();
if (!t || sending) return;
sending = true;
try { await sendDm(username, t); text = ""; } catch (e) { player.error = String(e); }
sending = false;
inputEl?.focus();
}
</script>
<div class="room-head">
<span class="art round idc"><Identicon seed={username} size={40} /></span>
<span class="b"><span class="t-sub">{st.status === 2 || st.status === "online" ? "online" : st.status === 1 || st.status === "away" ? "away" : "on Soulseek"}{st.file_count ? ` · ${st.file_count.toLocaleString()} files` : ""}</span></span>
</div>
<div class="chat">
{#if !lines.length}
<div class="sec-note" style="padding: 20px 0">Say something to {username}.</div>
{/if}
{#each lines as l, i (i)}
<div class="msg" class:me={l.me}>
<div class="bub">{l.text}</div>
<div class="at">{clock(l.ts)}</div>
</div>
{/each}
</div>
<div class="composer">
<input class="field" bind:this={inputEl} bind:value={text} placeholder="Message {username}…" autocomplete="off" enterkeyhint="send" onkeydown={(e) => { if (e.key === "Enter") { e.preventDefault(); send(); } }} />
<button class="disc sm" onclick={send} aria-label="Send" disabled={!text.trim()}><Icon name="send" size={16} /></button>
</div>
<div class="endpad"></div>
+69
View File
@@ -0,0 +1,69 @@
<script>
/* Library → Tracks: the whole index, read once, filtered on the device.
Sorted by artist, record, then file — which is track order, the way
the files are named. Capped at 300 rows on screen; the filter is how
you reach the rest. */
import Icon from "../../components/Icon.svelte";
import * as api from "../../lib/api.js";
import { player, playIndexRow, albumOfRow } from "../player.svelte.js";
import { openHalf, patch } from "../nav.svelte.js";
import { mmss, longDur } from "../util.js";
let { entry } = $props();
const CAP = 300;
let index = $state(null);
let failed = $state("");
$effect(() => {
if (index !== null) return;
api.libraryTrackIndex().then(
(rows) => { index = rows || []; },
(e) => { index = []; failed = String(e); },
);
});
let q = $state(entry.c.tf || "");
let inputEl = $state(null);
function setQ(v) { q = v; patch({ tf: v }); }
const norm = (s) => String(s || "").toLowerCase();
const words = $derived(norm(q).split(/\s+/).filter(Boolean));
const sorted = $derived.by(() => {
const l = (index || []).slice();
l.sort((a, b) => norm(a.artist).localeCompare(norm(b.artist)) || norm(a.album).localeCompare(norm(b.album)) || norm(a.path).localeCompare(norm(b.path)));
return l;
});
const list = $derived(
words.length
? sorted.filter((r) => { const hay = norm(r.title + " " + r.artist + " " + r.album); return words.every((w) => hay.includes(w)); })
: sorted,
);
const total = $derived((index || []).reduce((n, r) => n + (r.duration_secs || 0), 0));
function more(row) {
const album = albumOfRow(row) || { id: null, title: row.album, artist: row.artist, art_path: row.art_path ?? null };
openHalf("track", { album, track: { id: row.path, title: row.title, artist: row.artist, path: row.path, duration_secs: row.duration_secs ?? null } });
}
</script>
<div class="sfield">
<Icon name="search" size={18} />
<input bind:this={inputEl} value={q} oninput={(e) => setQ(e.currentTarget.value)} type="search" placeholder="Filter tracks" autocomplete="off" autocapitalize="off" spellcheck="false" aria-label="Filter tracks" />
{#if q}<button class="iconbtn sm" onclick={() => { setQ(""); inputEl?.focus(); }} aria-label="Clear"><Icon name="x" size={18} /></button>{/if}
</div>
<div class="sec-note">
{#if index === null}Reading the index…{:else}{index.length} tracks · {longDur(total)}{/if}
{#if list.length > CAP} · showing {CAP} of {list.length}{/if}
{#if failed} · {failed}{/if}
</div>
{#each list.slice(0, CAP) as r, i (r.path)}
<button class="trk" class:playing={player.track?.path === r.path} onclick={() => playIndexRow(r)} oncontextmenu={(e) => { e.preventDefault(); more(r); }}>
<span class="n">{i + 1}</span>
<span class="b"><span class="t">{r.title}</span><span class="m">{r.artist} · {r.album}</span></span>
<span class="d">{mmss(r.duration_secs)}</span>
<span class="r" role="button" tabindex="0" aria-label="More" onkeydown={() => {}} onclick={(e) => { e.stopPropagation(); more(r); }}><Icon name="more" size={17} /></span>
</button>
{/each}
{#if index && !list.length}
<div class="stub"><b>No tracks match.</b></div>
{/if}
+46
View File
@@ -0,0 +1,46 @@
<script>
/* Transfers — what Save is fetching into the library, newest first:
the file, the peer, the state or the progress; a tap on a live row
cancels it, finished rows clear together. The desktop's hub list. */
import Icon from "../../components/Icon.svelte";
import Identicon from "../../components/Identicon.svelte";
import { transfers, DONE, list, active, clearFinished, cancel, stateLine } from "../transfers.svelte.js";
import { fmtBytes } from "../util.js";
const rows = $derived(list());
const live = $derived(active().length);
const share = (d) => (d.total ? Math.max(0.02, Math.min(1, d.received / d.total)) : 0);
async function tap(d) {
if (DONE.has(d.state)) return;
await cancel(d.id);
}
</script>
<div class="phead">
<div class="row">
<h1>Transfers</h1>
{#if rows.some((d) => DONE.has(d.state))}<button class="all" onclick={clearFinished}>Clear finished</button>{/if}
</div>
</div>
{#if !rows.length}
<div class="stub">
<b>Nothing in flight.</b>
Save a record or a song from the catalog and it arrives here — the best copy by the download rules, filed into your library when it lands.
</div>
{:else}
<div class="sec"><h2>{live ? `${live} in flight` : "Done"} <em>· {rows.length} total</em></h2></div>
{#each rows as d (d.id)}
<button class="row xfer" class:done={DONE.has(d.state)} class:failed={d.state === "failed"} onclick={() => tap(d)}>
<span class="art idc"><Identicon seed={d.username} size={22} /></span>
<span class="b">
<span class="t">{d.name || d.path}</span>
<span class="m">{d.username} · {stateLine(d)}{d.total ? ` · ${fmtBytes(d.total)}` : ""}</span>
{#if d.state === "transferring" && d.total}<span class="lbar"><i style="transform: scaleX({share(d).toFixed(3)})"></i><em>{fmtBytes(d.received)}</em></span>{/if}
</span>
<span class="r">{#if DONE.has(d.state)}<Icon name={d.state === "complete" ? "check" : "x"} size={16} />{:else}<Icon name="x" size={16} />{/if}</span>
</button>
{/each}
{/if}
<div class="endpad"></div>
+124
View File
@@ -0,0 +1,124 @@
<script>
/* You — Appearance (real, kept on the device) and what this phone is
running. Playback, storage and the network settings come next. */
import Icon from "../../components/Icon.svelte";
import * as api from "../../lib/api.js";
import { lib, loadLibrary } from "../lib.svelte.js";
import { push } from "../nav.svelte.js";
import { transfers, active } from "../transfers.svelte.js";
import { player } from "../player.svelte.js";
import { settings, setSetting } from "../prefs.svelte.js";
let cfg = $state(null);
let scanning = $state(false);
$effect(() => { api.getSettings().then((s) => { cfg = s; }, () => {}); });
/* the desktop's Playback setting: every track brought to −14 LUFS (the
player crate's BS.1770 meter; on by default) */
const normalization = $derived(cfg ? cfg.volume_normalization !== false : true);
async function toggleNormalization() {
const next = !normalization;
try { await api.setSettings({ volume_normalization: next }); cfg = await api.getSettings(); } catch (e) { player.error = String(e); }
}
/* the library folder: picked on the phone, set as the app's download
dir (the folder IS the library), rescanned. A folder in shared storage
needs all-files access, granted by Kyle on the system screen. */
let access = $state(null);
async function checkAccess() { try { access = (await api.allFilesAccess())?.granted ?? null; } catch { access = null; } }
/* re-checked when the app comes back from the system's permission screen */
$effect(() => {
checkAccess();
const onShow = () => { if (!document.hidden) checkAccess(); };
document.addEventListener("visibilitychange", onShow);
return () => document.removeEventListener("visibilitychange", onShow);
});
const privateFolder = $derived(!cfg?.download_dir || /\/data\/(user\/\d+\/|data\/)com\.soulstream/.test(cfg.download_dir));
let picking = $state(false);
async function pickFolder() {
if (picking) return;
picking = true;
try {
const r = await api.pickLibraryFolder();
if (!r?.path) { player.error = "That folder has no path the phone can scan — pick one on this phone's storage."; return; }
await api.setSettings({ download_dir: r.path });
cfg = await api.getSettings();
await rescan();
} catch (e) {
if (!/cancelled/.test(String(e))) player.error = String(e);
} finally { picking = false; }
}
async function rescan() {
if (scanning) return;
scanning = true;
try { await api.rescanLibrary(); await loadLibrary(); } catch (e) { player.error = String(e); }
scanning = false;
}
const ROWS = [
["ambience", "image", "Ambience", "the artwork in play, blurred, under every screen"],
["accent", "sparkle", "Accent from artwork", "the app takes the record's colour"],
["motion", "layers", "Motion", "springs on screens, sheets and the nav"],
["glass", "display", "Glass blur", "the desktop's frosted chrome — costs frames on this phone"],
];
</script>
<div class="phead"><div class="row"><h1>You</h1></div></div>
<div class="sheet-sec" style="padding-top: 4px">Appearance</div>
<div class="group">
{#each ROWS as [k, icon, t, m] (k)}
<button class="row" onclick={() => setSetting(k, !settings[k])}>
<span class="lead"><Icon name={icon} size={20} /></span>
<span class="b"><span class="t">{t}</span><span class="m">{m}</span></span>
<span class="sw" class:on={settings[k]}><span></span></span>
</button>
{/each}
</div>
<div class="sheet-sec">Playback</div>
<div class="group">
<button class="row" onclick={toggleNormalization}>
<span class="lead"><Icon name="volume" size={20} /></span>
<span class="b"><span class="t">Volume normalization</span><span class="m">every track at about the same loudness (−14 LUFS); a track is measured on its first play and matched from then on</span></span>
<span class="sw" class:on={normalization}><span></span></span>
</button>
</div>
<div class="sheet-sec">Library</div>
<div class="group">
<button class="row" onclick={pickFolder}>
<span class="lead"><Icon name="folder" size={20} /></span>
<span class="b"><span class="t">{picking ? "Picking…" : "Library folder"}</span><span class="m mono">{cfg?.download_dir || "—"}</span></span>
<span class="r"><Icon name="chevron-right" size={17} /></span>
</button>
{#if !privateFolder && access === false}
<button class="row" onclick={() => api.requestAllFilesAccess().catch((e) => { player.error = String(e); })}>
<span class="lead"><Icon name="alert" size={20} /></span>
<span class="b"><span class="t">Allow all files access</span><span class="m">a folder on the phone's storage needs it — the system screen opens, come back after</span></span>
<span class="r"><Icon name="chevron-right" size={17} /></span>
</button>
{/if}
<div class="kv"><span class="k">Records</span><span class="v">{lib.albums.length}</span></div>
<div class="kv"><span class="k">Playlists</span><span class="v">{lib.playlists.length}</span></div>
<button class="row" onclick={rescan}>
<span class="lead"><Icon name="refresh" size={20} /></span>
<span class="b"><span class="t">{scanning ? "Scanning…" : "Rescan library"}</span><span class="m">looks for new files in the folder, forgets the ones that are gone</span></span>
</button>
<button class="row" onclick={() => push("transfers", {})}>
<span class="lead"><Icon name="download" size={20} /></span>
<span class="b"><span class="t">Transfers</span><span class="m">{active().length ? `${active().length} in flight` : Object.keys(transfers.items).length ? "nothing in flight" : "what Save is fetching into the library"}</span></span>
<span class="r"><Icon name="chevron-right" size={17} /></span>
</button>
</div>
<div class="sheet-sec">This phone</div>
<div class="group">
<div class="kv"><span class="k">SoulStream</span><span class="v">{lib.version || "—"}</span></div>
<div class="kv"><span class="k">Soulseek</span><span class="v dim">{lib.username || "not signed in"}</span></div>
</div>
<div class="stub">
<b>Playback, storage, the network and the account come next.</b>
They are the desktop's settings pages, drawn with the phone's components; the library folder you pick and Import land with them.
</div>
<div class="endpad"></div>
+1260
View File
File diff suppressed because it is too large. Load diff
+177
View File
@@ -0,0 +1,177 @@
/* The Soulseek hub on the phone: the connection, the rooms, the chats, the
buddies. The desktop's `slsk` store (state.svelte.js), the same events,
the same rules — the server echoes a room line back so it is not appended
locally; a direct message is not echoed so our half is. The private layer
(Freenet: Spaces, holdings, the sealed inbox) is not on the phone yet. */
import * as api from "../lib/api.js";
import { listen } from "../lib/events.js";
export const slsk = $state({
connection: "offline", // offline | connecting | online
username: "",
loginError: "",
/** the saved username, for the sign-in card */
savedUser: "",
hasPassword: false,
/** [{ name, users }] — the server's public list */
rooms: [],
/** room → { users: [{ username, … }], messages: [{ username, message, ts }] } */
joined: {},
/** username → { messages: [{ me, text, ts }], unread } */
chats: {},
buddies: [],
buddyState: {},
/** the room or the person whose screen is open, so its unread stays 0 */
openChat: null,
});
const FEED_MAX = 300;
function trim(arr) {
if (arr.length > FEED_MAX) arr.splice(0, arr.length - FEED_MAX);
}
export async function loadCredentials() {
try {
const c = await api.getCredentials();
slsk.savedUser = c?.username || "";
slsk.hasPassword = !!c?.has_password;
} catch { /* fine */ }
}
/** Sign in. The phone always remembers: there is no credential manager to
* ask, only the app's own sealed data directory. */
export async function connect(username, password) {
slsk.loginError = "";
slsk.connection = "connecting";
try {
await api.connectAndLogin(username.trim(), password, true);
slsk.savedUser = username.trim();
slsk.hasPassword = true;
} catch (e) {
slsk.connection = "offline";
slsk.loginError = String(e).replace(/^Error:\s*/, "");
}
}
/** At launch: the saved account signs in on its own, quietly. */
export async function connectStored() {
await loadCredentials();
if (!slsk.savedUser || !slsk.hasPassword) return;
slsk.connection = "connecting";
try {
await api.connectStored();
} catch (e) {
slsk.connection = "offline";
slsk.loginError = String(e).replace(/^Error:\s*/, "");
}
}
export async function disconnect() {
try { await api.disconnect(); } catch { /* fine */ }
slsk.connection = "offline";
slsk.joined = {};
}
export async function forgetAccount() {
try { await api.forgetCredentials(); } catch { /* fine */ }
slsk.savedUser = "";
slsk.hasPassword = false;
}
export function refreshRooms() {
api.slskRoomList().catch(() => {});
}
export async function joinRoom(name) {
await api.slskJoinRoom(name);
/* the roster arrives as soulseek:room-joined; nothing optimistic here */
}
export async function leaveRoom(name) {
await api.slskLeaveRoom(name);
delete slsk.joined[name];
}
/** The server echoes our line back to everyone, us included — send only. */
export async function sayRoom(room, text) {
const t = text.trim();
if (t) await api.slskSayRoom(room, t);
}
export function openThread(username) {
slsk.chats[username] ??= { messages: [], unread: 0 };
slsk.chats[username].unread = 0;
}
/** A direct message is not echoed back; our half is appended here. */
export async function sendDm(username, text) {
const t = text.trim();
if (!t) return;
await api.slskMessageUser(username, t);
openThread(username);
slsk.chats[username].messages.push({ me: true, text: t, ts: Date.now() });
trim(slsk.chats[username].messages);
}
export async function loadBuddies() {
try { slsk.buddies = (await api.buddyList()) || []; } catch { /* offline */ }
}
export async function addBuddy(username) {
slsk.buddies = (await api.buddyAdd(username)) || slsk.buddies;
}
export async function removeBuddy(username) {
slsk.buddies = (await api.buddyRemove(username)) || slsk.buddies;
delete slsk.buddyState[username];
}
export function unreadTotal() {
return Object.values(slsk.chats).reduce((n, c) => n + (c.unread || 0), 0);
}
let bound = false;
/** Subscribe to the hub's events. Returns the unlisteners. */
export async function bindSlsk() {
if (bound) return [];
bound = true;
const subs = [];
const on = async (name, fn) => { try { subs.push(await listen(name, (ev) => fn(ev.payload))); } catch { /* not a phone */ } };
await on("soulseek:state", (ev) => {
if (ev?.kind === "connected") {
slsk.connection = "online";
slsk.username = ev.username || slsk.savedUser;
slsk.loginError = "";
void loadBuddies();
refreshRooms();
} else if (ev?.kind === "disconnected") {
slsk.connection = "offline";
}
});
await on("soulseek:room-list", (rooms) => {
slsk.rooms = (rooms || []).slice().sort((a, b) => (b.users || 0) - (a.users || 0));
});
await on("soulseek:room-joined", (p) => {
const prev = slsk.joined[p.room];
slsk.joined[p.room] = { users: p.users || [], messages: prev?.messages ?? [] };
});
await on("soulseek:room-left", (p) => { delete slsk.joined[p.room]; });
await on("soulseek:room-user", (p) => {
const room = slsk.joined[p.room];
if (!room) return;
if (p.joined) {
if (!room.users.some((u) => u.username === p.user?.username)) room.users.push(p.user);
} else {
room.users = room.users.filter((u) => u.username !== p.username);
}
});
await on("soulseek:room-message", (p) => {
const room = slsk.joined[p.room];
if (!room) return;
room.messages.push({ username: p.username, message: p.message, ts: p.ts ? p.ts * 1000 : Date.now() });
trim(room.messages);
});
await on("soulseek:private-message", (p) => {
const chat = (slsk.chats[p.username] ??= { messages: [], unread: 0 });
chat.messages.push({ me: false, text: p.message, ts: (p.timestamp || 0) * 1000 || Date.now() });
trim(chat.messages);
if (slsk.openChat !== p.username) chat.unread += 1;
});
await on("soulseek:user-state", (p) => {
const s = (slsk.buddyState[p.username] ??= {});
Object.assign(s, p);
});
return subs;
}
+61
View File
@@ -0,0 +1,61 @@
/* Transfers on the phone: the downloads the backend is running — every
Save lands here — fed by soulseek:download exactly as the desktop's
`downloads` store is (latest update wins, a terminal update without
numbers keeps the last known progress), pruned once a burst drains. */
import * as api from "../lib/api.js";
import { listen } from "../lib/events.js";
export const transfers = $state({
/** id → payload { id, username, path, name, state, queue_place, local_path, error, received, total } */
items: {},
});
export const DONE = new Set(["complete", "failed", "cancelled"]);
const HISTORY_MAX = 60;
export function list() {
return Object.values(transfers.items).sort((a, b) => b.id - a.id);
}
export function active() {
return Object.values(transfers.items).filter((d) => !DONE.has(d.state));
}
export function clearFinished() {
for (const d of Object.values(transfers.items)) if (DONE.has(d.state)) delete transfers.items[d.id];
}
export async function cancel(id) {
try { await api.cancelDownload(id); } catch { /* already gone */ }
}
/** "queued · 3rd in line", "transferring · 42%", "saved", "failed · reason" */
export function stateLine(d) {
switch (d.state) {
case "connecting": return "connecting…";
case "requested": return "asked…";
case "queued": return d.queue_place ? `queued · ${d.queue_place} ahead` : "queued";
case "transferring": return d.total ? `${Math.round((d.received / d.total) * 100)}%` : "transferring…";
case "complete": return "saved";
case "failed": return d.error ? `failed · ${d.error}` : "failed";
case "cancelled": return "cancelled";
default: return d.state || "";
}
}
let bound = false;
export async function bindTransfers() {
if (bound) return;
bound = true;
try {
await listen("soulseek:download", (ev) => {
const d = ev.payload;
if (!d || d.id == null) return;
const prev = transfers.items[d.id];
if (prev && !d.total) { d.received = prev.received; d.total = prev.total; }
transfers.items[d.id] = d;
if (DONE.has(d.state)) {
/* keep an active burst whole; prune the oldest finished past the cap */
const done = Object.values(transfers.items).filter((x) => DONE.has(x.state)).sort((a, b) => a.id - b.id);
for (const x of done.slice(0, Math.max(0, done.length - HISTORY_MAX))) delete transfers.items[x.id];
}
});
} catch { /* not a phone */ }
}
+177
View File
@@ -0,0 +1,177 @@
/* Small helpers the phone's screens share. Nothing here touches the IPC. */
export function mmss(secs) {
secs = Math.max(0, Math.round(secs || 0));
const m = Math.floor(secs / 60);
const s = secs % 60;
return m + ":" + (s < 10 ? "0" : "") + s;
}
export function longDur(secs) {
secs = Math.max(0, Math.round(secs || 0));
const h = Math.floor(secs / 3600);
const m = Math.round((secs % 3600) / 60);
if (h) return h + " hr " + m + " min";
return m + " min";
}
/** A relative day, against the wall clock. `ts` is unix seconds. */
export function ago(ts) {
const d = Math.floor((Date.now() / 1000 - ts) / 86400);
if (d <= 0) return "today";
if (d === 1) return "yesterday";
if (d < 7) return d + " days ago";
if (d < 14) return "last week";
if (d < 60) return Math.round(d / 7) + " weeks ago";
if (d < 400) return Math.round(d / 30) + " months ago";
return Math.round(d / 365) + " years ago";
}
/** "3:07 PM" for a timestamp in milliseconds. */
export function clock(ms) {
const d = new Date(ms || Date.now());
let h = d.getHours();
const m = d.getMinutes();
const ap = h >= 12 ? "PM" : "AM";
h = h % 12 || 12;
return h + ":" + (m < 10 ? "0" : "") + m + " " + ap;
}
/** "03 - Wham City.flac" → "Wham City": the track number and the extension
* go, the rest is the title as the peer named it. */
export function titleFromName(name) {
let t = String(name || "").replace(/\.[^.]+$/, "");
t = t.replace(/^\s*\d{1,3}\s*[-._)\]]\s*/, "").replace(/^\s*\d{1,3}\s+/, "");
return t.trim() || String(name || "");
}
/** What a shared folder's name says about the record, by the common
* shapes: "Artist - 2007 - Album", "2007 - Album" under "Artist", "Artist - Album". */
export function folderMeta(folder) {
const parts = String(folder || "").split(/[\\/]/).filter(Boolean);
const last = (parts[parts.length - 1] || "").replace(/\s*[[(](flac|mp3|320|v0|web|cd|vinyl|lossless|24[^\])]*|16[^\])]*)[\])]\s*/gi, " ").trim();
const prev = parts[parts.length - 2] || "";
let m = last.match(/^(.+?)\s+-\s+\(?(\d{4})\)?\s+-\s+(.+)$/);
if (m) return { artist: m[1].trim(), year: m[2], album: m[3].trim() };
m = last.match(/^\(?(\d{4})\)?\s+-\s+(.+)$/);
if (m) return { artist: prev, year: m[1], album: m[2].trim() };
m = last.match(/^(.+?)\s+-\s+(.+?)(?:\s+\((\d{4})\))?$/);
if (m) return { artist: m[1].trim(), year: m[3] || "", album: m[2].trim() };
return { artist: prev, year: "", album: last };
}
export function fmtBytes(n) {
if (!n) return "";
if (n >= 1073741824) return (n / 1073741824).toFixed(1) + " GB";
if (n >= 1048576) return (n / 1048576).toFixed(1) + " MB";
return Math.round(n / 1024) + " kB";
}
export function basename(p) {
if (!p) return "";
const i = Math.max(p.lastIndexOf("/"), p.lastIndexOf("\\"));
return i >= 0 ? p.slice(i + 1) : p;
}
export function dirname(p) {
if (!p) return "";
const i = Math.max(p.lastIndexOf("/"), p.lastIndexOf("\\"));
return i >= 0 ? p.slice(0, i) : "";
}
const LOSSLESS = new Set(["flac", "wav", "aif", "aiff", "ape", "wv", "alac", "dsf", "dff"]);
/** "FLAC 16/44" for a lossless file, "MP3 320" for a lossy one — the
* desktop's format tag, read off the library's own track row. */
export function fmtOf(t) {
if (!t) return "";
const ext = String(t.path || "").split(".").pop().toLowerCase();
const name = ext ? ext.toUpperCase() : "";
if (LOSSLESS.has(ext)) {
const bits = t.bit_depth || 16;
const khz = t.sample_rate ? Math.round(t.sample_rate / 1000) : 44;
return name + " " + bits + "/" + khz;
}
if (t.bitrate_kbps) return name + " " + t.bitrate_kbps;
return name;
}
export function isLossless(fmt) {
return /^(FLAC|WAV|AIFF?|APE|WV|ALAC|DSF|DFF)\b/.test(fmt || "");
}
/** The format most of an album's tracks share. */
export function albumFmt(tracks) {
const count = new Map();
for (const t of tracks || []) {
const f = fmtOf(t);
if (f) count.set(f, (count.get(f) || 0) + 1);
}
let best = "", n = 0;
for (const [f, c] of count) if (c > n) { best = f; n = c; }
return best;
}
export function greeting() {
const h = new Date().getHours();
return h < 5 ? "Still up" : h < 12 ? "Good morning" : h < 18 ? "Good afternoon" : "Good evening";
}
export function dateKick() {
return new Date().toLocaleDateString(undefined, { weekday: "long", month: "long", day: "numeric" });
}
export function shuffled(list) {
const a = list.slice();
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[a[i], a[j]] = [a[j], a[i]];
}
return a;
}
/** A pointer drag with thresholds, for the mini bar and the player sheet.
* `from(target, event, box)` says whether a press starts a drag at all;
* `move(dx, dy, el, e)` and `end(dx, dy, el, e)` see the deltas. A Svelte
* action: use:drag={opts}. */
export function drag(el, opts) {
/* armed on pointerdown, DRAGGING only once the pointer has really moved:
capturing the pointer at once would retarget the pointerup, and a tap
on a button inside the element would never become that button's click */
const SLOP = 6;
let armed = false, on = false, x0 = 0, y0 = 0, o = opts;
const down = (e) => {
if (!o || e.button > 0) return;
const box = el.getBoundingClientRect();
if (o.from && !o.from(e.target, e, box)) return;
armed = true; on = false; x0 = e.clientX; y0 = e.clientY;
};
const move = (e) => {
if (!armed) return;
const dx = e.clientX - x0, dy = e.clientY - y0;
if (!on) {
if (Math.abs(dx) < SLOP && Math.abs(dy) < SLOP) return;
on = true;
try { el.setPointerCapture(e.pointerId); } catch { /* not capturable */ }
el.style.transition = "none";
if (o.start) o.start(el, e);
}
if (o.move) o.move(dx, dy, el, e);
};
const finish = (e) => {
if (!armed) return;
armed = false;
if (!on) return;
on = false;
el.style.transition = "";
if (o.end) o.end(e.clientX - x0, e.clientY - y0, el, e);
};
el.addEventListener("pointerdown", down);
el.addEventListener("pointermove", move);
el.addEventListener("pointerup", finish);
el.addEventListener("pointercancel", finish);
return {
update(next) { o = next; },
destroy() {
el.removeEventListener("pointerdown", down);
el.removeEventListener("pointermove", move);
el.removeEventListener("pointerup", finish);
el.removeEventListener("pointercancel", finish);
},
};
}