From 5bfc80a0dbea7015e9618f1aca44f9df490d5379 Mon Sep 17 00:00:00 2001 From: ranokay Date: Sat, 17 Oct 2026 12:15:13 +0300 Subject: [PATCH] feat(ui): add lyrics offset math and local preferences Signed-off-by: ranokay --- ui/src/audioplayer/lyrics.js | 31 +++++++- ui/src/audioplayer/lyrics.test.js | 84 ++++++++++++++++++++ ui/src/audioplayer/lyricsPreferences.js | 62 +++++++++++++++ ui/src/audioplayer/lyricsPreferences.test.js | 69 ++++++++++++++++ 4 files changed, 244 insertions(+), 2 deletions(-) create mode 100644 ui/src/audioplayer/lyricsPreferences.js create mode 100644 ui/src/audioplayer/lyricsPreferences.test.js diff --git a/ui/src/audioplayer/lyrics.js b/ui/src/audioplayer/lyrics.js index e618be92f..a48be3e83 100644 --- a/ui/src/audioplayer/lyrics.js +++ b/ui/src/audioplayer/lyrics.js @@ -242,6 +242,32 @@ const pickPreferredLyric = (lyrics, preferredLanguage) => { export const hasCueTiming = (structuredLyric) => hasTimedCueLines(structuredLyric) +export const LYRICS_OFFSET_FINE_MS = 50 +export const LYRICS_OFFSET_COARSE_MS = 500 +export const LYRICS_OFFSET_LIMIT_MS = 5000 + +export const clampLyricsOffset = (value) => { + const numeric = Number(value) + if (!Number.isFinite(numeric)) return 0 + return Math.max( + -LYRICS_OFFSET_LIMIT_MS, + Math.min(LYRICS_OFFSET_LIMIT_MS, Math.round(numeric)), + ) +} + +export const stepLyricsOffset = (current, delta) => + clampLyricsOffset((Number(current) || 0) + (Number(delta) || 0)) + +export const formatLyricsOffset = (ms) => { + const seconds = (clampLyricsOffset(ms) / 1000).toFixed(2) + return `${Number(seconds) > 0 ? '+' : ''}${seconds} s` +} + +// hasNudgeableTiming gates the offset control: plain lyrics have nothing to +// shift. +export const hasNudgeableTiming = (lyric) => + hasTimedLines(lyric) || hasCueTiming(lyric) + export const hasStructuredLyricContent = (structuredLyric) => Boolean( structuredLyric && @@ -542,10 +568,11 @@ export const buildKaraokeLinesFromCueLines = ( }) } -export const buildKaraokeLines = (structuredLyric) => { +export const buildKaraokeLines = (structuredLyric, userOffsetMs = 0) => { if (!structuredLyric) return [] - const offset = toTime(structuredLyric.offset) ?? 0 + const offset = + (toTime(structuredLyric.offset) ?? 0) + (toTime(userOffsetMs) ?? 0) const agentLookup = buildAgentLookup(structuredLyric) const baseLines = Array.isArray(structuredLyric.line) ? structuredLyric.line diff --git a/ui/src/audioplayer/lyrics.test.js b/ui/src/audioplayer/lyrics.test.js index 6f63b24f0..4452ffb72 100644 --- a/ui/src/audioplayer/lyrics.test.js +++ b/ui/src/audioplayer/lyrics.test.js @@ -1,10 +1,14 @@ import { describe, expect, it, vi } from 'vitest' import { buildKaraokeLines, + clampLyricsOffset, collectLyricVariants, + formatLyricsOffset, getPreferredLyricLanguage, + hasNudgeableTiming, hasStructuredLyricContent, selectLyricLayers, + stepLyricsOffset, utf8ByteRangeToCodeUnitRange, } from './lyrics' @@ -362,6 +366,86 @@ describe('lyrics helpers', () => { }) }) +describe('lyrics offset', () => { + it('clamps the offset to the limit', () => { + expect(clampLyricsOffset(0)).toBe(0) + expect(clampLyricsOffset(4999)).toBe(4999) + expect(clampLyricsOffset(5001)).toBe(5000) + expect(clampLyricsOffset(-5001)).toBe(-5000) + expect(clampLyricsOffset(Number.NaN)).toBe(0) + }) + + it('steps by the given delta and clamps at the limit', () => { + let offset = 3000 + for (let step = 0; step < 5; step += 1) { + offset = stepLyricsOffset(offset, 500) + } + expect(offset).toBe(5000) + }) + + it('formats the offset for display', () => { + expect(formatLyricsOffset(-50)).toBe('-0.05 s') + expect(formatLyricsOffset(0)).toBe('0.00 s') + expect(formatLyricsOffset(1250)).toBe('+1.25 s') + }) + + it('reports whether a lyric can take a timing nudge', () => { + expect( + hasNudgeableTiming({ + kind: 'main', + lang: 'en', + synced: false, + line: [{ value: 'plain words' }], + }), + ).toBe(false) + expect(hasNudgeableTiming(structured('main', 'en', 'timed line'))).toBe( + true, + ) + expect( + hasNudgeableTiming({ + kind: 'main', + lang: 'en', + synced: false, + line: [{ value: 'word timed' }], + cueLine: [ + { index: 0, value: 'word timed', cue: [{ start: 0, value: 'word' }] }, + ], + }), + ).toBe(true) + }) + + it('applies the user offset on top of the source offset', () => { + const lines = buildKaraokeLines( + { ...structured('main', 'en', 'Hello'), offset: 500 }, + -300, + ) + + expect(lines[0].start).toBe(200) + }) + + it('shifts cue word starts by the user offset', () => { + const lines = buildKaraokeLines( + { + kind: 'main', + lang: 'en', + synced: true, + line: [timed('Hello world', 8000, 11000)], + cueLine: [ + cueLine(0, 'Hello world', 8000, 11000, [ + { start: 8000, value: 'Hello ' }, + { start: 9500, value: 'world' }, + ]), + ], + }, + -500, + ) + + expect(lines[0].start).toBe(7500) + expect(lines[0].tokens[0].start).toBe(7500) + expect(lines[0].tokens[1].start).toBe(9000) + }) +}) + describe('lyrics variants', () => { it('groups main entries from the same source into one variant', () => { const lyrics = [ diff --git a/ui/src/audioplayer/lyricsPreferences.js b/ui/src/audioplayer/lyricsPreferences.js new file mode 100644 index 000000000..417110cad --- /dev/null +++ b/ui/src/audioplayer/lyricsPreferences.js @@ -0,0 +1,62 @@ +// Local-only lyrics preferences, keyed per track (and per source for the +// timing offset). Storage can be unavailable (private mode, blocked +// third-party contexts), so every access degrades to session behavior. + +const storage = () => + typeof window === 'undefined' ? null : window.localStorage + +const preferredSourceKey = (trackId) => `lyrics.source:${trackId}` + +const offsetStorageKey = (trackId, sourceKey) => + `lyrics.offset:${trackId}:${sourceKey}` + +export const readPreferredSource = (trackId) => { + if (!trackId) return null + try { + const value = storage()?.getItem(preferredSourceKey(trackId)) + return value && value.trim() !== '' ? value : null + } catch { + return null + } +} + +export const writePreferredSource = (trackId, sourceKey) => { + if (!trackId) return + try { + const key = preferredSourceKey(trackId) + if (sourceKey && String(sourceKey).trim() !== '') { + storage()?.setItem(key, String(sourceKey)) + } else { + storage()?.removeItem(key) + } + } catch { + // Session-only fallback. + } +} + +export const readLyricsOffset = (trackId, sourceKey) => { + if (!trackId || !sourceKey) return 0 + try { + const raw = storage()?.getItem(offsetStorageKey(trackId, sourceKey)) + if (raw == null || raw === '') return 0 + const value = Number(raw) + return Number.isFinite(value) ? value : 0 + } catch { + return 0 + } +} + +export const writeLyricsOffset = (trackId, sourceKey, offsetMs) => { + if (!trackId || !sourceKey) return + try { + const key = offsetStorageKey(trackId, sourceKey) + const value = Number(offsetMs) + if (!Number.isFinite(value) || value === 0) { + storage()?.removeItem(key) + } else { + storage()?.setItem(key, String(value)) + } + } catch { + // Session-only fallback. + } +} diff --git a/ui/src/audioplayer/lyricsPreferences.test.js b/ui/src/audioplayer/lyricsPreferences.test.js new file mode 100644 index 000000000..67519a442 --- /dev/null +++ b/ui/src/audioplayer/lyricsPreferences.test.js @@ -0,0 +1,69 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + readLyricsOffset, + readPreferredSource, + writeLyricsOffset, + writePreferredSource, +} from './lyricsPreferences' + +describe('lyrics preferences', () => { + afterEach(() => { + localStorage.clear() + vi.restoreAllMocks() + }) + + it('stores and reads the preferred source per track', () => { + writePreferredSource('song-1', 'netease') + + expect(readPreferredSource('song-1')).toBe('netease') + expect(localStorage.getItem('lyrics.source:song-1')).toBe('netease') + + writePreferredSource('song-1', null) + expect(readPreferredSource('song-1')).toBeNull() + expect(localStorage.getItem('lyrics.source:song-1')).toBeNull() + }) + + it('returns no preference for missing, blank, or unknown tracks', () => { + expect(readPreferredSource('song-2')).toBeNull() + expect(readPreferredSource(undefined)).toBeNull() + + writePreferredSource('song-2', ' ') + expect(readPreferredSource('song-2')).toBeNull() + }) + + it('stores the offset per track and source', () => { + writeLyricsOffset('song-1', 'netease', -250) + + expect(readLyricsOffset('song-1', 'netease')).toBe(-250) + expect(readLyricsOffset('song-1', 'lrclib')).toBe(0) + + writeLyricsOffset('song-1', 'netease', 0) + expect(localStorage.getItem('lyrics.offset:song-1:netease')).toBeNull() + }) + + it('returns zero for a corrupted stored offset', () => { + localStorage.setItem('lyrics.offset:song-1:netease', 'not-a-number') + + expect(readLyricsOffset('song-1', 'netease')).toBe(0) + }) + + it('degrades silently when storage is unavailable', () => { + const original = Object.getOwnPropertyDescriptor(window, 'localStorage') + const deny = () => { + throw new DOMException('Access denied', 'SecurityError') + } + Object.defineProperty(window, 'localStorage', { + configurable: true, + value: { getItem: deny, setItem: deny, removeItem: deny }, + }) + + try { + expect(() => writePreferredSource('song-1', 'netease')).not.toThrow() + expect(() => writeLyricsOffset('song-1', 'netease', -250)).not.toThrow() + expect(readPreferredSource('song-1')).toBeNull() + expect(readLyricsOffset('song-1', 'netease')).toBe(0) + } finally { + if (original) Object.defineProperty(window, 'localStorage', original) + } + }) +})