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:
KeelrLeek committed 2026-09-14 00:44:11 -05:00
1 parent 4fc6e9c630
commit 5738c2d7bf
31 files changed
+1358 -6

No files matched your search

+4
View File
@@ -506,6 +506,10 @@ export const requestAllFilesAccess = () => invoke("plugin:storage|request_all_fi
// The phone's share plugin (crates/tauri-plugin-share): what another app
// shared to SoulStream, { text } — read once.
export const pendingShare = () => invoke("plugin:share|pending_share");
// The phone's downloads notice (crates/tauri-plugin-transfer-notice): what
// Save is still fetching, mirrored into the shade; `clear` takes it down.
export const transferNotice = (state) => invoke("plugin:transfer-notice|update", { state });
export const clearTransferNotice = () => invoke("plugin:transfer-notice|clear");
// A photo from the phone (the storage plugin): { path } in the app's cache.
export const pickImage = () => invoke("plugin:storage|pick_image");
// Download a found album copy into (Cache)\_audition\<rel>\ for the version
+5
View File
@@ -635,6 +635,11 @@ function mockCatalog(cmd, args) {
if (cmd === "plugin:storage|all_files_access") return Promise.resolve({ granted: true });
if (cmd === "plugin:storage|request_all_files_access") return Promise.resolve();
if (cmd === "plugin:storage|pick_image") return Promise.resolve({ path: "C:\\mock\\photo.jpg" });
/* the downloads notice: the mock walk cannot see the shade, so the calls
are simply answered — what the walk reads is __ss.xfn, the app's own
record of having posted and cleared */
if (cmd === "plugin:transfer-notice|update") return Promise.resolve();
if (cmd === "plugin:transfer-notice|clear") return Promise.resolve();
if (cmd === "plugin:share|pending_share") {
/* the harness shares by setting window.__ssMock.share and waking the page */
const t = (typeof window !== "undefined" && window.__ssMock?.share) || null;
+11 -1
View File
@@ -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>
+6 -2
View File
@@ -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>
+12
View File
@@ -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; }
+6
View File
@@ -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) {
+115
View File
@@ -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 */
}
}