From 24c6857847931956b5b7162e79909620bce61f35 Mon Sep 17 00:00:00 2001
From: KeelrLeek <156356974+KeelrLeek@users.noreply.github.com>
Date: Sun, 13 Sep 2026 23:04:49 -0500
Subject: [PATCH] android: tap-to-sync, the per-track auto-sync switch, the
vocal-reduced copy
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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.
---
ANDROID.md | 22 ++++-
src/lib/mockLibrary.js | 18 +++-
src/mobile/HalfSheet.svelte | 22 ++++-
src/mobile/Mobile.svelte | 2 +
src/mobile/SyncRoom.svelte | 110 ++++++++++++++++++++++++
src/mobile/lyrics.svelte.js | 167 +++++++++++++++++++++++++++++++++++-
src/mobile/nav.svelte.js | 20 ++++-
src/mobile/shell.css | 22 +++++
tests/mobile/smoke.mjs | 62 +++++++++++++
9 files changed, 434 insertions(+), 11 deletions(-)
create mode 100644 src/mobile/SyncRoom.svelte
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}