Files
SoulStream/handoff/rounds/ins-r29-xfernote.txt
T
KeelrLeek 9f0601027c android: the handoff — the pending rounds, the pipeline, and what is unverified
Everything written this session that has not been through the device: rounds 43
to 48 (each patch adversarially verified, 43's transient-notice expectation
fixed), a partial round 49, the pipeline scripts, the Android Auto design and
the icon comparison mock. Also the two harness changes that were never A/B'd —
adaptive waits, which may only shorten a declared wait and never lengthen it,
and a ship that runs the mock walk and the deploy at the same time. The A/B and
its 255-step baseline are in handoff/tools; main stays at what the phone has
actually walked.
2026-09-14 07:57:41 -05:00

116 lines
4.4 KiB
Plaintext

/* 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 */
}
}