From 6017eaa00478b92bf00231f6c54852491b517009 Mon Sep 17 00:00:00 2001 From: ranokay Date: Sun, 23 Aug 2026 05:42:04 +0300 Subject: [PATCH] feat(ui): show lyrics source details Signed-off-by: ranokay --- ui/src/audioplayer/LyricsLayerControls.jsx | 149 +++++++++++++++++- .../audioplayer/LyricsLayerControls.test.jsx | 89 +++++++++++ ui/src/audioplayer/LyricsSidebar.jsx | 2 + ui/src/audioplayer/useEnhancedLyrics.test.js | 20 ++- ui/src/audioplayer/usePlayerLyrics.jsx | 16 ++ ui/src/i18n/en.json | 7 + 6 files changed, 281 insertions(+), 2 deletions(-) create mode 100644 ui/src/audioplayer/LyricsLayerControls.test.jsx 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",