From 58fdfb4e659bda0c91215fe45a382667d5b3044f Mon Sep 17 00:00:00 2001 From: ranokay Date: Sat, 17 Oct 2026 12:25:28 +0300 Subject: [PATCH] feat(ui): add per-song lyrics source, offset and download controls Signed-off-by: ranokay --- ui/src/audioplayer/LyricsLayerControls.jsx | 363 +++++++++++++----- .../audioplayer/LyricsLayerControls.test.jsx | 202 ++++++++-- ui/src/audioplayer/LyricsPanel.jsx | 14 +- ui/src/audioplayer/LyricsPanel.test.jsx | 22 ++ ui/src/audioplayer/LyricsSidebar.jsx | 21 +- ui/src/audioplayer/LyricsSidebar.test.jsx | 13 +- .../MobileKaraokeLyricsPortal.test.jsx | 21 +- ui/src/audioplayer/lyricsSource.js | 6 + ui/src/audioplayer/lyricsSource.test.js | 16 +- ui/src/audioplayer/usePlayerLyrics.jsx | 154 +++++++- ui/src/audioplayer/usePlayerLyrics.test.jsx | 163 ++++++++ ui/src/i18n/en.json | 10 + 12 files changed, 833 insertions(+), 172 deletions(-) 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 && ( + )} -
  • -
    + + selectSource(null)} + className={classes.sourceItem} + data-testid="lyrics-source-auto" + > + {labels.autoSource || 'Auto (best match)'} + + {variants.map((variant) => ( + selectSource(variant.key)} + className={classes.sourceItem} + data-testid={`lyrics-source-${variant.key}`} + > + + {getVariantLabel(variant.source, labels)} + + + {[ + formatSourceValue(variant.source?.format), + syncLabelFor(variant.sync, labels), + ] + .filter(Boolean) + .join(' ยท ')} + + + ))} + )} + + + 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",