diff --git a/ui/src/audioplayer/LyricsLayerControls.jsx b/ui/src/audioplayer/LyricsLayerControls.jsx
index 449a704d9..844fe16e9 100644
--- a/ui/src/audioplayer/LyricsLayerControls.jsx
+++ b/ui/src/audioplayer/LyricsLayerControls.jsx
@@ -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 (
event.stopPropagation()}
>
- {source && (
+ {hasVariants && (
<>
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,
})}
>
-
+
-
setSourceAnchor(null)}
anchorOrigin={{ vertical: 'top', horizontal: 'right' }}
transformOrigin={{ vertical: 'bottom', horizontal: 'right' }}
- PaperProps={{ className: classes.sourcePopover }}
+ PaperProps={{ className: classes.sourceMenu }}
+ onClick={(event) => event.stopPropagation()}
>
- event.stopPropagation()}
- >
+
{labels.sourceTitle || 'Lyrics source'}
-
- {getSourceName(source, labels)}
-
- {sourceDetails.length > 0 && (
-
- {sourceDetails.map((detail) => (
-
-
- {detail.label}
-
-
- {detail.value}
-
-
- ))}
-
+ {refreshing && (
+
)}
-
-
+
+
+ {variants.map((variant) => (
+
+ ))}
+
>
)}
+
+
+ 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,
+ })}
+ >
+
+
+
+
+
setOffsetAnchor(null)}
+ anchorOrigin={{ vertical: 'top', horizontal: 'right' }}
+ transformOrigin={{ vertical: 'bottom', horizontal: 'right' }}
+ PaperProps={{ className: classes.offsetPopover }}
+ onClick={(event) => event.stopPropagation()}
+ >
+
+
+
+
+ {formatLyricsOffset(offsetMs)}
+
+
+
+
+
+ onOffsetChange(0)}
+ aria-label={labels.offsetReset || 'Reset timing offset'}
+ data-testid="lyrics-offset-reset"
+ className={classes.controlButton}
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+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(
onToggleTranslation={() => {}}
onTogglePronunciation={() => {}}
labels={labels}
- source={source}
+ variants={variants}
+ {...props}
/>,
)
-describe(' source details', () => {
+const openSourceMenu = () =>
+ fireEvent.click(screen.getByRole('button', { name: labels.viewSource }))
+
+describe('', () => {
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)
+ })
})
diff --git a/ui/src/audioplayer/LyricsPanel.jsx b/ui/src/audioplayer/LyricsPanel.jsx
index 838645ac2..51e2297c8 100644
--- a/ui/src/audioplayer/LyricsPanel.jsx
+++ b/ui/src/audioplayer/LyricsPanel.jsx
@@ -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),
diff --git a/ui/src/audioplayer/LyricsPanel.test.jsx b/ui/src/audioplayer/LyricsPanel.test.jsx
index bb4877396..59b837c06 100644
--- a/ui/src/audioplayer/LyricsPanel.test.jsx
+++ b/ui/src/audioplayer/LyricsPanel.test.jsx
@@ -193,6 +193,28 @@ describe('', () => {
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,
diff --git a/ui/src/audioplayer/LyricsSidebar.jsx b/ui/src/audioplayer/LyricsSidebar.jsx
index c9940693a..5e8710f9d 100644
--- a/ui/src/audioplayer/LyricsSidebar.jsx
+++ b/ui/src/audioplayer/LyricsSidebar.jsx
@@ -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}
diff --git a/ui/src/audioplayer/LyricsSidebar.test.jsx b/ui/src/audioplayer/LyricsSidebar.test.jsx
index 0bafd3bcc..9e742801c 100644
--- a/ui/src/audioplayer/LyricsSidebar.test.jsx
+++ b/ui/src/audioplayer/LyricsSidebar.test.jsx
@@ -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('', () => {
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(),
)
})
diff --git a/ui/src/audioplayer/MobileKaraokeLyricsPortal.test.jsx b/ui/src/audioplayer/MobileKaraokeLyricsPortal.test.jsx
index eb2748684..beec199d8 100644
--- a/ui/src/audioplayer/MobileKaraokeLyricsPortal.test.jsx
+++ b/ui/src/audioplayer/MobileKaraokeLyricsPortal.test.jsx
@@ -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('', () => {
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()
@@ -222,12 +237,12 @@ describe('', () => {
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()
await waitFor(() =>
- expect(screen.queryByRole('dialog')).not.toBeInTheDocument(),
+ expect(screen.queryByRole('menu')).not.toBeInTheDocument(),
)
})
diff --git a/ui/src/audioplayer/lyricsSource.js b/ui/src/audioplayer/lyricsSource.js
index 07c4468b0..4aaaf82a0 100644
--- a/ui/src/audioplayer/lyricsSource.js
+++ b/ui/src/audioplayer/lyricsSource.js
@@ -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)
diff --git a/ui/src/audioplayer/lyricsSource.test.js b/ui/src/audioplayer/lyricsSource.test.js
index e60194365..681eb1ccd 100644
--- a/ui/src/audioplayer/lyricsSource.test.js
+++ b/ui/src/audioplayer/lyricsSource.test.js
@@ -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')
diff --git a/ui/src/audioplayer/usePlayerLyrics.jsx b/ui/src/audioplayer/usePlayerLyrics.jsx
index de8f2114c..1aed62b6f 100644
--- a/ui/src/audioplayer/usePlayerLyrics.jsx
+++ b/ui/src/audioplayer/usePlayerLyrics.jsx
@@ -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,
],
)
diff --git a/ui/src/audioplayer/usePlayerLyrics.test.jsx b/ui/src/audioplayer/usePlayerLyrics.test.jsx
index 0c46c13b4..8a894f067 100644
--- a/ui/src/audioplayer/usePlayerLyrics.test.jsx
+++ b/ui/src/audioplayer/usePlayerLyrics.test.jsx
@@ -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',
+ }),
+ )
+ })
})
diff --git a/ui/src/i18n/en.json b/ui/src/i18n/en.json
index 7a903b5a9..0bbd5affa 100644
--- a/ui/src/i18n/en.json
+++ b/ui/src/i18n/en.json
@@ -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",