diff --git a/ui/src/audioplayer/lyricsExport.js b/ui/src/audioplayer/lyricsExport.js new file mode 100644 index 000000000..c83f74aec --- /dev/null +++ b/ui/src/audioplayer/lyricsExport.js @@ -0,0 +1,175 @@ +// Serializes a structured lyric (already parsed by Navidrome) back into the +// format its source reported, for download-to-device. Timestamps are absolute: +// the source's own offset is already folded into them by the response, and the +// listener's local nudge is deliberately not exported. + +const pad = (value, length = 2) => String(value).padStart(length, '0') + +export const lyricsFileExtension = (format) => { + const normalized = String(format || '') + .trim() + .toLowerCase() + if (normalized === 'ttml') return 'ttml' + if (normalized === 'lrc' || normalized === 'elrc' || normalized === 'srt') { + return 'lrc' + } + return 'txt' +} + +const sanitizePart = (value, fallback) => { + const cleaned = String(value ?? '') + .replace(/[\\/:*?"<>|]+/g, '') + .replace(/\s+/g, ' ') + .trim() + .replace(/\.+$/, '') + return cleaned || fallback +} + +export const buildLyricsFileName = ({ artist, title, label, format } = {}) => + `${sanitizePart(artist, 'Unknown artist')} - ${sanitizePart( + title, + 'Unknown title', + )} (${sanitizePart(label, 'Lyrics')}).${lyricsFileExtension(format)}` + +const escapeXml = (value) => + String(value ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + +// LRC stamps use centiseconds; timestamps clamp at zero because LRC has no +// negative time. Past 99 minutes the hours form keeps the field width valid. +const lrcStamp = (ms, open = '[', close = ']') => { + const total = Math.max(0, Math.round(Number(ms) || 0)) + const centis = Math.floor(total / 10) + const seconds = Math.floor(centis / 100) + const minutes = Math.floor(seconds / 60) + if (minutes >= 100) { + const hours = Math.floor(minutes / 60) + return `${open}${pad(hours)}:${pad(minutes % 60)}:${pad( + seconds % 60, + )}.${pad(centis % 100)}${close}` + } + return `${open}${pad(minutes)}:${pad(seconds % 60)}.${pad( + centis % 100, + )}${close}` +} + +const ttmlClock = (ms) => { + const total = Math.max(0, Math.round(Number(ms) || 0)) + const hours = Math.floor(total / 3_600_000) + const minutes = Math.floor(total / 60_000) % 60 + const seconds = Math.floor(total / 1_000) % 60 + return `${pad(hours)}:${pad(minutes)}:${pad(seconds)}.${pad( + total % 1_000, + 3, + )}` +} + +const cuesByLineIndex = (lyric) => { + const byIndex = new Map() + for (const cueLine of Array.isArray(lyric.cueLine) ? lyric.cueLine : []) { + const index = Number(cueLine?.index) + if (!Number.isInteger(index)) continue + if (!byIndex.has(index)) byIndex.set(index, []) + byIndex.get(index).push(cueLine) + } + return byIndex +} + +const timedCues = (groups) => + groups.flatMap((cueLine) => + (Array.isArray(cueLine.cue) ? cueLine.cue : []).filter( + (cue) => cue?.start != null, + ), + ) + +const serializeLrc = (lyric, lines) => { + const cuesByIndex = cuesByLineIndex(lyric) + return lines + .map((line, index) => { + const cues = timedCues(cuesByIndex.get(index) || []) + if (cues.length === 0) { + return `${lrcStamp(line?.start)}${line?.value ?? ''}` + } + const body = cues + .map((cue) => `${lrcStamp(cue.start, '<', '>')}${cue.value ?? ''}`) + .join('') + return `${lrcStamp(line?.start)}${body}` + }) + .join('\n') +} + +const serializeTtml = (lyric, lines) => { + const lang = typeof lyric.lang === 'string' ? lyric.lang.trim() : '' + const cuesByIndex = cuesByLineIndex(lyric) + const hasCues = [...cuesByIndex.values()].some( + (groups) => timedCues(groups).length > 0, + ) + const attributes = [ + 'xmlns="http://www.w3.org/ns/ttml"', + 'xmlns:itunes="http://music.apple.com/lyric-ttml-internal"', + ...(hasCues ? ['itunes:timing="Word"'] : []), + ...(lang && lang !== 'xxx' ? [`xml:lang="${escapeXml(lang)}"`] : []), + ] + const paragraphs = lines.map((line, index) => { + const begin = line?.start == null ? '' : ` begin="${ttmlClock(line.start)}"` + const end = line?.end == null ? '' : ` end="${ttmlClock(line.end)}"` + const cues = timedCues(cuesByIndex.get(index) || []) + const content = + cues.length > 0 + ? cues + .map( + (cue) => + `${escapeXml(cue.value)}`, + ) + .join('') + : escapeXml(line?.value) + return `${content}

` + }) + + return [ + '', + ``, + '
', + ...paragraphs, + '
', + ].join('\n') +} + +export const serializeLyrics = (lyric, format) => { + if (!lyric) return '' + const lines = Array.isArray(lyric.line) ? lyric.line : [] + switch (lyricsFileExtension(format)) { + case 'ttml': + return serializeTtml(lyric, lines) + case 'lrc': + return serializeLrc(lyric, lines) + default: + return lines.map((line) => line?.value ?? '').join('\n') + } +} + +export const downloadLyrics = ( + lyric, + { format, artist, title, label } = {}, +) => { + if (!lyric || typeof document === 'undefined') return + const text = serializeLyrics(lyric, format) + if (!text) return + + const extension = lyricsFileExtension(format) + const type = extension === 'ttml' ? 'application/xml' : 'text/plain' + const blob = new Blob([text], { type: `${type};charset=utf-8` }) + const objectUrl = URL.createObjectURL(blob) + const anchor = document.createElement('a') + anchor.href = objectUrl + anchor.download = buildLyricsFileName({ artist, title, label, format }) + document.body.appendChild(anchor) + anchor.click() + anchor.remove() + window.setTimeout(() => URL.revokeObjectURL(objectUrl), 0) +} diff --git a/ui/src/audioplayer/lyricsExport.test.js b/ui/src/audioplayer/lyricsExport.test.js new file mode 100644 index 000000000..da64ca309 --- /dev/null +++ b/ui/src/audioplayer/lyricsExport.test.js @@ -0,0 +1,136 @@ +import { describe, expect, it } from 'vitest' +import { + buildLyricsFileName, + lyricsFileExtension, + serializeLyrics, +} from './lyricsExport' + +const line = (value, start, end) => ({ + start, + ...(end == null ? {} : { end }), + value, +}) + +const cueLine = (index, value, cues) => ({ index, value, cue: cues }) + +const wordLyric = () => ({ + kind: 'main', + lang: 'eng', + synced: true, + line: [line('Hello world', 8000, 11000)], + cueLine: [ + cueLine(0, 'Hello world', [ + { start: 8000, value: 'Hello ' }, + { start: 9500, value: 'world' }, + ]), + ], +}) + +describe('lyrics export', () => { + it('maps source formats to download extensions', () => { + expect(lyricsFileExtension('ttml')).toBe('ttml') + expect(lyricsFileExtension('ELRC')).toBe('lrc') + expect(lyricsFileExtension('srt')).toBe('lrc') + expect(lyricsFileExtension('plain')).toBe('txt') + expect(lyricsFileExtension(undefined)).toBe('txt') + }) + + it('builds a sanitized Artist - Title (source).ext filename', () => { + expect( + buildLyricsFileName({ + artist: 'AC/DC: Live', + title: 'Back? in Black', + label: 'LRCLib', + format: 'elrc', + }), + ).toBe('ACDC Live - Back in Black (LRCLib).lrc') + + expect(buildLyricsFileName({})).toBe( + 'Unknown artist - Unknown title (Lyrics).txt', + ) + }) + + it('serializes line and word timing to enhanced LRC', () => { + expect(serializeLyrics(wordLyric(), 'elrc')).toBe( + '[00:08.00]<00:08.00>Hello <00:09.50>world', + ) + }) + + it('serializes line timing to a plain LRC stamp per line', () => { + const lyric = { + kind: 'main', + lang: 'eng', + synced: true, + line: [line('First line', 8000), line('Second line', 95010)], + } + + expect(serializeLyrics(lyric, 'lrc')).toBe( + '[00:08.00]First line\n[01:35.01]Second line', + ) + }) + + it('switches to hh:mm:ss stamps past 99 minutes and clamps negatives', () => { + const lyric = { + kind: 'main', + lang: 'eng', + synced: false, + line: [line('Long track', 6000500), line('Early line', -300)], + } + + expect(serializeLyrics(lyric, 'lrc')).toBe( + '[01:40:00.50]Long track\n[00:00.00]Early line', + ) + }) + + it('serializes a word-timed variant to TTML with escaped text', () => { + const lyric = { + kind: 'main', + lang: 'eng', + synced: true, + line: [line('A & B', 8000, 11000)], + cueLine: [ + cueLine(0, 'A & B', [{ start: 8000, end: 9500, value: 'A & B' }]), + ], + } + + expect(serializeLyrics(lyric, 'ttml')).toBe( + [ + '', + '', + '
', + '

A & B

', + '
', + ].join('\n'), + ) + }) + + it('serializes untimed lyrics to TTML without a timing attribute', () => { + const lyric = { + kind: 'main', + lang: 'xxx', + synced: false, + line: [line('Plain words')], + } + + expect(serializeLyrics(lyric, 'ttml')).toBe( + [ + '', + '', + '
', + '

Plain words

', + '
', + ].join('\n'), + ) + }) + + it('serializes plain lyrics as text lines', () => { + const lyric = { + kind: 'main', + lang: 'eng', + synced: false, + line: [line('one'), line('two')], + } + + expect(serializeLyrics(lyric, 'plain')).toBe('one\ntwo') + }) +}) diff --git a/ui/src/audioplayer/lyricsSource.js b/ui/src/audioplayer/lyricsSource.js new file mode 100644 index 000000000..07c4468b0 --- /dev/null +++ b/ui/src/audioplayer/lyricsSource.js @@ -0,0 +1,36 @@ +// Display labels for a lyrics source, shared by the controls and the +// download filename. + +const UPPERCASE_SOURCE_VALUES = new Set([ + 'elrc', + 'lrc', + 'srt', + 'ttml', + 'txt', + 'yaml', +]) + +export const formatSourceValue = (value) => { + const normalized = typeof value === 'string' ? value.trim() : '' + if (!normalized) return '' + if (UPPERCASE_SOURCE_VALUES.has(normalized.toLowerCase())) { + return normalized.toUpperCase() + } + if (normalized.toLowerCase() === 'plain') return 'Plain text' + return normalized + .replace(/[-_]+/g, ' ') + .replace(/\b\w/g, (character) => character.toUpperCase()) +} + +export const getSourceName = (source, labels) => { + switch (source?.type) { + case 'embedded': + return labels.embeddedSource || 'Embedded lyrics' + case 'sidecar': + return labels.sidecarSource || 'Sidecar file' + case 'plugin': + return source.name || labels.pluginSource || 'Lyrics plugin' + default: + return source?.name || formatSourceValue(source?.type) || 'Lyrics' + } +} diff --git a/ui/src/audioplayer/lyricsSource.test.js b/ui/src/audioplayer/lyricsSource.test.js new file mode 100644 index 000000000..e60194365 --- /dev/null +++ b/ui/src/audioplayer/lyricsSource.test.js @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest' +import { formatSourceValue, getSourceName } from './lyricsSource' + +describe('lyrics source labels', () => { + it('formats known formats and slug-like values for display', () => { + expect(formatSourceValue('ttml')).toBe('TTML') + expect(formatSourceValue('elrc')).toBe('ELRC') + expect(formatSourceValue('plain')).toBe('Plain text') + expect(formatSourceValue('apple-lyrically')).toBe('Apple Lyrically') + expect(formatSourceValue(' ')).toBe('') + }) + + it('names built-in, plugin, and unknown sources', () => { + expect(getSourceName({ type: 'embedded' }, {})).toBe('Embedded lyrics') + expect(getSourceName({ type: 'sidecar', format: 'lrc' }, {})).toBe( + 'Sidecar file', + ) + expect( + getSourceName( + { type: 'plugin', name: 'Better Lyrics', provider: 'NetEase' }, + {}, + ), + ).toBe('Better Lyrics') + expect(getSourceName({ type: 'plugin' }, {})).toBe('Lyrics plugin') + expect(getSourceName(null, {})).toBe('Lyrics') + }) +})