mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-31 15:37:51 +01:00
feat(ui): show lyrics source details
Signed-off-by: ranokay <github@ranokay.com>
This commit is contained in:
parent
e90d94ff06
commit
6017eaa004
6 changed files with 281 additions and 2 deletions
|
|
@ -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}
|
||||
|
|
|
|||
89
ui/src/audioplayer/LyricsLayerControls.test.jsx
Normal file
89
ui/src/audioplayer/LyricsLayerControls.test.jsx
Normal 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'),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -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"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue