feat(ui): add lyrics offset math and local preferences

Signed-off-by: ranokay <github@ranokay.com>
This commit is contained in:
ranokay 2026-10-17 12:15:13 +03:00
parent 7d3c1bee0c
commit 5bfc80a0db
No known key found for this signature in database
4 changed files with 244 additions and 2 deletions

View File

@ -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

View File

@ -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 = [

View File

@ -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.
}
}

View File

@ -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)
}
})
})