android: keep a playlist offline

"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.
This commit is contained in:
KeelrLeek committed 2026-09-18 12:35:55 -05:00
1 parent 17942fc04b
commit 6f789687e4
5 files changed
+317 -6

No files matched your search

+9
View File
@@ -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)}
<div class="sheet-hair"></div>
{#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}
<div class="sheet-hair"></div>
{@render row("library", "Add to library", "files every already-streamed track — nothing is downloaded", act(() => { void runBulk(p, rows, "cached"); }))}
+14 -2
View File
@@ -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(); };
});
</script>
+259
View File
@@ -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);
}
+13
View File
@@ -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 @@
<button class="disc lg" onclick={() => playFrom(pl, items)} disabled={!playable} aria-label="Play"><Icon name="play" size={26} /></button>
<button class="iconbtn muted" onclick={() => openHalf("playlist-more", { playlist: pl, items })} aria-label="More"><Icon name="more" size={22} /></button>
</div>
{#if isOffline(pl.id)}
<div class="bulkline">
{#if offline.active && offline.playlistId === pl.id}
<span class="ring"></span>
<span>Keeping offline — {offline.done}/{offline.total}{offline.title ? ` · ${offline.title}` : ""}{offline.percent >= 0 ? ` · ${offline.percent}%` : ""}</span>
<button class="bulkx" onclick={cancelOffline} aria-label="Stop"><Icon name="x" size={14} /></button>
{:else}
<Icon name="check" size={14} />
<span>Kept offline{offline.ready[pl.id] ? ` · ${offline.ready[pl.id].here} of ${offline.ready[pl.id].total} here` : ""}</span>
{/if}
</div>
{/if}
{#if bulk.playlistId === pl.id && (bulk.active || bulk.summary)}
<div class="bulkline">
{#if bulk.active}
+22 -4
View File
@@ -15,6 +15,7 @@
import * as api from "../lib/api.js";
import { addPluginListener } from "@tauri-apps/api/core";
import { active, cancelAll } from "./transfers.svelte.js";
import { offline, cancelOffline } from "./offline.svelte.js";
import { note } from "./notice.svelte.js";
import { go, push, top, closeSheet } from "./nav.svelte.js";
@@ -44,14 +45,29 @@ let lastKey = "";
/** What is in flight, as the notice would say it — or null when nothing is. */
function shape() {
const live = active();
if (!live.length) return null;
/* a playlist being kept offline rides the same notice — and needs the
same foreground service, or the fetches stall the moment the app
leaves the screen (offline.svelte.js). Saves come first when both
are running; the kept rows are counted in. */
const keep = offline.active ? Math.max(1, offline.total - offline.done) : 0;
if (!live.length && !keep) return null;
if (!live.length) {
return {
count: keep,
title: keep === 1 ? "Keeping 1 track offline" : `Keeping ${keep} tracks offline`,
line: offline.title || "finding a source",
percent: offline.percent >= 0 ? offline.percent : 0,
indeterminate: offline.percent < 0,
};
}
const newest = [...live].sort((a, b) => b.id - a.id); /* the screen's own order */
const cur = newest.find((d) => d.state === "transferring") || newest[0];
let received = 0, total = 0, known = 0;
for (const d of live) if (d.total) { received += d.received || 0; total += d.total; known++; }
const n = live.length + keep;
return {
count: live.length,
title: live.length === 1 ? "Saving 1 file" : `Saving ${live.length} files`,
count: n,
title: n === 1 ? "Saving 1 file" : `Saving ${n} files`,
line: cur.state === "transferring" ? (cur.name || cur.path || "") : "waiting in their queue",
percent: total ? Math.round((received / total) * 100) : 0,
indeterminate: !known,
@@ -100,8 +116,10 @@ export async function bindXferNote() {
await addPluginListener("transfer-notice", "action", (p) => {
if (p?.action === "cancel") {
/* the same cancel(id) a row's tap takes, once per live row —
never a second path to the same thing */
never a second path to the same thing; and the offline
pass stops after the row it is on */
void cancelAll();
cancelOffline();
note("Downloads stopped.");
} else if (p?.action === "open") {
closeSheet();