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

+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();