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:
1 parent
e693dfb868
commit
24c6857847
9 files changed
+434
-11
No files matched your search
+17
-1
@@ -124,12 +124,14 @@ function plTouch(p) {
|
||||
setTimeout(() => mockEmit("soulseek:playlists-changed", {}), 30);
|
||||
}
|
||||
const plOf = (args) => PL.find((p) => p.id === Number(args?.id ?? args?.playlistId));
|
||||
/* the per-track auto-sync switch the lyrics sheet flips */
|
||||
let autoSyncOff = false;
|
||||
/* every track has three timed lines; the sheet's tools have something to act on */
|
||||
function mockLyrics(title) {
|
||||
return {
|
||||
status: "synced", source: "lrclib",
|
||||
synced: [{ time_ms: 0, text: "Here is " + (title || "a song") }, { time_ms: 4000, text: "Every word in time" }, { time_ms: 9000, text: "And the last one, late" }],
|
||||
plain: null, offset_ms: 0, auto_offset_ms: 0, auto_sync_off: false,
|
||||
plain: null, offset_ms: 0, auto_offset_ms: 0, auto_sync_off: autoSyncOff,
|
||||
};
|
||||
}
|
||||
function mockPlaylists(cmd, args) {
|
||||
@@ -714,6 +716,20 @@ function mockNet(cmd, args) {
|
||||
const synced = timed.every(Boolean);
|
||||
return Promise.resolve({ status: synced ? "synced" : "plain", source: "pasted", synced: synced ? timed : [], plain: synced ? null : lines.join("\n"), offset_ms: 0, auto_offset_ms: 0, auto_sync_off: false });
|
||||
}
|
||||
if (cmd === "lyrics_set_auto_sync") { autoSyncOff = !args?.enabled; return Promise.resolve(); }
|
||||
if (cmd === "lyrics_save_lines") {
|
||||
/* the Rust drops the empty lines and sorts by time — a half-tapped
|
||||
set is stored as the lines that HAVE one */
|
||||
const lines = (args?.lines || []).filter((l) => String(l?.text || "").trim())
|
||||
.map((l) => ({ time_ms: l.time_ms, text: l.text })).sort((a, b) => a.time_ms - b.time_ms);
|
||||
if (!lines.length) return Promise.reject(new Error("Nothing to save"));
|
||||
/* the Rust's payload_from does NOT carry the entry's auto-sync flag
|
||||
back from a write — false here, faithfully, so the walk notices if
|
||||
the store ever applies a save over the switch */
|
||||
return Promise.resolve({ status: "synced", source: "user", synced: lines, plain: null, offset_ms: 0, auto_offset_ms: 0, auto_sync_off: false });
|
||||
}
|
||||
/* the vocal-reduced copy: a render takes a beat, and it is a new file */
|
||||
if (cmd === "lyrics_prepare_instrumental") return new Promise((r) => setTimeout(() => r({ path: String(args?.path || "") + ".instrumental.wav", cached: false, durationSecs: 214 }), 400));
|
||||
if (cmd === "lyrics_clear") return Promise.resolve();
|
||||
if (cmd === "clear_play_history") return Promise.resolve();
|
||||
if (cmd === "refresh_album_art") return new Promise((r) => setTimeout(() => r("C:\\mock\\cover.jpg"), 200));
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
import { DOORS, start as startImport } from "./importer.svelte.js";
|
||||
import { slsk, refreshRooms, disconnect, openThread, addBuddy, removeBuddy, leaveRoom } from "./slsk.svelte.js";
|
||||
import { lib, albumById, albumsBy, tracksOf, loadLibrary, refreshPlaylists, refreshRecents } from "./lib.svelte.js";
|
||||
import { lyr, nudge, searchCandidates, closeCandidates, chooseCandidate, forgetLyrics, pasteLyrics } from "./lyrics.svelte.js";
|
||||
import { lyr, nudge, searchCandidates, closeCandidates, chooseCandidate, forgetLyrics, pasteLyrics, setAutoSync, openTapSync, tapProgress, reduceVocals, backToOriginal } from "./lyrics.svelte.js";
|
||||
import { art, wantArt } from "./art.svelte.js";
|
||||
import { metaOf, display, realName, setMeta, clearMeta } from "./contacts.svelte.js";
|
||||
import { downloadsAllowed } from "./network.svelte.js";
|
||||
@@ -69,6 +69,11 @@
|
||||
}
|
||||
return null;
|
||||
});
|
||||
/* the lyrics sheet: a tap-sync set left mid-tap for the playing track,
|
||||
and whether a vocal-reduced copy is what is playing */
|
||||
const tapLeft = $derived(kind === "lyrics" ? tapProgress() : null);
|
||||
const canReduce = $derived(!!player.track?.path && !player.track?.stream);
|
||||
const onReduced = $derived(!!lyr.reduced && player.track?.path === lyr.reduced.to);
|
||||
const onThisAlbum = $derived((id) => top().n === "album" && top().c.id === id);
|
||||
const after = $derived(Math.max(0, player.queue.length - player.qi - 1));
|
||||
|
||||
@@ -281,6 +286,14 @@
|
||||
</button>
|
||||
{/snippet}
|
||||
|
||||
{#snippet swrow(icon, t, m, on, fn)}
|
||||
<button class="row" onclick={fn}>
|
||||
<span class="lead"><Icon name={icon} size={20} /></span>
|
||||
<span class="b"><span class="t">{t}</span>{#if m}<span class="m">{m}</span>{/if}</span>
|
||||
<span class="sw" class:on><span></span></span>
|
||||
</button>
|
||||
{/snippet}
|
||||
|
||||
<div class="halfsheet" class:up={!!nav.half}>
|
||||
<div class="grab"></div>
|
||||
<div class="hs-body">
|
||||
@@ -621,10 +634,17 @@
|
||||
{#if lyr.lines.length}
|
||||
{@render row("skip-back", "Lyrics earlier", "half a second, kept for this track", () => nudge(-500))}
|
||||
{@render row("skip-forward", "Lyrics later", "half a second", () => nudge(500))}
|
||||
{@render swrow("history", "Auto-sync this track", lyr.autoOffset ? `correcting by ${lyr.autoOffset > 0 ? "+" : ""}${(lyr.autoOffset / 1000).toFixed(1)} s` : "let the app correct this track's timing", !lyr.autoSyncOff, () => { void setAutoSync(lyr.autoSyncOff); })}
|
||||
<div class="sheet-hair"></div>
|
||||
{/if}
|
||||
{@render row("search", "Wrong lyrics? Search again", "LRCLIB, by this track's name", () => { void searchCandidates(); })}
|
||||
{@render row("file-text", "Paste lyrics…", "plain text, or LRC with [mm:ss] timestamps", () => openHalf("prompt", { title: "Paste lyrics", placeholder: "[00:12.50] The first line…", ok: "Save", multiline: true, onsubmit: (v) => { void pasteLyrics(v); } }))}
|
||||
{#if lyr.lines.length || lyr.plain}
|
||||
{@render row("clock", lyr.lines.length ? "Re-time by hand" : "Sync these lyrics", tapLeft ? `${tapLeft.done} of ${tapLeft.total} lines timed — keep going` : "play the song and tap as each line begins", act(openTapSync))}
|
||||
{/if}
|
||||
{#if canReduce}
|
||||
{@render row("music", onReduced ? "Back to the original" : "Play with reduced vocals", onReduced ? "the recording as it is" : lyr.reducing ? "making the copy now — a moment" : "a rough centre cancel, made on this phone", act(() => { void (onReduced ? backToOriginal() : reduceVocals()); }))}
|
||||
{/if}
|
||||
{@render row("refresh", "Forget and look again", "", act(() => { void forgetLyrics(); }))}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
import Sheet from "./Sheet.svelte";
|
||||
import HalfSheet from "./HalfSheet.svelte";
|
||||
import Viewer from "./Viewer.svelte";
|
||||
import SyncRoom from "./SyncRoom.svelte";
|
||||
import Home from "./screens/Home.svelte";
|
||||
import Search from "./screens/Search.svelte";
|
||||
import Library from "./screens/Library.svelte";
|
||||
@@ -306,6 +307,7 @@
|
||||
<HalfSheet />
|
||||
<Sheet />
|
||||
<Viewer />
|
||||
<SyncRoom />
|
||||
|
||||
{#if player.error || lib.error}
|
||||
<div class="toast" onclick={() => { player.error = ""; lib.error = ""; }} role="status">{player.error || lib.error}</div>
|
||||
|
||||
@@ -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}
|
||||
+163
-4
@@ -4,7 +4,9 @@
|
||||
lookup forgotten and done again. One store, read by the player sheet's
|
||||
lyrics face and by the lyrics sheet. */
|
||||
import * as api from "../lib/api.js";
|
||||
import { player } from "./player.svelte.js";
|
||||
import { player, current, playQueueAt } from "./player.svelte.js";
|
||||
import { note } from "./notice.svelte.js";
|
||||
import { openTiming, closeTiming } from "./nav.svelte.js";
|
||||
|
||||
export const lyr = $state({
|
||||
key: null, // the track's path — the identity the words belong to
|
||||
@@ -17,6 +19,9 @@ export const lyr = $state({
|
||||
autoOffset: 0,
|
||||
candidates: null, // LRCLIB candidates while choosing, else null
|
||||
searching: false,
|
||||
autoSyncOff: false, // the user turned the automatic correction off here
|
||||
reducing: false, // a vocal-reduced copy is being made
|
||||
reduced: null, // { from, to } once one has been made for this track
|
||||
});
|
||||
|
||||
let gen = 0;
|
||||
@@ -32,13 +37,14 @@ export function identity() {
|
||||
key: t.path,
|
||||
};
|
||||
}
|
||||
function apply(p) {
|
||||
function apply(p, full = false) {
|
||||
lyr.status = p?.status || "none";
|
||||
lyr.source = p?.source || "";
|
||||
lyr.lines = p?.synced || [];
|
||||
lyr.plain = p?.plain || "";
|
||||
lyr.userOffset = p?.offset_ms || 0;
|
||||
lyr.autoOffset = p?.auto_offset_ms || 0;
|
||||
if (full) lyr.autoSyncOff = !!p?.auto_sync_off;
|
||||
lyr.offset = lyr.userOffset + lyr.autoOffset;
|
||||
}
|
||||
/** The words for the playing track; `force` asks the network again. */
|
||||
@@ -47,11 +53,11 @@ export async function loadLyrics(force = false) {
|
||||
if (!id) return;
|
||||
if (!force && lyr.key === id.key) return;
|
||||
const g = ++gen;
|
||||
Object.assign(lyr, { key: id.key, status: "loading", source: "", lines: [], plain: "", offset: 0, userOffset: 0, autoOffset: 0, candidates: null, searching: false });
|
||||
Object.assign(lyr, { key: id.key, status: "loading", source: "", lines: [], plain: "", offset: 0, userOffset: 0, autoOffset: 0, autoSyncOff: false, candidates: null, searching: false });
|
||||
try {
|
||||
const p = await api.lyricsGet({ title: id.title, artist: id.artist, album: id.album, durationSecs: id.durationSecs, path: id.path, refresh: force ? true : null });
|
||||
if (g !== gen) return;
|
||||
apply(p);
|
||||
apply(p, true);
|
||||
} catch {
|
||||
if (g === gen) Object.assign(lyr, { status: "none", lines: [], plain: "" });
|
||||
}
|
||||
@@ -120,3 +126,156 @@ export async function pasteLyrics(text) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Re-read the payload for the loaded track without emptying the panel —
|
||||
* a switch flips and the words stay on screen. The cache only, never the
|
||||
* network. */
|
||||
async function reread() {
|
||||
const id = identity();
|
||||
if (!id) return;
|
||||
const g = gen;
|
||||
try {
|
||||
const p = await api.lyricsGet({ title: id.title, artist: id.artist, album: id.album, durationSecs: id.durationSecs, path: id.path, refresh: null });
|
||||
if (g === gen) apply(p, true);
|
||||
} catch { /* what is showing stands */ }
|
||||
}
|
||||
|
||||
/** The app's automatic timing correction, on or off for this track. The
|
||||
* switch flips at once; the backend's own number comes with the re-read. */
|
||||
export async function setAutoSync(enabled) {
|
||||
const id = identity();
|
||||
if (!id) return;
|
||||
const before = lyr.autoSyncOff;
|
||||
lyr.autoSyncOff = !enabled;
|
||||
try {
|
||||
await api.lyricsSetAutoSync(id.title, id.artist, id.durationSecs, enabled);
|
||||
} catch (e) {
|
||||
lyr.autoSyncOff = before;
|
||||
player.error = String(e);
|
||||
return;
|
||||
}
|
||||
await reread();
|
||||
}
|
||||
|
||||
/* ── the vocal-reduced copy ──────────────────────────────────────────────
|
||||
The desktop's offline DSP: a band-limited centre cancel, not a learned
|
||||
separator, and the wording stays honest about that. The copy plays under
|
||||
the SAME title, artist and duration, so the lyric identity is still the
|
||||
recording's and the words keep following it. The track starts again from
|
||||
the top — there is no seeking a file the engine has only just been given. */
|
||||
async function swapPath(e, to) {
|
||||
/* a COPY of the entry's track, never the row itself: tracksOf caches one
|
||||
array per record and hands the SAME objects to the record page and to
|
||||
the queue, so writing the path in place would leave the record pointing
|
||||
at a cache file the LRU drops out from under it. The desktop does not
|
||||
rewrite its queue item either — it plays the copy as its own thing. */
|
||||
e.t = { ...e.t, path: to };
|
||||
await playQueueAt(player.qi);
|
||||
}
|
||||
export async function reduceVocals() {
|
||||
const e = current();
|
||||
const t = e?.t;
|
||||
if (!t?.path || t.stream || lyr.reducing) return false;
|
||||
const from = t.path;
|
||||
lyr.reducing = true;
|
||||
note("Reducing the vocals — this can take a moment");
|
||||
try {
|
||||
const res = await api.lyricsPrepareInstrumental(from);
|
||||
/* the render takes seconds: starting the OLD track's copy now would
|
||||
hijack whatever the queue has moved on to */
|
||||
const now = current();
|
||||
if (!res?.path || now?.t?.path !== from) {
|
||||
note("The track changed while preparing — try again");
|
||||
return false;
|
||||
}
|
||||
lyr.reduced = { from, to: res.path };
|
||||
await swapPath(now, res.path);
|
||||
return true;
|
||||
} catch (err) {
|
||||
player.error = String(err);
|
||||
return false;
|
||||
} finally {
|
||||
lyr.reducing = false;
|
||||
}
|
||||
}
|
||||
export async function backToOriginal() {
|
||||
const r = lyr.reduced;
|
||||
const e = current();
|
||||
if (!r || e?.t?.path !== r.to) return false;
|
||||
await swapPath(e, r.from);
|
||||
lyr.reduced = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
/* ── the tap-to-sync room ────────────────────────────────────────────────
|
||||
The song plays and one tap lands per line; the taps ARE the timing.
|
||||
Saving writes the track's own document (lyrics_save_lines), the top of
|
||||
the waterfall, the same shelf a paste lands on — and the backend drops
|
||||
every line without a time, so a half-tapped set is stored as the lines
|
||||
that have one. */
|
||||
export const tapSync = $state({
|
||||
rows: [], // [{ time_ms: number|null, text }]
|
||||
cursor: 0, // the row the next tap stamps
|
||||
identity: null, // the track the rows belong to, captured at open
|
||||
saving: false,
|
||||
error: "",
|
||||
});
|
||||
|
||||
const plainLines = (text) => String(text ?? "").split("\n").map((l) => l.trim())
|
||||
.filter((l) => l && !/^\[.*\]$/.test(l));
|
||||
|
||||
/** Open the room for the playing track. An already-timed set is re-tapped
|
||||
* from scratch: the room shows three lines, not a list, so it has no
|
||||
* per-line handle to nudge — it has the tap. */
|
||||
export function openTapSync() {
|
||||
const id = identity();
|
||||
if (!id) return;
|
||||
/* peeled by the back gesture mid-tap: the same track's stamps come back
|
||||
rather than the room starting over */
|
||||
if (tapSync.identity?.key === id.key && tapSync.rows.some((r) => r.time_ms != null)) { openTiming(); return; }
|
||||
const texts = (lyr.lines.length ? lyr.lines.map((l) => l.text) : plainLines(lyr.plain))
|
||||
.filter((t) => String(t || "").trim());
|
||||
if (!texts.length) return;
|
||||
tapSync.rows = texts.map((text) => ({ time_ms: null, text }));
|
||||
tapSync.cursor = 0;
|
||||
tapSync.identity = id;
|
||||
tapSync.saving = false;
|
||||
tapSync.error = "";
|
||||
openTiming();
|
||||
}
|
||||
export function closeTapSync() {
|
||||
closeTiming();
|
||||
tapSync.rows = [];
|
||||
tapSync.cursor = 0;
|
||||
tapSync.identity = null;
|
||||
tapSync.saving = false;
|
||||
tapSync.error = "";
|
||||
}
|
||||
/** What the lyrics sheet says under the row when a set was left mid-tap. */
|
||||
export function tapProgress() {
|
||||
const id = identity();
|
||||
if (!id || tapSync.identity?.key !== id.key) return null;
|
||||
const done = tapSync.rows.filter((r) => r.time_ms != null).length;
|
||||
return done ? { done, total: tapSync.rows.length } : null;
|
||||
}
|
||||
/** The taps, as this track's own timed document. */
|
||||
export async function saveTapSync() {
|
||||
const id = tapSync.identity;
|
||||
const rows = tapSync.rows.filter((r) => r.time_ms != null && r.text.trim());
|
||||
if (!id || !rows.length || tapSync.saving) return false;
|
||||
tapSync.saving = true;
|
||||
tapSync.error = "";
|
||||
try {
|
||||
const p = await api.lyricsSaveLines(id.title, id.artist, id.durationSecs,
|
||||
rows.map((r) => ({ time_ms: Math.max(0, Math.round(r.time_ms)), text: r.text })));
|
||||
/* the same track is still playing: show the result at once */
|
||||
if (identity()?.key === id.key && p && p.status) apply(p);
|
||||
closeTapSync();
|
||||
return true;
|
||||
} catch (e) {
|
||||
tapSync.error = String(e).replace(/^Error:\s*/, "").slice(0, 140);
|
||||
return false;
|
||||
} finally {
|
||||
tapSync.saving = false;
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,8 @@ export const nav = $state({
|
||||
/* the artwork viewer, and the art key it shows */
|
||||
viewer: false,
|
||||
viewerKey: null,
|
||||
/* the tap-to-sync room — over everything, peeled first */
|
||||
timing: false,
|
||||
});
|
||||
|
||||
/** Where the container transform starts — the mini bar's art, measured the
|
||||
@@ -57,7 +59,7 @@ export function isRoot() {
|
||||
}
|
||||
|
||||
function backables() {
|
||||
return (nav.viewer ? 1 : 0) + (nav.sheet ? 1 : 0) + (nav.half ? 1 : 0)
|
||||
return (nav.timing ? 1 : 0) + (nav.viewer ? 1 : 0) + (nav.sheet ? 1 : 0) + (nav.half ? 1 : 0)
|
||||
+ (nav.stacks[nav.tab].length - 1) + (nav.tab !== "home" ? 1 : 0);
|
||||
}
|
||||
|
||||
@@ -96,7 +98,8 @@ export function bootNav() {
|
||||
window.addEventListener("popstate", () => {
|
||||
if (suppress > 0) { suppress--; if (suppress === 0) sync(); return; }
|
||||
depth = Math.max(0, depth - 1);
|
||||
if (nav.viewer) nav.viewer = false;
|
||||
if (nav.timing) nav.timing = false;
|
||||
else if (nav.viewer) nav.viewer = false;
|
||||
else if (nav.sheet) nav.sheet = false;
|
||||
else if (nav.half) nav.half = null;
|
||||
else if (!popNow() && nav.tab !== "home") switchNow("home");
|
||||
@@ -149,7 +152,8 @@ export function push(n, c = {}, screenEl = null) {
|
||||
|
||||
/** The in-app back chevron. Same order as the system gesture. */
|
||||
export function back() {
|
||||
if (nav.viewer) nav.viewer = false;
|
||||
if (nav.timing) nav.timing = false;
|
||||
else if (nav.viewer) nav.viewer = false;
|
||||
else if (nav.sheet) nav.sheet = false;
|
||||
else if (nav.half) nav.half = null;
|
||||
else if (!popNow() && nav.tab !== "home") switchNow("home");
|
||||
@@ -200,6 +204,16 @@ export function closeViewer() {
|
||||
nav.viewer = false;
|
||||
sync();
|
||||
}
|
||||
export function openTiming() {
|
||||
if (nav.timing) return;
|
||||
nav.timing = true;
|
||||
sync();
|
||||
}
|
||||
export function closeTiming() {
|
||||
if (!nav.timing) return;
|
||||
nav.timing = false;
|
||||
sync();
|
||||
}
|
||||
export function openHalf(kind, arg = null) {
|
||||
nav.half = { kind, arg };
|
||||
sync();
|
||||
|
||||
@@ -1393,3 +1393,25 @@ html[data-motion="off"] *, html[data-motion="off"] *::before, html[data-motion="
|
||||
.tastechips { flex-wrap: wrap; overflow: visible; }
|
||||
.tastechips .tchip svg { opacity: 0.62; margin-right: -3px; }
|
||||
.tastechips .chip:disabled { opacity: 0.5; }
|
||||
|
||||
/* ── the tap-to-sync room: the song plays, the screen is the tap ───────── */
|
||||
.syncroom { position: fixed; inset: 0; z-index: 150; display: flex; flex-direction: column; background: radial-gradient(120% 70% at 20% 0%, color-mix(in srgb, var(--accent) 20%, transparent), transparent 58%), var(--bg-base); padding-top: var(--inset-top); padding-bottom: var(--inset-bottom); animation: viewer-in 160ms ease both; }
|
||||
.sy-head { display: flex; align-items: center; gap: 10px; padding: 8px var(--pad) 2px; flex: none; }
|
||||
.sy-close { width: auto; min-width: var(--tap); padding: 0 10px; border-radius: var(--r-pill); }
|
||||
.sy-discard { font-size: 12.5px; font-weight: 800; color: #f15e6c; }
|
||||
.sy-who { flex: 1; min-width: 0; text-align: center; }
|
||||
.sy-who b { display: block; font-size: 11px; font-weight: 800; letter-spacing: 0.8px; text-transform: uppercase; color: var(--accent-hover); }
|
||||
.sy-who i { display: block; font-style: normal; font-size: 13px; font-weight: 700; color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.sy-count { flex: none; min-width: 54px; text-align: right; font-size: 13px; font-weight: 800; color: var(--text-muted); font-variant-numeric: tabular-nums; }
|
||||
.sy-warn { margin: 4px var(--pad) 0; padding: 9px 13px; border-radius: 10px; background: rgba(232, 163, 61, 0.14); color: #e8a33d; font-size: 12.5px; font-weight: 700; flex: none; }
|
||||
.sy-pad { flex: 1; min-height: 0; width: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; padding: 12px var(--pad); text-align: center; }
|
||||
.sy-pad:active { background: rgba(255, 255, 255, 0.05); }
|
||||
.sy-pad:disabled { opacity: 0.5; }
|
||||
.sy-prev, .sy-next { display: block; width: 100%; min-height: 20px; font-size: 15px; font-weight: 650; line-height: 1.35; color: rgba(255, 255, 255, 0.3); overflow: hidden; }
|
||||
.sy-now { display: block; width: 100%; font-size: 27px; font-weight: 800; letter-spacing: -0.5px; line-height: 1.25; color: #fff; text-shadow: 0 0 26px color-mix(in srgb, var(--accent) 50%, transparent); }
|
||||
.sy-hint { display: block; margin-top: 6px; font-size: 12px; font-weight: 650; color: var(--text-muted); }
|
||||
.sy-mark { display: block; font-size: 13px; font-weight: 800; color: var(--accent-hover); font-variant-numeric: tabular-nums; }
|
||||
.sy-foot { display: flex; align-items: center; gap: 10px; padding: 8px var(--pad) 16px; flex: none; }
|
||||
.sy-foot .iconbtn { width: auto; min-width: var(--tap); padding: 0 12px; border-radius: var(--r-pill); font-size: 13px; font-weight: 750; }
|
||||
.sy-foot .iconbtn:disabled { opacity: 0.4; }
|
||||
.sy-save { flex: 1; }
|
||||
Reference in new issue
Block a user