mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-18 07:30:29 +00:00
feat(ui): add per-song lyrics source, offset and download controls
Signed-off-by: ranokay <github@ranokay.com>
This commit is contained in:
parent
cfc3c66f14
commit
58fdfb4e65
@ -1,45 +1,38 @@
|
||||
import Button from '@material-ui/core/Button'
|
||||
import CircularProgress from '@material-ui/core/CircularProgress'
|
||||
import IconButton from '@material-ui/core/IconButton'
|
||||
import Menu from '@material-ui/core/Menu'
|
||||
import MenuItem from '@material-ui/core/MenuItem'
|
||||
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 AvTimerIcon from '@material-ui/icons/AvTimer'
|
||||
import GetAppIcon from '@material-ui/icons/GetApp'
|
||||
import LibraryMusicIcon from '@material-ui/icons/LibraryMusic'
|
||||
import RecordVoiceOverIcon from '@material-ui/icons/RecordVoiceOver'
|
||||
import RotateLeftIcon from '@material-ui/icons/RotateLeft'
|
||||
import TranslateIcon from '@material-ui/icons/Translate'
|
||||
import clsx from 'clsx'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import React, { useCallback, useEffect, useState } from 'react'
|
||||
import {
|
||||
LYRICS_OFFSET_COARSE_MS,
|
||||
LYRICS_OFFSET_FINE_MS,
|
||||
formatLyricsOffset,
|
||||
stepLyricsOffset,
|
||||
} from './lyrics'
|
||||
import { formatSourceValue, getVariantLabel } from './lyricsSource'
|
||||
|
||||
const UPPERCASE_SOURCE_VALUES = new Set([
|
||||
'elrc',
|
||||
'lrc',
|
||||
'srt',
|
||||
'ttml',
|
||||
'txt',
|
||||
'yaml',
|
||||
])
|
||||
const noop = () => {}
|
||||
|
||||
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'
|
||||
const syncLabelFor = (sync, labels) => {
|
||||
switch (sync) {
|
||||
case 'word':
|
||||
return labels.wordSynced || 'Word-synced'
|
||||
case 'line':
|
||||
return labels.lineSynced || 'Line-synced'
|
||||
default:
|
||||
return source?.name || formatSourceValue(source?.type) || 'Lyrics'
|
||||
return labels.plainSynced || 'Plain text'
|
||||
}
|
||||
}
|
||||
|
||||
@ -103,36 +96,57 @@ const useStyles = makeStyles(
|
||||
},
|
||||
},
|
||||
controlActive: {},
|
||||
sourcePopover: {
|
||||
minWidth: 200,
|
||||
maxWidth: 280,
|
||||
padding: theme.spacing(1.5, 2),
|
||||
sourceMenu: {
|
||||
minWidth: 210,
|
||||
maxWidth: 320,
|
||||
},
|
||||
sourceHeader: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: theme.spacing(1),
|
||||
padding: theme.spacing(1, 2, 0.5),
|
||||
},
|
||||
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: {
|
||||
sourceSpinner: {
|
||||
color: alpha(theme.palette.text.primary, 0.58),
|
||||
},
|
||||
sourceDetailValue: {
|
||||
color: theme.palette.text.primary,
|
||||
textAlign: 'right',
|
||||
sourceItem: {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
gap: theme.spacing(2),
|
||||
},
|
||||
variantLabel: {
|
||||
overflowWrap: 'anywhere',
|
||||
},
|
||||
variantMeta: {
|
||||
color: alpha(theme.palette.text.primary, 0.58),
|
||||
whiteSpace: 'nowrap',
|
||||
},
|
||||
offsetPopover: {
|
||||
padding: theme.spacing(1, 1.5),
|
||||
},
|
||||
offsetRow: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: theme.spacing(0.5),
|
||||
},
|
||||
offsetStep: {
|
||||
minWidth: 0,
|
||||
padding: theme.spacing(0.25, 0.75),
|
||||
color: alpha(theme.palette.text.primary, 0.78),
|
||||
whiteSpace: 'nowrap',
|
||||
},
|
||||
offsetValue: {
|
||||
minWidth: 64,
|
||||
textAlign: 'center',
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
color: theme.palette.text.primary,
|
||||
},
|
||||
}),
|
||||
{ name: 'NDLyricsLayerControls' },
|
||||
)
|
||||
@ -175,29 +189,53 @@ const LyricsLayerControls = ({
|
||||
pronunciationEnabled,
|
||||
onToggleTranslation,
|
||||
onTogglePronunciation,
|
||||
source,
|
||||
variants = [],
|
||||
selectedSourceKey = null,
|
||||
onSelectSource = noop,
|
||||
onSourceMenuOpenChange = noop,
|
||||
refreshing = false,
|
||||
offsetMs = 0,
|
||||
offsetEnabled = false,
|
||||
onOffsetChange = noop,
|
||||
onDownload = noop,
|
||||
active = true,
|
||||
labels = {},
|
||||
testId = 'lyrics-layer-controls',
|
||||
}) => {
|
||||
const classes = useStyles()
|
||||
const [sourceAnchor, setSourceAnchor] = useState(null)
|
||||
const sourcePopoverId = `${testId}-source-popover`
|
||||
const sourceOpen = Boolean(active && source && sourceAnchor)
|
||||
const sourceDetails = [
|
||||
{
|
||||
label: labels.provider || 'Provider',
|
||||
value: formatSourceValue(source?.provider),
|
||||
},
|
||||
{
|
||||
label: labels.format || 'Format',
|
||||
value: formatSourceValue(source?.format),
|
||||
},
|
||||
].filter((detail) => detail.value)
|
||||
const [offsetAnchor, setOffsetAnchor] = useState(null)
|
||||
const sourceMenuId = `${testId}-source-menu`
|
||||
const offsetPopoverId = `${testId}-offset-popover`
|
||||
const hasVariants = variants.length > 0
|
||||
const sourceOpen = Boolean(active && hasVariants && sourceAnchor)
|
||||
const offsetOpen = Boolean(active && offsetEnabled && offsetAnchor)
|
||||
const offsetLabel = labels.offset || 'Lyrics timing offset'
|
||||
|
||||
useEffect(() => {
|
||||
if (!active || !source) setSourceAnchor(null)
|
||||
}, [active, source])
|
||||
if (!sourceOpen) return undefined
|
||||
onSourceMenuOpenChange(true)
|
||||
return () => onSourceMenuOpenChange(false)
|
||||
}, [onSourceMenuOpenChange, sourceOpen])
|
||||
|
||||
useEffect(() => {
|
||||
if (!active || !hasVariants) setSourceAnchor(null)
|
||||
}, [active, hasVariants])
|
||||
|
||||
useEffect(() => {
|
||||
if (!active || !offsetEnabled) setOffsetAnchor(null)
|
||||
}, [active, offsetEnabled])
|
||||
|
||||
const selectSource = useCallback(
|
||||
(sourceKey) => {
|
||||
setSourceAnchor(null)
|
||||
onSelectSource(sourceKey)
|
||||
},
|
||||
[onSelectSource],
|
||||
)
|
||||
|
||||
const stepOffset = (delta) =>
|
||||
onOffsetChange(stepLyricsOffset(offsetMs, delta))
|
||||
|
||||
return (
|
||||
<div
|
||||
@ -208,68 +246,187 @@ const LyricsLayerControls = ({
|
||||
data-testid={testId}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
{source && (
|
||||
{hasVariants && (
|
||||
<>
|
||||
<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-haspopup="menu"
|
||||
aria-expanded={sourceOpen}
|
||||
aria-controls={sourceOpen ? sourcePopoverId : undefined}
|
||||
aria-controls={sourceOpen ? sourceMenuId : undefined}
|
||||
data-testid="lyrics-source-button"
|
||||
className={clsx(classes.controlButton, {
|
||||
[classes.controlActive]: sourceOpen,
|
||||
})}
|
||||
>
|
||||
<InfoOutlinedIcon fontSize="small" />
|
||||
<LibraryMusicIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Popover
|
||||
id={sourcePopoverId}
|
||||
open={sourceOpen}
|
||||
<Menu
|
||||
id={sourceMenuId}
|
||||
anchorEl={sourceAnchor}
|
||||
open={sourceOpen}
|
||||
onClose={() => setSourceAnchor(null)}
|
||||
anchorOrigin={{ vertical: 'top', horizontal: 'right' }}
|
||||
transformOrigin={{ vertical: 'bottom', horizontal: 'right' }}
|
||||
PaperProps={{ className: classes.sourcePopover }}
|
||||
PaperProps={{ className: classes.sourceMenu }}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-label={labels.sourceTitle || 'Lyrics source'}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<li className={classes.sourceHeader} role="presentation">
|
||||
<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>
|
||||
{refreshing && (
|
||||
<CircularProgress
|
||||
size={12}
|
||||
className={classes.sourceSpinner}
|
||||
aria-label={
|
||||
labels.refreshingSources || 'Looking for more sources'
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Popover>
|
||||
</li>
|
||||
<MenuItem
|
||||
role="menuitemradio"
|
||||
aria-checked={selectedSourceKey == null}
|
||||
selected={selectedSourceKey == null}
|
||||
onClick={() => selectSource(null)}
|
||||
className={classes.sourceItem}
|
||||
data-testid="lyrics-source-auto"
|
||||
>
|
||||
{labels.autoSource || 'Auto (best match)'}
|
||||
</MenuItem>
|
||||
{variants.map((variant) => (
|
||||
<MenuItem
|
||||
key={variant.key}
|
||||
role="menuitemradio"
|
||||
aria-checked={selectedSourceKey === variant.key}
|
||||
selected={selectedSourceKey === variant.key}
|
||||
onClick={() => selectSource(variant.key)}
|
||||
className={classes.sourceItem}
|
||||
data-testid={`lyrics-source-${variant.key}`}
|
||||
>
|
||||
<span className={classes.variantLabel}>
|
||||
{getVariantLabel(variant.source, labels)}
|
||||
</span>
|
||||
<span className={classes.variantMeta}>
|
||||
{[
|
||||
formatSourceValue(variant.source?.format),
|
||||
syncLabelFor(variant.sync, labels),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')}
|
||||
</span>
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
</>
|
||||
)}
|
||||
<Tooltip title={offsetLabel}>
|
||||
<span>
|
||||
<IconButton
|
||||
size="small"
|
||||
disabled={!offsetEnabled}
|
||||
onClick={(event) => setOffsetAnchor(event.currentTarget)}
|
||||
aria-label={`${offsetLabel}: ${formatLyricsOffset(offsetMs)}`}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={offsetOpen}
|
||||
aria-controls={offsetOpen ? offsetPopoverId : undefined}
|
||||
data-testid="lyrics-offset-button"
|
||||
className={clsx(classes.controlButton, {
|
||||
[classes.controlActive]: offsetOpen || offsetMs !== 0,
|
||||
})}
|
||||
>
|
||||
<AvTimerIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Popover
|
||||
id={offsetPopoverId}
|
||||
open={offsetOpen}
|
||||
anchorEl={offsetAnchor}
|
||||
onClose={() => setOffsetAnchor(null)}
|
||||
anchorOrigin={{ vertical: 'top', horizontal: 'right' }}
|
||||
transformOrigin={{ vertical: 'bottom', horizontal: 'right' }}
|
||||
PaperProps={{ className: classes.offsetPopover }}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div
|
||||
className={classes.offsetRow}
|
||||
role="group"
|
||||
aria-label={offsetLabel}
|
||||
>
|
||||
<Button
|
||||
size="small"
|
||||
aria-label={labels.offsetCoarseEarlier}
|
||||
onClick={() => stepOffset(-LYRICS_OFFSET_COARSE_MS)}
|
||||
className={classes.offsetStep}
|
||||
>
|
||||
-0.5 s
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
aria-label={labels.offsetFineEarlier}
|
||||
onClick={() => stepOffset(-LYRICS_OFFSET_FINE_MS)}
|
||||
className={classes.offsetStep}
|
||||
>
|
||||
-0.05 s
|
||||
</Button>
|
||||
<Typography
|
||||
variant="body2"
|
||||
component="span"
|
||||
className={classes.offsetValue}
|
||||
>
|
||||
{formatLyricsOffset(offsetMs)}
|
||||
</Typography>
|
||||
<Button
|
||||
size="small"
|
||||
aria-label={labels.offsetFineLater}
|
||||
onClick={() => stepOffset(LYRICS_OFFSET_FINE_MS)}
|
||||
className={classes.offsetStep}
|
||||
>
|
||||
+0.05 s
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
aria-label={labels.offsetCoarseLater}
|
||||
onClick={() => stepOffset(LYRICS_OFFSET_COARSE_MS)}
|
||||
className={classes.offsetStep}
|
||||
>
|
||||
+0.5 s
|
||||
</Button>
|
||||
<Tooltip title={labels.offsetReset || 'Reset timing offset'}>
|
||||
<span>
|
||||
<IconButton
|
||||
size="small"
|
||||
disabled={offsetMs === 0}
|
||||
onClick={() => onOffsetChange(0)}
|
||||
aria-label={labels.offsetReset || 'Reset timing offset'}
|
||||
data-testid="lyrics-offset-reset"
|
||||
className={classes.controlButton}
|
||||
>
|
||||
<RotateLeftIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</Popover>
|
||||
<Tooltip title={labels.download || 'Download lyrics'}>
|
||||
<span>
|
||||
<IconButton
|
||||
size="small"
|
||||
disabled={!hasVariants}
|
||||
onClick={onDownload}
|
||||
aria-label={labels.download || 'Download lyrics'}
|
||||
data-testid="lyrics-download-button"
|
||||
className={classes.controlButton}
|
||||
>
|
||||
<GetAppIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<LayerButton
|
||||
active={showPronunciation}
|
||||
classes={classes}
|
||||
|
||||
@ -6,20 +6,53 @@ import {
|
||||
waitFor,
|
||||
} from '@testing-library/react'
|
||||
import React from 'react'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import LyricsLayerControls from './LyricsLayerControls'
|
||||
|
||||
const labels = {
|
||||
viewSource: 'View lyrics source',
|
||||
sourceTitle: 'Lyrics source',
|
||||
autoSource: 'Auto (best match)',
|
||||
wordSynced: 'Word-synced',
|
||||
lineSynced: 'Line-synced',
|
||||
plainSynced: 'Plain text',
|
||||
embeddedSource: 'Embedded lyrics',
|
||||
sidecarSource: 'Sidecar file',
|
||||
pluginSource: 'Lyrics plugin',
|
||||
provider: 'Provider',
|
||||
format: 'Format',
|
||||
refreshingSources: 'Looking for more sources',
|
||||
offset: 'Lyrics timing offset',
|
||||
offsetCoarseEarlier: 'Shift lyrics 0.5 s earlier',
|
||||
offsetFineEarlier: 'Shift lyrics 0.05 s earlier',
|
||||
offsetCoarseLater: 'Shift lyrics 0.5 s later',
|
||||
offsetFineLater: 'Shift lyrics 0.05 s later',
|
||||
offsetReset: 'Reset timing offset',
|
||||
download: 'Download lyrics',
|
||||
}
|
||||
|
||||
const renderControls = (source) =>
|
||||
const variants = [
|
||||
{
|
||||
key: 'netease',
|
||||
source: {
|
||||
type: 'plugin',
|
||||
name: 'Better Lyrics',
|
||||
provider: 'NetEase',
|
||||
format: 'elrc',
|
||||
},
|
||||
sync: 'word',
|
||||
},
|
||||
{
|
||||
key: 'lrclib',
|
||||
source: {
|
||||
type: 'plugin',
|
||||
name: 'Better Lyrics',
|
||||
provider: 'LRCLib',
|
||||
format: 'lrc',
|
||||
},
|
||||
sync: 'line',
|
||||
},
|
||||
]
|
||||
|
||||
const renderControls = (props = {}) =>
|
||||
render(
|
||||
<LyricsLayerControls
|
||||
showTranslation={false}
|
||||
@ -29,61 +62,156 @@ const renderControls = (source) =>
|
||||
onToggleTranslation={() => {}}
|
||||
onTogglePronunciation={() => {}}
|
||||
labels={labels}
|
||||
source={source}
|
||||
variants={variants}
|
||||
{...props}
|
||||
/>,
|
||||
)
|
||||
|
||||
describe('<LyricsLayerControls /> source details', () => {
|
||||
const openSourceMenu = () =>
|
||||
fireEvent.click(screen.getByRole('button', { name: labels.viewSource }))
|
||||
|
||||
describe('<LyricsLayerControls />', () => {
|
||||
afterEach(cleanup)
|
||||
|
||||
it('omits the source control when provenance is unavailable', () => {
|
||||
renderControls(null)
|
||||
it('omits the source and download controls when no variant has lyrics', () => {
|
||||
renderControls({ variants: [] })
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'View lyrics source' }),
|
||||
screen.queryByRole('button', { name: labels.viewSource }),
|
||||
).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: labels.download })).toBeDisabled()
|
||||
})
|
||||
|
||||
it.each([
|
||||
[{ type: 'embedded' }, 'Embedded lyrics', null],
|
||||
[{ type: 'sidecar', format: 'lrc' }, 'Sidecar file', 'LRC'],
|
||||
])('shows built-in source details', (source, name, format) => {
|
||||
renderControls(source)
|
||||
it('lists auto and every variant with badges and checked state', () => {
|
||||
renderControls({ selectedSourceKey: 'lrclib' })
|
||||
|
||||
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' })
|
||||
const button = screen.getByRole('button', { name: labels.viewSource })
|
||||
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.getByRole('menuitemradio', { name: labels.autoSource }),
|
||||
).toHaveAttribute('aria-checked', 'false')
|
||||
|
||||
const netease = screen.getByRole('menuitemradio', {
|
||||
name: /NetEase/,
|
||||
})
|
||||
expect(netease).toHaveAttribute('aria-checked', 'false')
|
||||
expect(netease).toHaveTextContent('ELRC')
|
||||
expect(netease).toHaveTextContent('Word-synced')
|
||||
|
||||
const lrclib = screen.getByRole('menuitemradio', { name: /LRCLib/ })
|
||||
expect(lrclib).toHaveAttribute('aria-checked', 'true')
|
||||
expect(lrclib).toHaveTextContent('LRC')
|
||||
expect(lrclib).toHaveTextContent('Line-synced')
|
||||
})
|
||||
|
||||
it('names built-in sources and hides extra metadata', () => {
|
||||
renderControls({
|
||||
variants: [
|
||||
{
|
||||
key: 'embedded',
|
||||
source: { type: 'embedded', path: '/music/private/song.ttml' },
|
||||
sync: 'plain',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
openSourceMenu()
|
||||
|
||||
expect(screen.getByText('Embedded lyrics')).toBeVisible()
|
||||
expect(screen.getByText('Plain text')).toBeVisible()
|
||||
expect(
|
||||
screen.queryByText('/music/private/song.ttml'),
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.keyDown(dialog.closest('[role="presentation"]'), {
|
||||
it('calls onSelectSource with the variant key and null for auto', () => {
|
||||
const onSelectSource = vi.fn()
|
||||
renderControls({ onSelectSource })
|
||||
|
||||
openSourceMenu()
|
||||
fireEvent.click(screen.getByRole('menuitemradio', { name: /LRCLib/ }))
|
||||
expect(onSelectSource).toHaveBeenLastCalledWith('lrclib')
|
||||
|
||||
openSourceMenu()
|
||||
fireEvent.click(
|
||||
screen.getByRole('menuitemradio', { name: labels.autoSource }),
|
||||
)
|
||||
expect(onSelectSource).toHaveBeenLastCalledWith(null)
|
||||
})
|
||||
|
||||
it('signals the source menu opening and closing', async () => {
|
||||
const onSourceMenuOpenChange = vi.fn()
|
||||
renderControls({ onSourceMenuOpenChange })
|
||||
|
||||
openSourceMenu()
|
||||
expect(onSourceMenuOpenChange).toHaveBeenLastCalledWith(true)
|
||||
|
||||
const menu = screen.getByRole('menu')
|
||||
fireEvent.keyDown(menu.closest('[role="presentation"]'), {
|
||||
key: 'Escape',
|
||||
code: 'Escape',
|
||||
})
|
||||
await waitFor(() =>
|
||||
expect(button).toHaveAttribute('aria-expanded', 'false'),
|
||||
expect(onSourceMenuOpenChange).toHaveBeenLastCalledWith(false),
|
||||
)
|
||||
})
|
||||
|
||||
it('steps and clamps the offset and resets to zero', () => {
|
||||
const onOffsetChange = vi.fn()
|
||||
renderControls({ offsetMs: 1500, offsetEnabled: true, onOffsetChange })
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', {
|
||||
name: `${labels.offset}: +1.50 s`,
|
||||
}),
|
||||
)
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: labels.offsetFineLater }),
|
||||
)
|
||||
expect(onOffsetChange).toHaveBeenLastCalledWith(1550)
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: labels.offsetCoarseEarlier }),
|
||||
)
|
||||
expect(onOffsetChange).toHaveBeenLastCalledWith(1000)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: labels.offsetReset }))
|
||||
expect(onOffsetChange).toHaveBeenLastCalledWith(0)
|
||||
})
|
||||
|
||||
it('clamps the offset at the limit', () => {
|
||||
const onOffsetChange = vi.fn()
|
||||
renderControls({ offsetMs: 5000, offsetEnabled: true, onOffsetChange })
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: `${labels.offset}: +5.00 s` }),
|
||||
)
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: labels.offsetCoarseLater }),
|
||||
)
|
||||
|
||||
expect(onOffsetChange).toHaveBeenLastCalledWith(5000)
|
||||
})
|
||||
|
||||
it('disables the offset control when the selected variant has no timing', () => {
|
||||
renderControls({ offsetEnabled: false })
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', {
|
||||
name: `${labels.offset}: 0.00 s`,
|
||||
}),
|
||||
).toBeDisabled()
|
||||
})
|
||||
|
||||
it('calls onDownload when the download button is pressed', () => {
|
||||
const onDownload = vi.fn()
|
||||
renderControls({ onDownload })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: labels.download }))
|
||||
|
||||
expect(onDownload).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
@ -414,6 +414,7 @@ const LyricsPanel = ({
|
||||
showTranslation,
|
||||
showPronunciation,
|
||||
audioInstance,
|
||||
offsetMs = 0,
|
||||
inline = false,
|
||||
loading = false,
|
||||
error = null,
|
||||
@ -440,14 +441,17 @@ const LyricsPanel = ({
|
||||
? KARAOKE_INLINE_ACTIVE_LINE_ANCHOR_RATIO
|
||||
: KARAOKE_DESKTOP_ACTIVE_LINE_ANCHOR_RATIO
|
||||
|
||||
const mainLines = useMemo(() => buildKaraokeLines(mainLyric), [mainLyric])
|
||||
const mainLines = useMemo(
|
||||
() => buildKaraokeLines(mainLyric, offsetMs),
|
||||
[mainLyric, offsetMs],
|
||||
)
|
||||
const translationLines = useMemo(
|
||||
() => buildKaraokeLines(translationLyric),
|
||||
[translationLyric],
|
||||
() => buildKaraokeLines(translationLyric, offsetMs),
|
||||
[offsetMs, translationLyric],
|
||||
)
|
||||
const pronunciationLines = useMemo(
|
||||
() => buildKaraokeLines(pronunciationLyric),
|
||||
[pronunciationLyric],
|
||||
() => buildKaraokeLines(pronunciationLyric, offsetMs),
|
||||
[offsetMs, pronunciationLyric],
|
||||
)
|
||||
const hasTimedMainLines = useMemo(
|
||||
() => hasUsableKaraokeTiming(mainLines),
|
||||
|
||||
@ -193,6 +193,28 @@ describe('<LyricsPanel />', () => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
it('applies the per-song user offset to the karaoke timeline', () => {
|
||||
const propsAt = (offsetMs) => ({
|
||||
mainLyric,
|
||||
offsetMs,
|
||||
audioInstance: { currentTime: 1.1, paused: true },
|
||||
})
|
||||
|
||||
const { rerenderPanel } = renderPanel(propsAt(0))
|
||||
|
||||
expect(screen.getByTestId('lyrics-line-group')).toHaveAttribute(
|
||||
'data-highlight-active',
|
||||
'false',
|
||||
)
|
||||
|
||||
rerenderPanel(propsAt(1000))
|
||||
|
||||
expect(screen.getByTestId('lyrics-line-group')).toHaveAttribute(
|
||||
'data-highlight-active',
|
||||
'true',
|
||||
)
|
||||
})
|
||||
|
||||
it('renders main, stacked pronunciation, and translation in layer order', () => {
|
||||
renderPanel({
|
||||
mainLyric: tokenizedMainLyric,
|
||||
|
||||
@ -97,9 +97,17 @@ const LyricsSidebar = ({
|
||||
showPronunciation,
|
||||
translationEnabled,
|
||||
pronunciationEnabled,
|
||||
source,
|
||||
onToggleTranslation,
|
||||
onTogglePronunciation,
|
||||
variants = [],
|
||||
selectedSourceKey = null,
|
||||
onSelectSource,
|
||||
onSourceMenuOpenChange,
|
||||
refreshing = false,
|
||||
offsetMs = 0,
|
||||
offsetEnabled = false,
|
||||
onOffsetChange,
|
||||
onDownload,
|
||||
audioInstance,
|
||||
loading = false,
|
||||
error = null,
|
||||
@ -279,7 +287,15 @@ const LyricsSidebar = ({
|
||||
translationEnabled={translationEnabled}
|
||||
onTogglePronunciation={onTogglePronunciation}
|
||||
onToggleTranslation={onToggleTranslation}
|
||||
source={source}
|
||||
variants={variants}
|
||||
selectedSourceKey={selectedSourceKey}
|
||||
onSelectSource={onSelectSource}
|
||||
onSourceMenuOpenChange={onSourceMenuOpenChange}
|
||||
refreshing={refreshing}
|
||||
offsetMs={offsetMs}
|
||||
offsetEnabled={offsetEnabled}
|
||||
onOffsetChange={onOffsetChange}
|
||||
onDownload={onDownload}
|
||||
active={interactive}
|
||||
labels={labels}
|
||||
testId="lyrics-sidebar-floating-controls"
|
||||
@ -293,6 +309,7 @@ const LyricsSidebar = ({
|
||||
showTranslation={showTranslation}
|
||||
showPronunciation={showPronunciation}
|
||||
audioInstance={audioInstance}
|
||||
offsetMs={offsetMs}
|
||||
loading={loading}
|
||||
error={error}
|
||||
errorMessage={errorMessage}
|
||||
|
||||
@ -37,6 +37,7 @@ const source = {
|
||||
provider: 'ttml',
|
||||
format: 'ttml',
|
||||
}
|
||||
const variants = [{ key: 'better-lyrics', source, sync: 'word' }]
|
||||
const originalPointerEvent = window.PointerEvent
|
||||
|
||||
const ControlledLyricsSidebar = ({
|
||||
@ -190,18 +191,16 @@ describe('<LyricsSidebar />', () => {
|
||||
expect(sidebar).not.toHaveAttribute('inert')
|
||||
})
|
||||
|
||||
it('closes the source popover when the queue obscures the sidebar', async () => {
|
||||
const { rerender } = renderSidebar({ source })
|
||||
it('closes the source menu when the queue obscures the sidebar', async () => {
|
||||
const { rerender } = renderSidebar({ variants })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'View lyrics source' }))
|
||||
expect(screen.getByRole('dialog', { name: 'Lyrics source' })).toBeVisible()
|
||||
expect(screen.getByRole('menu')).toBeVisible()
|
||||
|
||||
rerender(sidebarView({ source, obscuredByQueue: true }))
|
||||
rerender(sidebarView({ variants, obscuredByQueue: true }))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.queryByRole('dialog', { name: 'Lyrics source' }),
|
||||
).not.toBeInTheDocument(),
|
||||
expect(screen.queryByRole('menu')).not.toBeInTheDocument(),
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@ -28,9 +28,12 @@ const { defaultLyricsResponse, useEnhancedLyricsMock } = vi.hoisted(() => {
|
||||
translation: null,
|
||||
pronunciation: null,
|
||||
},
|
||||
variants: [],
|
||||
loading: false,
|
||||
refreshing: false,
|
||||
error: null,
|
||||
retry: vi.fn(),
|
||||
refresh: vi.fn(),
|
||||
}
|
||||
return {
|
||||
defaultLyricsResponse,
|
||||
@ -40,6 +43,18 @@ const { defaultLyricsResponse, useEnhancedLyricsMock } = vi.hoisted(() => {
|
||||
|
||||
const sourcedLyricsResponse = {
|
||||
...defaultLyricsResponse,
|
||||
variants: [
|
||||
{
|
||||
key: 'better-lyrics',
|
||||
source: {
|
||||
type: 'plugin',
|
||||
name: 'Better Lyrics',
|
||||
provider: 'ttml',
|
||||
format: 'ttml',
|
||||
},
|
||||
sync: 'word',
|
||||
},
|
||||
],
|
||||
layers: {
|
||||
...defaultLyricsResponse.layers,
|
||||
main: {
|
||||
@ -214,7 +229,7 @@ describe('<MobileKaraokeLyricsPortal />', () => {
|
||||
expect(layer).toHaveStyle({ pointerEvents: 'auto' })
|
||||
})
|
||||
|
||||
it('closes the source popover when the queue obscures mobile lyrics', async () => {
|
||||
it('closes the source menu when the queue obscures mobile lyrics', async () => {
|
||||
useEnhancedLyricsMock.mockImplementation(() => sourcedLyricsResponse)
|
||||
const host = createHost()
|
||||
const { rerender } = render(<MobileLyricsHarness />)
|
||||
@ -222,12 +237,12 @@ describe('<MobileKaraokeLyricsPortal />', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Toggle lyrics' }))
|
||||
const sourceButton = await within(host).findByTestId('lyrics-source-button')
|
||||
fireEvent.click(sourceButton)
|
||||
expect(screen.getByRole('dialog')).toBeVisible()
|
||||
expect(screen.getByRole('menu')).toBeVisible()
|
||||
|
||||
rerender(<MobileLyricsHarness obscuredByQueue />)
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument(),
|
||||
expect(screen.queryByRole('menu')).not.toBeInTheDocument(),
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@ -34,3 +34,9 @@ export const getSourceName = (source, labels) => {
|
||||
return source?.name || formatSourceValue(source?.type) || 'Lyrics'
|
||||
}
|
||||
}
|
||||
|
||||
// Variant labels identify the upstream that actually served the lyrics: with
|
||||
// a provider the plugin name is shared by every variant and would not
|
||||
// distinguish them.
|
||||
export const getVariantLabel = (source, labels) =>
|
||||
formatSourceValue(source?.provider) || getSourceName(source, labels)
|
||||
|
||||
@ -1,7 +1,21 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { formatSourceValue, getSourceName } from './lyricsSource'
|
||||
import {
|
||||
formatSourceValue,
|
||||
getSourceName,
|
||||
getVariantLabel,
|
||||
} from './lyricsSource'
|
||||
|
||||
describe('lyrics source labels', () => {
|
||||
it('prefers the upstream provider for variant labels', () => {
|
||||
expect(
|
||||
getVariantLabel(
|
||||
{ type: 'plugin', name: 'Better Lyrics', provider: 'NetEase' },
|
||||
{},
|
||||
),
|
||||
).toBe('NetEase')
|
||||
expect(getVariantLabel({ type: 'embedded' }, {})).toBe('Embedded lyrics')
|
||||
expect(getVariantLabel(null, {})).toBe('Lyrics')
|
||||
})
|
||||
it('formats known formats and slug-like values for display', () => {
|
||||
expect(formatSourceValue('ttml')).toBe('TTML')
|
||||
expect(formatSourceValue('elrc')).toBe('ELRC')
|
||||
|
||||
@ -1,14 +1,25 @@
|
||||
import React, { useCallback, useMemo, useRef, useState } from 'react'
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import LyricsLayerControls from './LyricsLayerControls'
|
||||
import LyricsPanel from './LyricsPanel'
|
||||
import MobileKaraokeLyricsPortal from './MobileKaraokeLyricsPortal'
|
||||
import { hasStructuredLyricContent } from './lyrics'
|
||||
import { hasNudgeableTiming, hasStructuredLyricContent } from './lyrics'
|
||||
import { downloadLyrics } from './lyricsExport'
|
||||
import {
|
||||
readLyricsOffset,
|
||||
readPreferredSource,
|
||||
writeLyricsOffset,
|
||||
writePreferredSource,
|
||||
} from './lyricsPreferences'
|
||||
import { getVariantLabel } from './lyricsSource'
|
||||
import {
|
||||
resolveLyricsSidebarState,
|
||||
toggleLayerPreference,
|
||||
} from './lyricsSidebarState'
|
||||
import useEnhancedLyrics from './useEnhancedLyrics'
|
||||
|
||||
const SOURCE_REFRESH_INTERVAL_MS = 5_000
|
||||
const SOURCE_REFRESH_MAX_TICKS = 12
|
||||
|
||||
const usePlayerLyrics = ({
|
||||
trackId,
|
||||
trackUpdatedAt,
|
||||
@ -21,30 +32,69 @@ const usePlayerLyrics = ({
|
||||
const [lyricsVisiblePreference, setLyricsVisiblePreference] = useState(false)
|
||||
const [translationPreference, setTranslationPreference] = useState(null)
|
||||
const [pronunciationPreference, setPronunciationPreference] = useState(null)
|
||||
const [selectedSourceKey, setSelectedSourceKey] = useState(() =>
|
||||
readPreferredSource(trackId),
|
||||
)
|
||||
const [offsetMs, setOffsetMs] = useState(0)
|
||||
const [sourceMenuOpen, setSourceMenuOpen] = useState(false)
|
||||
const lyricsToggleRef = useRef(null)
|
||||
|
||||
const {
|
||||
variants,
|
||||
layers: lyricLayers,
|
||||
loading: lyricsLoading,
|
||||
refreshing: lyricsRefreshing,
|
||||
error: lyricsError,
|
||||
retryAfterSeconds: lyricsRetryAfterSeconds,
|
||||
retry: retryLyrics,
|
||||
refresh: refreshLyrics,
|
||||
} = useEnhancedLyrics({
|
||||
trackId,
|
||||
updatedAt: trackUpdatedAt,
|
||||
disabled: isRadio,
|
||||
requested: lyricsVisiblePreference,
|
||||
selectedSourceKey,
|
||||
})
|
||||
|
||||
// The selected variant falls back to the plugin's winner when the saved
|
||||
// source is not in this track's response (cache expiry, config change).
|
||||
const resolvedVariantKey = useMemo(() => {
|
||||
if (
|
||||
selectedSourceKey &&
|
||||
variants.some((variant) => variant.key === selectedSourceKey)
|
||||
) {
|
||||
return selectedSourceKey
|
||||
}
|
||||
return variants[0]?.key ?? null
|
||||
}, [selectedSourceKey, variants])
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedSourceKey(readPreferredSource(trackId))
|
||||
setSourceMenuOpen(false)
|
||||
}, [trackId])
|
||||
|
||||
useEffect(() => {
|
||||
setOffsetMs(readLyricsOffset(trackId, resolvedVariantKey))
|
||||
}, [resolvedVariantKey, trackId])
|
||||
|
||||
// Progressive fill: while the source menu is open, ask for the served
|
||||
// variants again on a bounded interval so sweep-filled sources appear.
|
||||
useEffect(() => {
|
||||
if (!sourceMenuOpen) return undefined
|
||||
refreshLyrics()
|
||||
let ticks = 0
|
||||
const timer = window.setInterval(() => {
|
||||
ticks += 1
|
||||
refreshLyrics()
|
||||
if (ticks >= SOURCE_REFRESH_MAX_TICKS) window.clearInterval(timer)
|
||||
}, SOURCE_REFRESH_INTERVAL_MS)
|
||||
return () => window.clearInterval(timer)
|
||||
}, [refreshLyrics, sourceMenuOpen])
|
||||
|
||||
const hasTranslationLyric = hasStructuredLyricContent(lyricLayers.translation)
|
||||
const hasPronunciationLyric = hasStructuredLyricContent(
|
||||
lyricLayers.pronunciation,
|
||||
)
|
||||
const lyricsSource =
|
||||
lyricLayers.main?.source ||
|
||||
lyricLayers.translation?.source ||
|
||||
lyricLayers.pronunciation?.source ||
|
||||
null
|
||||
const { lyricsVisible, showTranslation, showPronunciation } =
|
||||
resolveLyricsSidebarState({
|
||||
lyricsVisiblePreference,
|
||||
@ -55,6 +105,7 @@ const usePlayerLyrics = ({
|
||||
})
|
||||
const lyricsToggleDisabled = !lyricsVisiblePreference && (isRadio || !trackId)
|
||||
const useInlineMobileLyrics = lyricsVisible && !isDesktop
|
||||
const offsetEnabled = hasNudgeableTiming(lyricLayers.main)
|
||||
|
||||
const labels = useMemo(
|
||||
() => ({
|
||||
@ -79,8 +130,26 @@ const usePlayerLyrics = ({
|
||||
embeddedSource: translate('player.embeddedLyricsSourceText'),
|
||||
sidecarSource: translate('player.sidecarLyricsSourceText'),
|
||||
pluginSource: translate('player.pluginLyricsSourceText'),
|
||||
provider: translate('player.lyricsProviderText'),
|
||||
format: translate('player.lyricsFormatText'),
|
||||
autoSource: translate('player.autoLyricsSourceText'),
|
||||
refreshingSources: translate('player.refreshingLyricsSourcesText'),
|
||||
wordSynced: translate('player.wordSyncedLyricText'),
|
||||
lineSynced: translate('player.lineSyncedLyricText'),
|
||||
plainSynced: translate('player.plainLyricText'),
|
||||
offset: translate('player.lyricsOffsetText'),
|
||||
offsetCoarseEarlier: translate('player.lyricsOffsetEarlierText', {
|
||||
seconds: '0.5',
|
||||
}),
|
||||
offsetFineEarlier: translate('player.lyricsOffsetEarlierText', {
|
||||
seconds: '0.05',
|
||||
}),
|
||||
offsetCoarseLater: translate('player.lyricsOffsetLaterText', {
|
||||
seconds: '0.5',
|
||||
}),
|
||||
offsetFineLater: translate('player.lyricsOffsetLaterText', {
|
||||
seconds: '0.05',
|
||||
}),
|
||||
offsetReset: translate('player.lyricsOffsetResetText'),
|
||||
download: translate('player.downloadLyricsText'),
|
||||
}),
|
||||
[lyricsRetryAfterSeconds, translate],
|
||||
)
|
||||
@ -109,6 +178,61 @@ const usePlayerLyrics = ({
|
||||
)
|
||||
}, [hasPronunciationLyric])
|
||||
|
||||
const selectSource = useCallback(
|
||||
(sourceKey) => {
|
||||
const next = sourceKey || null
|
||||
setSelectedSourceKey(next)
|
||||
writePreferredSource(trackId, next)
|
||||
},
|
||||
[trackId],
|
||||
)
|
||||
|
||||
const changeOffset = useCallback(
|
||||
(nextOffsetMs) => {
|
||||
setOffsetMs(nextOffsetMs)
|
||||
writeLyricsOffset(trackId, resolvedVariantKey, nextOffsetMs)
|
||||
},
|
||||
[resolvedVariantKey, trackId],
|
||||
)
|
||||
|
||||
const handleSourceMenuOpenChange = useCallback((open) => {
|
||||
setSourceMenuOpen(open)
|
||||
}, [])
|
||||
|
||||
const downloadSelected = useCallback(() => {
|
||||
downloadLyrics(lyricLayers.main, {
|
||||
format: lyricLayers.main?.source?.format,
|
||||
artist: lyricLayers.main?.displayArtist,
|
||||
title: lyricLayers.main?.displayTitle,
|
||||
label: getVariantLabel(lyricLayers.main?.source, labels),
|
||||
})
|
||||
}, [labels, lyricLayers.main])
|
||||
|
||||
const variantControlProps = useMemo(
|
||||
() => ({
|
||||
variants,
|
||||
selectedSourceKey,
|
||||
onSelectSource: selectSource,
|
||||
onSourceMenuOpenChange: handleSourceMenuOpenChange,
|
||||
refreshing: lyricsRefreshing,
|
||||
offsetMs,
|
||||
offsetEnabled,
|
||||
onOffsetChange: changeOffset,
|
||||
onDownload: downloadSelected,
|
||||
}),
|
||||
[
|
||||
changeOffset,
|
||||
downloadSelected,
|
||||
handleSourceMenuOpenChange,
|
||||
lyricsRefreshing,
|
||||
offsetEnabled,
|
||||
offsetMs,
|
||||
selectSource,
|
||||
selectedSourceKey,
|
||||
variants,
|
||||
],
|
||||
)
|
||||
|
||||
const toolbarLyricsProps = useMemo(
|
||||
() => ({
|
||||
onToggleLyrics: toggleLyrics,
|
||||
@ -132,9 +256,9 @@ const usePlayerLyrics = ({
|
||||
showPronunciation,
|
||||
translationEnabled: hasTranslationLyric,
|
||||
pronunciationEnabled: hasPronunciationLyric,
|
||||
source: lyricsSource,
|
||||
onToggleTranslation: toggleTranslation,
|
||||
onTogglePronunciation: togglePronunciation,
|
||||
...variantControlProps,
|
||||
audioInstance,
|
||||
loading: lyricsLoading,
|
||||
error: lyricsError,
|
||||
@ -154,13 +278,13 @@ const usePlayerLyrics = ({
|
||||
lyricLayers.translation,
|
||||
lyricsError,
|
||||
lyricsLoading,
|
||||
lyricsSource,
|
||||
lyricsVisible,
|
||||
obscuredByQueue,
|
||||
showPronunciation,
|
||||
showTranslation,
|
||||
togglePronunciation,
|
||||
toggleTranslation,
|
||||
variantControlProps,
|
||||
],
|
||||
)
|
||||
|
||||
@ -180,6 +304,7 @@ const usePlayerLyrics = ({
|
||||
showTranslation={showTranslation}
|
||||
showPronunciation={showPronunciation}
|
||||
audioInstance={audioInstance}
|
||||
offsetMs={offsetMs}
|
||||
loading={lyricsLoading}
|
||||
error={lyricsError}
|
||||
errorMessage={labels.rateLimited}
|
||||
@ -194,7 +319,7 @@ const usePlayerLyrics = ({
|
||||
pronunciationEnabled={hasPronunciationLyric}
|
||||
onToggleTranslation={toggleTranslation}
|
||||
onTogglePronunciation={togglePronunciation}
|
||||
source={lyricsSource}
|
||||
{...variantControlProps}
|
||||
active={useInlineMobileLyrics && !obscuredByQueue}
|
||||
labels={labels}
|
||||
testId="lyrics-mobile-layer-controls"
|
||||
@ -205,19 +330,20 @@ const usePlayerLyrics = ({
|
||||
audioInstance,
|
||||
hasPronunciationLyric,
|
||||
hasTranslationLyric,
|
||||
labels,
|
||||
lyricLayers.main,
|
||||
lyricLayers.pronunciation,
|
||||
lyricLayers.translation,
|
||||
lyricsError,
|
||||
lyricsLoading,
|
||||
lyricsSource,
|
||||
labels,
|
||||
obscuredByQueue,
|
||||
offsetMs,
|
||||
showPronunciation,
|
||||
showTranslation,
|
||||
togglePronunciation,
|
||||
toggleTranslation,
|
||||
useInlineMobileLyrics,
|
||||
variantControlProps,
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@ -2,6 +2,7 @@ import { act, waitFor } from '@testing-library/react'
|
||||
import { renderHook } from '@testing-library/react-hooks'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import subsonic from '../subsonic'
|
||||
import { downloadLyrics } from './lyricsExport'
|
||||
import { clearEnhancedLyricsCache } from './useEnhancedLyrics'
|
||||
import usePlayerLyrics from './usePlayerLyrics'
|
||||
|
||||
@ -11,16 +12,65 @@ vi.mock('../subsonic', () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('./lyricsExport', () => ({
|
||||
downloadLyrics: vi.fn(),
|
||||
}))
|
||||
|
||||
const translate = (key) => key
|
||||
|
||||
const sourceEntry = (provider, value) => ({
|
||||
kind: 'main',
|
||||
lang: 'en',
|
||||
synced: true,
|
||||
displayArtist: 'Artist',
|
||||
displayTitle: 'Title',
|
||||
line: [{ start: 0, value }],
|
||||
source: { type: 'plugin', name: 'Better Lyrics', provider, format: 'elrc' },
|
||||
})
|
||||
|
||||
const variantsResponse = () => ({
|
||||
json: {
|
||||
'subsonic-response': {
|
||||
lyricsList: {
|
||||
structuredLyrics: [
|
||||
sourceEntry('NetEase', 'NetEase line'),
|
||||
sourceEntry('LRCLib', 'LRCLib line'),
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const renderPlayerLyrics = (props = {}) =>
|
||||
renderHook(() =>
|
||||
usePlayerLyrics({
|
||||
trackId: 'song-1',
|
||||
trackUpdatedAt: undefined,
|
||||
isRadio: false,
|
||||
audioInstance: null,
|
||||
isDesktop: true,
|
||||
translate,
|
||||
...props,
|
||||
}),
|
||||
)
|
||||
|
||||
const showLyrics = async (result) => {
|
||||
act(() => result.current.toolbarLyricsProps.onToggleLyrics())
|
||||
await waitFor(() =>
|
||||
expect(result.current.desktopLyricsProps.mainLyric).not.toBeNull(),
|
||||
)
|
||||
}
|
||||
|
||||
describe('usePlayerLyrics', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.setItem('locale', 'en')
|
||||
clearEnhancedLyricsCache()
|
||||
subsonic.getLyricsBySongId.mockReset()
|
||||
downloadLyrics.mockReset()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
localStorage.clear()
|
||||
clearEnhancedLyricsCache()
|
||||
})
|
||||
@ -79,4 +129,117 @@ describe('usePlayerLyrics', () => {
|
||||
expect(result.current.toolbarLyricsProps.lyricsActive).toBe(true)
|
||||
expect(result.current.desktopLyricsProps.visible).toBe(true)
|
||||
})
|
||||
|
||||
it('restores the saved source choice per track and persists changes', async () => {
|
||||
localStorage.setItem('lyrics.source:song-1', 'lrclib')
|
||||
subsonic.getLyricsBySongId.mockResolvedValue(variantsResponse())
|
||||
|
||||
const { result } = renderPlayerLyrics()
|
||||
await showLyrics(result)
|
||||
|
||||
expect(result.current.desktopLyricsProps.selectedSourceKey).toBe('lrclib')
|
||||
expect(result.current.desktopLyricsProps.mainLyric.line[0].value).toBe(
|
||||
'LRCLib line',
|
||||
)
|
||||
|
||||
act(() => result.current.desktopLyricsProps.onSelectSource('netease'))
|
||||
expect(result.current.desktopLyricsProps.selectedSourceKey).toBe('netease')
|
||||
expect(localStorage.getItem('lyrics.source:song-1')).toBe('netease')
|
||||
|
||||
act(() => result.current.desktopLyricsProps.onSelectSource(null))
|
||||
expect(localStorage.getItem('lyrics.source:song-1')).toBeNull()
|
||||
await waitFor(() =>
|
||||
expect(result.current.desktopLyricsProps.mainLyric.line[0].value).toBe(
|
||||
'NetEase line',
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to auto when the saved source is missing from the response', async () => {
|
||||
localStorage.setItem('lyrics.source:song-1', 'gone')
|
||||
subsonic.getLyricsBySongId.mockResolvedValue(variantsResponse())
|
||||
|
||||
const { result } = renderPlayerLyrics()
|
||||
await showLyrics(result)
|
||||
|
||||
expect(result.current.desktopLyricsProps.mainLyric.line[0].value).toBe(
|
||||
'NetEase line',
|
||||
)
|
||||
expect(result.current.desktopLyricsProps.selectedSourceKey).toBe('gone')
|
||||
})
|
||||
|
||||
it('keys the offset by track and resolved source', async () => {
|
||||
localStorage.setItem('lyrics.offset:song-1:netease', '-350')
|
||||
subsonic.getLyricsBySongId.mockResolvedValue(variantsResponse())
|
||||
|
||||
const { result } = renderPlayerLyrics()
|
||||
await showLyrics(result)
|
||||
|
||||
expect(result.current.desktopLyricsProps.offsetMs).toBe(-350)
|
||||
expect(result.current.desktopLyricsProps.offsetEnabled).toBe(true)
|
||||
|
||||
act(() => result.current.desktopLyricsProps.onOffsetChange(-100))
|
||||
expect(localStorage.getItem('lyrics.offset:song-1:netease')).toBe('-100')
|
||||
|
||||
act(() => result.current.desktopLyricsProps.onSelectSource('lrclib'))
|
||||
await waitFor(() =>
|
||||
expect(result.current.desktopLyricsProps.offsetMs).toBe(0),
|
||||
)
|
||||
|
||||
act(() => result.current.desktopLyricsProps.onOffsetChange(200))
|
||||
expect(localStorage.getItem('lyrics.offset:song-1:lrclib')).toBe('200')
|
||||
expect(localStorage.getItem('lyrics.offset:song-1:netease')).toBe('-100')
|
||||
})
|
||||
|
||||
it('refreshes once per five seconds while the source menu is open and stops at the cap', async () => {
|
||||
vi.useFakeTimers()
|
||||
subsonic.getLyricsBySongId.mockResolvedValue(variantsResponse())
|
||||
|
||||
const { result } = renderPlayerLyrics()
|
||||
act(() => result.current.toolbarLyricsProps.onToggleLyrics())
|
||||
await act(async () => {
|
||||
await Promise.resolve()
|
||||
})
|
||||
expect(subsonic.getLyricsBySongId).toHaveBeenCalledTimes(1)
|
||||
|
||||
act(() => result.current.desktopLyricsProps.onSourceMenuOpenChange(true))
|
||||
expect(subsonic.getLyricsBySongId).toHaveBeenCalledTimes(2)
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(5_000)
|
||||
})
|
||||
expect(subsonic.getLyricsBySongId).toHaveBeenCalledTimes(3)
|
||||
|
||||
for (let step = 0; step < 11; step += 1) {
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(5_000)
|
||||
})
|
||||
}
|
||||
expect(subsonic.getLyricsBySongId).toHaveBeenCalledTimes(14)
|
||||
|
||||
act(() => result.current.desktopLyricsProps.onSourceMenuOpenChange(false))
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(15_000)
|
||||
})
|
||||
expect(subsonic.getLyricsBySongId).toHaveBeenCalledTimes(14)
|
||||
})
|
||||
|
||||
it('triggers a download for the selected variant with its format and display fields', async () => {
|
||||
subsonic.getLyricsBySongId.mockResolvedValue(variantsResponse())
|
||||
|
||||
const { result } = renderPlayerLyrics()
|
||||
await showLyrics(result)
|
||||
|
||||
act(() => result.current.desktopLyricsProps.onDownload())
|
||||
|
||||
expect(downloadLyrics).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ displayArtist: 'Artist' }),
|
||||
expect.objectContaining({
|
||||
format: 'elrc',
|
||||
artist: 'Artist',
|
||||
title: 'Title',
|
||||
label: 'NetEase',
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@ -725,6 +725,16 @@
|
||||
"pluginLyricsSourceText": "Lyrics plugin",
|
||||
"lyricsProviderText": "Provider",
|
||||
"lyricsFormatText": "Format",
|
||||
"autoLyricsSourceText": "Auto (best match)",
|
||||
"refreshingLyricsSourcesText": "Looking for more sources",
|
||||
"wordSyncedLyricText": "Word-synced",
|
||||
"lineSyncedLyricText": "Line-synced",
|
||||
"plainLyricText": "Plain text",
|
||||
"lyricsOffsetText": "Lyrics timing offset",
|
||||
"lyricsOffsetEarlierText": "Shift lyrics %{seconds} s earlier",
|
||||
"lyricsOffsetLaterText": "Shift lyrics %{seconds} s later",
|
||||
"lyricsOffsetResetText": "Reset timing offset",
|
||||
"downloadLyricsText": "Download lyrics",
|
||||
"toggleMiniModeText": "Minimize",
|
||||
"destroyText": "Destroy",
|
||||
"downloadText": "Download",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user