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.
153 lines
8.0 KiB
Svelte
153 lines
8.0 KiB
Svelte
<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}
|