From 6f789687e4c32320956532ceb480300416f3aafd Mon Sep 17 00:00:00 2001 From: KeelrLeek <156356974+KeelrLeek@users.noreply.github.com> Date: Fri, 18 Sep 2026 12:35:55 -0500 Subject: [PATCH] android: keep a playlist offline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Keep offline" on a playlist's More sheet: every streamed row fetched into the stream cache in the background and pinned there, so the whole list plays with the app shut — in a car, in a pocket — the way a downloaded playlist does in any other player. Not the library: "Download all tracks" files rows as albums, which is Save; this keeps the playlist a playlist of streamed rows that are simply already here. One pass at a time, one row at a time: the remembered peer file first, else the resolver's ladder over the network, then the same preload_stream the player uses for the next track, polled until whole, then pinned in the stream-source mirror — and the cache pruner leaves a pinned file alone, or the retention window would quietly undo the switch. The row being played right now is skipped (a second transfer of a file a peer is already streaming to us deadlocks both). The fetch rides the downloads notice in the shade, which is also what keeps the process alive once the app leaves the screen; Android 12+ lets that service start only from the foreground, so a pass that begins on screen keeps going in a pocket. The list lives in config.json (`offline_playlists`); the header says "Kept offline · N of M here" once a pass has looked. Verified on the phone: the shade read "Keeping 29 tracks offline", eleven rows were done in the first forty seconds, and the car's playlist row went from "8 of 40 here" to "10 of 40 here" two minutes later. --- src/mobile/HalfSheet.svelte | 9 + src/mobile/Mobile.svelte | 16 +- src/mobile/offline.svelte.js | 259 +++++++++++++++++++++++++++++ src/mobile/screens/Playlist.svelte | 13 ++ src/mobile/xfernote.svelte.js | 26 ++- 5 files changed, 317 insertions(+), 6 deletions(-) create mode 100644 src/mobile/offline.svelte.js diff --git a/src/mobile/HalfSheet.svelte b/src/mobile/HalfSheet.svelte index a5f686e..ca024be 100644 --- a/src/mobile/HalfSheet.svelte +++ b/src/mobile/HalfSheet.svelte @@ -14,6 +14,7 @@ import { streamSong, saveSong, streamCatalogAlbum, saveCatalogAlbum } from "./resolve.svelte.js"; import { catalog, yearOf, artistKey, picture, heroFromPhoto, heroClear } from "./catalog.svelte.js"; import { pls, bulk, bulkable, runBulk, coverFor, queueable, entryOf, playFrom, shufflePlay, togglePin, renamePlaylist, describePlaylist, duplicatePlaylist, deletePlaylist, removeItems, addEntries, createPlaylist, moveItem, coverFromPhoto, coverClear, EXPORTS, exportPlaylist } from "./playlists.svelte.js"; + import { isOffline, setOffline } from "./offline.svelte.js"; import { DOORS, start as startImport, pickImportFolder, startFolder, pickImportFile, startFile } from "./importer.svelte.js"; import { slsk, refreshRooms, disconnect, openThread, addBuddy, removeBuddy, leaveRoom } from "./slsk.svelte.js"; import { lib, albumById, tracksOf, loadLibrary, refreshPlaylists } from "./lib.svelte.js"; @@ -482,6 +483,14 @@ {#if kind === "playlist-more" && unres.length} {@render row("x", "Remove unresolved entries", unres.length + " that never matched a track", act(() => removeItems(p, rows, unres).catch(oops)))} {/if} + {#if kind === "playlist-more" && bulkable(rows)} +
+ {#if isOffline(p.id)} + {@render row("check", "Kept offline", "every streamed track fetched and kept on this phone — tap to stop keeping it", act(() => { void setOffline(p, false); }))} + {:else} + {@render row("download", "Keep offline", "every streamed track fetched in the background and kept, so it plays in the car with the app shut", act(() => { void setOffline(p, true); }))} + {/if} + {/if} {#if kind === "playlist-more" && bulkable(rows) && !bulk.active} {@render row("library", "Add to library", "files every already-streamed track — nothing is downloaded", act(() => { void runBulk(p, rows, "cached"); }))} diff --git a/src/mobile/Mobile.svelte b/src/mobile/Mobile.svelte index a214f0d..33458ea 100644 --- a/src/mobile/Mobile.svelte +++ b/src/mobile/Mobile.svelte @@ -37,6 +37,8 @@ import Diagnostics from "./screens/Diagnostics.svelte"; import { transfers, bindTransfers, active as activeTransfers } from "./transfers.svelte.js"; import { xfernote, syncShade, bindXferNote } from "./xfernote.svelte.js"; + import { offline, bindOffline, setOffline, isOffline, cancelOffline } from "./offline.svelte.js"; + import { syncStreamSourcesToBackend } from "../lib/sources.js"; import { home, bindHome, loadHome } from "./home.svelte.js"; import { lyr, loadLyrics } from "./lyrics.svelte.js"; import Stub from "./screens/Stub.svelte"; @@ -176,7 +178,12 @@ it — the walk, with nothing fetched — drives the notice exactly as a real one does. It reads the store and never the notice's own record, which is why that record is a plain object. */ - $effect(() => { syncShade(); }); + $effect(() => { + /* the offline worker rides the same notice: read what it shows so a + change of row or of progress re-posts the shade */ + void offline.active; void offline.done; void offline.title; void offline.percent; + syncShade(); + }); /* ── the first run stamps when it goes away, whatever closed it ──── The foot's button calls finishSetup itself; the scrim, the back @@ -300,6 +307,11 @@ watchLibrary(); bindTransfers(); bindXferNote(); + /* the playlists kept offline, and the stream-source mirror the cold + media service reads: memories made before the mirror existed reach + it once, here, so cache files a car could play are named */ + void bindOffline(); + setTimeout(() => { try { syncStreamSourcesToBackend(); } catch { /* fine */ } }, 3000); bindHome(); bindImporter(); bindShare(); @@ -323,7 +335,7 @@ requestAnimationFrame(() => requestAnimationFrame(() => { clearTimeout(sealTimer); sealBoot(); })); /* the probe handle — the desktop has __soulstream for the same reason: tests/mobile/smoke.mjs drives the real app through it */ - window.__ss = { nav, top, go, push, back, openSheet, closeSheet, openHalf, closeHalf, player, lib, art, catalog, home, loadHome, lyr, loadLyrics, slsk, boot, net, resolver, streamSong, streamCatalogAlbum, sources, api, transfers, xfn: xfernote, card: mediacard, pls, notice, importer, artTally, imp: { startFolder, startFile }, setup, openSetup, setupState, wifi: { downloadsAllowed, cellular }, next, prev, toggle, seekSecs, playQueueAt, patch, contacts, loadContacts, say, refreshRooms }; + window.__ss = { nav, top, go, push, back, openSheet, closeSheet, openHalf, closeHalf, player, lib, art, catalog, home, loadHome, lyr, loadLyrics, slsk, boot, net, resolver, streamSong, streamCatalogAlbum, sources, api, transfers, xfn: xfernote, card: mediacard, pls, notice, importer, artTally, imp: { startFolder, startFile }, setup, openSetup, setupState, wifi: { downloadsAllowed, cellular }, next, prev, toggle, seekSecs, playQueueAt, patch, contacts, loadContacts, say, refreshRooms, offline, offlineCtl: { setOffline, isOffline, cancelOffline } }; return () => { if (unlisten) unlisten(); }; }); diff --git a/src/mobile/offline.svelte.js b/src/mobile/offline.svelte.js new file mode 100644 index 0000000..083b10a --- /dev/null +++ b/src/mobile/offline.svelte.js @@ -0,0 +1,259 @@ +/* Keep a playlist offline: every streamed row fetched into the stream cache + in the background and pinned there, so the whole list plays with the app + shut — in a car, in a pocket — the way a downloaded playlist does in any + other player. + + WHAT IT IS NOT. Not the library: "Download all tracks" (playlists.svelte.js + runBulk) files every row as a real album in the library, which is Save. + This is the OTHER promise: the playlist stays a playlist of streamed rows, + each one simply already here. The bytes land where a stream's bytes always + land (the stream cache, by the same peer file), the row's memory of which + peer file that is goes to the Rust side too (stream_sources.rs), and the + pruner leaves a pinned file alone however old it gets. + + HOW IT RUNS. One pass at a time, one row at a time, in the background of + the page: the remembered peer file first (no search), else the resolver's + ladder over the network — the same source rules a play uses, so what is + kept is what would have played — then the cache fetch the player itself + uses for the next track (preload_stream), polled until the file is whole. + Never two fetches at once and never competing with a search the player is + running: the ladder's own pacing holds, and the row being PLAYED right now + is skipped (a second transfer of the file a peer is already streaming to + us deadlocks both, save_to_library learned that). + + WHEN. After boot once the library and the account have answered, again + whenever the account (re)connects, and whenever a playlist changes. Wi-Fi + only when the switch says so (network.svelte.js), like every download. + + IN THE SHADE. While it fetches, the downloads notice (xfernote.svelte.js) + carries it — which is also what keeps the process alive once the app is + in the background: without a foreground service Android freezes the + process a minute after the screen leaves it and every fetch stalls until + the app is opened again. Android 12+ only lets that service START from + the foreground, so a pass that begins on screen keeps going in a pocket; + one that begins in the background (a reconnect while the app is behind + another) runs only while the process is awake. */ +import * as api from "../lib/api.js"; +import { listen } from "../lib/events.js"; +import { lib } from "./lib.svelte.js"; +import { slsk, awaitOnline } from "./slsk.svelte.js"; +import { player } from "./player.svelte.js"; +import { + resolveSources, songCandidates, simplifyTitle, cachedStreamSourceFor, rememberStreamSource, + loadStreamSources, streamSourceKeys, normText, qualityOf, +} from "../lib/sources.js"; +import { downloadsAllowed } from "./network.svelte.js"; +import { note } from "./notice.svelte.js"; + +export const offline = $state({ + /** playlist ids kept offline — the config's list, read at boot */ + ids: [], + /** a pass is running */ + active: false, + /** the playlist and row it is on */ + playlistId: null, + title: "", + /** rows checked and rows to check, this pass */ + done: 0, + total: 0, + /** the in-flight fetch: 0–100, or -1 while nothing is in flight */ + percent: -1, + /** rows this pass could not source */ + missed: 0, + /** playlist id → { here, total }: what the header says once a pass has looked */ + ready: {}, +}); + +export const isOffline = (id) => offline.ids.some((x) => Number(x) === Number(id)); + +let cancel = false; +let timer = null; +/** the pass that is running, so a second ask waits for it rather than racing it */ +let running = null; + +export async function loadOffline() { + try { offline.ids = (await api.playlistOfflineList()) || []; } catch { /* a desktop, or the mock */ } +} + +/** Switch a playlist's keep on or off. On: a pass starts at once. Off: the + * rows' pins are lifted so the pruner may reclaim them in its own time; + * nothing is deleted here — a track kept for months does not vanish the + * second the switch flips. */ +export async function setOffline(p, keep) { + try { offline.ids = (await api.playlistOfflineSet(p.id, keep)) || []; } catch (e) { player.error = String(e); return; } + if (keep) { + note(`Keeping “${p.name}” offline`); + schedule(0); + } else { + note(`No longer keeping “${p.name}” offline`); + delete offline.ready[p.id]; + void unpin(p); + } +} + +export function cancelOffline() { cancel = true; } + +async function unpin(p) { + let items = []; + try { items = (await api.playlistItems(p.id)) || []; } catch { return; } + for (const r of items) { + if (r.kind !== "stream" || !r.artist || !r.title) continue; + api.streamSourceKeep(r.track_id ?? null, r.artist, r.title, false).catch(() => {}); + } +} + +/** A pass, soon. Coalesced: a burst of playlist changes is one pass. */ +export function schedule(ms = 1500) { + clearTimeout(timer); + timer = setTimeout(() => { void run(); }, ms); +} + +/** The remembered peer file for a row, whole or not — the fetch wants the + * identity even when the bytes are not all here yet. */ +function rememberedFor(tid, artist, title) { + const map = loadStreamSources(); + for (const key of streamSourceKeys(tid, artist, title)) { + const src = map.get(key); + if (src?.username && src.path && src.size > 0) return src; + } + return null; +} + +function playingNow(username, path) { + const t = player.track; + return !!t?.stream && t.stream.username === username && t.path === path + && (player.status === "playing" || player.status === "buffering"); +} + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +/** Wait for a fetch to be whole, reporting its progress; false on a stall + * (no byte for a while) or a cancel. */ +async function awaitWhole(src) { + const startedAt = Date.now(); + let lastLen = -1; + let lastGrowAt = Date.now(); + for (;;) { + if (cancel) return false; + let len = null; + try { len = await api.streamCacheLen(src.username, src.path); } catch { len = null; } + if (len != null && len >= src.size) return true; + if (len != null && len !== lastLen) { lastLen = len; lastGrowAt = Date.now(); } + offline.percent = src.size ? Math.min(99, Math.round(((len || 0) / src.size) * 100)) : -1; + /* a peer that has queued us can take minutes to start; a peer that + started and stopped is not coming back — two different patiences */ + const quiet = Date.now() - lastGrowAt; + if ((lastLen > 0 && quiet > 90_000) || (lastLen <= 0 && quiet > 8 * 60_000) || Date.now() - startedAt > 30 * 60_000) return false; + await sleep(3000); + } +} + +async function run() { + if (running) return running; + running = (async () => { + cancel = false; + if (!offline.ids.length) return; + if (!downloadsAllowed()) return; + offline.active = true; + offline.done = 0; + offline.total = 0; + offline.missed = 0; + offline.percent = -1; + try { + /* every stream row of every kept playlist, once each */ + const work = []; + const seen = new Set(); + for (const id of offline.ids) { + const p = lib.playlists.find((x) => Number(x.id) === Number(id)); + let items = []; + try { items = (await api.playlistItems(id)) || []; } catch { continue; } + let total = 0; + for (const r of items) { + if (r.kind !== "stream" || !r.artist || !r.title) continue; + total++; + const key = `${normText(r.artist)}|${normText(r.title)}`; + if (seen.has(key)) continue; + seen.add(key); + work.push({ playlistId: Number(id), name: p?.name || "", row: r }); + } + offline.ready[id] = { here: offline.ready[id]?.here ?? 0, total }; + } + offline.total = work.length; + const here = {}; + for (const w of work) { + if (cancel) break; + const { row } = w; + offline.playlistId = w.playlistId; + offline.title = row.title; + offline.percent = -1; + const tid = row.track_id != null && row.track_id !== "" ? Number(row.track_id) : null; + try { + /* already whole: pin it and move on */ + const whole = await cachedStreamSourceFor(tid, row.artist, row.title); + if (whole) { + api.streamSourceKeep(tid, row.artist, row.title, true).catch(() => {}); + here[w.playlistId] = (here[w.playlistId] || 0) + 1; + continue; + } + /* the remembered peer file, else a search of our own */ + let src = rememberedFor(tid, row.artist, row.title); + if (!src) { + if (slsk.connection !== "online" && !(await awaitOnline(slsk.connection === "connecting" ? 20_000 : 0))) { offline.missed++; continue; } + const { responses, artistIncluded } = await resolveSources( + `${row.artist} — ${row.title}`, + [`${row.artist} ${simplifyTitle(row.title)}`, simplifyTitle(row.title)], + (rs) => songCandidates(rs, row.title, true, row.album ?? null, row.duration_secs ?? null, row.artist).length, + row.artist, + null, + ); + const best = songCandidates(responses, row.title, true, row.album ?? null, row.duration_secs ?? null, row.artist, !artistIncluded)[0]; + if (!best) { offline.missed++; continue; } + src = { username: best.username, path: best.file.path, size: best.file.size, duration: best.file.duration ?? null, quality: qualityOf(best.file) }; + rememberStreamSource({ stream: { username: src.username, size: src.size }, path: src.path, title: row.title, artist: row.artist, trackId: tid, duration: src.duration, quality: src.quality }); + } + if (cancel) break; + if (playingNow(src.username, src.path)) { continue; } /* the player is fetching it */ + if (slsk.connection !== "online" && !(await awaitOnline(slsk.connection === "connecting" ? 20_000 : 0))) { offline.missed++; continue; } + try { await api.preloadStream(src.username, src.path, src.size); } catch { offline.missed++; continue; } + if (await awaitWhole(src)) { + api.streamSourceKeep(tid, row.artist, row.title, true).catch(() => {}); + here[w.playlistId] = (here[w.playlistId] || 0) + 1; + } else { + offline.missed++; + } + } catch { + offline.missed++; + } finally { + offline.done++; + offline.percent = -1; + } + await sleep(500); + } + for (const id of offline.ids) { + if (offline.ready[id]) offline.ready[id] = { ...offline.ready[id], here: here[id] || 0 }; + } + } finally { + offline.active = false; + offline.playlistId = null; + offline.title = ""; + offline.percent = -1; + } + })().finally(() => { running = null; }); + return running; +} + +let bound = false; +/** Wire the passes to what should start one. Harmless where nothing can. */ +export async function bindOffline() { + if (bound) return; + bound = true; + await loadOffline(); + if (offline.ids.length) schedule(12_000); + try { + await listen("soulseek:playlists-changed", () => { if (offline.ids.length) schedule(4000); }); + await listen("soulseek:state", (ev) => { if (ev.payload?.kind === "connected" && offline.ids.length) schedule(6000); }); + } catch { /* not a phone */ } + /* a long session: look again every half hour, for rows a peer had gone + offline for the last time */ + setInterval(() => { if (offline.ids.length && !offline.active) schedule(0); }, 30 * 60_000); +} diff --git a/src/mobile/screens/Playlist.svelte b/src/mobile/screens/Playlist.svelte index c3561c2..3b6cbde 100644 --- a/src/mobile/screens/Playlist.svelte +++ b/src/mobile/screens/Playlist.svelte @@ -14,6 +14,7 @@ import { player } from "../player.svelte.js"; import { openHalf } from "../nav.svelte.js"; import { pls, bulk, cancelBulk, dismissBulk, coverFor, isSetlist, queueable, unresolvedCount, missingCount, totalSecs, playFrom, shufflePlay, rowClick, moveItem } from "../playlists.svelte.js"; + import { offline, isOffline, cancelOffline } from "../offline.svelte.js"; import { say } from "../trouble.js"; import { mmss, longDur, stamp } from "../util.js"; import { reorder } from "../reorder.js"; @@ -126,6 +127,18 @@ + {#if isOffline(pl.id)} +