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:
1 parent
972dd103cb
commit
bb828ff2dc
102 files changed
+12258
-830
No files matched your search
+9
-1
@@ -1,6 +1,6 @@
|
||||
// Thin wrapper over Tauri IPC so components never import tauri directly.
|
||||
import { invoke as tauriInvoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { listen } from "./events.js";
|
||||
import { mockInvoke } from "./mockLibrary.js";
|
||||
|
||||
// mockInvoke is inert (returns undefined) unless localStorage.mockLibrary === "1".
|
||||
@@ -327,6 +327,8 @@ export const lyricPrepRequest = (path, durationSecs, lines, stream = null) =>
|
||||
});
|
||||
// Local-library candidates for the unresolved-row repair panel.
|
||||
export const libraryTrackSearch = (query) => invoke("library_track_search", { query });
|
||||
// The whole index, for a list that filters on the device (the phone's Tracks tab).
|
||||
export const libraryTrackIndex = () => invoke("library_track_index");
|
||||
|
||||
// ---- lyrics (see src-tauri/src/lyrics.rs) ----------------------------------
|
||||
export const lyricsGet = (args) =>
|
||||
@@ -489,6 +491,12 @@ export const importLocalArt = (sourceRoot, rel, parts) =>
|
||||
invoke("import_local_art", { sourceRoot, rel, parts });
|
||||
// Prune rows whose files are gone, then index anything new — the real rescan.
|
||||
export const rescanLibrary = () => invoke("rescan_library");
|
||||
// The phone's storage plugin (crates/tauri-plugin-storage): a picked folder
|
||||
// as { uri, path } (path null when the tree has none), and Android's
|
||||
// all-files access — { granted }, and the system screen that grants it.
|
||||
export const pickLibraryFolder = () => invoke("plugin:storage|pick_folder");
|
||||
export const allFilesAccess = () => invoke("plugin:storage|all_files_access");
|
||||
export const requestAllFilesAccess = () => invoke("plugin:storage|request_all_files_access");
|
||||
// Download a found album copy into (Cache)\_audition\<rel>\ for the version
|
||||
// comparison. Called track 1 first, then the rest — `startIndex` keeps the
|
||||
// second batch's indices continuing, since index 0 means "track 1".
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
/* Backend events, behind the same switch as the IPC: with the mock library
|
||||
on (localStorage.mockLibrary === "1") a listener is registered with the
|
||||
mock's own bus, which the mock and the harness can fire; otherwise it is
|
||||
Tauri's. Stores import `listen` from here so the phone's screens can be
|
||||
walked in a plain browser with a fake network behind them. */
|
||||
import { listen as tauriListen } from "@tauri-apps/api/event";
|
||||
import { mockListen } from "./mockLibrary.js";
|
||||
|
||||
export const listen = (event, cb) => mockListen(event, cb) ?? tauriListen(event, cb);
|
||||
+251
-1
@@ -97,7 +97,6 @@ export function mockInvoke(cmd, args) {
|
||||
if (!enabled) return undefined;
|
||||
if (cmd === "library_albums") return Promise.resolve(albums);
|
||||
if (cmd === "library_album_stats") return Promise.resolve(albums.map(statsFor));
|
||||
if (cmd === "followed_artists") return Promise.resolve([]);
|
||||
if (cmd === "recent_albums") return Promise.resolve([]);
|
||||
if (cmd === "album_tracks")
|
||||
return new Promise((r) => setTimeout(() => r(tracksFor(args.albumId)), 60));
|
||||
@@ -107,5 +106,256 @@ export function mockInvoke(cmd, args) {
|
||||
return Promise.resolve(svgArt((num * 47) % 360, String.fromCharCode(65 + (num % 26))));
|
||||
}
|
||||
if (cmd === "list_playlists") return Promise.resolve([]);
|
||||
// The track index, as the library crate shapes it (TrackIndexRow) — the
|
||||
// phone's Tracks tab and its library search read these.
|
||||
if (cmd === "library_track_index" || cmd === "library_track_search") {
|
||||
const rows = albums.flatMap((a) =>
|
||||
tracksFor(a.id).map((t) => ({
|
||||
title: t.title, artist: a.artist, album: a.title,
|
||||
duration_secs: t.duration_secs, path: t.path, art_path: a.art_path,
|
||||
})));
|
||||
if (cmd === "library_track_index") return Promise.resolve(rows);
|
||||
const words = String(args?.query || "").toLowerCase().split(/\s+/).filter(Boolean);
|
||||
return Promise.resolve(words.length
|
||||
? rows.filter((r) => { const hay = (r.artist + r.title + r.album).toLowerCase(); return words.every((w) => hay.includes(w)); }).slice(0, 40)
|
||||
: []);
|
||||
}
|
||||
return mockCatalog(cmd, args) ?? mockNet(cmd, args);
|
||||
}
|
||||
|
||||
/* ── the fake network ───────────────────────────────────────────────────
|
||||
A Soulseek behind the phone's screens: a stored account that signs in at
|
||||
boot, a room list, rooms with a few lines in them, a search answered by
|
||||
five peers, and a stream that arrives over twelve seconds with its
|
||||
waveform growing — enough to walk Search, Community and the rising lane
|
||||
in a plain browser. Events go out over the bus below; the harness can
|
||||
fire its own through window.__ssMock.emit. */
|
||||
const bus = new Map();
|
||||
/** Returns a Promise<unlisten> for a mocked page, or undefined to pass through. */
|
||||
export function mockListen(event, cb) {
|
||||
if (!enabled) return undefined;
|
||||
let set = bus.get(event);
|
||||
if (!set) { set = new Set(); bus.set(event, set); }
|
||||
set.add(cb);
|
||||
return Promise.resolve(() => { set.delete(cb); });
|
||||
}
|
||||
export function mockEmit(event, payload) {
|
||||
for (const cb of bus.get(event) || []) {
|
||||
try { cb({ event, payload }); } catch (e) { console.error(e); }
|
||||
}
|
||||
}
|
||||
const later = (ms, fn) => setTimeout(fn, ms);
|
||||
if (enabled && typeof window !== "undefined") window.__ssMock = { emit: mockEmit };
|
||||
|
||||
const MOCK_USER = "mockuser";
|
||||
const ROOMS = [["Lounge", 412], ["Electronic", 98], ["Vinyl Rips", 37], ["Jazz", 61], ["Lossless Only", 23]];
|
||||
const PEOPLE = ["ada", "bo", "cy", "dee"];
|
||||
const LINES = ["anyone got the 2024 remaster?", "the vinyl rip is warmer imo", "that folder is complete btw", "brb"];
|
||||
const PEERS = [
|
||||
{ username: "hifi_hank", slot: true, speed: 2400, queue: 0, ext: "flac", depth: 24, rate: 96000 },
|
||||
{ username: "dusty_crates", slot: true, speed: 900, queue: 0, ext: "flac", depth: 16, rate: 44100 },
|
||||
{ username: "bo", slot: false, speed: 1500, queue: 3, ext: "flac", depth: 16, rate: 44100 },
|
||||
{ username: "mp3_marge", slot: true, speed: 500, queue: 0, ext: "mp3", bitrate: 320 },
|
||||
{ username: "slowpoke", slot: false, speed: 60, queue: 12, ext: "mp3", bitrate: 192 },
|
||||
];
|
||||
const TITLES = ["One More Time", "Aerodynamic", "Digital Love", "Harder, Better, Faster, Stronger", "Crescendolls", "Nightvision", "Superheroes", "High Life", "Something About Us", "Voyager", "Veridis Quo", "Short Circuit", "Face to Face", "Too Long"];
|
||||
function peerResponse(token, peer, query) {
|
||||
const artist = /daft/i.test(query) ? "Daft Punk" : (query.split(/\s+/)[0] || "Various");
|
||||
const album = /discovery/i.test(query) ? "Discovery" : "Homework";
|
||||
const folder = "@@shared\\Music\\" + artist + "\\" + artist + " - 2001 - " + album + (peer.ext === "flac" ? " [FLAC]" : " [MP3 " + peer.bitrate + "]");
|
||||
const files = TITLES.map((t, i) => {
|
||||
const name = String(i + 1).padStart(2, "0") + " - " + t + "." + peer.ext;
|
||||
const duration = 200 + ((i * 37) % 180);
|
||||
const size = peer.ext === "flac" ? Math.round(duration * (peer.depth === 24 ? 320000 : 110000)) : Math.round(duration * peer.bitrate * 125);
|
||||
return { path: folder + "\\" + name, name, folder, size, extension: peer.ext, bitrate: peer.bitrate ?? null, duration, sample_rate: peer.rate ?? 44100, bit_depth: peer.depth ?? null, vbr: false };
|
||||
});
|
||||
return { token, username: peer.username, has_free_upload_slot: peer.slot, upload_speed: peer.speed, queue_length: peer.queue, files };
|
||||
}
|
||||
|
||||
/* the catalog: one artist, two records, the songs of one of them */
|
||||
const CAT_ARTIST = { id: 27, name: "Daft Punk", picture: svgArt(210, "DP"), album_count: 4, fans: 4200000 };
|
||||
const CAT_NAMESAKE = { id: 28, name: "Daft Punk", picture: "https://cdn-images.dzcdn.net/images/artist/d41d8cd98f00b204e9800998ecf8427e/1000x1000-000000-80-0-0.jpg", album_count: 1, fans: 120 };
|
||||
const CAT_ALBUMS = [
|
||||
{ id: 302127, title: "Discovery", artist: "Daft Punk", cover: null, release_date: "2001-03-12", record_type: "album", track_count: 14, edition_group: 302127, is_soundtrack: false },
|
||||
{ id: 302128, title: "Homework", artist: "Daft Punk", cover: null, release_date: "1997-01-20", record_type: "album", track_count: 16, edition_group: null, is_soundtrack: false },
|
||||
];
|
||||
const CAT_TRACKS = TITLES.map((t, i) => ({
|
||||
id: 3135550 + i, title: t, artist: "Daft Punk", artist_id: 27, rank: 900000 - i * 1000,
|
||||
album: "Discovery", album_id: 302127, cover: null, duration_secs: 200 + ((i * 37) % 180), preview: null, track_no: i + 1,
|
||||
}));
|
||||
const CAT_DISCO = [
|
||||
...CAT_ALBUMS,
|
||||
{ id: 302129, title: "Discovery (Deluxe Edition)", artist: "Daft Punk", cover: null, release_date: "2001-03-12", record_type: "album", track_count: 16, edition_group: 302127, is_soundtrack: false },
|
||||
{ id: 302130, title: "Alive 1997", artist: "Daft Punk", cover: null, release_date: "2001-10-01", record_type: "album", track_count: 1, edition_group: null, is_soundtrack: false },
|
||||
{ id: 302131, title: "One More Time", artist: "Daft Punk", cover: null, release_date: "2000-11-13", record_type: "single", track_count: 2, edition_group: null, is_soundtrack: false },
|
||||
{ id: 302132, title: "Musique Vol. 1 1993–2005", artist: "Daft Punk", cover: null, release_date: "2006-04-03", record_type: "compile", track_count: 15, edition_group: null, is_soundtrack: false },
|
||||
{ id: 302133, title: "TRON: Legacy (Original Motion Picture Soundtrack)", artist: "Daft Punk", cover: null, release_date: "2010-12-03", record_type: "album", track_count: 22, edition_group: null, is_soundtrack: true },
|
||||
{ id: 302134, title: "Human After All", artist: "Daft Punk", cover: null, release_date: "2005-03-14", record_type: "album", track_count: 10, edition_group: null, is_soundtrack: false },
|
||||
{ id: 302135, title: "Random Access Memories", artist: "Daft Punk", cover: null, release_date: "2013-05-17", record_type: "album", track_count: 13, edition_group: null, is_soundtrack: false },
|
||||
];
|
||||
/* the follows feed needs covers and dates near today */
|
||||
const dayIso = (offset) => new Date(Date.now() + offset * 86400000).toISOString().slice(0, 10);
|
||||
for (const a of CAT_DISCO) a.cover = svgArt((a.id * 31) % 360, a.title[0]);
|
||||
CAT_DISCO.push(
|
||||
{ id: 302136, title: "Infinity Repeating", artist: "Daft Punk", cover: svgArt(300, "I"), release_date: dayIso(-9), record_type: "single", track_count: 2, edition_group: null, is_soundtrack: false },
|
||||
{ id: 302137, title: "Alive 2027", artist: "Daft Punk", cover: svgArt(120, "A"), release_date: dayIso(21), record_type: "album", track_count: 12, edition_group: null, is_soundtrack: false },
|
||||
);
|
||||
const NEWS = [
|
||||
{ title: "Daft Punk announce Alive 2027 — a live record from the reunion shows", url: "https://example.com/news/1", source: "Pitchfork", date: Math.floor(Date.now() / 1000) - 3600 * 5 },
|
||||
{ title: "Infinity Repeating gets a video", url: "https://example.com/news/2", source: "NME", date: Math.floor(Date.now() / 1000) - 86400 * 2 },
|
||||
];
|
||||
const RELATED = [["Justice", 411], ["Air", 412], ["Kavinsky", 413], ["Chemical Brothers", 414]].map(([name, id], i) => ({ id, name, picture: i % 2 ? null : svgArt(40 + id, name[0]), album_count: 3, fans: 900000 - id * 100 }));
|
||||
let follows = [];
|
||||
function mockCatalog(cmd, args) {
|
||||
if (cmd === "catalog_artist") {
|
||||
const id = Number(args?.artistId);
|
||||
const a = id === CAT_ARTIST.id ? CAT_ARTIST : RELATED.find((r) => r.id === id);
|
||||
if (!a) return Promise.reject(new Error("catalog_artist: unknown artist"));
|
||||
const own = a === CAT_ARTIST;
|
||||
return new Promise((r) => setTimeout(() => r({
|
||||
artist: a, banner: null, banners: [], banner_custom: false, logos: [],
|
||||
popular_tracks: own ? CAT_TRACKS.slice(0, 7).map((t, i) => ({ ...t, listeners: 2_400_000 - i * 310_000 })) : [],
|
||||
albums: own ? CAT_DISCO : [{ id: 900 + id, title: a.name + " Live", artist: a.name, cover: null, release_date: "2015-01-01", record_type: "album", track_count: 9, edition_group: null, is_soundtrack: false }],
|
||||
top_genre: "Electronic", socials: [],
|
||||
}), 220));
|
||||
}
|
||||
if (cmd === "catalog_artist_socials") {
|
||||
const id = Number(args?.artistId);
|
||||
return new Promise((r) => setTimeout(() => r(id === CAT_ARTIST.id ? [{ kind: "website", url: "https://example.com" }, { kind: "wikipedia", url: "https://en.wikipedia.org/wiki/Daft_Punk" }] : []), 300));
|
||||
}
|
||||
if (cmd === "home_warm") return Promise.resolve();
|
||||
if (cmd === "home_artist_news") return new Promise((r) => setTimeout(() => r(Object.fromEntries((args?.artists || []).map((n) => [n, /daft/i.test(n) ? NEWS : []]))), 150));
|
||||
if (cmd === "catalog_artist_identity") return Promise.resolve({ artist_id: Number(args?.artistId), albums: [], socials: [], type_hints: [], owned_album_ids: [] });
|
||||
if (cmd === "catalog_artist_profile") return Promise.resolve({ listeners: 3_100_000, plays: 210_000_000, tags: ["electronic", "house", "french house", "dance"] });
|
||||
if (cmd === "catalog_artist_bio") return Promise.resolve("Daft Punk were a French electronic music duo formed in Paris in 1993 by Guy-Manuel de Homem-Christo and Thomas Bangalter. They achieved popularity in the late 1990s as part of the French house movement.");
|
||||
if (cmd === "catalog_related") return Promise.resolve(RELATED);
|
||||
if (cmd === "followed_artists") return Promise.resolve(follows.slice());
|
||||
if (cmd === "follow_artist") { follows.push({ artist_id: args?.artistId, name: args?.name, portrait: args?.portrait ?? null, followed_at: Date.now() / 1000 }); later(30, () => mockEmit("soulseek:follows-changed", {})); return Promise.resolve(); }
|
||||
if (cmd === "unfollow_artist") { follows = follows.filter((f) => f.artist_id !== args?.artistId); later(30, () => mockEmit("soulseek:follows-changed", {})); return Promise.resolve(); }
|
||||
if (cmd === "catalog_search") {
|
||||
const q = String(args?.query || "").toLowerCase();
|
||||
const hit = /daft|discovery|one more|punk/.test(q);
|
||||
return new Promise((r) => setTimeout(() => r(hit
|
||||
? { artists: [CAT_ARTIST, CAT_NAMESAKE], albums: CAT_ALBUMS, tracks: CAT_TRACKS.slice(0, 8) }
|
||||
: { artists: [], albums: [], tracks: [] }), 120));
|
||||
}
|
||||
if (cmd === "catalog_album") {
|
||||
const a = CAT_ALBUMS.find((x) => x.id === Number(args?.albumId));
|
||||
if (!a) return Promise.reject(new Error("catalog_album: unknown album"));
|
||||
return Promise.resolve({ album: a, tracks: a.id === 302127 ? CAT_TRACKS.map((t, i) => ({ ...t, listeners: 2_400_000 - i * 150_000 })) : [], album_plays: a.id === 302127 ? 310_000_000 : null, album_listeners: a.id === 302127 ? 4_100_000 : null, totals_summed: false });
|
||||
}
|
||||
if (cmd === "catalog_album_info") {
|
||||
const t = String(args?.title || "");
|
||||
return new Promise((r) => setTimeout(() => r(/discovery/i.test(t)
|
||||
? { description: "Discovery is the second studio album by the French electronic music duo Daft Punk, released on 12 March 2001. It marks a shift from the Chicago house of their debut to a house style more heavily inspired by disco, post-disco, garage house and synth-pop. The album's singles included the worldwide hit One More Time.", genre: "Electronic", label: "Virgin", source: "wikipedia" }
|
||||
: null), 150));
|
||||
}
|
||||
if (cmd === "download_file") {
|
||||
const id = ++downloadSeq;
|
||||
const total = args?.size || 20e6;
|
||||
const name = String(args?.path || "").split("\\").pop();
|
||||
const base = { id, username: args?.username, path: args?.path, name, queue_place: null, local_path: null, error: null };
|
||||
later(200, () => mockEmit("soulseek:download", { ...base, state: "connecting", received: 0, total }));
|
||||
let got = 0;
|
||||
const step = setInterval(() => {
|
||||
got = Math.min(total, got + total / 8);
|
||||
mockEmit("soulseek:download", { ...base, state: got >= total ? "complete" : "transferring", received: Math.round(got), total, local_path: got >= total ? "C:\\mock\\library\\" + name : null });
|
||||
if (got >= total) { clearInterval(step); mockEmit("soulseek:library-changed", {}); }
|
||||
}, 400 + (id % 3) * 120);
|
||||
return Promise.resolve(id);
|
||||
}
|
||||
if (cmd === "cancel_download") return Promise.resolve();
|
||||
if (cmd === "rescan_library") return Promise.resolve();
|
||||
if (cmd === "catalog_art") return Promise.resolve(svgArt(200, "D"));
|
||||
if (cmd === "stream_cache_ready") return Promise.resolve(cacheReady.has(args?.path) || (xfer && xfer.path === args?.path && xfer.received >= xfer.total));
|
||||
if (cmd === "preload_stream") {
|
||||
const path = args?.path;
|
||||
if (!cacheReady.has(path) && !preloading.has(path)) { preloading.add(path); later(4000, () => { preloading.delete(path); cacheReady.add(path); }); }
|
||||
return Promise.resolve();
|
||||
}
|
||||
if (cmd === "preload_cached_stream") return Promise.resolve(cacheReady.has(args?.path));
|
||||
if (cmd === "excluded_search_phrases") return Promise.resolve([]);
|
||||
if (cmd === "get_settings") return Promise.resolve({ download_dir: "C:\\mock\\Library" });
|
||||
if (cmd === "set_settings") return Promise.resolve();
|
||||
if (cmd === "plugin:storage|pick_folder") return Promise.resolve({ uri: "content://mock/tree/primary%3AMusic", path: "C:\\mock\\Music" });
|
||||
if (cmd === "plugin:storage|all_files_access") return Promise.resolve({ granted: true });
|
||||
if (cmd === "plugin:storage|request_all_files_access") return Promise.resolve();
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let token = 0;
|
||||
let downloadSeq = 0;
|
||||
const cacheReady = new Set(); // paths a preload has fully fetched
|
||||
const preloading = new Set();
|
||||
let xfer = null; // the one fake transfer: { received, total, timer }
|
||||
let posTimer = null; // the fake engine's position ticks
|
||||
function mockNet(cmd, args) {
|
||||
if (cmd === "get_credentials") return Promise.resolve({ username: MOCK_USER, has_password: true });
|
||||
if (cmd === "connect_stored" || cmd === "connect_and_login") {
|
||||
later(400, () => mockEmit("soulseek:state", { kind: "connected", username: MOCK_USER }));
|
||||
return Promise.resolve();
|
||||
}
|
||||
if (cmd === "disconnect") { later(50, () => mockEmit("soulseek:state", { kind: "disconnected" })); return Promise.resolve(); }
|
||||
if (cmd === "forget_credentials") return Promise.resolve();
|
||||
if (cmd === "buddy_list") return Promise.resolve(["ada", "cy"]);
|
||||
if (cmd === "buddy_add") return Promise.resolve(["ada", "cy", args?.username].filter(Boolean));
|
||||
if (cmd === "buddy_remove") return Promise.resolve(["ada", "cy"].filter((u) => u !== args?.username));
|
||||
if (cmd === "slsk_room_list") {
|
||||
later(200, () => mockEmit("soulseek:room-list", ROOMS.map(([name, users]) => ({ name, users }))));
|
||||
return Promise.resolve();
|
||||
}
|
||||
if (cmd === "slsk_join_room") {
|
||||
const room = args?.room;
|
||||
later(250, () => mockEmit("soulseek:room-joined", { room, users: [...PEOPLE, MOCK_USER].map((username) => ({ username })) }));
|
||||
LINES.forEach((message, i) => later(400 + i * 350, () => mockEmit("soulseek:room-message", { room, username: PEOPLE[i % PEOPLE.length], message, ts: Math.floor(Date.now() / 1000) })));
|
||||
return Promise.resolve();
|
||||
}
|
||||
if (cmd === "slsk_leave_room") { later(50, () => mockEmit("soulseek:room-left", { room: args?.room })); return Promise.resolve(); }
|
||||
if (cmd === "slsk_say_room") {
|
||||
later(120, () => mockEmit("soulseek:room-message", { room: args?.room, username: MOCK_USER, message: args?.message, ts: Math.floor(Date.now() / 1000) }));
|
||||
return Promise.resolve();
|
||||
}
|
||||
if (cmd === "slsk_message_user") {
|
||||
later(900, () => mockEmit("soulseek:private-message", { username: args?.username, message: "sure, sending", timestamp: Math.floor(Date.now() / 1000) }));
|
||||
return Promise.resolve();
|
||||
}
|
||||
if (cmd === "start_search") {
|
||||
const t = ++token;
|
||||
const q = String(args?.query || "");
|
||||
later(300, () => mockEmit("soulseek:search-results", [peerResponse(t, PEERS[1], q)]));
|
||||
later(700, () => mockEmit("soulseek:search-results", [peerResponse(t, PEERS[0], q), peerResponse(t, PEERS[3], q)]));
|
||||
later(1300, () => mockEmit("soulseek:search-results", [peerResponse(t, PEERS[2], q), peerResponse(t, PEERS[4], q)]));
|
||||
return Promise.resolve(t);
|
||||
}
|
||||
if (cmd === "stop_search") return Promise.resolve();
|
||||
if (cmd === "play_stream") {
|
||||
if (xfer) clearInterval(xfer.timer);
|
||||
clearInterval(posTimer);
|
||||
const total = args?.size || 30e6;
|
||||
const duration = args?.duration || 240;
|
||||
xfer = { received: 0, total, timer: null, path: args?.path };
|
||||
xfer.timer = setInterval(() => {
|
||||
xfer.received = Math.min(total, xfer.received + total / 30);
|
||||
const done = xfer.received >= total;
|
||||
mockEmit("soulseek:stream", { received: Math.round(xfer.received), total, done, failed: null });
|
||||
if (done) clearInterval(xfer.timer);
|
||||
}, 400);
|
||||
let secs = 0;
|
||||
later(1200, () => {
|
||||
mockEmit("soulseek:player", { kind: "playing" });
|
||||
posTimer = setInterval(() => { secs += 0.5; mockEmit("soulseek:player", { kind: "position", secs, duration }); }, 500);
|
||||
});
|
||||
return Promise.resolve();
|
||||
}
|
||||
if (cmd === "stream_waveform") {
|
||||
const coverage = xfer ? xfer.received / xfer.total : 0;
|
||||
const n = Math.round((args?.buckets || 900) * coverage);
|
||||
const peaks = Array.from({ length: n }, (_, i) => 0.25 + 0.7 * Math.abs(Math.sin(i * 0.37) * Math.cos(i * 0.011)));
|
||||
return Promise.resolve({ coverage, peaks });
|
||||
}
|
||||
if (cmd === "stream_cache_len") return Promise.resolve(xfer ? Math.round(xfer.received) : 0);
|
||||
if (cmd === "stream_embedded_art") return Promise.resolve(xfer && xfer.received > 200000 ? svgArt(200, "D") : null);
|
||||
if (cmd === "record_play") return Promise.resolve(1);
|
||||
if (cmd === "add_play_seconds") return Promise.resolve();
|
||||
return undefined;
|
||||
}
|
||||
@@ -0,0 +1,872 @@
|
||||
/* The source rules — the code behind every play button that turns a catalog
|
||||
track or record into a peer's copy: the query ladder and its poisoned-term
|
||||
hygiene, the paced search with its early exit, the quality window, the
|
||||
candidate scoring for songs and albums, the remembered stream sources, and
|
||||
the small text helpers they all share.
|
||||
|
||||
ONE copy, for the desktop (state.svelte.js) and the phone (src/mobile):
|
||||
the rules moved here verbatim from state.svelte.js on 2026-09-12 so the
|
||||
phone could play through the same resolver instead of a fork of it.
|
||||
Nothing in here touches a view or a store — the callers own the player. */
|
||||
import * as api from "./api.js";
|
||||
|
||||
export const RESOLVER_AUDIO_QUALITY = {
|
||||
flac: 4, ape: 4, aiff: 3.5, wav: 3.5,
|
||||
m4a: 2.5, ogg: 2.5, opus: 2.5, aac: 2.2, mp3: 2, wma: 1.5,
|
||||
};
|
||||
|
||||
// Formats that decode progressively while the file is still downloading.
|
||||
// MP4-family files (m4a/aac) put their index at the end and only play once
|
||||
// complete, so they're save-only.
|
||||
//
|
||||
// `opus` was here and must not be: this build has no Opus decoder, so the
|
||||
// resolver would pick an Opus source to stream, download it, and fail at the
|
||||
// moment of play. Streamable is a subset of PLAYABLE, never a superset —
|
||||
// see crates/audio-formats.
|
||||
const RESOLVER_STREAMABLE = new Set(["flac", "mp3", "ogg", "oga", "wav", "wave", "aiff", "aif"]);
|
||||
|
||||
// Lossless for LABELLING — what the file claims to be, which is a different
|
||||
// question from whether we can decode it. `.ape` and `.wv` are genuinely
|
||||
// lossless and genuinely unplayable here; a peer's file is still worth naming
|
||||
// accurately in search results.
|
||||
const LOSSLESS_EXTS = new Set(["flac", "ape", "wav", "wave", "aiff", "aif", "alac", "wv", "tta"]);
|
||||
|
||||
// User source-selection preferences, independent per mode. Loaded from
|
||||
// settings at startup and refreshed by the Settings view after a commit;
|
||||
// defaults reproduce the pre-feature behavior exactly.
|
||||
const DEFAULT_SOURCE_PREFS = { max: "any", min: "any", priority: "quality" };
|
||||
|
||||
export const sourcePrefs = {
|
||||
stream: { ...DEFAULT_SOURCE_PREFS },
|
||||
download: { ...DEFAULT_SOURCE_PREFS },
|
||||
};
|
||||
|
||||
export async function refreshSourcePrefs() {
|
||||
try {
|
||||
const s = await api.getSettings();
|
||||
sourcePrefs.stream = { ...DEFAULT_SOURCE_PREFS, ...(s?.stream_source_prefs ?? {}) };
|
||||
sourcePrefs.download = { ...DEFAULT_SOURCE_PREFS, ...(s?.download_source_prefs ?? {}) };
|
||||
} catch {
|
||||
// settings unavailable (fresh install / backend restart) → defaults
|
||||
}
|
||||
}
|
||||
|
||||
// ---- remembered stream sources ------------------------------------------
|
||||
// The last source each catalog track streamed from, persisted so a track can
|
||||
// play again WITHOUT a Soulseek search: its bytes are already in the stream
|
||||
// cache, and the resolve would only rediscover the peer that put them there.
|
||||
// The memory is a HINT, not a promise — before any search is skipped the
|
||||
// backend confirms the exact file is byte-complete on disk, so eviction, a
|
||||
// half-finished transfer, or a source that never delivered all fall back to
|
||||
// the normal resolver on their own.
|
||||
const STREAM_SOURCE_STORE = "soulstream.streamSources.v1";
|
||||
|
||||
const STREAM_SOURCE_CAP = 400;
|
||||
|
||||
let streamSources = null; // Map key → {username, path, size, duration, quality}
|
||||
|
||||
function streamSourceKeys(trackId, artist, title) {
|
||||
const keys = [];
|
||||
if (trackId != null) keys.push(`t:${trackId}`);
|
||||
if (artist && title) keys.push(`n:${artist}|${title}`.toLowerCase());
|
||||
return keys;
|
||||
}
|
||||
|
||||
function loadStreamSources() {
|
||||
if (streamSources) return streamSources;
|
||||
streamSources = new Map();
|
||||
try {
|
||||
for (const [k, v] of JSON.parse(localStorage.getItem(STREAM_SOURCE_STORE) ?? "[]")) {
|
||||
streamSources.set(k, v);
|
||||
}
|
||||
} catch {
|
||||
// corrupt store — start over; losing it only costs one search per track
|
||||
}
|
||||
return streamSources;
|
||||
}
|
||||
|
||||
function saveStreamSources() {
|
||||
try {
|
||||
const entries = [...streamSources.entries()];
|
||||
localStorage.setItem(
|
||||
STREAM_SOURCE_STORE,
|
||||
JSON.stringify(entries.slice(-STREAM_SOURCE_CAP)),
|
||||
);
|
||||
} catch {
|
||||
// quota — same story, the memory is only a shortcut
|
||||
}
|
||||
}
|
||||
|
||||
/** Called every time a stream item is queued to PLAY (not when it proves
|
||||
* itself) — eager on purpose: a source that never delivers leaves no complete
|
||||
* cache file, so the probe rejects it later and nothing was lost. Insertion
|
||||
* order doubles as the LRU for the cap in saveStreamSources. */
|
||||
function rememberStreamSource(item) {
|
||||
if (!item?.stream?.username || !item.path || !(item.stream.size > 0)) return;
|
||||
const map = loadStreamSources();
|
||||
const record = {
|
||||
username: item.stream.username,
|
||||
path: item.path,
|
||||
size: item.stream.size,
|
||||
duration: item.duration ?? null,
|
||||
quality: item.quality ?? null,
|
||||
};
|
||||
for (const key of streamSourceKeys(item.trackId ?? null, item.artist, item.title)) {
|
||||
map.delete(key);
|
||||
map.set(key, record);
|
||||
}
|
||||
saveStreamSources();
|
||||
}
|
||||
|
||||
/** The remembered source for this track, but ONLY if its cache file is
|
||||
* byte-complete right now. Anything else is null: resolve normally. */
|
||||
async function cachedStreamSourceFor(trackId, artist, title) {
|
||||
const map = loadStreamSources();
|
||||
for (const key of streamSourceKeys(trackId, artist, title)) {
|
||||
const src = map.get(key);
|
||||
if (!src) continue;
|
||||
// A remembered file whose NAME does not even contain the track's
|
||||
// base title is a mis-paired memory (the 2026-08-20 incident:
|
||||
// three unrelated rows each "remembered" the same Weezer FLAC and
|
||||
// played it from the warm cache forever). Delete it and resolve
|
||||
// normally — a wrongly-dropped legit source only costs one search.
|
||||
const base = normText(asciiFold((title ?? "").split(/[([]/)[0]));
|
||||
const fileName = normText(asciiFold((src.path ?? "").split("\\").pop() ?? ""));
|
||||
if (base.length >= 4 && fileName && !fileName.includes(base)) {
|
||||
map.delete(key);
|
||||
saveStreamSources();
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
if (await api.streamCacheReady(src.username, src.path, src.size)) return src;
|
||||
} catch {
|
||||
// backend unavailable — resolve normally
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Whether a peer file satisfies the user's quality window for this mode.
|
||||
* Lossless formats satisfy every kbps floor; a lossy file must carry a KNOWN
|
||||
* bitrate at or above it — a peer that hides its bitrate does not get the
|
||||
* benefit of the doubt when the user asked for a minimum. Thresholds sit
|
||||
* slightly under nominal so VBR files reporting 317 kbps still count as 320. */
|
||||
function passesSourcePrefs(f, prefs) {
|
||||
const lossless = LOSSLESS_EXTS.has(f.extension);
|
||||
if (prefs.max === "lossy_only" && lossless) return false;
|
||||
switch (prefs.min) {
|
||||
case "lossless":
|
||||
if (!lossless) return false;
|
||||
break;
|
||||
case "kbps320":
|
||||
if (!lossless && (f.bitrate ?? 0) < 315) return false;
|
||||
break;
|
||||
case "kbps256":
|
||||
if (!lossless && (f.bitrate ?? 0) < 250) return false;
|
||||
break;
|
||||
case "kbps192":
|
||||
if (!lossless && (f.bitrate ?? 0) < 186) return false;
|
||||
break;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Diacritics folded to ASCII ("Jóga" → "Joga", "Björk" → "Bjork"). Peers
|
||||
* overwhelmingly name files unaccented; without folding, normText simply
|
||||
* DELETED accented letters ("Jóga" → "jga") and the request could never
|
||||
* match the file. */
|
||||
const asciiFold = (s) => (s ?? "").normalize("NFKD").replace(/\p{M}/gu, "");
|
||||
|
||||
const normText = (s) =>
|
||||
asciiFold(s).toLowerCase().replace(/\(.*?\)|\[.*?\]/g, "").replace(/[^a-z0-9]/g, "");
|
||||
|
||||
/** Catalog titles are often verbose editions ("Album; or, The Long Subtitle…",
|
||||
* "Album (Deluxe Edition)"). Peer searches AND-match every word, so query
|
||||
* with just the primary segment, capped to a few words. */
|
||||
function simplifyTitle(s) {
|
||||
const first = (s ?? "").split(/[;:(\[]/)[0].trim();
|
||||
const words = (first || s || "").split(/\s+/).slice(0, 6).join(" ");
|
||||
return words || s || "";
|
||||
}
|
||||
|
||||
// Words that mark an *edition* of a release, not a distinct release. Used to
|
||||
// tell an edition suffix (noise, safe to drop) from a variant name that names
|
||||
// a different album — critical for self-titled records where the base title
|
||||
// alone can't tell "Weezer (Blue Album)" from "Weezer (Red Album)".
|
||||
const ALBUM_EDITION_RE =
|
||||
/\b(?:deluxe|expanded|remaster(?:ed)?|anniversary|edition|version|bonus|reissue|super|complete|definitive|legacy|special|collector'?s?|mono|stereo|redux|remix(?:es|ed)?)\b/i;
|
||||
|
||||
/** The disambiguating variant name inside a title's brackets — e.g. "Blue
|
||||
* Album" from "Weezer (Blue Album)", or "Red Album" from "Weezer (Deluxe
|
||||
* Edition / Red Album)" — skipping pure edition markers. "" when the brackets
|
||||
* only hold edition noise (or there are none). */
|
||||
export function albumDisambiguator(title) {
|
||||
const groups = [...(title ?? "").matchAll(/[([]([^)\]]+)[)\]]/g)].map((m) => m[1]);
|
||||
for (const inner of groups) {
|
||||
for (const part of inner.split(/\s*[/|;]\s*/)) {
|
||||
const p = part.trim();
|
||||
if (p && !ALBUM_EDITION_RE.test(p)) return p;
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
/** Search-query title-part for a release: the simplified title, plus the
|
||||
* disambiguating variant name when there is one, so distinct self-titled
|
||||
* albums search for their own colour instead of colliding on the band name. */
|
||||
function albumQueryTitle(album) {
|
||||
const base = simplifyTitle(album.title);
|
||||
// Fall back to a caller-supplied variant (the family's colour) when the
|
||||
// title itself dropped it — e.g. "Weezer (Deluxe Edition)" is the Red Album
|
||||
// but names no colour, so without this it searches bare "Weezer".
|
||||
const d = albumDisambiguator(album.title) || album.variant || "";
|
||||
return d ? `${base} ${d}` : base;
|
||||
}
|
||||
|
||||
// Server-distributed excluded search phrases (Soulseek message 160). A query
|
||||
// containing one is NEVER routed by the server — it returns zero responses
|
||||
// network-wide with no error. Verified live: every query containing "mgmt"
|
||||
// was silent while "little dark age" alone returned 738 responses. The list
|
||||
// arrives by event after login and is also fetchable on demand (covers a
|
||||
// WebView reload); the resolver strips these terms before they reach the wire.
|
||||
let excludedSearchPhrases = [];
|
||||
|
||||
export function setExcludedSearchPhrases(list) {
|
||||
excludedSearchPhrases = (Array.isArray(list) ? list : [])
|
||||
.map((p) => String(p ?? "").toLowerCase().trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
void api.excludedSearchPhrases?.().then(setExcludedSearchPhrases, () => {});
|
||||
|
||||
const escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
|
||||
/** Query with server-excluded phrases removed (whole-word, any position) and
|
||||
* whitespace collapsed. Returns "" when nothing usable remains. */
|
||||
export function sanitizeSearchQuery(query) {
|
||||
let out = ` ${query ?? ""} `;
|
||||
for (const phrase of [...excludedSearchPhrases, ...learnedPoisonedTerms]) {
|
||||
out = out.replace(new RegExp(`(?<=^|[^\\p{L}\\p{N}])${escapeRegExp(phrase)}(?=$|[^\\p{L}\\p{N}])`, "giu"), " ");
|
||||
}
|
||||
return out.replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
/** Run a background source search (own token — the Search view is untouched)
|
||||
* and collect responses. Ends early once `goodCount()` finds enough.
|
||||
*
|
||||
* The early exit is evaluated on a 250ms TICK, not only when a batch
|
||||
* arrives: peers deliver most results within the first second and then go
|
||||
* quiet, and the old on-batch-only check meant a plentiful-but-fast search
|
||||
* still sat out the full window waiting for one more batch that never came
|
||||
* (measured: 8.1s click-to-play on Discovery, ~7s of it idle waiting).
|
||||
* Thresholds are adaptive — a flood of good candidates releases at 900ms,
|
||||
* a workable set at 1800ms, and the full window only runs when results
|
||||
* are genuinely sparse. */
|
||||
/** Minimum gap between Soulseek searches, whoever asks. A search is SERVER
|
||||
* traffic, and the server drops sessions that flood it — measured
|
||||
* 2026-08-03: a fresh login was killed inside ~90 seconds while background
|
||||
* album fills re-searched on every rapid track click, and the reconnect
|
||||
* throttle then locked the account out. User-triggered resolves feel
|
||||
* instant (the first search never waits); only bursts are spread. */
|
||||
const SEARCH_MIN_GAP_MS = 1500;
|
||||
|
||||
let lastSearchStartedAt = 0;
|
||||
|
||||
// Exported for the import finder (import-audio.js): the pacing, poisoned-
|
||||
// term hygiene and early-exit logic must exist ONCE, not be re-coded.
|
||||
export async function collectSources(query, maxMs, goodCount) {
|
||||
const gap = lastSearchStartedAt + SEARCH_MIN_GAP_MS - performance.now();
|
||||
if (gap > 0) await new Promise((r) => setTimeout(r, gap));
|
||||
lastSearchStartedAt = performance.now();
|
||||
const token = await api.startSearch(query);
|
||||
const responses = [];
|
||||
let unlisten = null;
|
||||
await new Promise((resolve) => {
|
||||
const started = performance.now();
|
||||
const timer = setTimeout(() => {
|
||||
clearInterval(tick);
|
||||
resolve();
|
||||
}, maxMs);
|
||||
const tick = setInterval(() => {
|
||||
if (!goodCount) return;
|
||||
const elapsed = performance.now() - started;
|
||||
const good = goodCount(responses);
|
||||
if ((elapsed >= 900 && good >= 12) || (elapsed >= 1800 && good >= 5)) {
|
||||
clearTimeout(timer);
|
||||
clearInterval(tick);
|
||||
resolve();
|
||||
}
|
||||
}, 250);
|
||||
unlisten = api.onEvent("soulseek:search-results", (batch) => {
|
||||
for (const r of batch) if (r.token === token) responses.push(r);
|
||||
});
|
||||
});
|
||||
unlisten?.();
|
||||
api.stopSearch(token).catch(() => {});
|
||||
return responses;
|
||||
}
|
||||
|
||||
/** Queries measured silent (zero responses) recently — skipped for a while
|
||||
* so a poisoned rung ("mgmt …") costs its discovery timeout once, not on
|
||||
* every album/track of the same artist. */
|
||||
const silentQueries = new Map();
|
||||
|
||||
const SILENT_QUERY_TTL_MS = 10 * 60 * 1000;
|
||||
|
||||
function querySilent(q) {
|
||||
const at = silentQueries.get(q);
|
||||
if (at == null) return false;
|
||||
if (performance.now() - at > SILENT_QUERY_TTL_MS) {
|
||||
silentQueries.delete(q);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Terms PROVEN poisoned this session: a rung containing the term was silent
|
||||
// while a term-less rung answered — the network is fine, the term is dead
|
||||
// (the "mgmt" case; the server's own excluded list does not admit it).
|
||||
// Applied exactly like server-excluded phrases, so the second album by the
|
||||
// same artist never pays the discovery timeout again.
|
||||
const learnedPoisonedTerms = new Set();
|
||||
|
||||
/** Estimated seconds until this peer's file has fully arrived: transfer time
|
||||
* from the file's size and the peer's advertised upload speed (bytes/sec),
|
||||
* plus a rough wait for a busy slot / queued transfers. Pessimistic where the
|
||||
* peer hides its numbers — an unknown speed must not look like a fast one. */
|
||||
function estimatedTransferSecs(r, file) {
|
||||
const size = file.size > 0 ? file.size : 60_000_000; // unknown → assume large
|
||||
const speed = (r.upload_speed ?? 0) > 0 ? r.upload_speed : 75_000; // unknown → slow
|
||||
let secs = size / speed;
|
||||
if (!r.has_free_upload_slot) secs += 90;
|
||||
secs += Math.min(r.queue_length ?? 0, 50) * 45;
|
||||
return secs;
|
||||
}
|
||||
|
||||
function scoreSource(r, file, prefs = null) {
|
||||
const q = RESOLVER_AUDIO_QUALITY[file.extension] ?? 0;
|
||||
if (!q) return -1;
|
||||
const bitrate = file.bitrate;
|
||||
// Speed priority ranks by ESTIMATED ARRIVAL TIME — size ÷ peer speed plus
|
||||
// queue wait — not by raw peer speed. A 40 MB FLAC from a 5 MB/s peer
|
||||
// (~8s) legitimately beats a 7 MB MP3 from a 100 KB/s peer (~70s). This
|
||||
// only reorders sources that already passed the user's min/max quality
|
||||
// window (passesSourcePrefs runs before scoring), so "fastest" can never
|
||||
// dip below the configured floor. Format/bitrate remain as tie-breakers.
|
||||
if (prefs?.priority === "speed") {
|
||||
// Positive baseline: callers treat score <= 0 as "reject this
|
||||
// format", and a slow, deep-queued peer must rank last, not vanish.
|
||||
const eta = Math.min(estimatedTransferSecs(r, file), 3_600);
|
||||
let s = 100_000 - eta * 25;
|
||||
s += q * 50;
|
||||
if (bitrate) s += Math.min(bitrate, 1500) / 50;
|
||||
return s;
|
||||
}
|
||||
let s = q * 1000;
|
||||
if (bitrate) s += Math.min(bitrate, 1500) / 10;
|
||||
if (r.has_free_upload_slot) s += 600;
|
||||
s += Math.min(r.upload_speed ?? 0, 5_000_000) / 20_000;
|
||||
s -= Math.min(r.queue_length ?? 0, 50) * 12;
|
||||
return s;
|
||||
}
|
||||
|
||||
// Markers that name a DIFFERENT RECORDING of the same song — live takes,
|
||||
// remixes, demos, covers, radio edits… `normText` strips brackets, so
|
||||
// "Island in the Sun (Live at X).mp3" matches the studio request perfectly;
|
||||
// these are tested against the RAW filename + folder to catch that. Matching
|
||||
// is symmetric: a candidate must carry exactly the markers the requested
|
||||
// title carries — no live file for a studio request, no studio file for an
|
||||
// explicitly "(Live)" catalog track.
|
||||
const TRACK_VARIANT_MARKERS = [
|
||||
/\blive\b/i,
|
||||
/\bunplugged\b/i,
|
||||
/\bacoustic\b/i,
|
||||
/\bremix(?:es|ed)?\b/i,
|
||||
/\brmx\b/i,
|
||||
/\brework(?:ed)?\b/i,
|
||||
/\bdemo(?:s)?\b/i,
|
||||
/\binstrumental\b/i,
|
||||
/\bkaraoke\b/i,
|
||||
/\bcover(?:s|ed)?\b/i,
|
||||
/\btribute\b/i,
|
||||
/\b(?:radio|single)\s+edit\b/i,
|
||||
/\b(?:club|extended|dub|dance)\s+mix\b/i,
|
||||
/\bmedley\b/i,
|
||||
/\bmashup\b/i,
|
||||
/\bsession(?:s)?\b/i,
|
||||
/\brehearsal\b/i,
|
||||
/\bouttake(?:s)?\b/i,
|
||||
/\balternate\b/i,
|
||||
/\btake\s+\d+\b/i,
|
||||
/\bre-?recorded\b/i,
|
||||
// "<Name>'s Version" re-records (Taylor's Version and kin): a different
|
||||
// recording of the song, matched symmetrically like every marker here —
|
||||
// found live by the source audit when a request for "22" top-ranked
|
||||
// "22 (Taylor's Version)".
|
||||
/\b\w+['’]s\s+version\b/i,
|
||||
/\bsped\s+up\b/i,
|
||||
/\bslowed\b/i,
|
||||
/\b8[\s-]?bit\b/i,
|
||||
];
|
||||
|
||||
// Checked against the filename only: "Reprise" is also a record label
|
||||
// (Weezer's!), so folder paths like "… (Reprise, 1996)" must not reject.
|
||||
const TRACK_VARIANT_NAME_ONLY = [/\breprise\b/i];
|
||||
|
||||
/** True when the candidate names a different version of the song than the
|
||||
* request asked for (either direction). */
|
||||
function variantMismatch(wantedTitle, candidateName, candidateFolder) {
|
||||
const cand = `${candidateFolder}\\${candidateName}`;
|
||||
for (const re of TRACK_VARIANT_MARKERS) {
|
||||
if (re.test(cand) !== re.test(wantedTitle)) return true;
|
||||
}
|
||||
for (const re of TRACK_VARIANT_NAME_ONLY) {
|
||||
if (re.test(candidateName) !== re.test(wantedTitle)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** A peer file's playtime is the wrong recording when it's far from the
|
||||
* catalog's known length — most often a ripped 30-second streaming *preview*
|
||||
* whose filename matches the real track (e.g. a 30s "Long Season" for a
|
||||
* 41-minute live track). Only judges when BOTH durations are known; the band
|
||||
* is generous (±20%, min 45s) so gapless trims and a few seconds of trailing
|
||||
* silence never trip it. */
|
||||
function durationMismatch(expectSecs, candSecs) {
|
||||
if (!expectSecs || !candSecs || expectSecs <= 0 || candSecs <= 0) return false;
|
||||
const tol = Math.max(45, expectSecs * 0.2);
|
||||
return Math.abs(candSecs - expectSecs) > tol;
|
||||
}
|
||||
|
||||
/** Ranked candidate files for a song across all responses (best first).
|
||||
* `album` (title string, optional) biases toward folders of the release the
|
||||
* user is actually looking at. `expectSecs` (optional) is the catalog track's
|
||||
* known length, used to drop preview clips / wrong-length rips. */
|
||||
function songCandidates(
|
||||
responses,
|
||||
title,
|
||||
streamOnly = false,
|
||||
album = null,
|
||||
expectSecs = null,
|
||||
artist = null,
|
||||
requireIdentityEvidence = false,
|
||||
) {
|
||||
const want = normText(title);
|
||||
const wantedRaw = title ?? "";
|
||||
const wantAlbum = album ? normText(simplifyTitle(album)) : "";
|
||||
const artistKey = artist ? normText(artist) : "";
|
||||
const prefs = streamOnly ? sourcePrefs.stream : sourcePrefs.download;
|
||||
const out = [];
|
||||
for (const r of responses) {
|
||||
for (const f of r.files) {
|
||||
if (!normText(f.name).includes(want)) continue;
|
||||
if (streamOnly && !RESOLVER_STREAMABLE.has(f.extension)) continue;
|
||||
if (!passesSourcePrefs(f, prefs)) continue;
|
||||
if (variantMismatch(wantedRaw, f.name, f.folder)) continue;
|
||||
if (durationMismatch(expectSecs, f.duration)) continue;
|
||||
let s = scoreSource(r, f, prefs);
|
||||
if (s <= 0) continue;
|
||||
const albumEvidence = wantAlbum && normText(f.folder).includes(wantAlbum);
|
||||
const artistEvidence =
|
||||
artistKey && normText(`${f.folder}\\${f.name}`).includes(artistKey);
|
||||
// A rung that dropped the artist term (server-excluded phrase)
|
||||
// matches by title alone, which happily returns other artists'
|
||||
// covers of the song. The peer's PATH must then prove identity:
|
||||
// the artist somewhere in the folder/filename, or the exact
|
||||
// release folder.
|
||||
if (requireIdentityEvidence && artistKey && !artistEvidence && !albumEvidence) {
|
||||
continue;
|
||||
}
|
||||
// The requested release's own folder beats compilation/misc rips
|
||||
// (below a format-tier jump, so quality still wins).
|
||||
if (albumEvidence) s += 800;
|
||||
if (artistEvidence) s += 400;
|
||||
// Filename ends with the title → it IS this song, not a longer
|
||||
// title containing it ("Holiday" vs "Holiday in Cambodia").
|
||||
if (normText(f.name.replace(/\.[^.]+$/, "")).endsWith(want)) s += 250;
|
||||
out.push({ username: r.username, file: f, score: s, slotFree: !!r.has_free_upload_slot });
|
||||
}
|
||||
}
|
||||
out.sort((a, b) => b.score - a.score);
|
||||
// STREAMING needs bytes now: a peer with no free upload slot usually
|
||||
// queues the request and never starts inside the failover window, and the
|
||||
// quality-first score let busy lossless peers occupy the whole failover
|
||||
// list — "Da Funk", with hundreds of sources, spent over a minute walking
|
||||
// dead candidates (2026-08-03). Stable partition: peers that can deliver
|
||||
// immediately first, best quality within each half. Downloads keep the
|
||||
// pure ranking — a save can afford to wait in a queue for the better file.
|
||||
if (streamOnly) {
|
||||
return [...out.filter((c) => c.slotFree), ...out.filter((c) => !c.slotFree)];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Ranked candidate folders for an album (best first). `trackCountOverride`
|
||||
* is the authoritative track count from the catalog tracklist fetch; prefer it
|
||||
* over `album.track_count`, which is routinely null when Play is pressed on the
|
||||
* artist page (Deezer's artist-album LIST endpoint omits nb_tracks). */
|
||||
function albumCandidates(
|
||||
responses,
|
||||
album,
|
||||
streamOnly = false,
|
||||
trackCountOverride = null,
|
||||
requireIdentityEvidence = false,
|
||||
) {
|
||||
const artistKey = normText(album.artist ?? "");
|
||||
const wantAlbum = normText(simplifyTitle(album.title));
|
||||
const wanted = trackCountOverride ?? album.track_count ?? null;
|
||||
const year = album.release_date?.slice(0, 4) || null;
|
||||
// Self-titled disambiguation: the "(Blue Album)" variant, and its bare
|
||||
// colour word, so we can prefer the right folder and reject a wrong-colour
|
||||
// one that a fuzzy peer search returns anyway.
|
||||
const variant = (albumDisambiguator(album.title) || album.variant || "").toLowerCase();
|
||||
const wantColour = variant.match(/([a-z]+)\s+album\b/)?.[1] || null;
|
||||
const folders = new Map();
|
||||
for (const r of responses) {
|
||||
for (const f of r.files) {
|
||||
const key = `${r.username}|${f.folder}`;
|
||||
let g = folders.get(key);
|
||||
if (!g) {
|
||||
g = { username: r.username, folder: f.folder, files: [], r };
|
||||
folders.set(key, g);
|
||||
}
|
||||
g.files.push(f);
|
||||
}
|
||||
}
|
||||
const prefs = streamOnly ? sourcePrefs.stream : sourcePrefs.download;
|
||||
const out = [];
|
||||
for (const g of folders.values()) {
|
||||
const audio = g.files.filter(
|
||||
(f) =>
|
||||
(streamOnly
|
||||
? RESOLVER_STREAMABLE.has(f.extension)
|
||||
: RESOLVER_AUDIO_QUALITY[f.extension])
|
||||
&& passesSourcePrefs(f, prefs),
|
||||
);
|
||||
// Require enough streamable files to plausibly be this release, but
|
||||
// NEVER default an unknown count to 3 — that silently rejected every
|
||||
// single/2-track release (whose peer folders hold 1–2 matching files)
|
||||
// and is exactly why "No streamable album sources found" fired for
|
||||
// real, obviously-streamable singles like ICARUS. Known count → at most
|
||||
// 3, floor 1; unknown → just 1.
|
||||
const minAudio = wanted ? Math.min(3, Math.max(1, wanted)) : 1;
|
||||
if (audio.length < minAudio) continue;
|
||||
const top = audio
|
||||
.map((f) => scoreSource(g.r, f, prefs))
|
||||
.reduce((a, b) => a + b, 0) / audio.length;
|
||||
let s = top;
|
||||
const folderNorm = normText(g.folder);
|
||||
const albumEvidence = folderNorm.includes(wantAlbum);
|
||||
const artistEvidence = artistKey && folderNorm.includes(artistKey);
|
||||
// Artist-less query rung (server-excluded phrase): the folder path
|
||||
// must prove the release's identity — see songCandidates.
|
||||
if (requireIdentityEvidence && artistKey && !artistEvidence && !albumEvidence) {
|
||||
continue;
|
||||
}
|
||||
if (albumEvidence) s += 400;
|
||||
if (artistEvidence) s += 300;
|
||||
// Prefer folders whose track count matches the release.
|
||||
if (wanted) s -= Math.min(Math.abs(audio.length - wanted), 20) * 60;
|
||||
// The release year in the folder path is a strong per-release signal
|
||||
// (distinguishes reissues and same-named albums).
|
||||
if (year && g.folder.includes(year)) s += 300;
|
||||
// Self-titled colour disambiguation: reward the matching "(X Album)"
|
||||
// folder, and strongly reject a folder that clearly names a different
|
||||
// colour, so "Weezer (Blue Album)" can't stream from a Red folder.
|
||||
if (wantColour) {
|
||||
const folderColours = [
|
||||
...g.folder.toLowerCase().matchAll(/\b([a-z]+)\s+album\b/g),
|
||||
].map((m) => m[1]);
|
||||
if (folderColours.includes(wantColour)) s += 500;
|
||||
else if (folderColours.length) s -= 900;
|
||||
}
|
||||
audio.sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true }));
|
||||
out.push({
|
||||
username: g.username,
|
||||
folder: g.folder,
|
||||
files: g.files,
|
||||
audio,
|
||||
score: s,
|
||||
slotFree: !!g.r.has_free_upload_slot,
|
||||
});
|
||||
}
|
||||
out.sort((a, b) => b.score - a.score);
|
||||
// Same availability partition as songCandidates, same reason: a streamed
|
||||
// album must START; a busy peer's better folder is a save-time choice.
|
||||
if (streamOnly) {
|
||||
return [...out.filter((c) => c.slotFree), ...out.filter((c) => !c.slotFree)];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Distill a peer file's tags into the quality info the Now Playing panel
|
||||
* shows (format/bitrate/sample-rate/bit-depth). All fields are optional —
|
||||
* many clients only report some, so the panel hides what's missing. */
|
||||
function qualityOf(f) {
|
||||
if (!f) return null;
|
||||
const ext = f.extension || (f.name || f.path || "").split(".").pop() || "";
|
||||
return {
|
||||
format: ext.toUpperCase(),
|
||||
bitrate: f.bitrate ?? null,
|
||||
sampleRate: f.sample_rate ?? null,
|
||||
bitDepth: f.bit_depth ?? null,
|
||||
vbr: !!f.vbr,
|
||||
size: f.size ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Deezer's CDN renders the same cover at whatever size the URL's size
|
||||
* segment asks for. Track objects carry the 250px list thumb
|
||||
* (`cover_medium`), which looks terrible blown up in the player — rewrite it
|
||||
* to the 1000px xl the album pages use. Non-Deezer URLs pass through
|
||||
* untouched. */
|
||||
function hqCover(url) {
|
||||
if (!url) return url;
|
||||
try {
|
||||
if (!new URL(url).hostname.endsWith("dzcdn.net")) return url;
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
return url.replace(
|
||||
/\/\d{2,4}x\d{2,4}((?:-[0-9a-z]+)*)\.(jpg|png|webp)$/i,
|
||||
"/1000x1000$1.$2",
|
||||
);
|
||||
}
|
||||
|
||||
/** Match a peer file to a catalog track, so queue items carry the tagged
|
||||
* title instead of the filename.
|
||||
*
|
||||
* A track title contained in the filename normally identifies the track — but
|
||||
* only if it's *specific* to this file. When peers embed the album name in
|
||||
* every filename ("… - Eggbeater Jesus - 01 Happy Hour.flac"), and the album
|
||||
* name is also a track title, that album name is a substring of every filename
|
||||
* and — being long — used to out-rank each track's real, shorter title, so the
|
||||
* whole album collapsed to one repeated title. So: ignore any title that
|
||||
* appears in (roughly) every filename, take the longest *specific* match, and
|
||||
* otherwise fall back to album order when the folder is a complete album. */
|
||||
function catalogTrackFor(file, i, files, tracks) {
|
||||
if (!tracks?.length) return null;
|
||||
const fname = normText(file.name);
|
||||
const base = (t) => normText(t.title);
|
||||
const named = (t) => base(t).length >= 3 && fname.includes(base(t));
|
||||
|
||||
// PRIMARY: the catalog (Deezer) tracklist, in order. When the folder holds
|
||||
// exactly the album's track count it's almost always the whole album in
|
||||
// order, so file `i` is track `i`. That's the only reliable way to tell
|
||||
// same-named variants apart — "My Name Is Jonas (2024 Remaster)",
|
||||
// "(Live On BBC…)" and "(Kitchen Tape Demo)" all normalize to one base
|
||||
// title, so filename matching alone can't say which belongs here (it used
|
||||
// to pick the longest title, i.e. the verbose live/demo one). Only distrust
|
||||
// the position if the filename clearly names a DIFFERENT song than the one
|
||||
// sitting there (a reordered/mismatched folder — handled by the backup).
|
||||
if (files.length === tracks.length) {
|
||||
const at = tracks[i];
|
||||
if (!fname || named(at) || !tracks.some(named)) return at;
|
||||
}
|
||||
|
||||
// BACKUP: fuzzy filename→title matching for a partial/reordered folder.
|
||||
// Drop any title that appears in ~every filename (that's the album name),
|
||||
// then among the rest prefer the closest duration (disambiguates same-named
|
||||
// variants), then the longest title.
|
||||
const contained = tracks.filter(named);
|
||||
const ubiquity = (t) => files.reduce((n, f) => n + (normText(f.name).includes(base(t)) ? 1 : 0), 0);
|
||||
const ubiquitous = Math.max(2, Math.ceil(files.length * 0.5));
|
||||
let pool = contained.filter((t) => ubiquity(t) < ubiquitous);
|
||||
if (!pool.length) pool = contained;
|
||||
if (pool.length) {
|
||||
const dur = file.duration;
|
||||
if (dur) {
|
||||
const off = (t) => Math.abs((t.duration_secs ?? 1e9) - dur);
|
||||
// Closest duration wins; on a tie prefer the shorter title, i.e. the
|
||||
// plain studio version over a longer "(Live…)" / "(Demo…)" variant.
|
||||
return pool.reduce((a, b) =>
|
||||
off(b) < off(a) || (off(b) === off(a) && b.title.length < a.title.length) ? b : a,
|
||||
);
|
||||
}
|
||||
return pool.reduce((a, b) => (b.title.length > a.title.length ? b : a));
|
||||
}
|
||||
return files.length === tracks.length ? tracks[i] : null;
|
||||
}
|
||||
|
||||
/** Turn a peer filename into a readable title when no catalog track matches
|
||||
* it (a genuine bonus/extra file on an expanded edition): drop the extension
|
||||
* and a leading track-number prefix ("04. ", "1 - ", "07 ") so it reads as a
|
||||
* title rather than "04. Dragon Ball Z…". Conservative — only strips a number
|
||||
* that's clearly a track index (has a separator, or a leading zero), so a title
|
||||
* like "99 Luftballons" is left intact. */
|
||||
function cleanTrackName(name) {
|
||||
const stem = (name ?? "").replace(/\.[^.]+$/, "");
|
||||
const stripped = stem.replace(/^\s*(?:\d{1,2}\s*[.\-)]|0\d)\s+/, "").trim();
|
||||
return stripped || stem;
|
||||
}
|
||||
|
||||
/* ── the ladder ───────────────────────────────────────────────────────
|
||||
The query shapes a caller hands over, each also ASCII-folded, each
|
||||
sanitised of server-excluded and learned-poisoned terms; the rungs run in
|
||||
order with short windows, a silent rung is remembered, and a whole silent
|
||||
ladder gets one quiet retry for the post-login warm-up. `status` is the
|
||||
caller's resolver state ({ active, label, error }) — the desktop's toast,
|
||||
the phone's bar line. */
|
||||
export async function resolveSources(label, queries, goodCount, artistTerm = null, status = null) {
|
||||
if (status) { status.active = true; status.label = label; status.error = ""; }
|
||||
try {
|
||||
const artistKey = artistTerm ? normText(artistTerm) : "";
|
||||
const seen = new Set();
|
||||
const ladder = [];
|
||||
for (const raw of [].concat(queries).filter(Boolean)) {
|
||||
// Each shape also gets an ASCII-folded rung: peers name files
|
||||
// unaccented, and the server AND-matches query tokens, so
|
||||
// "Björk Jóga" finds nothing that "Bjork Joga" finds.
|
||||
for (const variant of [raw, asciiFold(raw)]) {
|
||||
const q = sanitizeSearchQuery(variant);
|
||||
if (!q || seen.has(q)) continue;
|
||||
seen.add(q);
|
||||
ladder.push(q);
|
||||
}
|
||||
}
|
||||
// Non-final rungs get a short window: a rung the network answers at
|
||||
// all answers within ~2s, so a silent (poisoned/warm-up) rung should
|
||||
// cost 3.5s, not the full budget. Recently-silent rungs are skipped
|
||||
// outright. The post-login warm-up retry sits BEHIND the ladder: if
|
||||
// ANY rung answered, connectivity is proven and no retry is owed.
|
||||
let sawSilentArtistRung = false;
|
||||
for (let index = 0; index < ladder.length; index++) {
|
||||
const q = ladder[index];
|
||||
if (querySilent(q)) continue;
|
||||
const final = index === ladder.length - 1;
|
||||
const hasArtist = artistKey && normText(q).includes(artistKey);
|
||||
const responses = await collectSources(q, final ? 7000 : 3500, goodCount);
|
||||
if (responses.length) {
|
||||
// An answered term-less rung after a silent artist rung PROVES
|
||||
// the artist term is what kills the query — learn it, so the
|
||||
// artist's other releases skip the dead rung entirely.
|
||||
if (sawSilentArtistRung && !hasArtist && artistTerm) {
|
||||
learnedPoisonedTerms.add(artistTerm.toLowerCase().trim());
|
||||
}
|
||||
return {
|
||||
responses,
|
||||
artistIncluded: !artistKey || normText(q).includes(artistKey),
|
||||
};
|
||||
}
|
||||
if (hasArtist) sawSilentArtistRung = true;
|
||||
silentQueries.set(q, performance.now());
|
||||
}
|
||||
// Whole ladder silent: either the terms are all poisoned or we are in
|
||||
// the post-login window where the server has not attached us to a
|
||||
// search parent yet. One quiet retry of the primary rung covers the
|
||||
// latter (and clears its silent mark on success).
|
||||
if (ladder.length) {
|
||||
const q = ladder[0];
|
||||
const responses = await collectSources(q, 7000, goodCount);
|
||||
if (responses.length) {
|
||||
silentQueries.delete(q);
|
||||
return {
|
||||
responses,
|
||||
artistIncluded: !artistKey || normText(q).includes(artistKey),
|
||||
};
|
||||
}
|
||||
}
|
||||
return { responses: [], artistIncluded: true };
|
||||
} finally {
|
||||
if (status) { status.active = false; status.label = ""; }
|
||||
}
|
||||
}
|
||||
|
||||
/* ── the library first ────────────────────────────────────────────────
|
||||
Over whatever album list the caller holds (the desktop's library store,
|
||||
the phone's). */
|
||||
/** The library's copy of a catalog record, or null. */
|
||||
export function libraryAlbumIn(albums, artist, title, releaseDate = null) {
|
||||
const a = normText(artist);
|
||||
const t1 = normText(title);
|
||||
if (!a || !t1) return null;
|
||||
// normText strips ALL bracket content, so base-title comparison alone
|
||||
// cannot tell self-titled variants apart: "Weezer (Blue Album)" and
|
||||
// "Weezer (Green Album)" both normalize to "weezer" and would exact-match
|
||||
// a library copy saved under the bare provider title "Weezer". After the
|
||||
// base titles agree, identity therefore needs the variant name
|
||||
// (albumDisambiguator) to agree — or, when either side lacks one, the
|
||||
// release YEAR to corroborate. A self-titled base carries no identity at
|
||||
// all (the title is just the artist), so even a bare↔bare title match
|
||||
// requires the year there. No usable evidence → no local match; streaming
|
||||
// the right album beats instantly playing the wrong one.
|
||||
const wantD = normText(albumDisambiguator(title));
|
||||
// The simplified-title fallback lets a library copy saved under a terse
|
||||
// name match a verbose catalog EDITION (library "Album" ↔ catalog "Album
|
||||
// (Deluxe Edition)"); disambiguated variants never take it.
|
||||
const t2 = wantD ? null : normText(simplifyTitle(title));
|
||||
const wantYear = releaseDate ? Number(String(releaseDate).slice(0, 4)) || null : null;
|
||||
const selfTitled = t1 === a;
|
||||
return (
|
||||
albums.find((al) => {
|
||||
if (normText(al.artist) !== a) return false;
|
||||
const lt = normText(al.title);
|
||||
if (lt !== t1 && !(t2 && lt === t2)) return false;
|
||||
const libD = normText(albumDisambiguator(al.title));
|
||||
if (wantD && wantD === libD) return true;
|
||||
if (wantD !== libD || selfTitled) {
|
||||
const libYear = Number(al.year) || null;
|
||||
return wantYear != null && libYear != null && libYear === wantYear;
|
||||
}
|
||||
return true;
|
||||
}) ?? null
|
||||
);
|
||||
}
|
||||
/** The library's copy of a song, as { album, tracks, index }, or null.
|
||||
* `albumTracks(id)` fetches a record's rows. */
|
||||
export async function libraryTrackIn(albums, albumTracks, artist, title) {
|
||||
const a = normText(artist);
|
||||
const t = normText(title);
|
||||
if (!a || !t) return null;
|
||||
for (const al of albums) {
|
||||
if (normText(al.artist) !== a) continue;
|
||||
try {
|
||||
const tracks = await albumTracks(al.id);
|
||||
const index = tracks.findIndex((tr) => normText(tr.title) === t);
|
||||
if (index >= 0) return { album: al, tracks, index };
|
||||
} catch {
|
||||
// library unavailable — fall through to streaming
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export {
|
||||
RESOLVER_STREAMABLE,
|
||||
LOSSLESS_EXTS,
|
||||
DEFAULT_SOURCE_PREFS,
|
||||
STREAM_SOURCE_STORE,
|
||||
STREAM_SOURCE_CAP,
|
||||
streamSources,
|
||||
streamSourceKeys,
|
||||
loadStreamSources,
|
||||
saveStreamSources,
|
||||
rememberStreamSource,
|
||||
cachedStreamSourceFor,
|
||||
passesSourcePrefs,
|
||||
asciiFold,
|
||||
normText,
|
||||
simplifyTitle,
|
||||
ALBUM_EDITION_RE,
|
||||
albumQueryTitle,
|
||||
excludedSearchPhrases,
|
||||
escapeRegExp,
|
||||
SEARCH_MIN_GAP_MS,
|
||||
lastSearchStartedAt,
|
||||
silentQueries,
|
||||
SILENT_QUERY_TTL_MS,
|
||||
querySilent,
|
||||
learnedPoisonedTerms,
|
||||
estimatedTransferSecs,
|
||||
scoreSource,
|
||||
TRACK_VARIANT_MARKERS,
|
||||
TRACK_VARIANT_NAME_ONLY,
|
||||
variantMismatch,
|
||||
durationMismatch,
|
||||
songCandidates,
|
||||
albumCandidates,
|
||||
qualityOf,
|
||||
hqCover,
|
||||
catalogTrackFor,
|
||||
cleanTrackName,
|
||||
};
|
||||
+32
-784
@@ -1,5 +1,34 @@
|
||||
// Shared reactive app state (Svelte 5 runes module).
|
||||
import * as api from "./api.js";
|
||||
import {
|
||||
sourcePrefs,
|
||||
refreshSourcePrefs,
|
||||
rememberStreamSource,
|
||||
cachedStreamSourceFor,
|
||||
asciiFold,
|
||||
normText,
|
||||
simplifyTitle,
|
||||
albumDisambiguator,
|
||||
albumQueryTitle,
|
||||
setExcludedSearchPhrases,
|
||||
sanitizeSearchQuery,
|
||||
collectSources,
|
||||
silentQueries,
|
||||
querySilent,
|
||||
learnedPoisonedTerms,
|
||||
variantMismatch,
|
||||
durationMismatch,
|
||||
songCandidates,
|
||||
albumCandidates,
|
||||
qualityOf,
|
||||
hqCover,
|
||||
catalogTrackFor,
|
||||
cleanTrackName,
|
||||
resolveSources,
|
||||
libraryAlbumIn,
|
||||
libraryTrackIn,
|
||||
} from "./sources.js";
|
||||
export { RESOLVER_AUDIO_QUALITY, sourcePrefs, refreshSourcePrefs, albumDisambiguator, setExcludedSearchPhrases, sanitizeSearchQuery, collectSources } from "./sources.js";
|
||||
import { nameNorm, pickCatalogMatch } from "./catalogMatch.js";
|
||||
import { accentFromImage, hexToRgb, rgbToHex, lerpRgb } from "./color.js";
|
||||
import {
|
||||
@@ -4083,133 +4112,7 @@ export const resolver = $state({
|
||||
error: "",
|
||||
});
|
||||
|
||||
export const RESOLVER_AUDIO_QUALITY = {
|
||||
flac: 4, ape: 4, aiff: 3.5, wav: 3.5,
|
||||
m4a: 2.5, ogg: 2.5, opus: 2.5, aac: 2.2, mp3: 2, wma: 1.5,
|
||||
};
|
||||
|
||||
// Formats that decode progressively while the file is still downloading.
|
||||
// MP4-family files (m4a/aac) put their index at the end and only play once
|
||||
// complete, so they're save-only.
|
||||
//
|
||||
// `opus` was here and must not be: this build has no Opus decoder, so the
|
||||
// resolver would pick an Opus source to stream, download it, and fail at the
|
||||
// moment of play. Streamable is a subset of PLAYABLE, never a superset —
|
||||
// see crates/audio-formats.
|
||||
const RESOLVER_STREAMABLE = new Set(["flac", "mp3", "ogg", "oga", "wav", "wave", "aiff", "aif"]);
|
||||
|
||||
// Lossless for LABELLING — what the file claims to be, which is a different
|
||||
// question from whether we can decode it. `.ape` and `.wv` are genuinely
|
||||
// lossless and genuinely unplayable here; a peer's file is still worth naming
|
||||
// accurately in search results.
|
||||
const LOSSLESS_EXTS = new Set(["flac", "ape", "wav", "wave", "aiff", "aif", "alac", "wv", "tta"]);
|
||||
|
||||
// User source-selection preferences, independent per mode. Loaded from
|
||||
// settings at startup and refreshed by the Settings view after a commit;
|
||||
// defaults reproduce the pre-feature behavior exactly.
|
||||
const DEFAULT_SOURCE_PREFS = { max: "any", min: "any", priority: "quality" };
|
||||
export const sourcePrefs = {
|
||||
stream: { ...DEFAULT_SOURCE_PREFS },
|
||||
download: { ...DEFAULT_SOURCE_PREFS },
|
||||
};
|
||||
export async function refreshSourcePrefs() {
|
||||
try {
|
||||
const s = await api.getSettings();
|
||||
sourcePrefs.stream = { ...DEFAULT_SOURCE_PREFS, ...(s?.stream_source_prefs ?? {}) };
|
||||
sourcePrefs.download = { ...DEFAULT_SOURCE_PREFS, ...(s?.download_source_prefs ?? {}) };
|
||||
} catch {
|
||||
// settings unavailable (fresh install / backend restart) → defaults
|
||||
}
|
||||
}
|
||||
|
||||
// ---- remembered stream sources ------------------------------------------
|
||||
// The last source each catalog track streamed from, persisted so a track can
|
||||
// play again WITHOUT a Soulseek search: its bytes are already in the stream
|
||||
// cache, and the resolve would only rediscover the peer that put them there.
|
||||
// The memory is a HINT, not a promise — before any search is skipped the
|
||||
// backend confirms the exact file is byte-complete on disk, so eviction, a
|
||||
// half-finished transfer, or a source that never delivered all fall back to
|
||||
// the normal resolver on their own.
|
||||
const STREAM_SOURCE_STORE = "soulstream.streamSources.v1";
|
||||
const STREAM_SOURCE_CAP = 400;
|
||||
let streamSources = null; // Map key → {username, path, size, duration, quality}
|
||||
|
||||
function streamSourceKeys(trackId, artist, title) {
|
||||
const keys = [];
|
||||
if (trackId != null) keys.push(`t:${trackId}`);
|
||||
if (artist && title) keys.push(`n:${artist}|${title}`.toLowerCase());
|
||||
return keys;
|
||||
}
|
||||
function loadStreamSources() {
|
||||
if (streamSources) return streamSources;
|
||||
streamSources = new Map();
|
||||
try {
|
||||
for (const [k, v] of JSON.parse(localStorage.getItem(STREAM_SOURCE_STORE) ?? "[]")) {
|
||||
streamSources.set(k, v);
|
||||
}
|
||||
} catch {
|
||||
// corrupt store — start over; losing it only costs one search per track
|
||||
}
|
||||
return streamSources;
|
||||
}
|
||||
function saveStreamSources() {
|
||||
try {
|
||||
const entries = [...streamSources.entries()];
|
||||
localStorage.setItem(
|
||||
STREAM_SOURCE_STORE,
|
||||
JSON.stringify(entries.slice(-STREAM_SOURCE_CAP)),
|
||||
);
|
||||
} catch {
|
||||
// quota — same story, the memory is only a shortcut
|
||||
}
|
||||
}
|
||||
/** Called every time a stream item is queued to PLAY (not when it proves
|
||||
* itself) — eager on purpose: a source that never delivers leaves no complete
|
||||
* cache file, so the probe rejects it later and nothing was lost. Insertion
|
||||
* order doubles as the LRU for the cap in saveStreamSources. */
|
||||
function rememberStreamSource(item) {
|
||||
if (!item?.stream?.username || !item.path || !(item.stream.size > 0)) return;
|
||||
const map = loadStreamSources();
|
||||
const record = {
|
||||
username: item.stream.username,
|
||||
path: item.path,
|
||||
size: item.stream.size,
|
||||
duration: item.duration ?? null,
|
||||
quality: item.quality ?? null,
|
||||
};
|
||||
for (const key of streamSourceKeys(item.trackId ?? null, item.artist, item.title)) {
|
||||
map.delete(key);
|
||||
map.set(key, record);
|
||||
}
|
||||
saveStreamSources();
|
||||
}
|
||||
/** The remembered source for this track, but ONLY if its cache file is
|
||||
* byte-complete right now. Anything else is null: resolve normally. */
|
||||
async function cachedStreamSourceFor(trackId, artist, title) {
|
||||
const map = loadStreamSources();
|
||||
for (const key of streamSourceKeys(trackId, artist, title)) {
|
||||
const src = map.get(key);
|
||||
if (!src) continue;
|
||||
// A remembered file whose NAME does not even contain the track's
|
||||
// base title is a mis-paired memory (the 2026-08-20 incident:
|
||||
// three unrelated rows each "remembered" the same Weezer FLAC and
|
||||
// played it from the warm cache forever). Delete it and resolve
|
||||
// normally — a wrongly-dropped legit source only costs one search.
|
||||
const base = normText(asciiFold((title ?? "").split(/[([]/)[0]));
|
||||
const fileName = normText(asciiFold((src.path ?? "").split("\\").pop() ?? ""));
|
||||
if (base.length >= 4 && fileName && !fileName.includes(base)) {
|
||||
map.delete(key);
|
||||
saveStreamSources();
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
if (await api.streamCacheReady(src.username, src.path, src.size)) return src;
|
||||
} catch {
|
||||
// backend unavailable — resolve normally
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
void refreshSourcePrefs();
|
||||
|
||||
// ---- artwork for a stream that arrived without any -----------------------
|
||||
@@ -4301,225 +4204,6 @@ async function ensureStreamArt(item) {
|
||||
}
|
||||
|
||||
|
||||
/** Whether a peer file satisfies the user's quality window for this mode.
|
||||
* Lossless formats satisfy every kbps floor; a lossy file must carry a KNOWN
|
||||
* bitrate at or above it — a peer that hides its bitrate does not get the
|
||||
* benefit of the doubt when the user asked for a minimum. Thresholds sit
|
||||
* slightly under nominal so VBR files reporting 317 kbps still count as 320. */
|
||||
function passesSourcePrefs(f, prefs) {
|
||||
const lossless = LOSSLESS_EXTS.has(f.extension);
|
||||
if (prefs.max === "lossy_only" && lossless) return false;
|
||||
switch (prefs.min) {
|
||||
case "lossless":
|
||||
if (!lossless) return false;
|
||||
break;
|
||||
case "kbps320":
|
||||
if (!lossless && (f.bitrate ?? 0) < 315) return false;
|
||||
break;
|
||||
case "kbps256":
|
||||
if (!lossless && (f.bitrate ?? 0) < 250) return false;
|
||||
break;
|
||||
case "kbps192":
|
||||
if (!lossless && (f.bitrate ?? 0) < 186) return false;
|
||||
break;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Diacritics folded to ASCII ("Jóga" → "Joga", "Björk" → "Bjork"). Peers
|
||||
* overwhelmingly name files unaccented; without folding, normText simply
|
||||
* DELETED accented letters ("Jóga" → "jga") and the request could never
|
||||
* match the file. */
|
||||
const asciiFold = (s) => (s ?? "").normalize("NFKD").replace(/\p{M}/gu, "");
|
||||
|
||||
const normText = (s) =>
|
||||
asciiFold(s).toLowerCase().replace(/\(.*?\)|\[.*?\]/g, "").replace(/[^a-z0-9]/g, "");
|
||||
|
||||
/** Catalog titles are often verbose editions ("Album; or, The Long Subtitle…",
|
||||
* "Album (Deluxe Edition)"). Peer searches AND-match every word, so query
|
||||
* with just the primary segment, capped to a few words. */
|
||||
function simplifyTitle(s) {
|
||||
const first = (s ?? "").split(/[;:(\[]/)[0].trim();
|
||||
const words = (first || s || "").split(/\s+/).slice(0, 6).join(" ");
|
||||
return words || s || "";
|
||||
}
|
||||
|
||||
// Words that mark an *edition* of a release, not a distinct release. Used to
|
||||
// tell an edition suffix (noise, safe to drop) from a variant name that names
|
||||
// a different album — critical for self-titled records where the base title
|
||||
// alone can't tell "Weezer (Blue Album)" from "Weezer (Red Album)".
|
||||
const ALBUM_EDITION_RE =
|
||||
/\b(?:deluxe|expanded|remaster(?:ed)?|anniversary|edition|version|bonus|reissue|super|complete|definitive|legacy|special|collector'?s?|mono|stereo|redux|remix(?:es|ed)?)\b/i;
|
||||
|
||||
/** The disambiguating variant name inside a title's brackets — e.g. "Blue
|
||||
* Album" from "Weezer (Blue Album)", or "Red Album" from "Weezer (Deluxe
|
||||
* Edition / Red Album)" — skipping pure edition markers. "" when the brackets
|
||||
* only hold edition noise (or there are none). */
|
||||
export function albumDisambiguator(title) {
|
||||
const groups = [...(title ?? "").matchAll(/[([]([^)\]]+)[)\]]/g)].map((m) => m[1]);
|
||||
for (const inner of groups) {
|
||||
for (const part of inner.split(/\s*[/|;]\s*/)) {
|
||||
const p = part.trim();
|
||||
if (p && !ALBUM_EDITION_RE.test(p)) return p;
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
/** Search-query title-part for a release: the simplified title, plus the
|
||||
* disambiguating variant name when there is one, so distinct self-titled
|
||||
* albums search for their own colour instead of colliding on the band name. */
|
||||
function albumQueryTitle(album) {
|
||||
const base = simplifyTitle(album.title);
|
||||
// Fall back to a caller-supplied variant (the family's colour) when the
|
||||
// title itself dropped it — e.g. "Weezer (Deluxe Edition)" is the Red Album
|
||||
// but names no colour, so without this it searches bare "Weezer".
|
||||
const d = albumDisambiguator(album.title) || album.variant || "";
|
||||
return d ? `${base} ${d}` : base;
|
||||
}
|
||||
|
||||
// Server-distributed excluded search phrases (Soulseek message 160). A query
|
||||
// containing one is NEVER routed by the server — it returns zero responses
|
||||
// network-wide with no error. Verified live: every query containing "mgmt"
|
||||
// was silent while "little dark age" alone returned 738 responses. The list
|
||||
// arrives by event after login and is also fetchable on demand (covers a
|
||||
// WebView reload); the resolver strips these terms before they reach the wire.
|
||||
let excludedSearchPhrases = [];
|
||||
export function setExcludedSearchPhrases(list) {
|
||||
excludedSearchPhrases = (Array.isArray(list) ? list : [])
|
||||
.map((p) => String(p ?? "").toLowerCase().trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
void api.excludedSearchPhrases?.().then(setExcludedSearchPhrases, () => {});
|
||||
|
||||
const escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
|
||||
/** Query with server-excluded phrases removed (whole-word, any position) and
|
||||
* whitespace collapsed. Returns "" when nothing usable remains. */
|
||||
export function sanitizeSearchQuery(query) {
|
||||
let out = ` ${query ?? ""} `;
|
||||
for (const phrase of [...excludedSearchPhrases, ...learnedPoisonedTerms]) {
|
||||
out = out.replace(new RegExp(`(?<=^|[^\\p{L}\\p{N}])${escapeRegExp(phrase)}(?=$|[^\\p{L}\\p{N}])`, "giu"), " ");
|
||||
}
|
||||
return out.replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
/** Run a background source search (own token — the Search view is untouched)
|
||||
* and collect responses. Ends early once `goodCount()` finds enough.
|
||||
*
|
||||
* The early exit is evaluated on a 250ms TICK, not only when a batch
|
||||
* arrives: peers deliver most results within the first second and then go
|
||||
* quiet, and the old on-batch-only check meant a plentiful-but-fast search
|
||||
* still sat out the full window waiting for one more batch that never came
|
||||
* (measured: 8.1s click-to-play on Discovery, ~7s of it idle waiting).
|
||||
* Thresholds are adaptive — a flood of good candidates releases at 900ms,
|
||||
* a workable set at 1800ms, and the full window only runs when results
|
||||
* are genuinely sparse. */
|
||||
/** Minimum gap between Soulseek searches, whoever asks. A search is SERVER
|
||||
* traffic, and the server drops sessions that flood it — measured
|
||||
* 2026-08-03: a fresh login was killed inside ~90 seconds while background
|
||||
* album fills re-searched on every rapid track click, and the reconnect
|
||||
* throttle then locked the account out. User-triggered resolves feel
|
||||
* instant (the first search never waits); only bursts are spread. */
|
||||
const SEARCH_MIN_GAP_MS = 1500;
|
||||
let lastSearchStartedAt = 0;
|
||||
|
||||
// Exported for the import finder (import-audio.js): the pacing, poisoned-
|
||||
// term hygiene and early-exit logic must exist ONCE, not be re-coded.
|
||||
export async function collectSources(query, maxMs, goodCount) {
|
||||
const gap = lastSearchStartedAt + SEARCH_MIN_GAP_MS - performance.now();
|
||||
if (gap > 0) await new Promise((r) => setTimeout(r, gap));
|
||||
lastSearchStartedAt = performance.now();
|
||||
const token = await api.startSearch(query);
|
||||
const responses = [];
|
||||
let unlisten = null;
|
||||
await new Promise((resolve) => {
|
||||
const started = performance.now();
|
||||
const timer = setTimeout(() => {
|
||||
clearInterval(tick);
|
||||
resolve();
|
||||
}, maxMs);
|
||||
const tick = setInterval(() => {
|
||||
if (!goodCount) return;
|
||||
const elapsed = performance.now() - started;
|
||||
const good = goodCount(responses);
|
||||
if ((elapsed >= 900 && good >= 12) || (elapsed >= 1800 && good >= 5)) {
|
||||
clearTimeout(timer);
|
||||
clearInterval(tick);
|
||||
resolve();
|
||||
}
|
||||
}, 250);
|
||||
unlisten = api.onEvent("soulseek:search-results", (batch) => {
|
||||
for (const r of batch) if (r.token === token) responses.push(r);
|
||||
});
|
||||
});
|
||||
unlisten?.();
|
||||
api.stopSearch(token).catch(() => {});
|
||||
return responses;
|
||||
}
|
||||
|
||||
/** Queries measured silent (zero responses) recently — skipped for a while
|
||||
* so a poisoned rung ("mgmt …") costs its discovery timeout once, not on
|
||||
* every album/track of the same artist. */
|
||||
const silentQueries = new Map();
|
||||
const SILENT_QUERY_TTL_MS = 10 * 60 * 1000;
|
||||
function querySilent(q) {
|
||||
const at = silentQueries.get(q);
|
||||
if (at == null) return false;
|
||||
if (performance.now() - at > SILENT_QUERY_TTL_MS) {
|
||||
silentQueries.delete(q);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Terms PROVEN poisoned this session: a rung containing the term was silent
|
||||
// while a term-less rung answered — the network is fine, the term is dead
|
||||
// (the "mgmt" case; the server's own excluded list does not admit it).
|
||||
// Applied exactly like server-excluded phrases, so the second album by the
|
||||
// same artist never pays the discovery timeout again.
|
||||
const learnedPoisonedTerms = new Set();
|
||||
|
||||
/** Estimated seconds until this peer's file has fully arrived: transfer time
|
||||
* from the file's size and the peer's advertised upload speed (bytes/sec),
|
||||
* plus a rough wait for a busy slot / queued transfers. Pessimistic where the
|
||||
* peer hides its numbers — an unknown speed must not look like a fast one. */
|
||||
function estimatedTransferSecs(r, file) {
|
||||
const size = file.size > 0 ? file.size : 60_000_000; // unknown → assume large
|
||||
const speed = (r.upload_speed ?? 0) > 0 ? r.upload_speed : 75_000; // unknown → slow
|
||||
let secs = size / speed;
|
||||
if (!r.has_free_upload_slot) secs += 90;
|
||||
secs += Math.min(r.queue_length ?? 0, 50) * 45;
|
||||
return secs;
|
||||
}
|
||||
|
||||
function scoreSource(r, file, prefs = null) {
|
||||
const q = RESOLVER_AUDIO_QUALITY[file.extension] ?? 0;
|
||||
if (!q) return -1;
|
||||
const bitrate = file.bitrate;
|
||||
// Speed priority ranks by ESTIMATED ARRIVAL TIME — size ÷ peer speed plus
|
||||
// queue wait — not by raw peer speed. A 40 MB FLAC from a 5 MB/s peer
|
||||
// (~8s) legitimately beats a 7 MB MP3 from a 100 KB/s peer (~70s). This
|
||||
// only reorders sources that already passed the user's min/max quality
|
||||
// window (passesSourcePrefs runs before scoring), so "fastest" can never
|
||||
// dip below the configured floor. Format/bitrate remain as tie-breakers.
|
||||
if (prefs?.priority === "speed") {
|
||||
// Positive baseline: callers treat score <= 0 as "reject this
|
||||
// format", and a slow, deep-queued peer must rank last, not vanish.
|
||||
const eta = Math.min(estimatedTransferSecs(r, file), 3_600);
|
||||
let s = 100_000 - eta * 25;
|
||||
s += q * 50;
|
||||
if (bitrate) s += Math.min(bitrate, 1500) / 50;
|
||||
return s;
|
||||
}
|
||||
let s = q * 1000;
|
||||
if (bitrate) s += Math.min(bitrate, 1500) / 10;
|
||||
if (r.has_free_upload_slot) s += 600;
|
||||
s += Math.min(r.upload_speed ?? 0, 5_000_000) / 20_000;
|
||||
s -= Math.min(r.queue_length ?? 0, 50) * 12;
|
||||
return s;
|
||||
}
|
||||
|
||||
function requireOnline() {
|
||||
if (app.connection !== "online") {
|
||||
app.showLogin = true;
|
||||
@@ -4528,135 +4212,6 @@ function requireOnline() {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Markers that name a DIFFERENT RECORDING of the same song — live takes,
|
||||
// remixes, demos, covers, radio edits… `normText` strips brackets, so
|
||||
// "Island in the Sun (Live at X).mp3" matches the studio request perfectly;
|
||||
// these are tested against the RAW filename + folder to catch that. Matching
|
||||
// is symmetric: a candidate must carry exactly the markers the requested
|
||||
// title carries — no live file for a studio request, no studio file for an
|
||||
// explicitly "(Live)" catalog track.
|
||||
const TRACK_VARIANT_MARKERS = [
|
||||
/\blive\b/i,
|
||||
/\bunplugged\b/i,
|
||||
/\bacoustic\b/i,
|
||||
/\bremix(?:es|ed)?\b/i,
|
||||
/\brmx\b/i,
|
||||
/\brework(?:ed)?\b/i,
|
||||
/\bdemo(?:s)?\b/i,
|
||||
/\binstrumental\b/i,
|
||||
/\bkaraoke\b/i,
|
||||
/\bcover(?:s|ed)?\b/i,
|
||||
/\btribute\b/i,
|
||||
/\b(?:radio|single)\s+edit\b/i,
|
||||
/\b(?:club|extended|dub|dance)\s+mix\b/i,
|
||||
/\bmedley\b/i,
|
||||
/\bmashup\b/i,
|
||||
/\bsession(?:s)?\b/i,
|
||||
/\brehearsal\b/i,
|
||||
/\bouttake(?:s)?\b/i,
|
||||
/\balternate\b/i,
|
||||
/\btake\s+\d+\b/i,
|
||||
/\bre-?recorded\b/i,
|
||||
// "<Name>'s Version" re-records (Taylor's Version and kin): a different
|
||||
// recording of the song, matched symmetrically like every marker here —
|
||||
// found live by the source audit when a request for "22" top-ranked
|
||||
// "22 (Taylor's Version)".
|
||||
/\b\w+['’]s\s+version\b/i,
|
||||
/\bsped\s+up\b/i,
|
||||
/\bslowed\b/i,
|
||||
/\b8[\s-]?bit\b/i,
|
||||
];
|
||||
// Checked against the filename only: "Reprise" is also a record label
|
||||
// (Weezer's!), so folder paths like "… (Reprise, 1996)" must not reject.
|
||||
const TRACK_VARIANT_NAME_ONLY = [/\breprise\b/i];
|
||||
|
||||
/** True when the candidate names a different version of the song than the
|
||||
* request asked for (either direction). */
|
||||
function variantMismatch(wantedTitle, candidateName, candidateFolder) {
|
||||
const cand = `${candidateFolder}\\${candidateName}`;
|
||||
for (const re of TRACK_VARIANT_MARKERS) {
|
||||
if (re.test(cand) !== re.test(wantedTitle)) return true;
|
||||
}
|
||||
for (const re of TRACK_VARIANT_NAME_ONLY) {
|
||||
if (re.test(candidateName) !== re.test(wantedTitle)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** A peer file's playtime is the wrong recording when it's far from the
|
||||
* catalog's known length — most often a ripped 30-second streaming *preview*
|
||||
* whose filename matches the real track (e.g. a 30s "Long Season" for a
|
||||
* 41-minute live track). Only judges when BOTH durations are known; the band
|
||||
* is generous (±20%, min 45s) so gapless trims and a few seconds of trailing
|
||||
* silence never trip it. */
|
||||
function durationMismatch(expectSecs, candSecs) {
|
||||
if (!expectSecs || !candSecs || expectSecs <= 0 || candSecs <= 0) return false;
|
||||
const tol = Math.max(45, expectSecs * 0.2);
|
||||
return Math.abs(candSecs - expectSecs) > tol;
|
||||
}
|
||||
|
||||
/** Ranked candidate files for a song across all responses (best first).
|
||||
* `album` (title string, optional) biases toward folders of the release the
|
||||
* user is actually looking at. `expectSecs` (optional) is the catalog track's
|
||||
* known length, used to drop preview clips / wrong-length rips. */
|
||||
function songCandidates(
|
||||
responses,
|
||||
title,
|
||||
streamOnly = false,
|
||||
album = null,
|
||||
expectSecs = null,
|
||||
artist = null,
|
||||
requireIdentityEvidence = false,
|
||||
) {
|
||||
const want = normText(title);
|
||||
const wantedRaw = title ?? "";
|
||||
const wantAlbum = album ? normText(simplifyTitle(album)) : "";
|
||||
const artistKey = artist ? normText(artist) : "";
|
||||
const prefs = streamOnly ? sourcePrefs.stream : sourcePrefs.download;
|
||||
const out = [];
|
||||
for (const r of responses) {
|
||||
for (const f of r.files) {
|
||||
if (!normText(f.name).includes(want)) continue;
|
||||
if (streamOnly && !RESOLVER_STREAMABLE.has(f.extension)) continue;
|
||||
if (!passesSourcePrefs(f, prefs)) continue;
|
||||
if (variantMismatch(wantedRaw, f.name, f.folder)) continue;
|
||||
if (durationMismatch(expectSecs, f.duration)) continue;
|
||||
let s = scoreSource(r, f, prefs);
|
||||
if (s <= 0) continue;
|
||||
const albumEvidence = wantAlbum && normText(f.folder).includes(wantAlbum);
|
||||
const artistEvidence =
|
||||
artistKey && normText(`${f.folder}\\${f.name}`).includes(artistKey);
|
||||
// A rung that dropped the artist term (server-excluded phrase)
|
||||
// matches by title alone, which happily returns other artists'
|
||||
// covers of the song. The peer's PATH must then prove identity:
|
||||
// the artist somewhere in the folder/filename, or the exact
|
||||
// release folder.
|
||||
if (requireIdentityEvidence && artistKey && !artistEvidence && !albumEvidence) {
|
||||
continue;
|
||||
}
|
||||
// The requested release's own folder beats compilation/misc rips
|
||||
// (below a format-tier jump, so quality still wins).
|
||||
if (albumEvidence) s += 800;
|
||||
if (artistEvidence) s += 400;
|
||||
// Filename ends with the title → it IS this song, not a longer
|
||||
// title containing it ("Holiday" vs "Holiday in Cambodia").
|
||||
if (normText(f.name.replace(/\.[^.]+$/, "")).endsWith(want)) s += 250;
|
||||
out.push({ username: r.username, file: f, score: s, slotFree: !!r.has_free_upload_slot });
|
||||
}
|
||||
}
|
||||
out.sort((a, b) => b.score - a.score);
|
||||
// STREAMING needs bytes now: a peer with no free upload slot usually
|
||||
// queues the request and never starts inside the failover window, and the
|
||||
// quality-first score let busy lossless peers occupy the whole failover
|
||||
// list — "Da Funk", with hundreds of sources, spent over a minute walking
|
||||
// dead candidates (2026-08-03). Stable partition: peers that can deliver
|
||||
// immediately first, best quality within each half. Downloads keep the
|
||||
// pure ranking — a save can afford to wait in a queue for the better file.
|
||||
if (streamOnly) {
|
||||
return [...out.filter((c) => c.slotFree), ...out.filter((c) => !c.slotFree)];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Exposed for the source-resolution audit harness (session-scratchpad CDP
|
||||
* probes drive the REAL matching/scoring code against live search responses).
|
||||
@@ -4691,103 +4246,6 @@ function waitForStreamStart(ms) {
|
||||
});
|
||||
}
|
||||
|
||||
/** Ranked candidate folders for an album (best first). `trackCountOverride`
|
||||
* is the authoritative track count from the catalog tracklist fetch; prefer it
|
||||
* over `album.track_count`, which is routinely null when Play is pressed on the
|
||||
* artist page (Deezer's artist-album LIST endpoint omits nb_tracks). */
|
||||
function albumCandidates(
|
||||
responses,
|
||||
album,
|
||||
streamOnly = false,
|
||||
trackCountOverride = null,
|
||||
requireIdentityEvidence = false,
|
||||
) {
|
||||
const artistKey = normText(album.artist ?? "");
|
||||
const wantAlbum = normText(simplifyTitle(album.title));
|
||||
const wanted = trackCountOverride ?? album.track_count ?? null;
|
||||
const year = album.release_date?.slice(0, 4) || null;
|
||||
// Self-titled disambiguation: the "(Blue Album)" variant, and its bare
|
||||
// colour word, so we can prefer the right folder and reject a wrong-colour
|
||||
// one that a fuzzy peer search returns anyway.
|
||||
const variant = (albumDisambiguator(album.title) || album.variant || "").toLowerCase();
|
||||
const wantColour = variant.match(/([a-z]+)\s+album\b/)?.[1] || null;
|
||||
const folders = new Map();
|
||||
for (const r of responses) {
|
||||
for (const f of r.files) {
|
||||
const key = `${r.username}|${f.folder}`;
|
||||
let g = folders.get(key);
|
||||
if (!g) {
|
||||
g = { username: r.username, folder: f.folder, files: [], r };
|
||||
folders.set(key, g);
|
||||
}
|
||||
g.files.push(f);
|
||||
}
|
||||
}
|
||||
const prefs = streamOnly ? sourcePrefs.stream : sourcePrefs.download;
|
||||
const out = [];
|
||||
for (const g of folders.values()) {
|
||||
const audio = g.files.filter(
|
||||
(f) =>
|
||||
(streamOnly
|
||||
? RESOLVER_STREAMABLE.has(f.extension)
|
||||
: RESOLVER_AUDIO_QUALITY[f.extension])
|
||||
&& passesSourcePrefs(f, prefs),
|
||||
);
|
||||
// Require enough streamable files to plausibly be this release, but
|
||||
// NEVER default an unknown count to 3 — that silently rejected every
|
||||
// single/2-track release (whose peer folders hold 1–2 matching files)
|
||||
// and is exactly why "No streamable album sources found" fired for
|
||||
// real, obviously-streamable singles like ICARUS. Known count → at most
|
||||
// 3, floor 1; unknown → just 1.
|
||||
const minAudio = wanted ? Math.min(3, Math.max(1, wanted)) : 1;
|
||||
if (audio.length < minAudio) continue;
|
||||
const top = audio
|
||||
.map((f) => scoreSource(g.r, f, prefs))
|
||||
.reduce((a, b) => a + b, 0) / audio.length;
|
||||
let s = top;
|
||||
const folderNorm = normText(g.folder);
|
||||
const albumEvidence = folderNorm.includes(wantAlbum);
|
||||
const artistEvidence = artistKey && folderNorm.includes(artistKey);
|
||||
// Artist-less query rung (server-excluded phrase): the folder path
|
||||
// must prove the release's identity — see songCandidates.
|
||||
if (requireIdentityEvidence && artistKey && !artistEvidence && !albumEvidence) {
|
||||
continue;
|
||||
}
|
||||
if (albumEvidence) s += 400;
|
||||
if (artistEvidence) s += 300;
|
||||
// Prefer folders whose track count matches the release.
|
||||
if (wanted) s -= Math.min(Math.abs(audio.length - wanted), 20) * 60;
|
||||
// The release year in the folder path is a strong per-release signal
|
||||
// (distinguishes reissues and same-named albums).
|
||||
if (year && g.folder.includes(year)) s += 300;
|
||||
// Self-titled colour disambiguation: reward the matching "(X Album)"
|
||||
// folder, and strongly reject a folder that clearly names a different
|
||||
// colour, so "Weezer (Blue Album)" can't stream from a Red folder.
|
||||
if (wantColour) {
|
||||
const folderColours = [
|
||||
...g.folder.toLowerCase().matchAll(/\b([a-z]+)\s+album\b/g),
|
||||
].map((m) => m[1]);
|
||||
if (folderColours.includes(wantColour)) s += 500;
|
||||
else if (folderColours.length) s -= 900;
|
||||
}
|
||||
audio.sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true }));
|
||||
out.push({
|
||||
username: g.username,
|
||||
folder: g.folder,
|
||||
files: g.files,
|
||||
audio,
|
||||
score: s,
|
||||
slotFree: !!g.r.has_free_upload_slot,
|
||||
});
|
||||
}
|
||||
out.sort((a, b) => b.score - a.score);
|
||||
// Same availability partition as songCandidates, same reason: a streamed
|
||||
// album must START; a busy peer's better folder is a save-time choice.
|
||||
if (streamOnly) {
|
||||
return [...out.filter((c) => c.slotFree), ...out.filter((c) => !c.slotFree)];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Walk a LADDER of query shapes until one returns responses. Each rung is
|
||||
* sanitized against the server's excluded-phrase list first (a poisoned term
|
||||
@@ -4799,74 +4257,8 @@ function albumCandidates(
|
||||
*
|
||||
* `queries` may be a string (single rung) or an array, best-first. */
|
||||
async function resolve(label, queries, goodCount, artistTerm = null) {
|
||||
resolver.active = true;
|
||||
resolver.label = label;
|
||||
resolver.error = "";
|
||||
player.error = "";
|
||||
try {
|
||||
const artistKey = artistTerm ? normText(artistTerm) : "";
|
||||
const seen = new Set();
|
||||
const ladder = [];
|
||||
for (const raw of [].concat(queries).filter(Boolean)) {
|
||||
// Each shape also gets an ASCII-folded rung: peers name files
|
||||
// unaccented, and the server AND-matches query tokens, so
|
||||
// "Björk Jóga" finds nothing that "Bjork Joga" finds.
|
||||
for (const variant of [raw, asciiFold(raw)]) {
|
||||
const q = sanitizeSearchQuery(variant);
|
||||
if (!q || seen.has(q)) continue;
|
||||
seen.add(q);
|
||||
ladder.push(q);
|
||||
}
|
||||
}
|
||||
// Non-final rungs get a short window: a rung the network answers at
|
||||
// all answers within ~2s, so a silent (poisoned/warm-up) rung should
|
||||
// cost 3.5s, not the full budget. Recently-silent rungs are skipped
|
||||
// outright. The post-login warm-up retry moved BEHIND the ladder: if
|
||||
// ANY rung answered, connectivity is proven and no retry is owed —
|
||||
// the old first-rung retry burned 7 extra seconds on every poisoned
|
||||
// artist query before the fallback rung even ran.
|
||||
let sawSilentArtistRung = false;
|
||||
for (let index = 0; index < ladder.length; index++) {
|
||||
const q = ladder[index];
|
||||
if (querySilent(q)) continue;
|
||||
const final = index === ladder.length - 1;
|
||||
const hasArtist = artistKey && normText(q).includes(artistKey);
|
||||
const responses = await collectSources(q, final ? 7000 : 3500, goodCount);
|
||||
if (responses.length) {
|
||||
// An answered term-less rung after a silent artist rung PROVES
|
||||
// the artist term is what kills the query — learn it, so the
|
||||
// artist's other releases skip the dead rung entirely.
|
||||
if (sawSilentArtistRung && !hasArtist && artistTerm) {
|
||||
learnedPoisonedTerms.add(artistTerm.toLowerCase().trim());
|
||||
}
|
||||
return {
|
||||
responses,
|
||||
artistIncluded: !artistKey || normText(q).includes(artistKey),
|
||||
};
|
||||
}
|
||||
if (hasArtist) sawSilentArtistRung = true;
|
||||
silentQueries.set(q, performance.now());
|
||||
}
|
||||
// Whole ladder silent: either the terms are all poisoned or we are in
|
||||
// the post-login window where the server has not attached us to a
|
||||
// search parent yet. One quiet retry of the primary rung covers the
|
||||
// latter (and clears its silent mark on success).
|
||||
if (ladder.length) {
|
||||
const q = ladder[0];
|
||||
const responses = await collectSources(q, 7000, goodCount);
|
||||
if (responses.length) {
|
||||
silentQueries.delete(q);
|
||||
return {
|
||||
responses,
|
||||
artistIncluded: !artistKey || normText(q).includes(artistKey),
|
||||
};
|
||||
}
|
||||
}
|
||||
return { responses: [], artistIncluded: true };
|
||||
} finally {
|
||||
resolver.active = false;
|
||||
resolver.label = "";
|
||||
}
|
||||
return resolveSources(label, queries, goodCount, artistTerm, resolver);
|
||||
}
|
||||
|
||||
// ---- library-first playback ----
|
||||
@@ -4877,40 +4269,7 @@ async function resolve(label, queries, goodCount, artistTerm = null) {
|
||||
* artist page's ✓ badges, plus the simplified-title variant so verbose
|
||||
* catalog editions still match). */
|
||||
function libraryAlbumFor(artist, title, releaseDate = null) {
|
||||
const a = normText(artist);
|
||||
const t1 = normText(title);
|
||||
if (!a || !t1) return null;
|
||||
// normText strips ALL bracket content, so base-title comparison alone
|
||||
// cannot tell self-titled variants apart: "Weezer (Blue Album)" and
|
||||
// "Weezer (Green Album)" both normalize to "weezer" and would exact-match
|
||||
// a library copy saved under the bare provider title "Weezer". After the
|
||||
// base titles agree, identity therefore needs the variant name
|
||||
// (albumDisambiguator) to agree — or, when either side lacks one, the
|
||||
// release YEAR to corroborate. A self-titled base carries no identity at
|
||||
// all (the title is just the artist), so even a bare↔bare title match
|
||||
// requires the year there. No usable evidence → no local match; streaming
|
||||
// the right album beats instantly playing the wrong one.
|
||||
const wantD = normText(albumDisambiguator(title));
|
||||
// The simplified-title fallback lets a library copy saved under a terse
|
||||
// name match a verbose catalog EDITION (library "Album" ↔ catalog "Album
|
||||
// (Deluxe Edition)"); disambiguated variants never take it.
|
||||
const t2 = wantD ? null : normText(simplifyTitle(title));
|
||||
const wantYear = releaseDate ? Number(String(releaseDate).slice(0, 4)) || null : null;
|
||||
const selfTitled = t1 === a;
|
||||
return (
|
||||
libraryStore.albums.find((al) => {
|
||||
if (normText(al.artist) !== a) return false;
|
||||
const lt = normText(al.title);
|
||||
if (lt !== t1 && !(t2 && lt === t2)) return false;
|
||||
const libD = normText(albumDisambiguator(al.title));
|
||||
if (wantD && wantD === libD) return true;
|
||||
if (wantD !== libD || selfTitled) {
|
||||
const libYear = Number(al.year) || null;
|
||||
return wantYear != null && libYear != null && libYear === wantYear;
|
||||
}
|
||||
return true;
|
||||
}) ?? null
|
||||
);
|
||||
return libraryAlbumIn(libraryStore.albums, artist, title, releaseDate);
|
||||
}
|
||||
|
||||
/** The library album matching a catalog release, or null — the same evidence
|
||||
@@ -4930,37 +4289,9 @@ export function libraryHasAlbum(artist, album) {
|
||||
/** Locate a track in the library by artist + title, searching only that
|
||||
* artist's albums. Returns { album, tracks, index } or null. */
|
||||
async function libraryTrackFor(artist, title) {
|
||||
const a = normText(artist);
|
||||
const t = normText(title);
|
||||
if (!a || !t) return null;
|
||||
for (const al of libraryStore.albums) {
|
||||
if (normText(al.artist) !== a) continue;
|
||||
try {
|
||||
const tracks = await api.albumTracks(al.id);
|
||||
const index = tracks.findIndex((tr) => normText(tr.title) === t);
|
||||
if (index >= 0) return { album: al, tracks, index };
|
||||
} catch {
|
||||
// library unavailable — fall through to streaming
|
||||
}
|
||||
}
|
||||
return null;
|
||||
return libraryTrackIn(libraryStore.albums, (id) => api.albumTracks(id), artist, title);
|
||||
}
|
||||
|
||||
/** Distill a peer file's tags into the quality info the Now Playing panel
|
||||
* shows (format/bitrate/sample-rate/bit-depth). All fields are optional —
|
||||
* many clients only report some, so the panel hides what's missing. */
|
||||
function qualityOf(f) {
|
||||
if (!f) return null;
|
||||
const ext = f.extension || (f.name || f.path || "").split(".").pop() || "";
|
||||
return {
|
||||
format: ext.toUpperCase(),
|
||||
bitrate: f.bitrate ?? null,
|
||||
sampleRate: f.sample_rate ?? null,
|
||||
bitDepth: f.bit_depth ?? null,
|
||||
vbr: !!f.vbr,
|
||||
size: f.size ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Quality info for a local library track: format from the path, the rest
|
||||
* from the DB (sample-rate/bit-depth/size backfilled for older files at
|
||||
@@ -4980,23 +4311,6 @@ function localQuality(t) {
|
||||
};
|
||||
}
|
||||
|
||||
/** Deezer's CDN renders the same cover at whatever size the URL's size
|
||||
* segment asks for. Track objects carry the 250px list thumb
|
||||
* (`cover_medium`), which looks terrible blown up in the player — rewrite it
|
||||
* to the 1000px xl the album pages use. Non-Deezer URLs pass through
|
||||
* untouched. */
|
||||
function hqCover(url) {
|
||||
if (!url) return url;
|
||||
try {
|
||||
if (!new URL(url).hostname.endsWith("dzcdn.net")) return url;
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
return url.replace(
|
||||
/\/\d{2,4}x\d{2,4}((?:-[0-9a-z]+)*)\.(jpg|png|webp)$/i,
|
||||
"/1000x1000$1.$2",
|
||||
);
|
||||
}
|
||||
|
||||
/** The tier gridCover asks for, chosen by device pixel ratio.
|
||||
*
|
||||
@@ -5236,72 +4550,6 @@ export async function saveSong(artist, title) {
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Match a peer file to a catalog track, so queue items carry the tagged
|
||||
* title instead of the filename.
|
||||
*
|
||||
* A track title contained in the filename normally identifies the track — but
|
||||
* only if it's *specific* to this file. When peers embed the album name in
|
||||
* every filename ("… - Eggbeater Jesus - 01 Happy Hour.flac"), and the album
|
||||
* name is also a track title, that album name is a substring of every filename
|
||||
* and — being long — used to out-rank each track's real, shorter title, so the
|
||||
* whole album collapsed to one repeated title. So: ignore any title that
|
||||
* appears in (roughly) every filename, take the longest *specific* match, and
|
||||
* otherwise fall back to album order when the folder is a complete album. */
|
||||
function catalogTrackFor(file, i, files, tracks) {
|
||||
if (!tracks?.length) return null;
|
||||
const fname = normText(file.name);
|
||||
const base = (t) => normText(t.title);
|
||||
const named = (t) => base(t).length >= 3 && fname.includes(base(t));
|
||||
|
||||
// PRIMARY: the catalog (Deezer) tracklist, in order. When the folder holds
|
||||
// exactly the album's track count it's almost always the whole album in
|
||||
// order, so file `i` is track `i`. That's the only reliable way to tell
|
||||
// same-named variants apart — "My Name Is Jonas (2024 Remaster)",
|
||||
// "(Live On BBC…)" and "(Kitchen Tape Demo)" all normalize to one base
|
||||
// title, so filename matching alone can't say which belongs here (it used
|
||||
// to pick the longest title, i.e. the verbose live/demo one). Only distrust
|
||||
// the position if the filename clearly names a DIFFERENT song than the one
|
||||
// sitting there (a reordered/mismatched folder — handled by the backup).
|
||||
if (files.length === tracks.length) {
|
||||
const at = tracks[i];
|
||||
if (!fname || named(at) || !tracks.some(named)) return at;
|
||||
}
|
||||
|
||||
// BACKUP: fuzzy filename→title matching for a partial/reordered folder.
|
||||
// Drop any title that appears in ~every filename (that's the album name),
|
||||
// then among the rest prefer the closest duration (disambiguates same-named
|
||||
// variants), then the longest title.
|
||||
const contained = tracks.filter(named);
|
||||
const ubiquity = (t) => files.reduce((n, f) => n + (normText(f.name).includes(base(t)) ? 1 : 0), 0);
|
||||
const ubiquitous = Math.max(2, Math.ceil(files.length * 0.5));
|
||||
let pool = contained.filter((t) => ubiquity(t) < ubiquitous);
|
||||
if (!pool.length) pool = contained;
|
||||
if (pool.length) {
|
||||
const dur = file.duration;
|
||||
if (dur) {
|
||||
const off = (t) => Math.abs((t.duration_secs ?? 1e9) - dur);
|
||||
// Closest duration wins; on a tie prefer the shorter title, i.e. the
|
||||
// plain studio version over a longer "(Live…)" / "(Demo…)" variant.
|
||||
return pool.reduce((a, b) =>
|
||||
off(b) < off(a) || (off(b) === off(a) && b.title.length < a.title.length) ? b : a,
|
||||
);
|
||||
}
|
||||
return pool.reduce((a, b) => (b.title.length > a.title.length ? b : a));
|
||||
}
|
||||
return files.length === tracks.length ? tracks[i] : null;
|
||||
}
|
||||
|
||||
/** Turn a peer filename into a readable title when no catalog track matches
|
||||
* it (a genuine bonus/extra file on an expanded edition): drop the extension
|
||||
* and a leading track-number prefix ("04. ", "1 - ", "07 ") so it reads as a
|
||||
* title rather than "04. Dragon Ball Z…". Conservative — only strips a number
|
||||
* that's clearly a track index (has a separator, or a leading zero), so a title
|
||||
* like "99 Luftballons" is left intact. */
|
||||
function cleanTrackName(name) {
|
||||
const stem = (name ?? "").replace(/\.[^.]+$/, "");
|
||||
const stripped = stem.replace(/^\s*(?:\d{1,2}\s*[.\-)]|0\d)\s+/, "").trim();
|
||||
return stripped || stem;
|
||||
}
|
||||
|
||||
/** Find the best folder for an album; stream it as a queue, failing over to
|
||||
* the next-best folder when a peer never starts sending. Albums already in
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
// The phone's document. Same modules as the desktop shell and the mini
|
||||
// player, mounted through its own entry so the layout is the phone's own
|
||||
// (research/mobile-mockup is the design it implements).
|
||||
import "./mobile/mobile.css";
|
||||
import "./mobile/shell.css";
|
||||
import { mount } from "svelte";
|
||||
import Mobile from "./mobile/Mobile.svelte";
|
||||
|
||||
mount(Mobile, { target: document.getElementById("mobile") });
|
||||
@@ -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>
|
||||
@@ -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 & 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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",
|
||||
};
|
||||
@@ -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)));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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; }
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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 : "");
|
||||
}
|
||||
@@ -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 + " | ||||