feat(ui): show lyrics source details

Signed-off-by: ranokay <github@ranokay.com>
This commit is contained in:
ranokay 2026-08-23 05:42:04 +03:00
commit 6017eaa004
No known key found for this signature in database
6 changed files with 281 additions and 2 deletions

View file

@ -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 (
<div
@ -122,6 +207,68 @@ const LyricsLayerControls = ({
data-testid={testId}
onClick={(event) => event.stopPropagation()}
>
{source && (
<>
<Tooltip title={labels.viewSource || 'View lyrics source'}>
<IconButton
size="small"
onClick={(event) => 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,
})}
>
<InfoOutlinedIcon fontSize="small" />
</IconButton>
</Tooltip>
<Popover
id={sourcePopoverId}
open={sourceOpen}
anchorEl={sourceAnchor}
onClose={() => setSourceAnchor(null)}
anchorOrigin={{ vertical: 'top', horizontal: 'right' }}
transformOrigin={{ vertical: 'bottom', horizontal: 'right' }}
PaperProps={{ className: classes.sourcePopover }}
>
<div
role="dialog"
aria-label={labels.sourceTitle || 'Lyrics source'}
onClick={(event) => event.stopPropagation()}
>
<Typography variant="overline" className={classes.sourceHeading}>
{labels.sourceTitle || 'Lyrics source'}
</Typography>
<Typography variant="subtitle2" className={classes.sourceName}>
{getSourceName(source, labels)}
</Typography>
{sourceDetails.length > 0 && (
<div className={classes.sourceDetails}>
{sourceDetails.map((detail) => (
<React.Fragment key={detail.label}>
<Typography
variant="caption"
className={classes.sourceDetailLabel}
>
{detail.label}
</Typography>
<Typography
variant="caption"
className={classes.sourceDetailValue}
>
{detail.value}
</Typography>
</React.Fragment>
))}
</div>
)}
</div>
</Popover>
</>
)}
<LayerButton
active={showPronunciation}
classes={classes}

View file

@ -0,0 +1,89 @@
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
} from '@testing-library/react'
import React from 'react'
import { afterEach, describe, expect, it } from 'vitest'
import LyricsLayerControls from './LyricsLayerControls'
const labels = {
viewSource: 'View lyrics source',
sourceTitle: 'Lyrics source',
embeddedSource: 'Embedded lyrics',
sidecarSource: 'Sidecar file',
pluginSource: 'Lyrics plugin',
provider: 'Provider',
format: 'Format',
}
const renderControls = (source) =>
render(
<LyricsLayerControls
showTranslation={false}
showPronunciation={false}
translationEnabled={false}
pronunciationEnabled={false}
onToggleTranslation={() => {}}
onTogglePronunciation={() => {}}
labels={labels}
source={source}
/>,
)
describe('<LyricsLayerControls /> 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'),
)
})
})

View file

@ -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"
/>

View file

@ -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

View file

@ -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,

View file

@ -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",