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(
+ [
+ '',
+ '',
+ '',
+ ].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(
+ [
+ '',
+ '',
+ '',
+ ].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')
+ })
+})