diff --git a/ui/src/audioplayer/LyricsLayerControls.jsx b/ui/src/audioplayer/LyricsLayerControls.jsx
index 62c43f37a..bcb396e44 100644
--- a/ui/src/audioplayer/LyricsLayerControls.jsx
+++ b/ui/src/audioplayer/LyricsLayerControls.jsx
@@ -1,10 +1,47 @@
import IconButton from '@material-ui/core/IconButton'
+import Popover from '@material-ui/core/Popover'
import Tooltip from '@material-ui/core/Tooltip'
+import Typography from '@material-ui/core/Typography'
import { alpha, makeStyles } from '@material-ui/core/styles'
+import InfoOutlinedIcon from '@material-ui/icons/InfoOutlined'
import RecordVoiceOverIcon from '@material-ui/icons/RecordVoiceOver'
import TranslateIcon from '@material-ui/icons/Translate'
import clsx from 'clsx'
-import React from 'react'
+import React, { useEffect, useState } from 'react'
+
+const UPPERCASE_SOURCE_VALUES = new Set([
+ 'elrc',
+ 'lrc',
+ 'srt',
+ 'ttml',
+ 'txt',
+ 'yaml',
+])
+
+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())
+}
+
+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'
+ }
+}
const useStyles = makeStyles(
(theme) => ({
@@ -66,6 +103,36 @@ const useStyles = makeStyles(
},
},
controlActive: {},
+ sourcePopover: {
+ minWidth: 200,
+ maxWidth: 280,
+ padding: theme.spacing(1.5, 2),
+ },
+ sourceHeading: {
+ display: 'block',
+ color: alpha(theme.palette.text.primary, 0.58),
+ lineHeight: 1.4,
+ },
+ sourceName: {
+ marginTop: theme.spacing(0.25),
+ color: theme.palette.text.primary,
+ overflowWrap: 'anywhere',
+ },
+ sourceDetails: {
+ display: 'grid',
+ gridTemplateColumns: 'auto minmax(0, 1fr)',
+ columnGap: theme.spacing(1.5),
+ rowGap: theme.spacing(0.5),
+ marginTop: theme.spacing(1),
+ },
+ sourceDetailLabel: {
+ color: alpha(theme.palette.text.primary, 0.58),
+ },
+ sourceDetailValue: {
+ color: theme.palette.text.primary,
+ textAlign: 'right',
+ overflowWrap: 'anywhere',
+ },
}),
{ name: 'NDLyricsLayerControls' },
)
@@ -108,10 +175,28 @@ const LyricsLayerControls = ({
pronunciationEnabled,
onToggleTranslation,
onTogglePronunciation,
+ source,
labels = {},
testId = 'lyrics-layer-controls',
}) => {
const classes = useStyles()
+ const [sourceAnchor, setSourceAnchor] = useState(null)
+ const sourcePopoverId = `${testId}-source-popover`
+ const sourceOpen = Boolean(source && sourceAnchor)
+ const sourceDetails = [
+ {
+ label: labels.provider || 'Provider',
+ value: formatSourceValue(source?.provider),
+ },
+ {
+ label: labels.format || 'Format',
+ value: formatSourceValue(source?.format),
+ },
+ ].filter((detail) => detail.value)
+
+ useEffect(() => {
+ if (!source) setSourceAnchor(null)
+ }, [source])
return (
event.stopPropagation()}
>
+ {source && (
+ <>
+
+ setSourceAnchor(event.currentTarget)}
+ aria-label={labels.viewSource || 'View lyrics source'}
+ aria-haspopup="dialog"
+ aria-expanded={sourceOpen}
+ aria-controls={sourceOpen ? sourcePopoverId : undefined}
+ data-testid="lyrics-source-button"
+ className={clsx(classes.controlButton, {
+ [classes.controlActive]: sourceOpen,
+ })}
+ >
+
+
+
+
setSourceAnchor(null)}
+ anchorOrigin={{ vertical: 'top', horizontal: 'right' }}
+ transformOrigin={{ vertical: 'bottom', horizontal: 'right' }}
+ PaperProps={{ className: classes.sourcePopover }}
+ >
+ event.stopPropagation()}
+ >
+
+ {labels.sourceTitle || 'Lyrics source'}
+
+
+ {getSourceName(source, labels)}
+
+ {sourceDetails.length > 0 && (
+
+ {sourceDetails.map((detail) => (
+
+
+ {detail.label}
+
+
+ {detail.value}
+
+
+ ))}
+
+ )}
+
+
+ >
+ )}
+ render(
+ {}}
+ onTogglePronunciation={() => {}}
+ labels={labels}
+ source={source}
+ />,
+ )
+
+describe(' source details', () => {
+ afterEach(cleanup)
+
+ it('omits the source control when provenance is unavailable', () => {
+ renderControls(null)
+
+ expect(
+ screen.queryByRole('button', { name: 'View lyrics source' }),
+ ).not.toBeInTheDocument()
+ })
+
+ it.each([
+ [{ type: 'embedded' }, 'Embedded lyrics', null],
+ [{ type: 'sidecar', format: 'lrc' }, 'Sidecar file', 'LRC'],
+ ])('shows built-in source details', (source, name, format) => {
+ renderControls(source)
+
+ fireEvent.click(screen.getByRole('button', { name: 'View lyrics source' }))
+
+ expect(screen.getByText(name)).toBeVisible()
+ if (format) expect(screen.getByText(format)).toBeVisible()
+ })
+
+ it('shows plugin, provider, and format without exposing extra metadata', async () => {
+ renderControls({
+ type: 'plugin',
+ name: 'Better Lyrics',
+ provider: 'unison',
+ format: 'ttml',
+ path: '/music/private/song.ttml',
+ })
+
+ const button = screen.getByRole('button', { name: 'View lyrics source' })
+ fireEvent.click(button)
+
+ expect(button).toHaveAttribute('aria-expanded', 'true')
+ const dialog = screen.getByRole('dialog', { name: 'Lyrics source' })
+ expect(dialog).toBeVisible()
+ expect(screen.getByText('Better Lyrics')).toBeVisible()
+ expect(screen.getByText('Unison')).toBeVisible()
+ expect(screen.getByText('TTML')).toBeVisible()
+ expect(
+ screen.queryByText('/music/private/song.ttml'),
+ ).not.toBeInTheDocument()
+
+ fireEvent.keyDown(dialog.closest('[role="presentation"]'), {
+ key: 'Escape',
+ code: 'Escape',
+ })
+ await waitFor(() =>
+ expect(button).toHaveAttribute('aria-expanded', 'false'),
+ )
+ })
+})
diff --git a/ui/src/audioplayer/LyricsSidebar.jsx b/ui/src/audioplayer/LyricsSidebar.jsx
index ae4995f32..42a54029b 100644
--- a/ui/src/audioplayer/LyricsSidebar.jsx
+++ b/ui/src/audioplayer/LyricsSidebar.jsx
@@ -97,6 +97,7 @@ const LyricsSidebar = ({
showPronunciation,
translationEnabled,
pronunciationEnabled,
+ source,
onToggleTranslation,
onTogglePronunciation,
audioInstance,
@@ -278,6 +279,7 @@ const LyricsSidebar = ({
translationEnabled={translationEnabled}
onTogglePronunciation={onTogglePronunciation}
onToggleTranslation={onToggleTranslation}
+ source={source}
labels={labels}
testId="lyrics-sidebar-floating-controls"
/>
diff --git a/ui/src/audioplayer/useEnhancedLyrics.test.js b/ui/src/audioplayer/useEnhancedLyrics.test.js
index 9ad835cc2..9e871c873 100644
--- a/ui/src/audioplayer/useEnhancedLyrics.test.js
+++ b/ui/src/audioplayer/useEnhancedLyrics.test.js
@@ -13,7 +13,7 @@ vi.mock('../subsonic', () => ({
},
}))
-const responseFor = (value, lang = 'en') => ({
+const responseFor = (value, lang = 'en', source) => ({
json: {
'subsonic-response': {
lyricsList: {
@@ -23,6 +23,7 @@ const responseFor = (value, lang = 'en') => ({
lang,
synced: true,
line: [{ start: 0, value }],
+ ...(source ? { source } : {}),
},
],
},
@@ -80,6 +81,23 @@ describe('useEnhancedLyrics', () => {
expect(subsonic.getLyricsBySongId).toHaveBeenCalledTimes(2)
})
+ it('preserves source provenance on the selected lyric layer', async () => {
+ const source = {
+ type: 'plugin',
+ name: 'Better Lyrics',
+ provider: 'kugou',
+ format: 'lrc',
+ }
+ subsonic.getLyricsBySongId.mockResolvedValueOnce(
+ responseFor('Sourced lyrics', 'en', source),
+ )
+
+ const { result } = renderLyrics({ trackId: 'song-source' })
+
+ await expectLyric(result, 'Sourced lyrics')
+ expect(result.current.layers.main.source).toEqual(source)
+ })
+
it('clears previous lyrics while loading an uncached track', async () => {
const nextRequest = createDeferred()
subsonic.getLyricsBySongId
diff --git a/ui/src/audioplayer/usePlayerLyrics.jsx b/ui/src/audioplayer/usePlayerLyrics.jsx
index 9c09e8cb2..32669facf 100644
--- a/ui/src/audioplayer/usePlayerLyrics.jsx
+++ b/ui/src/audioplayer/usePlayerLyrics.jsx
@@ -40,6 +40,11 @@ const usePlayerLyrics = ({
const hasPronunciationLyric = hasStructuredLyricContent(
lyricLayers.pronunciation,
)
+ const lyricsSource =
+ lyricLayers.main?.source ||
+ lyricLayers.translation?.source ||
+ lyricLayers.pronunciation?.source ||
+ null
const { lyricsVisible, showTranslation, showPronunciation } =
resolveLyricsSidebarState({
lyricsVisiblePreference,
@@ -69,6 +74,13 @@ const usePlayerLyrics = ({
hideTranslation: translate('player.hideLyricsTranslationText'),
showPronunciation: translate('player.showLyricsPronunciationText'),
hidePronunciation: translate('player.hideLyricsPronunciationText'),
+ viewSource: translate('player.viewLyricsSourceText'),
+ sourceTitle: translate('player.lyricsSourceText'),
+ embeddedSource: translate('player.embeddedLyricsSourceText'),
+ sidecarSource: translate('player.sidecarLyricsSourceText'),
+ pluginSource: translate('player.pluginLyricsSourceText'),
+ provider: translate('player.lyricsProviderText'),
+ format: translate('player.lyricsFormatText'),
}),
[lyricsRetryAfterSeconds, translate],
)
@@ -120,6 +132,7 @@ const usePlayerLyrics = ({
showPronunciation,
translationEnabled: hasTranslationLyric,
pronunciationEnabled: hasPronunciationLyric,
+ source: lyricsSource,
onToggleTranslation: toggleTranslation,
onTogglePronunciation: togglePronunciation,
audioInstance,
@@ -141,6 +154,7 @@ const usePlayerLyrics = ({
lyricLayers.translation,
lyricsError,
lyricsLoading,
+ lyricsSource,
lyricsVisible,
obscuredByQueue,
showPronunciation,
@@ -180,6 +194,7 @@ const usePlayerLyrics = ({
pronunciationEnabled={hasPronunciationLyric}
onToggleTranslation={toggleTranslation}
onTogglePronunciation={togglePronunciation}
+ source={lyricsSource}
labels={labels}
testId="lyrics-mobile-layer-controls"
/>
@@ -194,6 +209,7 @@ const usePlayerLyrics = ({
lyricLayers.translation,
lyricsError,
lyricsLoading,
+ lyricsSource,
labels,
obscuredByQueue,
showPronunciation,
diff --git a/ui/src/i18n/en.json b/ui/src/i18n/en.json
index 46c4a82aa..01ae30d1a 100644
--- a/ui/src/i18n/en.json
+++ b/ui/src/i18n/en.json
@@ -664,6 +664,13 @@
"hideLyricsTranslationText": "Hide translation",
"showLyricsPronunciationText": "Show pronunciation",
"hideLyricsPronunciationText": "Hide pronunciation",
+ "viewLyricsSourceText": "View lyrics source",
+ "lyricsSourceText": "Lyrics source",
+ "embeddedLyricsSourceText": "Embedded lyrics",
+ "sidecarLyricsSourceText": "Sidecar file",
+ "pluginLyricsSourceText": "Lyrics plugin",
+ "lyricsProviderText": "Provider",
+ "lyricsFormatText": "Format",
"toggleMiniModeText": "Minimize",
"destroyText": "Destroy",
"downloadText": "Download",