feat(ui): add per-song lyrics source, offset and download controls

Signed-off-by: ranokay <github@ranokay.com>
This commit is contained in:
ranokay 2026-10-17 12:25:28 +03:00
parent cfc3c66f14
commit 58fdfb4e65
No known key found for this signature in database
12 changed files with 833 additions and 172 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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(),
)
})

View File

@ -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(),
)
})

View File

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

View File

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

View File

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

View File

@ -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',
}),
)
})
})

View File

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