"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.
134 lines
5.3 KiB
JavaScript
134 lines
5.3 KiB
JavaScript
/* The downloads in the phone's shade.
|
|
|
|
The Transfers screen is inside the app: the desktop says what a Save is
|
|
doing with its title-bar ring, the phone with a badge on the nav, and
|
|
neither is anywhere to be seen once the app is in the background — which
|
|
is exactly when a Save is left to run on its own. So a running Save says
|
|
so where the phone says things: one notice with how many files are left,
|
|
the one it is on, a bar, Stop all and Open.
|
|
|
|
Its own channel, so it can be silenced without silencing the player's
|
|
card, and its own foreground service, so the fetch outlives the playback
|
|
that happened to be keeping the process alive. Everything real is in
|
|
crates/tauri-plugin-transfer-notice; this is the wiring — the mirror of
|
|
transfers.svelte.js, exactly as media.svelte.js mirrors the player. */
|
|
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";
|
|
|
|
/* What the phone was last told, and how often. A PLAIN object, not $state:
|
|
nothing renders it, and the effect that drives the post must never read
|
|
what it writes — a self-read in an $effect kills the subtree. The walk
|
|
reads it through __ss.xfn, since a notification is not a thing a headless
|
|
page can be asked about. */
|
|
export const xfernote = {
|
|
posted: false,
|
|
updates: 0,
|
|
clears: 0,
|
|
max: 0,
|
|
title: "",
|
|
line: "",
|
|
percent: -1,
|
|
failed: 0,
|
|
};
|
|
|
|
/* a re-post is not free, and progress ticks arrive far faster than anyone
|
|
reads them: the lever is the rate */
|
|
const MIN_GAP = 700;
|
|
let timer = null;
|
|
let lastAt = 0;
|
|
let lastKey = "";
|
|
|
|
/** What is in flight, as the notice would say it — or null when nothing is. */
|
|
function shape() {
|
|
const live = active();
|
|
/* 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: 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,
|
|
};
|
|
}
|
|
const keyOf = (s) => (s ? `${s.count}|${s.title}|${s.line}|${s.percent}` : "");
|
|
|
|
/** Called from Mobile.svelte's effect: reads the store — so the effect
|
|
* tracks it — and schedules at most one post per MIN_GAP. */
|
|
export function syncShade() {
|
|
const key = keyOf(shape());
|
|
if (key === lastKey || timer) return;
|
|
timer = setTimeout(post, Math.max(0, MIN_GAP - (Date.now() - lastAt)));
|
|
}
|
|
|
|
/** The one place that talks to the plugin. Reads the store again on the way
|
|
* out, so what lands in the shade is the latest, not the change that
|
|
* scheduled it. */
|
|
async function post() {
|
|
timer = null;
|
|
lastAt = Date.now();
|
|
const s = shape();
|
|
lastKey = keyOf(s);
|
|
if (!s) {
|
|
if (!xfernote.posted) return;
|
|
xfernote.posted = false;
|
|
xfernote.clears++;
|
|
try { await api.clearTransferNotice(); } catch { xfernote.failed++; }
|
|
return;
|
|
}
|
|
xfernote.posted = true;
|
|
xfernote.updates++;
|
|
if (s.count > xfernote.max) xfernote.max = s.count;
|
|
xfernote.title = s.title;
|
|
xfernote.line = s.line;
|
|
xfernote.percent = s.percent;
|
|
try { await api.transferNotice(s); } catch { xfernote.failed++; }
|
|
}
|
|
|
|
let bound = false;
|
|
/** The notice's own two buttons. Harmless where there is no plugin. */
|
|
export async function bindXferNote() {
|
|
if (bound) return;
|
|
bound = true;
|
|
try {
|
|
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; and the offline
|
|
pass stops after the row it is on */
|
|
void cancelAll();
|
|
cancelOffline();
|
|
note("Downloads stopped.");
|
|
} else if (p?.action === "open") {
|
|
closeSheet();
|
|
go("you");
|
|
if (top().n !== "transfers") push("transfers", {});
|
|
}
|
|
});
|
|
} catch {
|
|
/* not a phone */
|
|
}
|
|
}
|