Files
SoulStream/src/mobile/SyncRoom.svelte
T
KeelrLeek 44eda8dff5 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.
2026-09-14 00:58:14 -05:00

153 lines
8.0 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script>
/* The tap-to-sync room — the desktop's correction editor
(LyricsSyncEditor.svelte) in the phone's idiom. The song plays and you
tap once as each line begins; the taps become the timing. One line
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.
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, estimateTimings, clearEstimate } from "./lyrics.svelte.js";
import { mmss } from "./util.js";
/* the ✕ asks twice once there is work to lose; the back gesture keeps
the taps instead (the lyrics sheet offers them back). The room is
mounted for the life of the shell, so the question is put down when
it closes — otherwise the next visit opens already asking. */
let confirmDiscard = $state(false);
$effect(() => { if (!nav.timing) confirmDiscard = false; });
/* position anchors land a couple of times a second, so a tap between
them reads the last anchor plus the wall clock — the stamp carries
the tap's own precision rather than the tick's. Plain lets, never
read back inside the effect that writes them. */
let lastPos = 0;
let lastAt = 0;
$effect(() => {
void player.status;
lastPos = player.position ?? 0;
lastAt = performance.now();
});
function nowMs() {
const base = player.status === "playing"
? lastPos + (performance.now() - lastAt) / 1000
: (player.position ?? 0);
return Math.max(0, Math.round(base * 1000));
}
const rows = $derived(tapSync.rows);
const done = $derived(rows.filter((r) => r.time_ms != null).length);
const at = $derived(Math.min(tapSync.cursor, rows.length));
const playing = $derived(player.status === "playing" || player.status === "buffering");
/* 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() {
const last = tapSync.cursor - 1;
if (last < 0) return;
tapSync.rows[last].time_ms = null;
tapSync.cursor = last;
}
function back5() { seekSecs(Math.max(0, (player.position ?? 0) - 5)); }
function close() {
if (done > 0 && !confirmDiscard) { confirmDiscard = true; return; }
closeTapSync();
}
/* a stamp reads to a tenth — the precision a thumb actually has */
function mark(ms) {
if (ms == null) return "";
return `${mmss(Math.floor(ms / 1000))}.${Math.floor((ms % 1000) / 100)}`;
}
</script>
{#if nav.timing}
<div class="syncroom" role="dialog" aria-modal="true" aria-label="Tap in time">
<div class="sy-head">
<button class="iconbtn sy-close" onclick={close} aria-label={confirmDiscard ? "Discard the timing" : "Close"}>
{#if confirmDiscard}<span class="sy-discard">Discard?</span>{:else}<Icon name="x" size={22} />{/if}
</button>
<span class="sy-who"><b>Tap in time</b><i>{tapSync.identity?.title ?? ""}</i></span>
<span class="sy-count">{done} / {rows.length}</span>
</div>
{#if wrong}
<div class="sy-warn">Another track is playing — a tap follows what plays. Play “{tapSync.identity?.title}” again to keep going.</div>
{:else if tapSync.error}
<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">{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>
<span class="sy-hint">Undo puts the last one back</span>
{/if}
<span class="sy-mark">{at > 0 ? mark(rows[at - 1].time_ms) : mmss(player.position)}</span>
</button>
<div class="sy-foot">
<button class="iconbtn sy-undo" onclick={undo} disabled={tapSync.cursor === 0} aria-label="Undo the last tap"><Icon name="history" size={22} /></button>
<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"}` : (tapSync.estimated && done === rows.length ? "Save the estimate" : "Save timing"))}
</button>
</div>
</div>
{/if}