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

+20 -2
View File
@@ -21,7 +21,7 @@ implements is `research/mobile-mockup/` (gitignored; see its README).
| `src/mobile/slsk.svelte.js` | the Soulseek hub: the connection, the rooms, the chats, the buddies — the desktop's `slsk` store and events |
| `src/mobile/contacts.svelte.js` | the local view of a person on the network: a petname, a group, a note and the two marks (`contacts_meta_list` / `contact_meta_set`) — this phone only, published nowhere |
| `crates/tauri-plugin-media-session/` | a Tauri plugin with a Kotlin foreground service (`MediaService`) that owns the `MediaSession`, the MediaStyle notification, audio focus and the becoming-noisy pause. The audio never passes through it; it is what keeps the process alive in the background and what the lock screen shows |
| `src/mobile/Sheet.svelte`, `HalfSheet.svelte`, `Lane.svelte`, `Cover.svelte`, `Release.svelte` | the player (art / lyrics / queue faces, container transform from the mini bar, drags), the bottom sheets (track, record, sort, the page overflow, the sleep timer), the waveform lane, the two card pieces (a long press on a card is its sheet) |
| `src/mobile/Sheet.svelte`, `HalfSheet.svelte`, `SyncRoom.svelte`, `Lane.svelte`, `Cover.svelte`, `Release.svelte` | the player (art / lyrics / queue faces, container transform from the mini bar, drags), the bottom sheets (track, record, sort, the page overflow, the sleep timer), the tap-to-sync room, the waveform lane, the two card pieces (a long press on a card is its sheet) |
| `src/mobile/screens/` | Home, Search (the library half: records and artists here, tracks through `library_track_search`), Library (albums / artists / tracks / playlists), Album, Artist (the library's shell at once, the catalog dissolving in: portrait, Popular, discography with what is here marked), CatalogAlbum (a catalog record, rows the library has play), Playlist, Community (sign in; rooms, chat, buddies, taste — the Soulseek side; the private layer waits), Room, Thread, You (Appearance switches, real) |
| `src/mobile/prefs.svelte.js` | Appearance, kept in localStorage: ambience, accent from artwork, motion |
| `src-tauri/tauri.android.conf.json` | merged over `tauri.conf.json` on Android: one window at `mobile.html`, no desktop resources, `minSdkVersion` 26 |
@@ -241,7 +241,25 @@ record the queue holds. A moment after boot the phone warms the library's thumbn
desktop's way (`library_art_prewarm` at the cards' edge, one decode at a
time behind the tiles' gate). The network results carry the hub's filters (a free slot only; FLAC or
MP3 320+), and the lyrics sheet has the desktop's paste door (plain text,
or LRC with timestamps, kept for the track). One switch the desktop never needed: You → Downloads → "Downloads on
or LRC with timestamps, kept for the track).
The rest of the lyrics tools came with it. The sheet carries the per-track
auto-sync switch (`lyrics_set_auto_sync`), so the timing correction the app
makes from a file's own lead-in can be turned off for one recording and back
on. "Re-time by hand" opens the tap-to-sync room (`SyncRoom.svelte`, one
more layer the back gesture peels): 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 — the last tap undone, −5 s
and play/pause under the thumb, and Save writing the track's own document
(`lyrics_save_lines`, the top of the waterfall, where a paste lands too).
Lines never tapped are left out of it and the button says how many are kept;
a set left mid-tap by the back gesture is offered back by the row that
opened it. "Play with reduced vocals" is the desktop's offline DSP
(`lyrics_prepare_instrumental` — a band-limited centre cancel, not a
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, and one row puts the original
back.
One switch the desktop never needed: You → Downloads → "Downloads on
Wi-Fi only" (`network.svelte.js`, off by default) holds every Save — a
record, a track, a folder, a file, a playlist's bulk, the now-playing
Save — while the phone is on mobile data (`navigator.connection.type`);
+17 -1
View File
@@ -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));
+21 -1
View File
@@ -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}
+2
View File
@@ -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>
+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}
+163 -4
View File
@@ -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;
}
}
+17 -3
View File
@@ -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();
+22
View File
@@ -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; }
+62
View File
@@ -134,6 +134,11 @@ const PROBE = `(function () {
fmtBadges: document.querySelectorAll('.grid2 .release .ed-badge').length,
played: (document.querySelector('.hero-acts .stats .played') || {}).textContent || '',
lyrLines: document.querySelectorAll('.lyr .ln').length,
sync: !!document.querySelector('.syncroom'),
syCount: (document.querySelector('.sy-count') || {}).textContent || '',
syNow: (document.querySelector('.sy-now') || {}).textContent || '',
lyrAuto: (function () { var r = [].slice.call(document.querySelectorAll('.halfsheet .row')).find(function (x) { return /Auto-sync/.test(x.textContent); }); return r ? (r.querySelector('.sw.on') ? 'on' : 'off') : ''; })(),
trackPath: (function () { try { return __ss.player.track ? String(__ss.player.track.path || '') : ''; } catch (e) { return ''; } })(),
sheetTitle: (document.querySelector('.halfsheet .sheet-title') || {}).textContent || '',
recents: document.querySelectorAll('.recent-chips .chip').length,
dym: (document.querySelector('.dym') || {}).textContent || '',
@@ -215,6 +220,31 @@ const MOCK_STEPS = [
{ do: `__click('.halfsheet .row', 'Paste lyrics')`, wait: 600 },
{ do: `(function () { var i = document.querySelector('.halfsheet .prompt .field'); i.value = '[00:01.00] Pasted line one\\n[00:05.00] Pasted line two'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, wait: 300 },
{ do: `__click('.halfsheet .prompt-acts .btn-fill', 'Save')`, name: "lyrics-pasted", wait: 800 },
/* auto-sync: the app's own correction turned off for this track, then
back on — the re-read leaves the sheet up so the switch is seen */
{ do: `__click('.lyr-tools', null)`, wait: 600 },
{ do: `__click('.halfsheet .row', 'Auto-sync')`, name: "lyrics-autosync-off", wait: 800 },
{ do: `__click('.halfsheet .row', 'Auto-sync')`, name: "lyrics-autosync-on", wait: 800 },
/* off again, and left off: a save returns the document with that flag at
its default, and the switch must not take it */
{ do: `__click('.halfsheet .row', 'Auto-sync')`, wait: 800 },
/* the tap-to-sync room: taps become the timing, one undone, the room
peeled by the back gesture and the set offered back, then saved */
{ do: `__click('.halfsheet .row', 'Re-time by hand')`, name: "sync-room", wait: 800 },
{ do: `__click('.sy-pad', null)`, wait: 400 },
{ do: `__click('.sy-pad', null)`, name: "sync-tapped", wait: 500 },
{ do: `__click('.sy-undo', null)`, name: "sync-undone", wait: 500 },
{ do: `history.back()`, wait: 600 },
{ do: `__click('.lyr-tools', null)`, name: "sync-left", wait: 700 },
{ do: `__click('.halfsheet .row', 'lines timed')`, name: "sync-resumed", wait: 800 },
{ do: `__click('.sy-pad', null)`, wait: 400 },
{ do: `__click('.sy-pad', null)`, name: "sync-all", wait: 500 },
{ do: `__click('.sy-foot .btn-fill', 'Save')`, name: "sync-saved", wait: 900 },
/* the vocal-reduced copy, and back to the recording */
{ do: `__click('.lyr-tools', null)`, name: "sync-saved-sheet", wait: 600 },
{ do: `__click('.halfsheet .row', 'reduced vocals')`, name: "lyrics-reduced", wait: 1800 },
{ do: `__click('.lyr-tools', null)`, wait: 700 },
{ do: `__click('.halfsheet .row', 'Back to the original')`, name: "lyrics-original", wait: 1800 },
{ do: `document.querySelectorAll('.fbtn')[0].click()`, wait: 300 },
{ do: `history.back()`, name: "after-back", wait: 900 },
{ do: `document.querySelector('.rec-artist').click()`, name: "artist", wait: 900 },
@@ -517,6 +547,19 @@ const MOCK_EXPECT = {
"lyrics-candidates": (i) => /Choose the right lyrics/.test(i.halfText) && /Live/.test(i.halfText),
"lyrics-chosen": (i) => !i.half.includes("up") && i.lyrLines >= 3,
"lyrics-pasted": (i) => !i.half.includes("up") && i.lyrLines === 2,
"lyrics-autosync-off": (i) => i.half.includes("up") && i.lyrAuto === "off",
"lyrics-autosync-on": (i) => i.half.includes("up") && i.lyrAuto === "on",
"sync-room": (i) => i.sync && i.syCount === "0 / 3" && !!i.syNow,
"sync-tapped": (i) => i.sync && i.syCount === "2 / 3",
"sync-undone": (i) => i.sync && i.syCount === "1 / 3",
"sync-left": (i) => !i.sync && i.half.includes("up") && /1 of 3 lines timed/.test(i.halfText),
"sync-resumed": (i) => i.sync && i.syCount === "1 / 3",
"sync-all": (i) => i.sync && i.syCount === "3 / 3" && /every line/.test(i.syNow),
"sync-saved": (i) => !i.sync && i.lyrLines === 3 && !i.toast,
/* the save's own payload carries no auto-sync flag: the switch kept its */
"sync-saved-sheet": (i) => i.half.includes("up") && i.lyrAuto === "off",
"lyrics-reduced": (i) => /instrumental\.wav$/.test(i.trackPath) && !i.toast,
"lyrics-original": (i) => !!i.trackPath && !/instrumental\.wav$/.test(i.trackPath) && !i.toast,
viewer: (i) => i.viewer,
"viewer-closed": (i) => !i.viewer && i.sheet.includes("up"),
"album-playing": (i) => i.mini,
@@ -642,6 +685,17 @@ const PHONE_STEPS = [
{ do: `(function () { var b = document.querySelector('.lyr-tools'); if (b) b.click(); else __ss.openHalf('lyrics'); })()`, name: "lyrics-sheet", wait: 800 },
{ do: `(function () { var r = [].slice.call(document.querySelectorAll('.halfsheet .row')).find(function (x) { return /Lyrics later/.test(x.textContent); }); if (r) r.click(); })()`, name: "lyrics-nudged", wait: 700 },
{ do: `(function () { var r = [].slice.call(document.querySelectorAll('.halfsheet .row')).find(function (x) { return /Lyrics earlier/.test(x.textContent); }); if (r) r.click(); })()`, wait: 700 },
/* auto-sync off and straight back on — the cache entry ends as it began */
{ do: `(function () { var r = [].slice.call(document.querySelectorAll('.halfsheet .row')).find(function (x) { return /Auto-sync/.test(x.textContent); }); if (r) r.click(); })()`, name: "lyrics-autosync-off", wait: 1200 },
{ do: `(function () { var r = [].slice.call(document.querySelectorAll('.halfsheet .row')).find(function (x) { return /Auto-sync/.test(x.textContent); }); if (r) r.click(); })()`, name: "lyrics-autosync-on", wait: 1200 },
/* the tap room on the real clock: two taps, one undone, then discarded —
nothing is written to Kyle's own lyrics for this track */
{ do: `(function () { var r = [].slice.call(document.querySelectorAll('.halfsheet .row')).find(function (x) { return /Re-time by hand|Sync these lyrics/.test(x.textContent); }); if (r) r.click(); })()`, name: "sync-room", wait: 1000 },
{ do: `(function () { var p = document.querySelector('.sy-pad'); if (p) p.click(); })()`, wait: 700 },
{ do: `(function () { var p = document.querySelector('.sy-pad'); if (p) p.click(); })()`, name: "sync-tapped", wait: 700 },
{ do: `(function () { var u = document.querySelector('.sy-undo'); if (u) u.click(); })()`, name: "sync-undone", wait: 700 },
{ do: `(function () { var c = document.querySelector('.sy-close'); if (c) c.click(); })()`, wait: 500 },
{ do: `(function () { var c = document.querySelector('.sy-close'); if (c) c.click(); })()`, name: "sync-discarded", wait: 800 },
{ do: `document.querySelector('.scrim').click()`, wait: 500 },
{ do: `document.querySelectorAll('.fbtn')[0].click()`, wait: 400 },
{ do: `history.back()`, name: "after-back", wait: 1000 },
@@ -728,6 +782,14 @@ const PHONE_EXPECT = {
"album-playing": (i) => i.mini && !i.toast,
"lyrics-sheet": (i) => i.half.includes("up") && /Search again/.test(i.halfText),
"lyrics-nudged": (i) => i.half.includes("up") && !i.toast,
/* the walked album's track may genuinely have no words, so the room's
own rows are conditional — the discard is not */
"lyrics-autosync-off": (i) => !i.toast && (i.lyrAuto === "" || i.lyrAuto === "off"),
"lyrics-autosync-on": (i) => !i.toast && (i.lyrAuto === "" || i.lyrAuto === "on"),
"sync-room": (i) => !i.toast && (!i.sync || /^0 \/ [1-9]/.test(i.syCount)),
"sync-tapped": (i) => !i.toast && (!i.sync || /^2 \//.test(i.syCount)),
"sync-undone": (i) => !i.toast && (!i.sync || /^1 \//.test(i.syCount)),
"sync-discarded": (i) => !i.sync && !i.toast,
viewer: (i) => i.viewer,
"viewer-closed": (i) => !i.viewer && i.sheet.includes("up"),
"home-ambience": (i) => /^(Good |Still)/.test(i.title) && i.amb.includes("on:img"),