android: the room can measure the song, and a cold artist page stops waiting on one call

The tap-to-sync room draws the recording's own vocal band
(lyrics_vocal_activity, the desktop's coarse envelope) as a strip under the
head, and one button lays every line across the stretches it says are sung —
the desktop's own generator, shared and unit-tested, which refuses rather
than guesses when there is too little voice to read. The result is labelled
an estimate everywhere it shows, the cursor goes back to the top so the taps
correct it, and one more tap drops the whole thing back to an empty set,
because a rough fill stamps every line at once and leaves Undo nothing to
take back. The artist page now fires the hero seed and the overview beside
the full page, the way the desktop stages them: a hero Kyle picked on this
phone comes back off the file with no network at all, the discography paints
from the overview's first page until the exhaustive pass lands with the
classified one, and both lanes are dropped with the page when a hero
changes. ANDROID.md's "Not done yet" is rewritten as the true map of the
port — what is absent by design and why, what is waiting on Rust rather than
on a file picker, and one bullet about the artwork viewer that had been done
for rounds.
This commit is contained in:
KeelrLeek committed 2026-09-14 00:58:14 -05:00
1 parent 5738c2d7bf
commit 44eda8dff5
9 files changed
+374 -39

No files matched your search

+46 -4
View File
@@ -5,11 +5,17 @@
large, the line behind and the line ahead faint around it, and the
whole middle of the screen is the tap. Saving writes the track's own
document; lines never tapped are left out of it, and the button says
how many are kept. */
how many are kept.
The strip under the head is the recording's own measured vocal band,
and the button under it lays every line across the stretches it says
are sung — the desktop's generator (src/lib/lyric-sync.js), never an
even spread, and never presented as finished timing: the estimate is
labelled one wherever it shows, and one more tap drops it. */
import Icon from "../components/Icon.svelte";
import { nav } from "./nav.svelte.js";
import { player, toggle, seekSecs } from "./player.svelte.js";
import { tapSync, saveTapSync, closeTapSync } from "./lyrics.svelte.js";
import { tapSync, saveTapSync, closeTapSync, estimateTimings, clearEstimate } from "./lyrics.svelte.js";
import { mmss } from "./util.js";
/* the ✕ asks twice once there is work to lose; the back gesture keeps
@@ -44,10 +50,36 @@
/* playback moved on: a stamp would land on another song's clock */
const wrong = $derived(!!tapSync.identity && !!player.track && player.track.title !== tapSync.identity.title);
/* the envelope at the width the strip draws: 64 columns, each the
loudest frame in its slice — the shape of the singing, not the wave */
const cols = $derived.by(() => {
const f = tapSync.activity?.frames;
if (!f?.length) return [];
const per = f.length / 64;
return Array.from({ length: 64 }, (_, i) => {
let m = 0;
for (let j = Math.floor(i * per); j < Math.floor((i + 1) * per) && j < f.length; j++) m = Math.max(m, f[j]);
return m;
});
});
const spanMs = $derived((tapSync.activity?.frames?.length ?? 0) * (tapSync.activity?.frameMs ?? 100));
const pct = (ms) => (spanMs ? Math.max(0, Math.min(100, (ms / spanMs) * 100)) : 0);
/* one button, four states: reading the file, nothing worth reading, the
estimate, and the way back out of it */
const estLabel = $derived(
tapSync.reading === "ready"
? (tapSync.estimated ? "Clear the estimate" : "Estimate the timing")
: tapSync.reading === "none" ? "No estimate for this one" : "Reading the song…",
);
function stamp() {
if (wrong || tapSync.saving || at >= rows.length) return;
tapSync.rows[at].time_ms = nowMs();
tapSync.cursor = at + 1;
/* the cursor only ever walks forward from where the estimate put it,
so reaching the end means every line has been tapped over — there
is none of the estimate left, and nothing may still call it one */
if (tapSync.estimated && tapSync.cursor >= rows.length) tapSync.estimated = false;
confirmDiscard = false;
}
function undo() {
@@ -84,12 +116,22 @@
<div class="sy-warn">{tapSync.error}</div>
{/if}
{#if cols.length}
<div class="sy-strip">
{#each cols as v, i (i)}<i class="sy-col" style="height:{Math.max(3, v * 100).toFixed(0)}%"></i>{/each}
{#each rows as r, i (i)}
{#if r.time_ms != null}<span class="sy-tick" style="left:{pct(r.time_ms).toFixed(2)}%"></span>{/if}
{/each}
<span class="sy-play" style="left:{pct((player.position ?? 0) * 1000).toFixed(2)}%"></span>
</div>
{/if}
<button class="sy-est" onclick={() => { if (tapSync.estimated) clearEstimate(); else estimateTimings(); }} disabled={tapSync.reading !== "ready"}>{estLabel}</button>
<button class="sy-pad" onclick={stamp} disabled={wrong}>
<span class="sy-prev">{at > 0 ? rows[at - 1].text : ""}</span>
{#if at < rows.length}
<span class="sy-now">{rows[at].text}</span>
<span class="sy-next">{at + 1 < rows.length ? rows[at + 1].text : ""}</span>
<span class="sy-hint">Tap anywhere as the line begins</span>
<span class="sy-hint">{tapSync.estimated ? "Estimated — tap to correct each line" : "Tap anywhere as the line begins"}</span>
{:else}
<span class="sy-now">That’s every line — save the timing.</span>
<span class="sy-next"></span>
@@ -103,7 +145,7 @@
<button class="iconbtn sy-back5" onclick={back5} aria-label="Back five seconds">−5 s</button>
<button class="disc" onclick={toggle} aria-label={playing ? "Pause" : "Play"}><Icon name={playing ? "pause" : "play"} size={22} /></button>
<button class="btn btn-fill sy-save" onclick={() => { void saveTapSync(); }} disabled={!done || tapSync.saving}>
{tapSync.saving ? "Saving…" : (done && done < rows.length ? `Save ${done} line${done === 1 ? "" : "s"}` : "Save timing")}
{tapSync.saving ? "Saving…" : (done && done < rows.length ? `Save ${done} line${done === 1 ? "" : "s"}` : (tapSync.estimated && done === rows.length ? "Save the estimate" : "Save timing"))}
</button>
</div>
</div>