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
@@ -34,6 +34,7 @@
|
||||
import Transfers from "./screens/Transfers.svelte";
|
||||
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 { bindHome } from "./home.svelte.js";
|
||||
import Stub from "./screens/Stub.svelte";
|
||||
import { lib, loadLibrary, refreshRecents, albumById, albumsBy, watchLibrary } from "./lib.svelte.js";
|
||||
@@ -148,6 +149,14 @@
|
||||
});
|
||||
});
|
||||
|
||||
/* ── the shade's download notice follows the transfer store ───────
|
||||
An effect, not a hook off the download event: the effect tracks the
|
||||
store itself, progress ticks and all, so a row written straight into
|
||||
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(); });
|
||||
|
||||
/* ── the card gets the cover once the thumbnail has landed ───────── */
|
||||
$effect(() => {
|
||||
const k = player.album?.art_path;
|
||||
@@ -213,6 +222,7 @@
|
||||
bindNet();
|
||||
watchLibrary();
|
||||
bindTransfers();
|
||||
bindXferNote();
|
||||
bindHome();
|
||||
bindImporter();
|
||||
bindShare();
|
||||
@@ -223,7 +233,7 @@
|
||||
setTimeout(() => { api.libraryArtPrewarm([360]).catch(() => {}); }, 8000);
|
||||
/* 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, slsk, net, resolver, streamSong, streamCatalogAlbum, sources, api, transfers, pls, notice, importer, imp: { startFolder }, wifi: { downloadsAllowed, cellular }, next, prev, toggle, seekSecs, playQueueAt, patch, contacts, loadContacts };
|
||||
window.__ss = { nav, top, go, push, back, openSheet, closeSheet, openHalf, closeHalf, player, lib, art, slsk, net, resolver, streamSong, streamCatalogAlbum, sources, api, transfers, xfn: xfernote, pls, notice, importer, imp: { startFolder }, wifi: { downloadsAllowed, cellular }, next, prev, toggle, seekSecs, playQueueAt, patch, contacts, loadContacts };
|
||||
return () => { if (unlisten) unlisten(); };
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
cancels it, finished rows clear together. The desktop's hub list. */
|
||||
import Icon from "../../components/Icon.svelte";
|
||||
import Identicon from "../../components/Identicon.svelte";
|
||||
import { transfers, DONE, list, active, clearFinished, cancel, stateLine } from "../transfers.svelte.js";
|
||||
import { transfers, DONE, list, active, clearFinished, cancel, cancelAll, stateLine } from "../transfers.svelte.js";
|
||||
import { fmtBytes } from "../util.js";
|
||||
|
||||
const rows = $derived(list());
|
||||
@@ -19,7 +19,10 @@
|
||||
<div class="phead">
|
||||
<div class="row">
|
||||
<h1>Transfers</h1>
|
||||
{#if rows.some((d) => DONE.has(d.state))}<button class="all" onclick={clearFinished}>Clear finished</button>{/if}
|
||||
<div class="acts">
|
||||
{#if live}<button class="all" onclick={cancelAll}>Cancel all</button>{/if}
|
||||
{#if rows.some((d) => DONE.has(d.state))}<button class="all" onclick={clearFinished}>Clear finished</button>{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -27,6 +30,7 @@
|
||||
<div class="stub">
|
||||
<b>Nothing in flight.</b>
|
||||
Save a record or a song from the catalog and it arrives here — the best copy by the download rules, filed into your library when it lands.
|
||||
A Save keeps going with the app in the background, and says so in the shade — one notice with what is left, and Stop all on it.
|
||||
</div>
|
||||
{:else}
|
||||
<div class="sec"><h2>{live ? `${live} in flight` : "Done"} <em>· {rows.length} total</em></h2></div>
|
||||
|
||||
@@ -1513,3 +1513,15 @@ html[data-motion="off"] *, html[data-motion="off"] *::before, html[data-motion="
|
||||
with its words already in it (PreviewOverlay.svelte's .sr-announce, at
|
||||
phone scale) */
|
||||
.sr-live { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
|
||||
|
||||
/* The Transfers head's two words: Cancel all while anything is in flight,
|
||||
Clear finished after. The .sec's own small caps, which this head never
|
||||
had — "Clear finished" has been sitting there at body size.
|
||||
|
||||
The space between them goes on the words, NOT on .phead .acts: Library
|
||||
and Community hang their header icons in that same wrapper and they sit
|
||||
flush, so a gap there would push three round buttons apart on two
|
||||
screens this round never looked at. */
|
||||
.phead .all { font-size: 12.5px; font-weight: 750; color: var(--text-secondary); white-space: nowrap; }
|
||||
.phead .all:active { color: #fff; }
|
||||
.phead .all + .all { margin-left: 15px; }
|
||||
@@ -26,6 +26,12 @@ export async function cancel(id) {
|
||||
try { await api.cancelDownload(id); } catch { /* already gone */ }
|
||||
}
|
||||
|
||||
/** Stop everything still in flight — what the shade's Stop all and the
|
||||
* screen's own button both call, through the one path a row's tap takes. */
|
||||
export async function cancelAll() {
|
||||
for (const d of active()) await cancel(d.id);
|
||||
}
|
||||
|
||||
/** "queued · 3rd in line", "transferring · 42%", "saved", "failed · reason" */
|
||||
export function stateLine(d) {
|
||||
switch (d.state) {
|
||||
|
||||
@@ -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