android: the downloads say so in the shade
A Save still running with the app in the background now posts a progress notice — how many files, the one it is on, a bar, Stop all and Open — and takes it down when the queue empties. It sits on its own low-importance channel so it can be silenced without silencing the player's card, and rides its own dataSync foreground service rather than the media one: a mediaPlayback service stops itself when the bar empties, a download is not playback, and nothing else was keeping the process alive for a Save with nothing playing — which is how the first real Save died, four tracks in. Stop all goes through the same cancel(id) a row's tap takes, and Transfers grew the matching button beside Clear finished. The walk pushes two rows straight into the transfer store and reads the real notification back out of `dumpsys notification`, without fetching anything.
This commit is contained in:
1 parent
4fc6e9c630
commit
5738c2d7bf
31 files changed
+1358
-6
No files matched your search
@@ -0,0 +1,115 @@
|
||||
/* 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 { 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();
|
||||
if (!live.length) return null;
|
||||
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++; }
|
||||
return {
|
||||
count: live.length,
|
||||
title: live.length === 1 ? "Saving 1 file" : `Saving ${live.length} 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 */
|
||||
void cancelAll();
|
||||
note("Downloads stopped.");
|
||||
} else if (p?.action === "open") {
|
||||
closeSheet();
|
||||
go("you");
|
||||
if (top().n !== "transfers") push("transfers", {});
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
/* not a phone */
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user