/* Lyrics on the phone — the app's own pipeline, per track, with the desktop's tools: the offset nudged half a second at a time and kept for the track, another LRCLIB candidate chosen when the words are wrong, the 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"; export const lyr = $state({ key: null, // the track's path — the identity the words belong to status: "none", // loading | synced | plain | instrumental | none source: "", lines: [], // [{ time_ms, text, words? }] plain: "", offset: 0, // the user's offset plus the automatic correction, ms userOffset: 0, autoOffset: 0, candidates: null, // LRCLIB candidates while choosing, else null searching: false, }); let gen = 0; export function identity() { const t = player.track; if (!t) return null; return { title: t.title, artist: t.artist || player.album?.artist || "", album: player.album?.title ?? null, durationSecs: t.duration_secs ?? null, path: t.path, key: t.path, }; } function apply(p) { 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; lyr.offset = lyr.userOffset + lyr.autoOffset; } /** The words for the playing track; `force` asks the network again. */ export async function loadLyrics(force = false) { const id = identity(); 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 }); 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); } catch { if (g === gen) Object.assign(lyr, { status: "none", lines: [], plain: "" }); } } /* the desktop's nudge: half a second at a time, kept for the track */ let persist = 0; export function nudge(deltaMs) { const id = identity(); if (!id || !lyr.lines.length) return; lyr.userOffset = Math.max(-30_000, Math.min(30_000, lyr.userOffset + deltaMs)); lyr.offset = lyr.userOffset + lyr.autoOffset; clearTimeout(persist); const value = lyr.userOffset; persist = setTimeout(() => { api.lyricsSetOffset(id.title, id.artist, id.durationSecs, value).catch(() => {}); }, 500); } /** Search LRCLIB by this track's name; the candidates wait on the store. */ export async function searchCandidates() { const id = identity(); if (!id) return; const g = gen; lyr.searching = true; lyr.candidates = []; try { const found = await api.lyricsSearch(id.title, id.artist); if (g === gen) lyr.candidates = found || []; } catch { if (g === gen) lyr.candidates = []; } finally { if (g === gen) lyr.searching = false; } } export function closeCandidates() { lyr.candidates = null; } /** Pin a picked candidate as this track's lyrics. */ export async function chooseCandidate(candidateId) { const id = identity(); if (!id) return; const g = gen; try { const p = await api.lyricsSelect(id.title, id.artist, id.durationSecs, candidateId); if (g !== gen) return; apply(p); lyr.candidates = null; } catch { /* keep what was showing */ } } /** Forget the words and look again. */ export async function forgetLyrics() { const id = identity(); if (!id) return; try { await api.lyricsClear(id.title, id.artist, id.durationSecs); } catch { /* nothing kept */ } await loadLyrics(true); }