diff --git a/ANDROID.md b/ANDROID.md index 0eb4049..7c03de8 100644 --- a/ANDROID.md +++ b/ANDROID.md @@ -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`); diff --git a/src/lib/mockLibrary.js b/src/lib/mockLibrary.js index 92c1da5..87e10e6 100644 --- a/src/lib/mockLibrary.js +++ b/src/lib/mockLibrary.js @@ -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)); diff --git a/src/mobile/HalfSheet.svelte b/src/mobile/HalfSheet.svelte index db92ea3..1e700ad 100644 --- a/src/mobile/HalfSheet.svelte +++ b/src/mobile/HalfSheet.svelte @@ -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 @@ {/snippet} +{#snippet swrow(icon, t, m, on, fn)} + +{/snippet} +
@@ -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); })}
{/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(); }))}
{/if} diff --git a/src/mobile/Mobile.svelte b/src/mobile/Mobile.svelte index 26d30c4..9a4c3eb 100644 --- a/src/mobile/Mobile.svelte +++ b/src/mobile/Mobile.svelte @@ -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 @@ + {#if player.error || lib.error}
{ player.error = ""; lib.error = ""; }} role="status">{player.error || lib.error}
diff --git a/src/mobile/SyncRoom.svelte b/src/mobile/SyncRoom.svelte new file mode 100644 index 0000000..230b5d7 --- /dev/null +++ b/src/mobile/SyncRoom.svelte @@ -0,0 +1,110 @@ + + +{#if nav.timing} +
+
+ + Tap in time{tapSync.identity?.title ?? ""} + {done} / {rows.length} +
+ + {#if wrong} +
Another track is playing — a tap follows what plays. Play “{tapSync.identity?.title}” again to keep going.
+ {:else if tapSync.error} +
{tapSync.error}
+ {/if} + + + +
+ + + + +
+
+{/if} diff --git a/src/mobile/lyrics.svelte.js b/src/mobile/lyrics.svelte.js index 29f6746..d9b8428 100644 --- a/src/mobile/lyrics.svelte.js +++ b/src/mobile/lyrics.svelte.js @@ -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; + } +} diff --git a/src/mobile/nav.svelte.js b/src/mobile/nav.svelte.js index 1b79fe6..d04a493 100644 --- a/src/mobile/nav.svelte.js +++ b/src/mobile/nav.svelte.js @@ -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(); diff --git a/src/mobile/shell.css b/src/mobile/shell.css index 1a6101a..d847467 100644 --- a/src/mobile/shell.css +++ b/src/mobile/shell.css @@ -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; } diff --git a/tests/mobile/smoke.mjs b/tests/mobile/smoke.mjs index 947c96c..88051f6 100644 --- a/tests/mobile/smoke.mjs +++ b/tests/mobile/smoke.mjs @@ -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"),