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.
111 lines
5.3 KiB
Plaintext
111 lines
5.3 KiB
Plaintext
<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. */
|
||
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 { 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);
|
||
|
||
function stamp() {
|
||
if (wrong || tapSync.saving || at >= rows.length) return;
|
||
tapSync.rows[at].time_ms = nowMs();
|
||
tapSync.cursor = at + 1;
|
||
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">
|
||
<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}
|
||
|
||
<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>
|
||
{: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"}` : "Save timing")}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
{/if}
|