mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-18 07:30:29 +00:00
feat(ui): add lyrics offset math and local preferences
Signed-off-by: ranokay <github@ranokay.com>
This commit is contained in:
parent
7d3c1bee0c
commit
5bfc80a0db
@ -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
|
||||
|
||||
@ -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 = [
|
||||
|
||||
62
ui/src/audioplayer/lyricsPreferences.js
Normal file
62
ui/src/audioplayer/lyricsPreferences.js
Normal 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.
|
||||
}
|
||||
}
|
||||
69
ui/src/audioplayer/lyricsPreferences.test.js
Normal file
69
ui/src/audioplayer/lyricsPreferences.test.js
Normal 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)
|
||||
}
|
||||
})
|
||||
})
|
||||
Loading…
x
Reference in New Issue
Block a user