android: tap-to-sync, the per-track auto-sync switch, the vocal-reduced copy

The editor the desktop drives with a mouse is better under a thumb: the
song plays, the line to time sits large in the middle of the screen with
the one behind and the one ahead faint around it, and a tap anywhere
stamps it. One layer the back gesture peels, and a set left mid-tap is
offered back by the row that opened it; Save writes the track's own
document, lines never tapped left out of it and the button saying how
many are kept. The lyrics sheet also gains the per-track auto-sync switch
and the desktop's reduced-vocals copy — the same title, artist and
duration, so the words keep following it, and one row puts the original
back.
This commit is contained in:
KeelrLeek committed 2026-09-13 23:04:49 -05:00
1 parent e693dfb868
commit 24c6857847
9 files changed
+434 -11

No files matched your search

+110
View File
@@ -0,0 +1,110 @@
<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}