diff --git a/core/lyrics/lyrics.go b/core/lyrics/lyrics.go
index d3e8d72b8..2ef20f04f 100644
--- a/core/lyrics/lyrics.go
+++ b/core/lyrics/lyrics.go
@@ -87,6 +87,7 @@ func songsByArtistTitleWithLyricsFirst(artist, title string) model.QueryOptions
}
func (l *lyricsService) getLyricsForCandidates(ctx context.Context, mediaFiles []*model.MediaFile) (model.LyricList, error) {
+ var pluginErr error
for pattern := range strings.SplitSeq(conf.Server.LyricsPriority, ",") {
pattern = strings.TrimSpace(pattern)
if pattern == "" {
@@ -101,6 +102,9 @@ func (l *lyricsService) getLyricsForCandidates(ctx context.Context, mediaFiles [
lyricsList, err := l.getLyricsFromSource(ctx, mf, pattern)
if err != nil {
log.Error(ctx, "error getting lyrics", "source", pattern, err)
+ if pattern != "embedded" && !strings.HasPrefix(pattern, ".") {
+ pluginErr = err
+ }
continue
}
@@ -110,7 +114,7 @@ func (l *lyricsService) getLyricsForCandidates(ctx context.Context, mediaFiles [
}
}
- return nil, nil
+ return nil, pluginErr
}
func (l *lyricsService) getLyricsFromSource(ctx context.Context, mf *model.MediaFile, pattern string) (model.LyricList, error) {
diff --git a/core/lyrics/lyrics_test.go b/core/lyrics/lyrics_test.go
index b00bcd576..fbe85914d 100644
--- a/core/lyrics/lyrics_test.go
+++ b/core/lyrics/lyrics_test.go
@@ -365,6 +365,18 @@ var _ = Describe("Lyrics", func() {
Expect(err).To(BeNil())
Expect(list).To(Equal(embeddedLyrics)) // falls through to embedded
})
+
+ It("should return a plugin error when no later source has lyrics", func() {
+ conf.Server.LyricsPriority = "test-lyrics-plugin,embedded"
+ mf.Lyrics = ""
+ mockLoader.err = fmt.Errorf("remote lyrics cooldown active for 30s")
+ svc := lyrics.NewLyrics(nil, mockLoader)
+
+ list, err := svc.GetLyrics(ctx, &mf)
+
+ Expect(list).To(BeEmpty())
+ Expect(err).To(MatchError("remote lyrics cooldown active for 30s"))
+ })
})
var _ = Describe("GetLyricsByArtistTitle", func() {
diff --git a/ui/src/App.jsx b/ui/src/App.jsx
index 4de369394..23f2635b1 100644
--- a/ui/src/App.jsx
+++ b/ui/src/App.jsx
@@ -23,6 +23,7 @@ import share from './share'
import library from './library'
import plugin from './plugin'
import { Player } from './audioplayer'
+import { LyricsLayoutProvider } from './audioplayer/LyricsLayoutContext'
import customRoutes from './routes'
import {
libraryReducer,
@@ -85,7 +86,9 @@ const adminStore = createAdminStore({
const App = () => (
-
+
+
+
)
diff --git a/ui/src/audioplayer/AudioTitle.jsx b/ui/src/audioplayer/AudioTitle.jsx
index df37edfbb..340b8d730 100644
--- a/ui/src/audioplayer/AudioTitle.jsx
+++ b/ui/src/audioplayer/AudioTitle.jsx
@@ -7,11 +7,12 @@ import { decisionService } from '../transcode'
import useStyle from './styles'
import { useDrag } from 'react-dnd'
import { DraggableTypes } from '../consts'
+import { PLAYER_DESKTOP_MEDIA_QUERY } from './playerBreakpoints'
const AudioTitle = React.memo(({ audioInfo, gainInfo, isMobile }) => {
const classes = useStyle()
const className = classes.audioTitle
- const isDesktop = useMediaQuery('(min-width:810px)')
+ const isDesktop = useMediaQuery(PLAYER_DESKTOP_MEDIA_QUERY)
const song = audioInfo.song
const [, dragSongRef] = useDrag(
diff --git a/ui/src/audioplayer/LyricsLayerControls.jsx b/ui/src/audioplayer/LyricsLayerControls.jsx
new file mode 100644
index 000000000..62c43f37a
--- /dev/null
+++ b/ui/src/audioplayer/LyricsLayerControls.jsx
@@ -0,0 +1,157 @@
+import IconButton from '@material-ui/core/IconButton'
+import Tooltip from '@material-ui/core/Tooltip'
+import { alpha, makeStyles } from '@material-ui/core/styles'
+import RecordVoiceOverIcon from '@material-ui/icons/RecordVoiceOver'
+import TranslateIcon from '@material-ui/icons/Translate'
+import clsx from 'clsx'
+import React from 'react'
+
+const useStyles = makeStyles(
+ (theme) => ({
+ controls: {
+ position: 'absolute',
+ bottom: theme.spacing(1),
+ zIndex: 2,
+ display: 'flex',
+ alignItems: 'center',
+ gap: theme.spacing(0.25),
+ padding: theme.spacing(0.25),
+ borderRadius: theme.shape.borderRadius * 2,
+ backgroundColor: 'transparent',
+ },
+ sidebarPlacement: {
+ right: theme.spacing(0.75),
+ },
+ mobilePlacement: {
+ left: '50%',
+ transform: 'translateX(-50%)',
+ },
+ controlButton: {
+ padding: theme.spacing(0.75),
+ color: alpha(theme.palette.text.primary, 0.58),
+ backgroundColor: 'transparent',
+ WebkitTapHighlightColor: 'transparent',
+ transition:
+ 'color 160ms ease, background-color 160ms ease, transform 160ms cubic-bezier(0.23, 1, 0.32, 1)',
+ '@media (hover: hover) and (pointer: fine)': {
+ '&:hover': {
+ color: theme.palette.text.primary,
+ backgroundColor: alpha(theme.palette.primary.main, 0.08),
+ },
+ '&$controlActive:hover': {
+ color: theme.palette.primary.main,
+ },
+ },
+ '&:focus-visible': {
+ color: theme.palette.text.primary,
+ backgroundColor: alpha(theme.palette.primary.main, 0.1),
+ },
+ '&:active:not(:disabled)': {
+ transform: 'scale(0.97)',
+ },
+ '&$controlActive': {
+ color: theme.palette.primary.main,
+ },
+ '&$controlActive:focus-visible': {
+ color: theme.palette.primary.main,
+ },
+ '&:disabled': {
+ color: alpha(theme.palette.text.primary, 0.28),
+ },
+ '@media (prefers-reduced-motion: reduce)': {
+ transition: 'none',
+ '&:active:not(:disabled)': {
+ transform: 'none',
+ },
+ },
+ },
+ controlActive: {},
+ }),
+ { name: 'NDLyricsLayerControls' },
+)
+
+const LayerButton = ({
+ active,
+ classes,
+ disabled,
+ label,
+ onClick,
+ testId,
+ children,
+}) => {
+ return (
+
+
+
+ {children}
+
+
+
+ )
+}
+
+const LyricsLayerControls = ({
+ placement = 'sidebar',
+ showTranslation,
+ showPronunciation,
+ translationEnabled,
+ pronunciationEnabled,
+ onToggleTranslation,
+ onTogglePronunciation,
+ labels = {},
+ testId = 'lyrics-layer-controls',
+}) => {
+ const classes = useStyles()
+
+ return (
+
event.stopPropagation()}
+ >
+
+
+
+
+
+
+
+ )
+}
+
+export default LyricsLayerControls
diff --git a/ui/src/audioplayer/LyricsLayoutContext.jsx b/ui/src/audioplayer/LyricsLayoutContext.jsx
new file mode 100644
index 000000000..70aa0b06c
--- /dev/null
+++ b/ui/src/audioplayer/LyricsLayoutContext.jsx
@@ -0,0 +1,41 @@
+/* eslint-disable react-refresh/only-export-components */
+import React, {
+ createContext,
+ useCallback,
+ useContext,
+ useMemo,
+ useState,
+} from 'react'
+
+const noop = () => {}
+
+const fallbackContext = {
+ desktopLyricsProps: null,
+ setDesktopLyricsProps: noop,
+}
+
+const LyricsLayoutContext = createContext(fallbackContext)
+
+export const LyricsLayoutProvider = ({ children }) => {
+ const [desktopLyricsProps, setDesktopLyricsPropsState] = useState(null)
+
+ const setDesktopLyricsProps = useCallback((nextProps) => {
+ setDesktopLyricsPropsState(nextProps || null)
+ }, [])
+
+ const value = useMemo(
+ () => ({
+ desktopLyricsProps,
+ setDesktopLyricsProps,
+ }),
+ [desktopLyricsProps, setDesktopLyricsProps],
+ )
+
+ return (
+
+ {children}
+
+ )
+}
+
+export const useLyricsLayout = () => useContext(LyricsLayoutContext)
diff --git a/ui/src/audioplayer/LyricsLineRows.jsx b/ui/src/audioplayer/LyricsLineRows.jsx
new file mode 100644
index 000000000..27894020b
--- /dev/null
+++ b/ui/src/audioplayer/LyricsLineRows.jsx
@@ -0,0 +1,621 @@
+import Typography from '@material-ui/core/Typography'
+import clsx from 'clsx'
+import React, {
+ memo,
+ useCallback,
+ useLayoutEffect,
+ useMemo,
+ useRef,
+ useState,
+} from 'react'
+import { buildSegmentsFromLine } from './lyricsSegments'
+import {
+ TOKEN_ACTIVE_ALPHA,
+ TOKEN_FUTURE_ALPHA,
+ TOKEN_WIPE_EDGE_PCT,
+ TOKEN_WIPE_SOFT_SPREAD_PCT,
+} from './lyricsKaraokeConstants'
+import {
+ buildEmphasisStyle,
+ isEmphasisRole,
+ parseColorRGB,
+} from './lyricsKaraokeStyles'
+import { resolveKaraokeTokenWindows } from './lyricsTimeline'
+
+const EMPHASIS_TONE = 0.7
+const EMPHASIS_PAINT_OVERHANG = '0.18em'
+const DEFAULT_RGB = [255, 255, 255]
+
+const emphasisPaintOverhangStyle = {
+ marginInlineEnd: `-${EMPHASIS_PAINT_OVERHANG}`,
+ paddingInlineEnd: EMPHASIS_PAINT_OVERHANG,
+}
+
+const graphemeSegmenter =
+ typeof Intl !== 'undefined' && typeof Intl.Segmenter === 'function'
+ ? new Intl.Segmenter(undefined, { granularity: 'grapheme' })
+ : null
+
+const splitGraphemes = (value) => {
+ const text = String(value || '')
+ return graphemeSegmenter
+ ? Array.from(graphemeSegmenter.segment(text), ({ segment }) => segment)
+ : Array.from(text)
+}
+
+const waveTextRootStyle = {
+ display: 'inline-block',
+ position: 'relative',
+ verticalAlign: 'baseline',
+}
+
+const waveTextMeasureStyle = { visibility: 'hidden' }
+
+const waveTextOverlayStyle = {
+ display: 'block',
+ inset: 0,
+ position: 'absolute',
+ whiteSpace: 'nowrap',
+}
+
+const renderWaveText = (text, eligible, enabled, emphasized, className) => {
+ if (!eligible) return text
+
+ return (
+
+ {enabled ? (
+ <>
+
+ {text}
+
+
+ {splitGraphemes(text).map((character, index) => {
+ if (/^\s+$/.test(character)) return character
+ return (
+
+ {character}
+
+ )
+ })}
+
+ >
+ ) : (
+ text
+ )}
+
+ )
+}
+
+const tokenColor = (rgb, alpha) => {
+ const [r, g, b] = rgb || [255, 255, 255]
+ return `rgba(${r}, ${g}, ${b}, ${alpha})`
+}
+
+const toneEmphasisRGB = (rgb) =>
+ rgb ? rgb.map((channel) => Math.round(channel * EMPHASIS_TONE)) : rgb
+
+const getTokenRGB = (token, rgb) =>
+ isEmphasisRole(token) ? toneEmphasisRGB(rgb) : rgb
+
+const stripLayerColors = (style) => {
+ const result = { ...(style || {}) }
+ delete result.opacity
+ delete result.color
+ delete result.WebkitTextFillColor
+ delete result['--lyrics-active-color']
+ return result
+}
+
+const buildLineStyle = (line, style) => {
+ const emphasisStyle = buildEmphasisStyle(line)
+ return {
+ ...stripLayerColors(style),
+ ...emphasisStyle,
+ ...(emphasisStyle ? { filter: `brightness(${EMPHASIS_TONE})` } : {}),
+ }
+}
+
+const buildStaticEmphasisStyle = (token) => {
+ const emphasisStyle = buildEmphasisStyle(token)
+ if (!emphasisStyle) return undefined
+ return {
+ ...emphasisStyle,
+ filter: `brightness(${EMPHASIS_TONE})`,
+ }
+}
+
+const buildTokenData = (token, rgb, line) => {
+ const emphasized = isEmphasisRole(token) || isEmphasisRole(line)
+ const tonedRGB = getTokenRGB(token, rgb)
+ const futureColor = tokenColor(tonedRGB, TOKEN_FUTURE_ALPHA)
+ const gradientDoneColor = tokenColor(
+ tonedRGB,
+ 'var(--lyrics-token-active-alpha, 1)',
+ )
+ const softColor = tokenColor(
+ tonedRGB,
+ TOKEN_FUTURE_ALPHA + (TOKEN_ACTIVE_ALPHA - TOKEN_FUTURE_ALPHA) * 0.58,
+ )
+ const sweepRange = 100 + TOKEN_WIPE_SOFT_SPREAD_PCT
+ const activeStop = `calc(var(--lyrics-progress) * ${sweepRange}% - ${TOKEN_WIPE_SOFT_SPREAD_PCT}%)`
+ const softStop = `calc(var(--lyrics-progress) * ${sweepRange}% - ${TOKEN_WIPE_EDGE_PCT}%)`
+ const futureStop = `calc(var(--lyrics-progress) * ${sweepRange}%)`
+ const gradient = `linear-gradient(90deg, ${gradientDoneColor} 0%, ${gradientDoneColor} ${activeStop}, ${softColor} ${softStop}, ${futureColor} ${futureStop}, ${futureColor} 100%)`
+
+ return {
+ style: {
+ '--lyrics-progress': 0,
+ '--lyrics-token-active-alpha': TOKEN_ACTIVE_ALPHA,
+ color: 'transparent',
+ WebkitTextFillColor: 'transparent',
+ backgroundImage: gradient,
+ backgroundSize: '100% 100%',
+ backgroundClip: 'text',
+ WebkitBackgroundClip: 'text',
+ ...buildEmphasisStyle(token),
+ ...(emphasized ? emphasisPaintOverhangStyle : {}),
+ },
+ presentation: {
+ futureAlpha: TOKEN_FUTURE_ALPHA,
+ activeAlpha: TOKEN_ACTIVE_ALPHA,
+ gradient,
+ },
+ }
+}
+
+const isTimedWindow = (window) =>
+ Number.isFinite(window?.start) && Number.isFinite(window?.end)
+
+const useStableTokenRef = (registerToken) => {
+ const registerTokenRef = useRef(registerToken)
+ const callbacksRef = useRef(new Map())
+ registerTokenRef.current = registerToken
+
+ return useCallback(
+ ({ key, lineIndex, window, presentation, waveEnabled }) => {
+ const signature = [
+ lineIndex,
+ window?.start ?? '',
+ window?.end ?? '',
+ presentation?.futureAlpha ?? '',
+ presentation?.activeAlpha ?? '',
+ presentation?.gradient ?? '',
+ waveEnabled ? 1 : 0,
+ ].join('\u0000')
+ const cached = callbacksRef.current.get(key)
+ if (cached?.signature === signature) return cached.callback
+
+ const descriptor = { lineIndex, window, presentation }
+ const callback = (node) => {
+ registerTokenRef.current?.(key, descriptor, node)
+ }
+ callbacksRef.current.set(key, { signature, callback })
+ return callback
+ },
+ [],
+ )
+}
+
+export const KaraokeLineRow = memo(
+ ({
+ lineIndex,
+ line,
+ nextLineStart,
+ className,
+ style,
+ tokenClassName,
+ waveCharacterClassName,
+ registerToken,
+ renderCharacterWave = true,
+ rowKey = 'main',
+ testId,
+ }) => {
+ const getTokenRef = useStableTokenRef(registerToken)
+ const segments = useMemo(() => buildSegmentsFromLine(line), [line])
+ const windows = useMemo(
+ () => resolveKaraokeTokenWindows(line, nextLineStart),
+ [line, nextLineStart],
+ )
+ const tokenRGB = useMemo(
+ () => (style?.color ? parseColorRGB(style.color) : DEFAULT_RGB),
+ [style?.color],
+ )
+ const hasTimedTokens = windows.some(
+ (window) => window?.start != null && window?.end != null,
+ )
+ const lineStyle = useMemo(() => buildLineStyle(line, style), [line, style])
+
+ return (
+
+ {segments.map((segment, idx) => {
+ if (!segment.token)
+ return {segment.text}
+
+ const window = windows[segment.tokenIndex]
+ const timedWindow = isTimedWindow(window) ? window : null
+ const key = `${lineIndex}:${rowKey}:${segment.tokenIndex}:main`
+ const emphasized =
+ isEmphasisRole(segment.token) || isEmphasisRole(line)
+ const tokenData = buildTokenData(segment.token, tokenRGB, line)
+ return (
+
+ {renderWaveText(
+ segment.text,
+ Boolean(timedWindow),
+ Boolean(renderCharacterWave && timedWindow),
+ emphasized,
+ waveCharacterClassName,
+ )}
+
+ )
+ })}
+
+ )
+ },
+)
+
+KaraokeLineRow.displayName = 'KaraokeLineRow'
+
+const splitTextSegments = (value) =>
+ (value || '')
+ .split(/(\s+)/)
+ .filter((text) => text.length > 0)
+ .map((text) => ({
+ text,
+ token: null,
+ tokenIndex: -1,
+ isWhitespace: /^\s+$/.test(text),
+ }))
+
+const buildPronunciationParts = (line) => {
+ if (!line?.value) return []
+
+ const segments = buildSegmentsFromLine(line)
+ const tokenParts = segments
+ .filter((segment) => segment.token && segment.text.trim())
+ .map((segment) => ({
+ text: segment.text.trim(),
+ segment,
+ }))
+
+ if (tokenParts.length > 0) return tokenParts
+
+ return splitTextSegments(line.value)
+ .filter((segment) => !segment.isWhitespace)
+ .map((segment) => ({
+ text: segment.text.trim(),
+ segment: null,
+ }))
+}
+
+const canPairPronunciationSegment = (segment, hasTokenSegments) =>
+ Boolean(
+ segment.token ||
+ (!hasTokenSegments && !segment.isWhitespace && segment.text.trim()),
+ )
+
+const buildStackedPronunciationSegments = (line, pronunciationLine) => {
+ const lineSegments = buildSegmentsFromLine(line)
+ const hasTokenSegments = lineSegments.some((segment) => segment.token)
+ const mainSegments = hasTokenSegments
+ ? lineSegments
+ : splitTextSegments(line?.value || '')
+ const pronunciationParts = buildPronunciationParts(pronunciationLine)
+ const pairableSegments = mainSegments.filter((segment) =>
+ canPairPronunciationSegment(segment, hasTokenSegments),
+ )
+
+ if (
+ !hasTokenSegments &&
+ pronunciationParts.length > 0 &&
+ pairableSegments.length > 0 &&
+ pronunciationParts.length !== pairableSegments.length
+ ) {
+ return [
+ {
+ text: line?.value || '',
+ token: null,
+ tokenIndex: -1,
+ isWhitespace: false,
+ pronunciation: pronunciationLine?.value?.trim() || '',
+ pronunciationSegment: null,
+ },
+ ]
+ }
+
+ const plainPronunciationParts = []
+ const emphasisPronunciationParts = []
+ for (const part of pronunciationParts) {
+ if (hasTokenSegments && isEmphasisRole(part.segment?.token)) {
+ emphasisPronunciationParts.push(part)
+ } else {
+ plainPronunciationParts.push(part)
+ }
+ }
+
+ let pronunciationIndex = 0
+ let plainPronunciationIndex = 0
+ let emphasisPronunciationIndex = 0
+
+ const getPronunciationPart = (segment) => {
+ if (!hasTokenSegments) {
+ const part = pronunciationParts[pronunciationIndex] || null
+ pronunciationIndex += 1
+ return part
+ }
+
+ if (isEmphasisRole(segment.token)) {
+ const part =
+ emphasisPronunciationParts[emphasisPronunciationIndex] || null
+ emphasisPronunciationIndex += 1
+ return part
+ }
+
+ const part = plainPronunciationParts[plainPronunciationIndex] || null
+ plainPronunciationIndex += 1
+ return part
+ }
+
+ return mainSegments.map((segment) => {
+ if (!canPairPronunciationSegment(segment, hasTokenSegments)) {
+ return { ...segment, pronunciation: '' }
+ }
+ const pronunciationPart = getPronunciationPart(segment)
+ return {
+ ...segment,
+ pronunciation: pronunciationPart?.text || '',
+ pronunciationSegment: pronunciationPart?.segment || null,
+ }
+ })
+}
+
+export const KaraokeStackedLineRow = memo(
+ ({
+ lineIndex,
+ line,
+ pronunciationLine,
+ pronunciationStyle,
+ nextLineStart,
+ className,
+ style,
+ tokenClassName,
+ waveCharacterClassName,
+ classes,
+ registerToken,
+ renderCharacterWave = true,
+ rowKey = 'main',
+ testId,
+ }) => {
+ const rowRef = useRef(null)
+ const getTokenRef = useStableTokenRef(registerToken)
+ const [isWrapped, setIsWrapped] = useState(false)
+ const segments = useMemo(
+ () => buildStackedPronunciationSegments(line, pronunciationLine),
+ [line, pronunciationLine],
+ )
+ const mainWindows = useMemo(
+ () => resolveKaraokeTokenWindows(line, nextLineStart),
+ [line, nextLineStart],
+ )
+ const pronunciationWindows = useMemo(
+ () => resolveKaraokeTokenWindows(pronunciationLine, null),
+ [pronunciationLine],
+ )
+ const tokenRGB = useMemo(
+ () => (style?.color ? parseColorRGB(style.color) : [255, 255, 255]),
+ [style?.color],
+ )
+ const pronunciationRGB = useMemo(
+ () =>
+ pronunciationStyle?.color
+ ? parseColorRGB(pronunciationStyle.color)
+ : DEFAULT_RGB,
+ [pronunciationStyle?.color],
+ )
+ const hasTimedTokens = mainWindows.some(
+ (window) => window?.start != null && window?.end != null,
+ )
+ const lineStyle = useMemo(() => buildLineStyle(line, style), [line, style])
+
+ useLayoutEffect(() => {
+ const row = rowRef.current
+ if (!row) return undefined
+
+ const updateWrappedState = () => {
+ const tokenRows = new Set(
+ Array.from(row.querySelectorAll('[data-stacked-token="true"]')).map(
+ (node) => node.offsetTop,
+ ),
+ )
+ const wrapped = tokenRows.size > 1
+ setIsWrapped((current) => (current === wrapped ? current : wrapped))
+ }
+
+ updateWrappedState()
+ const ResizeObserverConstructor =
+ typeof window !== 'undefined' ? window.ResizeObserver : null
+ if (!ResizeObserverConstructor) return undefined
+ const resizeObserver = new ResizeObserverConstructor(updateWrappedState)
+ resizeObserver.observe(row)
+ return () => resizeObserver.disconnect()
+ }, [segments])
+
+ return (
+
+ {segments.map((segment, idx) => {
+ const resolvedMainWindow = segment.token
+ ? mainWindows[segment.tokenIndex]
+ : null
+ const mainWindow = isTimedWindow(resolvedMainWindow)
+ ? resolvedMainWindow
+ : null
+ const mainKey = `${lineIndex}:${rowKey}:${segment.tokenIndex}:main`
+ const mainTokenData = segment.token
+ ? buildTokenData(segment.token, tokenRGB, line)
+ : null
+ const mainEmphasized =
+ isEmphasisRole(segment.token) || isEmphasisRole(line)
+ const mainText = segment.token ? (
+
+ {renderWaveText(
+ segment.text,
+ Boolean(mainWindow?.start != null && mainWindow?.end != null),
+ Boolean(
+ renderCharacterWave &&
+ mainWindow?.start != null &&
+ mainWindow?.end != null,
+ ),
+ mainEmphasized,
+ waveCharacterClassName,
+ )}
+
+ ) : (
+
+ {segment.text}
+
+ )
+
+ if (!segment.pronunciation) return mainText
+
+ const pronunciationToken = segment.pronunciationSegment?.token
+ const resolvedPronunciationWindow = pronunciationToken
+ ? pronunciationWindows[segment.pronunciationSegment.tokenIndex]
+ : null
+ const pronunciationWindow =
+ mainWindow ||
+ (isTimedWindow(resolvedPronunciationWindow)
+ ? resolvedPronunciationWindow
+ : null)
+ const pronunciationTokenIndex =
+ segment.pronunciationSegment?.tokenIndex >= 0
+ ? segment.pronunciationSegment.tokenIndex
+ : segment.tokenIndex >= 0
+ ? segment.tokenIndex
+ : idx
+ const pronunciationKey = `${lineIndex}:${rowKey}:${pronunciationTokenIndex}:pronunciation`
+ const pronunciationTokenData = pronunciationWindow
+ ? buildTokenData(
+ pronunciationToken || segment.token,
+ pronunciationRGB,
+ pronunciationLine || line,
+ )
+ : null
+ const pronunciationEmphasized = Boolean(
+ isEmphasisRole(pronunciationToken) ||
+ isEmphasisRole(segment.token) ||
+ isEmphasisRole(pronunciationLine) ||
+ isEmphasisRole(line),
+ )
+ return (
+
+ {mainText}
+
+ {renderWaveText(
+ segment.pronunciation,
+ Boolean(pronunciationWindow),
+ Boolean(renderCharacterWave && pronunciationWindow),
+ pronunciationEmphasized,
+ waveCharacterClassName,
+ )}
+
+
+ )
+ })}
+
+ )
+ },
+)
+
+KaraokeStackedLineRow.displayName = 'KaraokeStackedLineRow'
diff --git a/ui/src/audioplayer/LyricsPanel.jsx b/ui/src/audioplayer/LyricsPanel.jsx
new file mode 100644
index 000000000..838645ac2
--- /dev/null
+++ b/ui/src/audioplayer/LyricsPanel.jsx
@@ -0,0 +1,942 @@
+import { alpha, makeStyles, useTheme } from '@material-ui/core/styles'
+import clsx from 'clsx'
+import React, {
+ useCallback,
+ useEffect,
+ useLayoutEffect,
+ useMemo,
+ useRef,
+ useState,
+} from 'react'
+import {
+ buildKaraokeLines,
+ hasStructuredLyricContent,
+ hasUsableKaraokeTiming,
+} from './lyrics'
+import { KaraokeLineRow, KaraokeStackedLineRow } from './LyricsLineRows'
+import {
+ KARAOKE_LINE_OPACITY_MS,
+ KARAOKE_AUX_LINE_HEIGHT,
+ KARAOKE_DESKTOP_ACTIVE_LINE_ANCHOR_RATIO,
+ KARAOKE_EASING,
+ KARAOKE_IDLE_LAYER_OPACITY,
+ KARAOKE_INLINE_ACTIVE_LINE_ANCHOR_RATIO,
+ KARAOKE_LINE_LIFT_PX,
+ KARAOKE_LINE_MOTION_EASING,
+ KARAOKE_MANUAL_SCROLL_PAUSE_MS,
+ KARAOKE_SCROLLBAR_VISIBLE_MS,
+} from './lyricsKaraokeConstants'
+import {
+ animateScrollTop,
+ cancelScrollAnimation,
+ getAnchoredScrollTop,
+ getScrollEndPadding,
+} from './lyricsScroll'
+import { finiteTime } from './lyricsTimeline'
+import useLyricsTimeline from './useLyricsTimeline'
+
+const KARAOKE_LAYER_OPACITY_TRANSITION = `opacity ${KARAOKE_LINE_OPACITY_MS}ms ${KARAOKE_EASING}`
+const KARAOKE_LAYER_COLOR_TRANSITION = `color ${KARAOKE_LINE_OPACITY_MS}ms ${KARAOKE_EASING}, -webkit-text-fill-color ${KARAOKE_LINE_OPACITY_MS}ms ${KARAOKE_EASING}`
+
+const useStyles = makeStyles((theme) => ({
+ root: {
+ height: '100%',
+ minHeight: 0,
+ display: 'flex',
+ flexDirection: 'column',
+ color: theme.palette.text.primary,
+ },
+ inlineRoot: {
+ borderRadius: 'inherit',
+ background: 'transparent',
+ backdropFilter: 'blur(16px)',
+ WebkitBackdropFilter: 'blur(16px)',
+ },
+ body: {
+ flex: 1,
+ minHeight: 0,
+ overflowY: 'auto',
+ overflowX: 'hidden',
+ padding: theme.spacing(4, 2.25, 3.25),
+ overscrollBehavior: 'contain',
+ scrollbarGutter: 'stable',
+ scrollbarWidth: 'thin',
+ scrollbarColor: 'transparent transparent',
+ maskImage:
+ 'linear-gradient(to bottom, transparent 0, rgba(0, 0, 0, 0.15) 12px, #000 40px, #000 calc(100% - 120px), rgba(0, 0, 0, 0.12) calc(100% - 48px), transparent 100%)',
+ WebkitMaskImage:
+ 'linear-gradient(to bottom, transparent 0, rgba(0, 0, 0, 0.15) 12px, #000 40px, #000 calc(100% - 120px), rgba(0, 0, 0, 0.12) calc(100% - 48px), transparent 100%)',
+ '&::-webkit-scrollbar': {
+ width: 8,
+ height: 8,
+ },
+ // Player themes use two-class scrollbar selectors.
+ '&&&::-webkit-scrollbar-thumb': {
+ backgroundColor: 'transparent',
+ borderRadius: 999,
+ },
+ '&::-webkit-scrollbar-track': {
+ backgroundColor: 'transparent',
+ },
+ },
+ bodyTopFade: {
+ maskImage:
+ 'linear-gradient(to bottom, transparent 0, rgba(0, 0, 0, 0.15) 8px, #000 24px, #000 calc(100% - 120px), rgba(0, 0, 0, 0.12) calc(100% - 48px), transparent 100%)',
+ WebkitMaskImage:
+ 'linear-gradient(to bottom, transparent 0, rgba(0, 0, 0, 0.15) 8px, #000 24px, #000 calc(100% - 120px), rgba(0, 0, 0, 0.12) calc(100% - 48px), transparent 100%)',
+ },
+ bodyUserScrolling: {
+ scrollbarColor: `${theme.palette.action.disabled} transparent`,
+ '&&&::-webkit-scrollbar-thumb': {
+ backgroundColor: theme.palette.action.disabled,
+ },
+ },
+ inlineBody: {
+ padding: theme.spacing(0.5, 1.25, 1.5),
+ textAlign: 'center',
+ },
+ lines: {
+ display: 'flex',
+ flexDirection: 'column',
+ alignItems: 'stretch',
+ gap: theme.spacing(3),
+ },
+ lineGroup: {
+ width: '100%',
+ borderRadius: theme.shape.borderRadius,
+ '--lyrics-main-current-color':
+ 'var(--lyrics-main-idle-color, currentColor)',
+ '--lyrics-pronunciation-current-color':
+ 'var(--lyrics-pronunciation-idle-color, currentColor)',
+ '--lyrics-translation-current-color':
+ 'var(--lyrics-translation-idle-color, currentColor)',
+ '--lyrics-layer-opacity': KARAOKE_IDLE_LAYER_OPACITY,
+ transform: 'translateY(0)',
+ transition: `background-color 150ms ${KARAOKE_LINE_MOTION_EASING}`,
+ '&[role="button"]:focus-visible': {
+ backgroundColor: alpha(theme.palette.text.primary, 0.055),
+ },
+ '@media (hover: hover) and (pointer: fine)': {
+ '&[role="button"]:hover': {
+ backgroundColor: alpha(theme.palette.text.primary, 0.055),
+ },
+ },
+ '&[data-raised="true"][data-line-motion="character"][data-character-wave="false"] $line':
+ {
+ transform: `translateY(-${KARAOKE_LINE_LIFT_PX}px)`,
+ transition: KARAOKE_LAYER_OPACITY_TRANSITION,
+ },
+ '&[data-active="true"]': {
+ '--lyrics-main-current-color':
+ 'var(--lyrics-main-active-color, var(--lyrics-main-idle-color, currentColor))',
+ '--lyrics-pronunciation-current-color':
+ 'var(--lyrics-pronunciation-active-color, var(--lyrics-pronunciation-idle-color, currentColor))',
+ '--lyrics-translation-current-color':
+ 'var(--lyrics-translation-active-color, var(--lyrics-translation-idle-color, currentColor))',
+ '--lyrics-layer-opacity': 1,
+ },
+ '@media (prefers-reduced-motion: reduce)': {
+ transition: 'none',
+ transform: 'none',
+ '& $line': {
+ transition: 'none',
+ transform: 'none',
+ },
+ },
+ },
+ waveCharacter: {
+ display: 'inline-block',
+ verticalAlign: 'baseline',
+ transform: 'translate3d(0, 0, 0)',
+ transformOrigin: 'center bottom',
+ willChange: 'transform',
+ '@media (prefers-reduced-motion: reduce)': {
+ transform: 'none !important',
+ willChange: 'auto',
+ },
+ },
+ line: {
+ display: 'inline-block',
+ width: '100%',
+ maxWidth: '100%',
+ fontWeight: 700,
+ fontSize: 24,
+ lineHeight: 1.18,
+ overflowWrap: 'anywhere',
+ whiteSpace: 'pre-wrap',
+ letterSpacing: 0,
+ color: 'var(--lyrics-main-current-color, currentColor)',
+ WebkitTextFillColor: 'var(--lyrics-main-current-color, currentColor)',
+ '&[data-tokenized="false"]': {
+ opacity: 'var(--lyrics-layer-opacity)',
+ color: 'var(--lyrics-main-active-color, currentColor)',
+ WebkitTextFillColor: 'var(--lyrics-main-active-color, currentColor)',
+ transition: KARAOKE_LAYER_OPACITY_TRANSITION,
+ },
+ '@media (prefers-reduced-motion: reduce)': {
+ transition: 'none',
+ },
+ },
+ translationLine: {
+ display: 'block',
+ opacity: 1,
+ fontWeight: 600,
+ fontSize: 15,
+ lineHeight: KARAOKE_AUX_LINE_HEIGHT,
+ overflowWrap: 'anywhere',
+ whiteSpace: 'pre-wrap',
+ letterSpacing: 0,
+ color: 'var(--lyrics-translation-current-color, currentColor)',
+ WebkitTextFillColor:
+ 'var(--lyrics-translation-current-color, currentColor)',
+ transition: KARAOKE_LAYER_COLOR_TRANSITION,
+ '&&': {
+ marginTop: theme.spacing(0.6),
+ },
+ '@media (prefers-reduced-motion: reduce)': {
+ transition: 'none',
+ },
+ },
+ stackedToken: {
+ display: 'inline-flex',
+ flexDirection: 'column',
+ alignItems: 'flex-start',
+ verticalAlign: 'top',
+ width: 'max-content',
+ minWidth: 0,
+ maxWidth: '100%',
+ overflowWrap: 'anywhere',
+ whiteSpace: 'pre-wrap',
+ paddingRight: theme.spacing(0.5),
+ marginBottom: theme.spacing(0.25),
+ },
+ wrappedStackedLine: {
+ '& $stackedToken': {
+ marginBottom: theme.spacing(0.95),
+ },
+ },
+ stackedSpacer: {
+ display: 'inline',
+ whiteSpace: 'pre-wrap',
+ },
+ stackedMainText: {
+ display: 'block',
+ lineHeight: 1.05,
+ maxWidth: '100%',
+ overflowWrap: 'anywhere',
+ whiteSpace: 'pre-wrap',
+ },
+ stackedPronunciation: {
+ display: 'block',
+ marginTop: theme.spacing(0.15),
+ fontSize: 15,
+ lineHeight: 1.05,
+ fontWeight: 700,
+ maxWidth: '100%',
+ overflowWrap: 'anywhere',
+ whiteSpace: 'pre-wrap',
+ color: 'var(--lyrics-pronunciation-current-color, currentColor)',
+ WebkitTextFillColor:
+ 'var(--lyrics-pronunciation-current-color, currentColor)',
+ '&[data-timed="false"]': {
+ color: 'var(--lyrics-pronunciation-active-color, currentColor)',
+ WebkitTextFillColor:
+ 'var(--lyrics-pronunciation-active-color, currentColor)',
+ },
+ '&[data-timed="true"]': {
+ transition: 'none',
+ },
+ '@media (prefers-reduced-motion: reduce)': {
+ transition: 'none',
+ },
+ },
+ token: {
+ whiteSpace: 'pre-wrap',
+ overflowWrap: 'anywhere',
+ fontKerning: 'none',
+ fontVariantLigatures: 'none',
+ },
+ voiceLanes: {
+ display: 'flex',
+ flexDirection: 'column',
+ alignItems: 'stretch',
+ gap: theme.spacing(0.7),
+ },
+ secondaryVoiceLane: {
+ fontSize: 22,
+ },
+ emptyState: {
+ flex: 1,
+ minHeight: 0,
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ padding: theme.spacing(3),
+ color: alpha(theme.palette.text.primary, 0.68),
+ fontWeight: 600,
+ textAlign: 'center',
+ },
+}))
+
+const normalizeLineText = (value) =>
+ String(value || '')
+ .normalize('NFKC')
+ .toLocaleLowerCase()
+ .replace(/[\p{P}\p{S}\s]+/gu, '')
+
+const shouldShowAuxLine = (mainLine, auxLine) =>
+ Boolean(
+ auxLine?.value &&
+ normalizeLineText(auxLine.value) !== normalizeLineText(mainLine?.value),
+ )
+
+const getLayerMatchWindow = (lines, index) => {
+ const line = lines[index]
+ if (!line) return { start: null, end: null }
+ return {
+ start: finiteTime(line.start),
+ end: finiteTime(line.end) ?? finiteTime(lines[index + 1]?.start),
+ }
+}
+
+const buildUniqueLayerMap = (mainLines, layerLines) => {
+ if (!mainLines.length || !layerLines.length) return {}
+
+ const candidates = []
+ for (let layerIndex = 0; layerIndex < layerLines.length; layerIndex += 1) {
+ if (layerLines[layerIndex]?.renderable === false) continue
+ const layerWindow = getLayerMatchWindow(layerLines, layerIndex)
+ for (let mainIndex = 0; mainIndex < mainLines.length; mainIndex += 1) {
+ if (mainLines[mainIndex]?.renderable === false) continue
+ const mainWindow = getLayerMatchWindow(mainLines, mainIndex)
+ let score = null
+
+ if (layerWindow.start != null && mainWindow.start != null) {
+ const mainEnd = mainWindow.end ?? mainWindow.start
+ const layerEnd = layerWindow.end ?? layerWindow.start
+ const overlap =
+ Math.min(mainEnd, layerEnd) -
+ Math.max(mainWindow.start, layerWindow.start)
+ const mainDuration = Math.max(0, mainEnd - mainWindow.start)
+ const maxDelta = Math.max(550, Math.min(1400, mainDuration + 420))
+ const startDelta = Math.abs(layerWindow.start - mainWindow.start)
+ if (overlap < 0 && startDelta > maxDelta) continue
+ score =
+ startDelta +
+ Math.abs(layerIndex - mainIndex) * 30 +
+ (overlap < 0 ? 200 : 0)
+ } else if (layerIndex === mainIndex) {
+ score = 2000
+ }
+
+ if (score != null) {
+ candidates.push({ layerIndex, mainIndex, score })
+ }
+ }
+ }
+
+ candidates.sort(
+ (left, right) =>
+ left.score - right.score ||
+ left.layerIndex - right.layerIndex ||
+ left.mainIndex - right.mainIndex,
+ )
+
+ const usedLayers = new Set()
+ const usedMainLines = new Set()
+ const result = {}
+ for (const candidate of candidates) {
+ if (
+ usedLayers.has(candidate.layerIndex) ||
+ usedMainLines.has(candidate.mainIndex)
+ ) {
+ continue
+ }
+ usedLayers.add(candidate.layerIndex)
+ usedMainLines.add(candidate.mainIndex)
+ result[candidate.mainIndex] = layerLines[candidate.layerIndex]
+ }
+ return result
+}
+
+const getLineLanes = (line) =>
+ Array.isArray(line?.lanes) && line.lanes.length > 0 ? line.lanes : [line]
+
+const buildStaticTranslationLine = (translationLine) => {
+ if (!translationLine) return null
+ return {
+ ...translationLine,
+ tokens: [],
+ lanes: undefined,
+ }
+}
+
+const buildLineGroupStyle = (canSeekLine, layerStyles) => ({
+ cursor: canSeekLine ? 'pointer' : undefined,
+ '--lyrics-main-idle-color': layerStyles.main.color,
+ '--lyrics-main-active-color':
+ layerStyles.main['--lyrics-active-color'] || layerStyles.main.color,
+ '--lyrics-pronunciation-idle-color': layerStyles.pronunciation.color,
+ '--lyrics-pronunciation-active-color':
+ layerStyles.pronunciation['--lyrics-active-color'] ||
+ layerStyles.pronunciation.color,
+ '--lyrics-translation-idle-color': layerStyles.translation.color,
+ '--lyrics-translation-active-color':
+ layerStyles.translation['--lyrics-active-color'],
+})
+
+const usePrefersReducedMotion = () => {
+ const [prefersReducedMotion, setPrefersReducedMotion] = useState(false)
+
+ useEffect(() => {
+ if (typeof window === 'undefined' || !window.matchMedia) return undefined
+ const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)')
+ const updatePreference = () =>
+ setPrefersReducedMotion(Boolean(mediaQuery.matches))
+ updatePreference()
+ if (mediaQuery.addEventListener) {
+ mediaQuery.addEventListener('change', updatePreference)
+ return () => mediaQuery.removeEventListener('change', updatePreference)
+ }
+ mediaQuery.addListener(updatePreference)
+ return () => mediaQuery.removeListener(updatePreference)
+ }, [])
+
+ return prefersReducedMotion
+}
+
+const LyricsPanel = ({
+ visible = true,
+ obscured = false,
+ mainLyric,
+ translationLyric,
+ pronunciationLyric,
+ showTranslation,
+ showPronunciation,
+ audioInstance,
+ inline = false,
+ loading = false,
+ error = null,
+ errorMessage = null,
+ labels = {},
+}) => {
+ const classes = useStyles()
+ const theme = useTheme()
+ const bodyRef = useRef(null)
+ const scrollAnimationRef = useRef(null)
+ const scrollbarTimerRef = useRef(null)
+ const manualScrollTimerRef = useRef(null)
+ const manualScrollUntilRef = useRef(0)
+ const [showScrollbar, setShowScrollbar] = useState(false)
+ const [autoScrollResumeKey, setAutoScrollResumeKey] = useState(0)
+ const [layoutVersion, setLayoutVersion] = useState(0)
+ const [hasTopFade, setHasTopFade] = useState(false)
+ const [scrollEndPadding, setScrollEndPadding] = useState(0)
+ const [scrollStartPadding, setScrollStartPadding] = useState(0)
+ const [focusedLineIndex, setFocusedLineIndex] = useState(null)
+ const prefersReducedMotion = usePrefersReducedMotion()
+ const timelineVisible = visible && !obscured
+ const activeLineAnchorRatio = inline
+ ? KARAOKE_INLINE_ACTIVE_LINE_ANCHOR_RATIO
+ : KARAOKE_DESKTOP_ACTIVE_LINE_ANCHOR_RATIO
+
+ const mainLines = useMemo(() => buildKaraokeLines(mainLyric), [mainLyric])
+ const translationLines = useMemo(
+ () => buildKaraokeLines(translationLyric),
+ [translationLyric],
+ )
+ const pronunciationLines = useMemo(
+ () => buildKaraokeLines(pronunciationLyric),
+ [pronunciationLyric],
+ )
+ const hasTimedMainLines = useMemo(
+ () => hasUsableKaraokeTiming(mainLines),
+ [mainLines],
+ )
+ const {
+ activeIndexes,
+ primaryIndex: activeIndex,
+ scrollTargetIndex,
+ registerLine,
+ registerToken,
+ getLineNode,
+ syncNow,
+ timeline,
+ } = useLyricsTimeline({
+ lines: mainLines,
+ audioInstance,
+ visible: timelineVisible && hasTimedMainLines,
+ reducedMotion: prefersReducedMotion,
+ })
+ const activeIndexSet = useMemo(() => new Set(activeIndexes), [activeIndexes])
+ const seekableLineIndexes = useMemo(() => {
+ if (!audioInstance) return []
+ return mainLines.reduce((indexes, line, index) => {
+ if (line.renderable !== false && line.start != null) indexes.push(index)
+ return indexes
+ }, [])
+ }, [audioInstance, mainLines])
+ const tabbableLineIndex = seekableLineIndexes.includes(focusedLineIndex)
+ ? focusedLineIndex
+ : seekableLineIndexes.includes(activeIndex)
+ ? activeIndex
+ : seekableLineIndexes[0]
+
+ useEffect(() => {
+ setFocusedLineIndex(null)
+ }, [mainLyric])
+
+ const trByMainIndex = useMemo(() => {
+ if (!showTranslation || translationLines.length === 0) return {}
+ return buildUniqueLayerMap(mainLines, translationLines)
+ }, [mainLines, translationLines, showTranslation])
+
+ const prByMainIndex = useMemo(() => {
+ if (!showPronunciation || pronunciationLines.length === 0) return {}
+ return buildUniqueLayerMap(mainLines, pronunciationLines)
+ }, [mainLines, pronunciationLines, showPronunciation])
+
+ const colors = useMemo(
+ () => ({
+ main: theme.palette.text.primary,
+ pronunciation: theme.palette.primary.main,
+ }),
+ [theme],
+ )
+
+ const layerStyles = useMemo(() => {
+ const styleFor = (layer) => {
+ const sourceColor = colors[layer]
+ if (!hasTimedMainLines) {
+ return {
+ opacity: 1,
+ color: alpha(sourceColor, layer === 'main' ? 0.98 : 0.86),
+ }
+ }
+ const activeAlpha = layer === 'main' ? 0.98 : 0.78
+ const pronunciationFadeRatio = 0.38 / 0.78
+ const idleAlpha = activeAlpha * pronunciationFadeRatio
+ return {
+ opacity: 1,
+ color: alpha(sourceColor, idleAlpha),
+ '--lyrics-active-color': alpha(sourceColor, activeAlpha),
+ }
+ }
+ return {
+ main: styleFor('main'),
+ pronunciation: styleFor('pronunciation'),
+ translation: {
+ color: theme.palette.text.disabled,
+ '--lyrics-active-color': theme.palette.text.secondary,
+ },
+ }
+ }, [colors, hasTimedMainLines, theme])
+
+ const showScrollbarForManualScroll = useCallback(() => {
+ if (scrollbarTimerRef.current) {
+ window.clearTimeout(scrollbarTimerRef.current)
+ }
+ setShowScrollbar(true)
+ scrollbarTimerRef.current = window.setTimeout(() => {
+ setShowScrollbar(false)
+ scrollbarTimerRef.current = null
+ }, KARAOKE_SCROLLBAR_VISIBLE_MS)
+ }, [])
+
+ const clearManualScrollTimer = useCallback(() => {
+ if (manualScrollTimerRef.current) {
+ window.clearTimeout(manualScrollTimerRef.current)
+ manualScrollTimerRef.current = null
+ }
+ }, [])
+
+ const resumeAutoScroll = useCallback(() => {
+ clearManualScrollTimer()
+ manualScrollUntilRef.current = 0
+ setAutoScrollResumeKey((current) => current + 1)
+ }, [clearManualScrollTimer])
+
+ const markManualScrollIntent = useCallback(() => {
+ cancelScrollAnimation(scrollAnimationRef)
+ clearManualScrollTimer()
+ manualScrollUntilRef.current =
+ performance.now() + KARAOKE_MANUAL_SCROLL_PAUSE_MS
+ manualScrollTimerRef.current = window.setTimeout(() => {
+ manualScrollTimerRef.current = null
+ resumeAutoScroll()
+ }, KARAOKE_MANUAL_SCROLL_PAUSE_MS)
+ showScrollbarForManualScroll()
+ }, [clearManualScrollTimer, resumeAutoScroll, showScrollbarForManualScroll])
+
+ const updateTopFade = useCallback(() => {
+ const body = bodyRef.current
+ setHasTopFade((current) => {
+ const next = Boolean(body && body.scrollTop > 1)
+ return current === next ? current : next
+ })
+ }, [])
+
+ useEffect(
+ () => () => {
+ if (scrollbarTimerRef.current) {
+ window.clearTimeout(scrollbarTimerRef.current)
+ }
+ if (manualScrollTimerRef.current) {
+ window.clearTimeout(manualScrollTimerRef.current)
+ }
+ cancelScrollAnimation(scrollAnimationRef)
+ },
+ [],
+ )
+
+ useLayoutEffect(() => {
+ const body = bodyRef.current
+ if (!visible || !body) return undefined
+ const ResizeObserverConstructor =
+ typeof window !== 'undefined' ? window.ResizeObserver : null
+ if (!ResizeObserverConstructor) return undefined
+ const resizeObserver = new ResizeObserverConstructor(() => {
+ setLayoutVersion((current) => current + 1)
+ })
+ resizeObserver.observe(body)
+ return () => resizeObserver.disconnect()
+ }, [visible])
+
+ useLayoutEffect(() => {
+ const body = bodyRef.current
+ if (!visible || !body) {
+ setScrollEndPadding(0)
+ setScrollStartPadding(0)
+ return
+ }
+ const nextEndPadding = getScrollEndPadding(body, activeLineAnchorRatio)
+ setScrollEndPadding((current) =>
+ current === nextEndPadding ? current : nextEndPadding,
+ )
+ if (inline) {
+ const nextStartPadding = Math.round(
+ body.clientHeight * activeLineAnchorRatio,
+ )
+ setScrollStartPadding((current) =>
+ current === nextStartPadding ? current : nextStartPadding,
+ )
+ } else {
+ setScrollStartPadding(0)
+ }
+ }, [activeLineAnchorRatio, inline, layoutVersion, mainLines.length, visible])
+
+ useLayoutEffect(() => {
+ const body = bodyRef.current
+ if (!visible || !body) return
+ cancelScrollAnimation(scrollAnimationRef)
+ clearManualScrollTimer()
+ manualScrollUntilRef.current = 0
+ body.scrollTop = 0
+ setHasTopFade(false)
+ }, [clearManualScrollTimer, mainLyric, visible])
+
+ useEffect(() => {
+ if (!timelineVisible || !hasTimedMainLines || scrollTargetIndex < 0) {
+ cancelScrollAnimation(scrollAnimationRef)
+ return undefined
+ }
+
+ const animFrameId = window.requestAnimationFrame(() => {
+ if (manualScrollUntilRef.current > performance.now()) return
+ const body = bodyRef.current
+ const targetNode = getLineNode(scrollTargetIndex)
+ if (!body || !targetNode) return
+ animateScrollTop({
+ body,
+ targetTop: getAnchoredScrollTop(
+ body,
+ targetNode,
+ activeLineAnchorRatio,
+ ),
+ reducedMotion: prefersReducedMotion,
+ scrollAnimationRef,
+ })
+ })
+
+ return () => window.cancelAnimationFrame(animFrameId)
+ }, [
+ activeLineAnchorRatio,
+ autoScrollResumeKey,
+ getLineNode,
+ hasTimedMainLines,
+ layoutVersion,
+ prefersReducedMotion,
+ scrollTargetIndex,
+ timelineVisible,
+ ])
+
+ if (!visible) return null
+
+ if (!hasStructuredLyricContent(mainLyric) || mainLines.length === 0) {
+ const message = loading
+ ? labels.loading || 'Loading lyrics'
+ : error
+ ? errorMessage || labels.unavailable || 'Lyrics unavailable'
+ : labels.empty || 'No lyrics available'
+ return (
+ event.stopPropagation() : undefined}
+ >
+
+ {message}
+
+
+ )
+ }
+
+ const seekToLine = (line) => {
+ if (!audioInstance || line.start == null) return
+ audioInstance.currentTime = line.start / 1000
+ syncNow(line.start, true)
+ resumeAutoScroll()
+ }
+
+ const focusSeekableLine = (index) => {
+ setFocusedLineIndex(index)
+ getLineNode(index)?.focus()
+ }
+
+ const handleLineKeyDown = (event, index, line) => {
+ if (event.key === 'Enter' || event.key === ' ') {
+ event.preventDefault()
+ seekToLine(line)
+ return
+ }
+
+ const currentPosition = seekableLineIndexes.indexOf(index)
+ if (currentPosition < 0) return
+ let nextPosition = null
+ if (event.key === 'ArrowDown' || event.key === 'ArrowRight') {
+ nextPosition = Math.min(
+ currentPosition + 1,
+ seekableLineIndexes.length - 1,
+ )
+ } else if (event.key === 'ArrowUp' || event.key === 'ArrowLeft') {
+ nextPosition = Math.max(currentPosition - 1, 0)
+ } else if (event.key === 'Home') {
+ nextPosition = 0
+ } else if (event.key === 'End') {
+ nextPosition = seekableLineIndexes.length - 1
+ }
+ if (nextPosition == null) return
+ event.preventDefault()
+ focusSeekableLine(seekableLineIndexes[nextPosition])
+ }
+
+ return (
+ event.stopPropagation() : undefined}
+ >
+
+
0 || scrollStartPadding > 0
+ ? {
+ paddingTop: scrollStartPadding || undefined,
+ paddingBottom: scrollEndPadding || undefined,
+ }
+ : undefined
+ }
+ >
+ {mainLines.map((line, idx) => {
+ if (line.renderable === false) return null
+ const trLine = trByMainIndex[idx]
+ const prLine = prByMainIndex[idx]
+ const mainNextLineStart =
+ timeline.windows[idx]?.nextTimedStart ?? null
+ const showTr = shouldShowAuxLine(line, trLine)
+ const showPr = shouldShowAuxLine(line, prLine)
+ const lineLanes = getLineLanes(line)
+ const usesCharacterRise = lineLanes.some(
+ (lane) => Array.isArray(lane?.tokens) && lane.tokens.length > 0,
+ )
+ const canSeekLine = Boolean(audioInstance && line.start != null)
+ const isActiveLine = activeIndexSet.has(idx)
+ const renderCharacterWave = Boolean(
+ !prefersReducedMotion &&
+ hasTimedMainLines &&
+ usesCharacterRise &&
+ isActiveLine,
+ )
+ const isStaticLine = !hasTimedMainLines
+ return (
+
registerLine(idx, node)
+ : undefined
+ }
+ className={classes.lineGroup}
+ data-line-motion={usesCharacterRise ? 'character' : 'line'}
+ data-character-wave={renderCharacterWave ? 'true' : 'false'}
+ data-active={isStaticLine || isActiveLine ? 'true' : 'false'}
+ {...(isStaticLine
+ ? {
+ 'data-lifecycle': 'active',
+ 'data-highlight-active': 'true',
+ 'data-raised': 'false',
+ }
+ : {})}
+ aria-current={idx === activeIndex ? 'true' : undefined}
+ data-scroll-target={
+ idx === scrollTargetIndex ? 'true' : 'false'
+ }
+ data-testid="lyrics-line-group"
+ style={buildLineGroupStyle(canSeekLine, layerStyles)}
+ role={canSeekLine ? 'button' : undefined}
+ tabIndex={
+ canSeekLine ? (idx === tabbableLineIndex ? 0 : -1) : undefined
+ }
+ onClick={() => seekToLine(line)}
+ onFocus={
+ canSeekLine ? () => setFocusedLineIndex(idx) : undefined
+ }
+ onBlur={
+ canSeekLine
+ ? (event) => {
+ if (!bodyRef.current?.contains(event.relatedTarget)) {
+ setFocusedLineIndex(null)
+ }
+ }
+ : undefined
+ }
+ onMouseDown={
+ canSeekLine
+ ? (event) => {
+ event.preventDefault()
+ }
+ : undefined
+ }
+ onKeyDown={
+ canSeekLine
+ ? (event) => handleLineKeyDown(event, idx, line)
+ : undefined
+ }
+ >
+ {lineLanes.length > 1 ? (
+
+ {lineLanes.map((lane, laneIdx) => {
+ const laneClassName = clsx(classes.line, {
+ [classes.secondaryVoiceLane]: laneIdx > 0,
+ })
+ const rowKey = lane.key || `lane-${laneIdx}`
+
+ return showPr && laneIdx === 0 ? (
+
+ ) : (
+
+ )
+ })}
+
+ ) : showPr ? (
+
+ ) : (
+
+ )}
+ {showTr && (
+
+ )}
+
+ )
+ })}
+
+
+
+ )
+}
+
+export default LyricsPanel
diff --git a/ui/src/audioplayer/LyricsPanel.test.jsx b/ui/src/audioplayer/LyricsPanel.test.jsx
new file mode 100644
index 000000000..bbc632178
--- /dev/null
+++ b/ui/src/audioplayer/LyricsPanel.test.jsx
@@ -0,0 +1,1009 @@
+import React from 'react'
+import {
+ act,
+ cleanup,
+ fireEvent,
+ render,
+ screen,
+ waitFor,
+} from '@testing-library/react'
+import { ThemeProvider, createTheme } from '@material-ui/core/styles'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import LyricsPanel from './LyricsPanel'
+import { KaraokeLineRow } from './LyricsLineRows'
+import {
+ KARAOKE_EASING,
+ KARAOKE_LINE_OPACITY_MS,
+ KARAOKE_CHARACTER_LIFT_PX,
+ KARAOKE_CHARACTER_RISE_MS,
+ KARAOKE_HIGHLIGHT_LEAD_MS,
+ KARAOKE_LINE_LIFT_PX,
+ KARAOKE_MANUAL_SCROLL_PAUSE_MS,
+ KARAOKE_SCROLLBAR_VISIBLE_MS,
+ TOKEN_FUTURE_ALPHA,
+} from './lyricsKaraokeConstants'
+
+const theme = createTheme({
+ palette: {
+ primary: { main: '#2266aa' },
+ text: { primary: '#111111', secondary: '#778899' },
+ },
+})
+
+const translationColorsFor = (selectedTheme) => ({
+ active: selectedTheme.palette.text.secondary,
+ idle: selectedTheme.palette.text.disabled,
+})
+
+const panel = (props, selectedTheme = theme) => (
+
+
+
+)
+
+const renderPanel = (props, selectedTheme = theme) => {
+ const view = render(panel(props, selectedTheme))
+ return {
+ ...view,
+ rerenderPanel: (nextProps) =>
+ view.rerender(panel(nextProps, selectedTheme)),
+ }
+}
+
+const parseCssColor = (value) => {
+ const hex = String(value).match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i)
+ if (hex) {
+ const normalized =
+ hex[1].length === 3
+ ? hex[1]
+ .split('')
+ .map((channel) => channel.repeat(2))
+ .join('')
+ : hex[1]
+ return {
+ channels: [
+ parseInt(normalized.slice(0, 2), 16),
+ parseInt(normalized.slice(2, 4), 16),
+ parseInt(normalized.slice(4, 6), 16),
+ ],
+ alpha: 1,
+ }
+ }
+
+ const rgb = String(value).match(
+ /^rgba?\(\s*(\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\s*\)$/i,
+ )
+ if (!rgb) throw new Error(`Unsupported CSS color: ${value}`)
+ return {
+ channels: rgb.slice(1, 4).map(Number),
+ alpha: rgb[4] == null ? 1 : Number(rgb[4]),
+ }
+}
+
+const compositeColor = (foreground, background) => {
+ const fg = parseCssColor(foreground)
+ const bg = parseCssColor(background)
+ return fg.channels.map(
+ (channel, index) =>
+ channel * fg.alpha + bg.channels[index] * (1 - fg.alpha),
+ )
+}
+
+const relativeLuminance = (channels) =>
+ channels
+ .map((channel) => channel / 255)
+ .map((channel) =>
+ channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4,
+ )
+ .reduce(
+ (total, channel, index) =>
+ total + channel * [0.2126, 0.7152, 0.0722][index],
+ 0,
+ )
+
+const contrastRatio = (foreground, background) => {
+ const foregroundLuminance = relativeLuminance(
+ compositeColor(foreground, background),
+ )
+ const backgroundLuminance = relativeLuminance(
+ parseCssColor(background).channels,
+ )
+ const lighter = Math.max(foregroundLuminance, backgroundLuminance)
+ const darker = Math.min(foregroundLuminance, backgroundLuminance)
+ return (lighter + 0.05) / (darker + 0.05)
+}
+
+const mainLyric = {
+ synced: true,
+ line: [{ start: 0, end: 1000, value: 'Main line' }],
+}
+
+const createTokenizedLyric = (value, first, second) => ({
+ synced: true,
+ line: [{ start: 0, end: 1000, value }],
+ cueLine: [
+ {
+ index: 0,
+ start: 0,
+ end: 1000,
+ value,
+ cue: [
+ { start: 0, end: 500, value: first, byteStart: 0, byteEnd: 3 },
+ { start: 500, end: 1000, value: second, byteStart: 5, byteEnd: 8 },
+ ],
+ },
+ ],
+})
+
+const tokenizedMainLyric = createTokenizedLyric('Main line', 'Main', 'line')
+const tokenizedPronunciationLyric = createTokenizedLyric(
+ 'mein lain',
+ 'mein',
+ 'lain',
+)
+
+const voiceCue = (value, agentId, start, end) => ({
+ index: 0,
+ start,
+ end,
+ value,
+ agentId,
+ cue: [{ start, end, value }],
+})
+
+const multiAgentLyric = {
+ synced: true,
+ agents: [
+ { id: 'lead', role: 'main' },
+ { id: 'all', role: 'group' },
+ { id: 'echo', role: 'bg' },
+ ],
+ line: [{ start: 1000, end: 4000, value: 'Lead all echo' }],
+ cueLine: [
+ voiceCue('Lead', 'lead', 1000, 2000),
+ voiceCue('all', 'all', 1500, 2600),
+ voiceCue('echo', 'echo', 2200, 3400),
+ ],
+}
+
+const createAudio = ({ currentTime = 0, duration = 10 } = {}) => {
+ const audio = new EventTarget()
+ audio.currentTime = currentTime
+ audio.duration = duration
+ audio.paused = true
+ audio.playbackRate = 1
+ audio.seeking = false
+ return audio
+}
+
+describe('', () => {
+ const originalMatchMedia = window.matchMedia
+ const originalResizeObserver = window.ResizeObserver
+
+ beforeEach(() => {
+ vi.spyOn(window, 'requestAnimationFrame').mockImplementation(() => 0)
+ vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {})
+ })
+
+ afterEach(() => {
+ vi.useRealTimers()
+ vi.restoreAllMocks()
+ window.matchMedia = originalMatchMedia
+ window.ResizeObserver = originalResizeObserver
+ cleanup()
+ })
+
+ it('renders main, stacked pronunciation, and translation in layer order', () => {
+ renderPanel({
+ mainLyric: tokenizedMainLyric,
+ pronunciationLyric: tokenizedPronunciationLyric,
+ translationLyric: {
+ synced: true,
+ line: [{ start: 0, end: 1000, value: 'translation line' }],
+ },
+ showPronunciation: true,
+ showTranslation: true,
+ })
+
+ const pronunciation = screen.getAllByTestId('lyrics-pronunciation-token')
+ expect(pronunciation).toHaveLength(2)
+ expect(pronunciation[0]).toHaveTextContent('mein')
+ expect(pronunciation[1]).toHaveTextContent('lain')
+ expect(screen.getByText('translation line')).toBeInTheDocument()
+ })
+
+ it('updates every pronunciation-only token with a distinct registry key', () => {
+ const audioInstance = createAudio()
+ renderPanel({
+ mainLyric,
+ pronunciationLyric: tokenizedPronunciationLyric,
+ showPronunciation: true,
+ audioInstance,
+ })
+
+ const pronunciation = screen.getAllByTestId('lyrics-pronunciation-token')
+ act(() => {
+ audioInstance.currentTime = 0.75
+ audioInstance.dispatchEvent(new Event('seeking'))
+ })
+
+ expect(pronunciation[0]).toHaveAttribute('data-lyrics-state', 'completed')
+ expect(pronunciation[1]).toHaveAttribute('data-lyrics-state', 'active')
+ })
+
+ it('renders incomplete pronunciation timing as static text', () => {
+ renderPanel({
+ mainLyric,
+ pronunciationLyric: {
+ synced: true,
+ line: [{ value: 'mein lain' }],
+ cueLine: [
+ {
+ index: 0,
+ value: 'mein lain',
+ cue: [{ value: 'mein' }, { value: 'lain' }],
+ },
+ ],
+ },
+ showPronunciation: true,
+ })
+
+ screen.getAllByTestId('lyrics-pronunciation-token').forEach((token) => {
+ expect(token).toHaveAttribute('data-timed', 'false')
+ expect(token).not.toHaveAttribute('aria-label')
+ })
+ })
+
+ it('keeps token ref callbacks stable across equivalent rerenders', () => {
+ const registerToken = vi.fn()
+ const line = {
+ start: 0,
+ end: 1000,
+ value: 'Main',
+ tokens: [
+ {
+ start: 0,
+ end: 1000,
+ value: 'Main',
+ byteStart: 0,
+ byteEnd: 3,
+ },
+ ],
+ }
+ const row = () => (
+
+ )
+ const { rerender } = render(row())
+
+ expect(registerToken).toHaveBeenCalledWith(
+ expect.any(String),
+ expect.any(Object),
+ expect.any(HTMLElement),
+ )
+ registerToken.mockClear()
+
+ rerender(row())
+
+ expect(registerToken).not.toHaveBeenCalled()
+ })
+
+ it('renders each translation line under only its closest main line', () => {
+ renderPanel({
+ mainLyric: {
+ synced: true,
+ line: [
+ { start: 0, end: 1000, value: 'First main line' },
+ { start: 1000, end: 2000, value: 'Closest main line' },
+ { start: 2000, end: 3000, value: 'Later main line' },
+ ],
+ },
+ translationLyric: {
+ synced: true,
+ line: [{ start: 1100, end: 2800, value: 'One translated line' }],
+ },
+ showTranslation: true,
+ })
+
+ const translations = screen.getAllByText('One translated line')
+ expect(translations).toHaveLength(1)
+ expect(
+ translations[0].closest('[data-testid="lyrics-line-group"]'),
+ ).toHaveTextContent('Closest main line')
+ })
+
+ it('suppresses duplicate translations without shifting untimed indexes', () => {
+ renderPanel({
+ mainLyric: {
+ synced: false,
+ line: [{ value: 'Hello' }, { value: 'World' }],
+ },
+ translationLyric: {
+ synced: false,
+ line: [{ value: 'Hello' }, { value: 'Mundo' }],
+ },
+ showTranslation: true,
+ })
+
+ const groups = screen.getAllByTestId('lyrics-line-group')
+ expect(groups[0]).toHaveTextContent('Hello')
+ expect(groups[0]).not.toHaveTextContent('Mundo')
+ expect(groups[1]).toHaveTextContent('World')
+ expect(groups[1]).toHaveTextContent('Mundo')
+ })
+
+ it('renders line-level pronunciation without inventing word timing', () => {
+ renderPanel({
+ mainLyric: {
+ synced: true,
+ line: [{ start: 0, end: 1000, value: '我总要给一些别的' }],
+ },
+ pronunciationLyric: {
+ synced: true,
+ line: [{ start: 0, end: 1000, value: 'wo zong yao gei yi xie bie de' }],
+ },
+ showPronunciation: true,
+ audioInstance: { currentTime: 0.2, paused: true },
+ })
+
+ expect(screen.getByText('我总要给一些别的')).toBeInTheDocument()
+ const pronunciation = screen.getByText('wo zong yao gei yi xie bie de')
+ const group = pronunciation.closest('[data-testid="lyrics-line-group"]')
+ expect(group).toHaveAttribute('data-active', 'true')
+ expect(
+ group.style.getPropertyValue('--lyrics-pronunciation-active-color'),
+ ).not.toBe('')
+ expect(pronunciation.style.color).toBe('')
+ })
+
+ it('highlights translations with the shared layer transition', () => {
+ const propsAt = (currentTime) => ({
+ mainLyric,
+ pronunciationLyric: {
+ synced: true,
+ line: [{ start: 0, end: 1000, value: 'main pronunciation' }],
+ },
+ translationLyric: {
+ synced: true,
+ line: [{ start: 0, end: 1000, value: 'translated line' }],
+ },
+ showPronunciation: true,
+ showTranslation: true,
+ audioInstance: { currentTime, paused: true },
+ })
+ const { rerenderPanel } = renderPanel(propsAt(0.5))
+
+ const lineGroup = screen.getByTestId('lyrics-line-group')
+ const mainRow = lineGroup.querySelector('[data-tokenized]')
+ const translationRow = screen
+ .getByText('translated line')
+ .closest('[data-tokenized]')
+ const pronunciation = screen.getAllByTestId('lyrics-pronunciation-token')
+
+ ;[mainRow, translationRow].forEach((row) => {
+ expect(row).toHaveAttribute('data-tokenized', 'false')
+ expect(row.style.opacity).toBe('')
+ })
+ expect(window.getComputedStyle(mainRow).transition).toContain(
+ `opacity ${KARAOKE_LINE_OPACITY_MS}ms ${KARAOKE_EASING}`,
+ )
+ expect(window.getComputedStyle(translationRow).transition).toContain(
+ `color ${KARAOKE_LINE_OPACITY_MS}ms ${KARAOKE_EASING}`,
+ )
+ pronunciation.forEach((token) =>
+ expect(token).toHaveAttribute('data-timed', 'false'),
+ )
+ expect(window.getComputedStyle(translationRow).opacity).toBe('1')
+ const translationColors = translationColorsFor(theme)
+ expect(
+ lineGroup.style.getPropertyValue('--lyrics-translation-idle-color'),
+ ).toBe(translationColors.idle)
+ expect(
+ lineGroup.style.getPropertyValue('--lyrics-translation-active-color'),
+ ).toBe(translationColors.active)
+ expect(window.getComputedStyle(translationRow).transform).toBe('')
+
+ rerenderPanel(propsAt(1.1))
+
+ expect(lineGroup).toHaveAttribute('data-active', 'false')
+ expect(window.getComputedStyle(translationRow).opacity).toBe('1')
+ expect(window.getComputedStyle(translationRow).transition).toContain(
+ `color ${KARAOKE_LINE_OPACITY_MS}ms ${KARAOKE_EASING}`,
+ )
+ expect(window.getComputedStyle(translationRow).transform).toBe('')
+ })
+
+ it('keeps line-timed main and translation rows still', () => {
+ const { rerenderPanel } = renderPanel({
+ mainLyric,
+ translationLyric: {
+ synced: true,
+ line: [{ start: 0, end: 1000, value: 'translated line' }],
+ },
+ showTranslation: true,
+ audioInstance: { currentTime: 0.5, paused: true },
+ })
+
+ const group = screen.getByTestId('lyrics-line-group')
+ const translation = screen
+ .getByText('translated line')
+ .closest('[data-tokenized]')
+ const mainRow = screen.getByText('Main line').closest('[data-tokenized]')
+ const activeStyle = window.getComputedStyle(group)
+ expect(group).toHaveAttribute('data-highlight-active', 'true')
+ expect(group).toHaveAttribute('data-raised', 'true')
+ expect(group).toHaveAttribute('data-line-motion', 'line')
+ expect(group).toHaveAttribute('data-character-wave', 'false')
+ expect(activeStyle.transform).toBe('translateY(0)')
+ expect(window.getComputedStyle(mainRow).transform).toBe('')
+ expect(window.getComputedStyle(mainRow).transition).toContain(
+ `opacity ${KARAOKE_LINE_OPACITY_MS}ms ${KARAOKE_EASING}`,
+ )
+ expect(window.getComputedStyle(mainRow).transition).not.toContain(
+ 'transform',
+ )
+ expect(window.getComputedStyle(translation).transform).toBe('')
+
+ rerenderPanel({
+ mainLyric,
+ translationLyric: {
+ synced: true,
+ line: [{ start: 0, end: 1000, value: 'translated line' }],
+ },
+ showTranslation: true,
+ audioInstance: { currentTime: 1.1, paused: true },
+ })
+
+ const releasedStyle = window.getComputedStyle(group)
+ expect(group).toHaveAttribute('data-highlight-active', 'false')
+ expect(group).toHaveAttribute('data-raised', 'true')
+ expect(releasedStyle.transform).toBe('translateY(0)')
+ expect(window.getComputedStyle(mainRow).transform).toBe('')
+ expect(window.getComputedStyle(translation).transform).toBe('')
+ })
+
+ it('keeps word-timed translations still while main and pronunciation rise', () => {
+ const settledFirstCharacterTime =
+ (KARAOKE_CHARACTER_RISE_MS - KARAOKE_HIGHLIGHT_LEAD_MS) / 1000
+ const propsAt = (currentTime) => ({
+ mainLyric: tokenizedMainLyric,
+ pronunciationLyric: tokenizedPronunciationLyric,
+ translationLyric: {
+ synced: true,
+ line: [{ start: 0, end: 1000, value: 'translated line' }],
+ },
+ showPronunciation: true,
+ showTranslation: true,
+ audioInstance: { currentTime, paused: true },
+ })
+ const { rerenderPanel } = renderPanel(propsAt(settledFirstCharacterTime))
+
+ const group = screen.getByTestId('lyrics-line-group')
+ const translation = screen
+ .getByText('translated line')
+ .closest('[data-tokenized]')
+ const mainToken = screen.getAllByTestId('lyrics-token')[0]
+ const pronunciationToken = screen.getAllByTestId(
+ 'lyrics-pronunciation-token',
+ )[0]
+ const mainCharacters = mainToken.querySelectorAll(
+ '[data-lyrics-character="true"]',
+ )
+ const pronunciationCharacters = pronunciationToken.querySelectorAll(
+ '[data-lyrics-character="true"]',
+ )
+
+ expect(group).toHaveAttribute('data-line-motion', 'character')
+ expect(group).toHaveAttribute('data-character-wave', 'true')
+ expect(window.getComputedStyle(group).transform).toBe('translateY(0)')
+ expect(window.getComputedStyle(translation).transform).toBe('')
+ expect(mainCharacters).toHaveLength(4)
+ expect(pronunciationCharacters).toHaveLength(4)
+ expect(mainCharacters[0].style.transform).toBe(
+ `translate3d(0, -${KARAOKE_CHARACTER_LIFT_PX.toFixed(4)}px, 0)`,
+ )
+ expect(window.getComputedStyle(mainCharacters[0]).willChange).toBe(
+ 'transform',
+ )
+ expect(mainCharacters[3].style.transform).not.toBe(
+ mainCharacters[0].style.transform,
+ )
+ expect(pronunciationCharacters[0].style.transform).toBe(
+ mainCharacters[0].style.transform,
+ )
+ expect(mainToken.style.backgroundImage).toBe('none')
+ expect(mainCharacters[0].style.backgroundImage).toContain('linear-gradient')
+
+ rerenderPanel(propsAt(1.1))
+
+ expect(group).toHaveAttribute('data-highlight-active', 'false')
+ expect(group).toHaveAttribute('data-raised', 'true')
+ expect(group).toHaveAttribute('data-character-wave', 'false')
+ expect(
+ group.querySelectorAll('[data-lyrics-character="true"]'),
+ ).toHaveLength(0)
+ expect(window.getComputedStyle(group).transform).toBe('translateY(0)')
+ const releasedMainRow = screen
+ .getAllByTestId('lyrics-token')[0]
+ .closest('[data-tokenized]')
+ expect(window.getComputedStyle(releasedMainRow).transform).toBe(
+ `translateY(-${KARAOKE_LINE_LIFT_PX}px)`,
+ )
+ expect(window.getComputedStyle(releasedMainRow).transition).not.toContain(
+ 'transform',
+ )
+ expect(window.getComputedStyle(translation).transform).toBe('')
+ const idleText = screen
+ .getAllByTestId('lyrics-token')[0]
+ .querySelector('[data-lyrics-wave-text="true"]')
+ expect(idleText.style.backgroundImage).toContain('linear-gradient')
+ })
+
+ it('keeps the translation still when a word-timed line wraps or unwraps', () => {
+ let wrapped = false
+ const observers = []
+ window.ResizeObserver = class {
+ constructor(callback) {
+ this.callback = callback
+ this.targets = []
+ observers.push(this)
+ }
+
+ observe(target) {
+ this.targets.push(target)
+ }
+
+ unobserve(target) {
+ this.targets = this.targets.filter((candidate) => candidate !== target)
+ }
+
+ disconnect() {
+ this.targets = []
+ }
+ }
+ vi.spyOn(HTMLElement.prototype, 'offsetTop', 'get').mockImplementation(
+ function getOffsetTop() {
+ if (!wrapped || this.dataset.stackedToken !== 'true') return 0
+ const tokens = Array.from(
+ this.parentElement?.querySelectorAll('[data-stacked-token="true"]') ||
+ [],
+ )
+ return tokens.indexOf(this) > 0 ? 32 : 0
+ },
+ )
+ const notifyResizeObservers = () => {
+ observers.forEach(({ callback, targets }) => {
+ callback(targets.map((target) => ({ target })))
+ })
+ }
+ const propsAt = (currentTime) => ({
+ mainLyric: tokenizedMainLyric,
+ pronunciationLyric: tokenizedPronunciationLyric,
+ translationLyric: {
+ synced: true,
+ line: [{ start: 0, end: 1000, value: 'translated line' }],
+ },
+ showPronunciation: true,
+ showTranslation: true,
+ audioInstance: { currentTime, paused: true },
+ })
+ const { rerenderPanel } = renderPanel(propsAt(0.25))
+ const row = screen
+ .getAllByTestId('lyrics-token')[0]
+ .closest('[data-wrapped]')
+ const translation = screen
+ .getByText('translated line')
+ .closest('[data-tokenized]')
+
+ expect(row).toHaveAttribute('data-wrapped', 'false')
+ expect(window.getComputedStyle(translation).transform).toBe('')
+
+ wrapped = true
+ act(notifyResizeObservers)
+ expect(row).toHaveAttribute('data-wrapped', 'true')
+ expect(window.getComputedStyle(translation).transform).toBe('')
+
+ rerenderPanel(propsAt(1.1))
+ expect(window.getComputedStyle(translation).transform).toBe('')
+
+ wrapped = false
+ act(notifyResizeObservers)
+ expect(row).toHaveAttribute('data-wrapped', 'false')
+ expect(window.getComputedStyle(translation).transform).toBe('')
+ })
+
+ it('keeps detailed grapheme markup on active lines without changing token layout', () => {
+ const values = ['First word', 'Second word', 'Active word', 'Fourth word']
+ const lyricFor = (pronunciation = false) => ({
+ synced: true,
+ line: values.map((value, index) => ({
+ start: index * 1000,
+ end: (index + 1) * 1000,
+ value: pronunciation ? `spoken ${index}` : value,
+ })),
+ cueLine: values.map((value, index) => ({
+ index,
+ start: index * 1000,
+ end: (index + 1) * 1000,
+ value: pronunciation ? `spoken ${index}` : value,
+ cue: (pronunciation ? `spoken ${index}` : value)
+ .split(' ')
+ .map((word, wordIndex, words) => ({
+ start: index * 1000 + (wordIndex * 1000) / words.length,
+ end: index * 1000 + ((wordIndex + 1) * 1000) / words.length,
+ value: word,
+ })),
+ })),
+ })
+ const propsAt = (currentTime) => ({
+ mainLyric: lyricFor(),
+ pronunciationLyric: lyricFor(true),
+ showPronunciation: true,
+ audioInstance: { currentTime, paused: true },
+ })
+ const { rerenderPanel } = renderPanel(propsAt(2.5))
+
+ let groups = screen.getAllByTestId('lyrics-line-group')
+ expect(
+ groups.map((group) =>
+ Boolean(group.querySelector('[data-lyrics-character="true"]')),
+ ),
+ ).toEqual([false, false, true, false])
+ expect(
+ groups[2].querySelectorAll('[data-lyrics-wave-measure="true"]'),
+ ).not.toHaveLength(0)
+ const activeRow = groups[2].querySelector('[data-wrapped]')
+ const spacers = activeRow.querySelectorAll(
+ ':scope > span:not([data-stacked-token="true"])',
+ )
+ expect(activeRow).toHaveAttribute('data-wrapped', 'false')
+ expect(spacers).not.toHaveLength(0)
+ spacers.forEach((spacer) =>
+ expect(window.getComputedStyle(spacer).display).toBe('inline'),
+ )
+
+ rerenderPanel(propsAt(3.5))
+ groups = screen.getAllByTestId('lyrics-line-group')
+ expect(
+ groups[2].querySelectorAll('[data-lyrics-character="true"]'),
+ ).toHaveLength(0)
+ expect(
+ groups[3].querySelectorAll('[data-lyrics-character="true"]'),
+ ).not.toHaveLength(0)
+ })
+
+ it('renders unsynced lyrics as static selectable text', () => {
+ renderPanel({
+ mainLyric: {
+ synced: false,
+ line: [{ value: 'first plain line' }, { value: 'second plain line' }],
+ },
+ })
+
+ const groups = screen.getAllByTestId('lyrics-line-group')
+ expect(groups).toHaveLength(2)
+ groups.forEach((group) => {
+ expect(group).toHaveAttribute('data-active', 'true')
+ expect(group).toHaveAttribute('data-lifecycle', 'active')
+ expect(group).toHaveAttribute('data-highlight-active', 'true')
+ expect(group).not.toHaveAttribute('aria-current')
+ expect(group).toHaveAttribute('data-scroll-target', 'false')
+ })
+ })
+
+ it('tracks overlapping lines while selecting one primary line', () => {
+ renderPanel({
+ mainLyric: {
+ synced: true,
+ line: [
+ { start: 1000, end: 4000, value: 'Lead vocal' },
+ { start: 2000, end: 3000, value: 'Answer vocal' },
+ { start: 5000, end: 6000, value: 'Later vocal' },
+ ],
+ },
+ audioInstance: { currentTime: 2.5, paused: true },
+ })
+
+ const groups = screen.getAllByTestId('lyrics-line-group')
+ expect(groups[0]).toHaveAttribute('data-active', 'true')
+ expect(groups[1]).toHaveAttribute('data-active', 'true')
+ expect(groups[2]).toHaveAttribute('data-active', 'false')
+ expect(groups[0]).not.toHaveAttribute('aria-current')
+ expect(groups[1]).toHaveAttribute('aria-current', 'true')
+ expect(groups[1]).toHaveAttribute('data-scroll-target', 'true')
+ })
+
+ it('keeps multi-agent cue lines in separate voice lanes', () => {
+ renderPanel({
+ mainLyric: multiAgentLyric,
+ audioInstance: { currentTime: 2.5, paused: true },
+ })
+
+ const lanes = screen.getAllByTestId('lyrics-voice-lane')
+ expect(lanes).toHaveLength(3)
+ expect(lanes[0]).toHaveTextContent('Lead')
+ expect(lanes[1]).toHaveTextContent('all')
+ expect(lanes[1].style.fontStyle).toBe('italic')
+ expect(lanes[2]).toHaveTextContent('echo')
+ expect(lanes[2].style.fontStyle).toBe('italic')
+
+ const mainToken = lanes[0].querySelector('[data-testid="lyrics-token"]')
+ const emphasisToken = lanes[1].querySelector('[data-testid="lyrics-token"]')
+ const emphasisCharacter = emphasisToken.querySelector(
+ '[data-lyrics-character="true"]',
+ )
+ expect(mainToken).toHaveTextContent('Lead')
+ expect(emphasisToken.style.paddingInlineEnd).not.toBe('')
+ expect(emphasisToken.style.marginInlineEnd).toBe(
+ `-${emphasisToken.style.paddingInlineEnd}`,
+ )
+ expect(emphasisCharacter.style.paddingInlineEnd).toBe(
+ emphasisToken.style.paddingInlineEnd,
+ )
+ })
+
+ it('seeks and synchronizes immediately when a line is activated', () => {
+ const audioInstance = { currentTime: 0 }
+ renderPanel({
+ mainLyric: {
+ synced: true,
+ line: [{ start: 2300, end: 3200, value: 'Seek line' }],
+ },
+ audioInstance,
+ })
+
+ const group = screen
+ .getByText('Seek line')
+ .closest('[data-testid="lyrics-line-group"]')
+ fireEvent.click(group)
+ expect(audioInstance.currentTime).toBe(2.3)
+ expect(group).toHaveAttribute('data-active', 'true')
+
+ audioInstance.currentTime = 0
+ fireEvent.keyDown(group, { key: 'Enter' })
+ expect(audioInstance.currentTime).toBe(2.3)
+ })
+
+ it('uses roving focus for seekable lines without seeking on navigation', () => {
+ const audioInstance = createAudio({ currentTime: 1.5 })
+ renderPanel({
+ mainLyric: {
+ synced: true,
+ line: [
+ { start: 0, end: 900, value: 'First seekable line' },
+ { value: 'Untimed line' },
+ { start: 1000, end: 1900, value: 'Active seekable line' },
+ { start: 2000, end: 2900, value: 'Last seekable line' },
+ ],
+ },
+ audioInstance,
+ })
+
+ const first = screen
+ .getByText('First seekable line')
+ .closest('[data-testid="lyrics-line-group"]')
+ const untimed = screen
+ .getByText('Untimed line')
+ .closest('[data-testid="lyrics-line-group"]')
+ const active = screen
+ .getByText('Active seekable line')
+ .closest('[data-testid="lyrics-line-group"]')
+ const last = screen
+ .getByText('Last seekable line')
+ .closest('[data-testid="lyrics-line-group"]')
+
+ expect(first).toHaveAttribute('tabindex', '-1')
+ expect(untimed).not.toHaveAttribute('tabindex')
+ expect(active).toHaveAttribute('tabindex', '0')
+ expect(last).toHaveAttribute('tabindex', '-1')
+
+ active.focus()
+ fireEvent.keyDown(active, { key: 'ArrowDown' })
+ expect(document.activeElement).toBe(last)
+ expect(last).toHaveAttribute('tabindex', '0')
+ expect(audioInstance.currentTime).toBe(1.5)
+
+ fireEvent.keyDown(last, { key: 'ArrowRight' })
+ expect(document.activeElement).toBe(last)
+ fireEvent.keyDown(last, { key: 'ArrowUp' })
+ expect(document.activeElement).toBe(active)
+ fireEvent.keyDown(active, { key: 'ArrowLeft' })
+ expect(document.activeElement).toBe(first)
+ expect(audioInstance.currentTime).toBe(1.5)
+
+ fireEvent.keyDown(first, { key: 'End' })
+ expect(document.activeElement).toBe(last)
+ fireEvent.keyDown(last, { key: 'Home' })
+ expect(document.activeElement).toBe(first)
+ expect(audioInstance.currentTime).toBe(1.5)
+
+ fireEvent.keyDown(first, { key: ' ' })
+ expect(audioInstance.currentTime).toBe(0)
+ fireEvent.keyDown(last, { key: 'Enter' })
+ expect(audioInstance.currentTime).toBe(2)
+ })
+
+ it('keeps lyrics mounted but pauses timeline frames while obscured', () => {
+ const audioInstance = createAudio({ currentTime: 0.25 })
+ audioInstance.paused = false
+ window.requestAnimationFrame.mockImplementation(() => 7)
+ window.requestAnimationFrame.mockClear()
+ window.cancelAnimationFrame.mockClear()
+
+ const { rerenderPanel } = renderPanel({
+ mainLyric: tokenizedMainLyric,
+ audioInstance,
+ obscured: true,
+ })
+
+ expect(screen.getByTestId('karaoke-lyrics-panel')).toBeInTheDocument()
+ expect(window.requestAnimationFrame).not.toHaveBeenCalled()
+
+ rerenderPanel({
+ mainLyric: tokenizedMainLyric,
+ audioInstance,
+ obscured: false,
+ })
+ expect(window.requestAnimationFrame).toHaveBeenCalled()
+
+ rerenderPanel({
+ mainLyric: tokenizedMainLyric,
+ audioInstance,
+ obscured: true,
+ })
+ expect(screen.getByTestId('karaoke-lyrics-panel')).toBeInTheDocument()
+ expect(window.cancelAnimationFrame).toHaveBeenCalledWith(7)
+ })
+
+ it('pauses auto-scroll only for genuine manual scroll intent', async () => {
+ vi.useFakeTimers()
+ const requestAnimationFrameSpy = vi
+ .spyOn(window, 'requestAnimationFrame')
+ .mockImplementation(() => 0)
+ renderPanel({
+ mainLyric,
+ audioInstance: { currentTime: 0.5, paused: true },
+ })
+
+ const body = screen.getByTestId('lyrics-scroll-body')
+ const initialFrames = requestAnimationFrameSpy.mock.calls.length
+ fireEvent.wheel(body)
+ expect(body).toHaveAttribute('data-scrollbar-visible', 'true')
+ expect(requestAnimationFrameSpy).toHaveBeenCalledTimes(initialFrames)
+
+ act(() => {
+ vi.advanceTimersByTime(KARAOKE_MANUAL_SCROLL_PAUSE_MS)
+ })
+ await waitFor(() => {
+ expect(requestAnimationFrameSpy.mock.calls.length).toBeGreaterThan(
+ initialFrames,
+ )
+ })
+ })
+
+ it('keeps scrollbar geometry stable before, during, and after manual scroll', () => {
+ vi.useFakeTimers()
+ renderPanel({ mainLyric })
+
+ const body = screen.getByTestId('lyrics-scroll-body')
+ const initialStyle = window.getComputedStyle(body)
+ expect(initialStyle.scrollbarGutter).toBe('stable')
+ expect(initialStyle.scrollbarWidth).toBe('thin')
+
+ fireEvent.wheel(body)
+ expect(body).toHaveAttribute('data-scrollbar-visible', 'true')
+ expect(window.getComputedStyle(body).scrollbarGutter).toBe('stable')
+ expect(window.getComputedStyle(body).scrollbarWidth).toBe('thin')
+
+ act(() => {
+ vi.advanceTimersByTime(KARAOKE_SCROLLBAR_VISIBLE_MS)
+ })
+ expect(body).toHaveAttribute('data-scrollbar-visible', 'false')
+ expect(window.getComputedStyle(body).scrollbarGutter).toBe('stable')
+ expect(window.getComputedStyle(body).scrollbarWidth).toBe('thin')
+ })
+
+ it('resets scroll position when lyric content changes', () => {
+ const { rerenderPanel } = renderPanel({ mainLyric })
+ const body = screen.getByTestId('lyrics-scroll-body')
+ body.scrollTop = 180
+
+ rerenderPanel({
+ mainLyric: {
+ synced: true,
+ line: [{ start: 0, end: 1000, value: 'Different song' }],
+ },
+ })
+ expect(body.scrollTop).toBe(0)
+ })
+
+ it('respects reduced motion and empty states', async () => {
+ window.matchMedia = vi.fn().mockImplementation((query) => ({
+ matches: query === '(prefers-reduced-motion: reduce)',
+ media: query,
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ }))
+ const { unmount } = renderPanel({ mainLyric })
+ await waitFor(() => {
+ expect(screen.getByTestId('lyrics-scroll-body')).toHaveAttribute(
+ 'data-reduced-motion',
+ 'true',
+ )
+ })
+ unmount()
+
+ renderPanel({ mainLyric: null, loading: true })
+ expect(screen.getByTestId('lyrics-empty-state')).toHaveTextContent(
+ 'Loading lyrics',
+ )
+ })
+
+ it('explains a temporary lyrics cooldown', () => {
+ renderPanel({
+ mainLyric: null,
+ error: new Error('rate limited'),
+ errorMessage: 'Remote lyrics are rate-limited. Retrying in 30 seconds.',
+ })
+
+ expect(screen.getByTestId('lyrics-empty-state')).toHaveTextContent(
+ 'Remote lyrics are rate-limited. Retrying in 30 seconds.',
+ )
+ })
+
+ it.each(['light', 'dark'])(
+ 'keeps future tokens readable and translations subordinate in the %s theme',
+ (type) => {
+ const accessibleTheme = createTheme({ palette: { type } })
+ const background = accessibleTheme.palette.background.default
+
+ const propsAt = (currentTime) => ({
+ mainLyric: tokenizedMainLyric,
+ translationLyric: {
+ synced: true,
+ line: [{ start: 0, end: 1000, value: 'Readable translation' }],
+ },
+ showTranslation: true,
+ audioInstance: { currentTime, paused: true },
+ })
+ const { rerenderPanel } = renderPanel(propsAt(0.25), accessibleTheme)
+
+ const translation = screen
+ .getByText('Readable translation')
+ .closest('[data-tokenized]')
+ expect(translation).toBeInTheDocument()
+ expect(window.getComputedStyle(translation).opacity).toBe('1')
+ const token = screen.getAllByTestId('lyrics-token')[0]
+ const gradientNode = [token, ...token.querySelectorAll('*')].find(
+ (node) => node.style.backgroundImage.includes('linear-gradient'),
+ )
+ const gradientColors = Array.from(
+ gradientNode.style.backgroundImage.matchAll(/rgba?\([^)]+\)/g),
+ (match) => match[0],
+ )
+ const futureColor = gradientColors.at(-1)
+ const translationColors = translationColorsFor(accessibleTheme)
+
+ expect(parseCssColor(futureColor).alpha).toBe(TOKEN_FUTURE_ALPHA)
+ expect(contrastRatio(futureColor, background)).toBeGreaterThanOrEqual(4.5)
+ expect(
+ contrastRatio(translationColors.active, background),
+ ).toBeGreaterThanOrEqual(4.5)
+ expect(contrastRatio(translationColors.idle, background)).toBeLessThan(
+ contrastRatio(translationColors.active, background),
+ )
+
+ rerenderPanel(propsAt(1.25))
+ expect(screen.getByTestId('lyrics-line-group')).toHaveAttribute(
+ 'data-active',
+ 'false',
+ )
+ expect(window.getComputedStyle(translation).opacity).toBe('1')
+ },
+ )
+})
diff --git a/ui/src/audioplayer/LyricsSidebar.jsx b/ui/src/audioplayer/LyricsSidebar.jsx
new file mode 100644
index 000000000..ae4995f32
--- /dev/null
+++ b/ui/src/audioplayer/LyricsSidebar.jsx
@@ -0,0 +1,303 @@
+import { alpha, makeStyles } from '@material-ui/core/styles'
+import React, { useCallback, useEffect, useRef } from 'react'
+import LyricsLayerControls from './LyricsLayerControls'
+import LyricsPanel from './LyricsPanel'
+import {
+ LYRICS_SIDEBAR_DEFAULT_WIDTH,
+ LYRICS_SIDEBAR_MAX_WIDTH,
+ LYRICS_SIDEBAR_MIN_WIDTH,
+ LYRICS_SIDEBAR_TRANSITION_MS,
+ LYRICS_SIDEBAR_WIDTH_STEP,
+ clampSidebarMaxWidth,
+ clampSidebarWidth,
+} from './lyricsSidebarWidth'
+import useEnterExitTransition from './useEnterExitTransition'
+import useRestoreFocusOnExit from './useRestoreFocusOnExit'
+
+const useStyles = makeStyles((theme) => ({
+ sidebar: {
+ position: 'fixed',
+ top: 48,
+ right: 0,
+ bottom: 80,
+ minHeight: 0,
+ minWidth: LYRICS_SIDEBAR_MIN_WIDTH,
+ maxWidth: LYRICS_SIDEBAR_MAX_WIDTH,
+ display: 'flex',
+ flexDirection: 'column',
+ color: theme.palette.text.primary,
+ backgroundColor: theme.palette.background.default,
+ backgroundImage: 'none',
+ borderLeft: `1px solid ${alpha(theme.palette.divider, 0.1)}`,
+ boxShadow: 'none',
+ zIndex: theme.zIndex.appBar - 1,
+ transition: `transform ${LYRICS_SIDEBAR_TRANSITION_MS}ms cubic-bezier(0.22, 1, 0.36, 1), opacity ${LYRICS_SIDEBAR_TRANSITION_MS}ms cubic-bezier(0.22, 1, 0.36, 1)`,
+ willChange: 'transform, opacity',
+ '@media (prefers-reduced-motion: reduce)': {
+ transition: 'none',
+ },
+ '@media (hover: hover) and (pointer: fine)': {
+ '&:hover $resizer::after': {
+ background: alpha(theme.palette.primary.main, 0.32),
+ },
+ },
+ },
+ resizer: {
+ position: 'absolute',
+ top: 0,
+ bottom: 0,
+ left: -6,
+ width: 12,
+ padding: 0,
+ border: 0,
+ background: 'transparent',
+ cursor: 'ew-resize',
+ zIndex: 3,
+ '&::after': {
+ content: '""',
+ position: 'absolute',
+ top: 0,
+ bottom: 0,
+ left: 5,
+ width: 2,
+ background: 'transparent',
+ transition: 'background 160ms ease',
+ },
+ '&:focus::after': {
+ background: alpha(theme.palette.primary.main, 0.48),
+ },
+ '@media (hover: hover) and (pointer: fine)': {
+ '&:hover::after': {
+ background: alpha(theme.palette.primary.main, 0.48),
+ },
+ },
+ '&:focus': {
+ outline: 'none',
+ },
+ },
+ panel: {
+ flex: 1,
+ minHeight: 0,
+ position: 'relative',
+ },
+}))
+
+const noop = () => {}
+
+const LyricsSidebar = ({
+ visible,
+ obscuredByQueue = false,
+ width = LYRICS_SIDEBAR_DEFAULT_WIDTH,
+ maxWidth = LYRICS_SIDEBAR_MAX_WIDTH,
+ onWidthChange = noop,
+ mainLyric,
+ translationLyric,
+ pronunciationLyric,
+ showTranslation,
+ showPronunciation,
+ translationEnabled,
+ pronunciationEnabled,
+ onToggleTranslation,
+ onTogglePronunciation,
+ audioInstance,
+ loading = false,
+ error = null,
+ errorMessage = null,
+ labels = {},
+ returnFocusRef,
+}) => {
+ const resolvedMaxWidth = clampSidebarMaxWidth(maxWidth)
+ const resolvedWidth = clampSidebarWidth(width, resolvedMaxWidth)
+ const sidebarRef = useRef(null)
+ const resizerRef = useRef(null)
+ const widthRef = useRef(resolvedWidth)
+ const resizeCleanupRef = useRef(null)
+ const { rendered, entered } = useEnterExitTransition(
+ visible,
+ LYRICS_SIDEBAR_TRANSITION_MS,
+ )
+ const interactive = entered && !obscuredByQueue
+ const classes = useStyles()
+
+ useRestoreFocusOnExit({
+ surfaceRef: sidebarRef,
+ entered: interactive,
+ returnFocusRef,
+ })
+
+ useEffect(() => {
+ widthRef.current = resolvedWidth
+ }, [resolvedWidth])
+
+ useEffect(
+ () => () => {
+ resizeCleanupRef.current?.()
+ resizeCleanupRef.current = null
+ },
+ [],
+ )
+
+ const applyWidth = useCallback(
+ (next, { persist = false } = {}) => {
+ const nextWidth = clampSidebarWidth(
+ typeof next === 'function' ? next(widthRef.current) : next,
+ resolvedMaxWidth,
+ )
+ widthRef.current = nextWidth
+ if (sidebarRef.current) {
+ sidebarRef.current.style.width = `${nextWidth}px`
+ }
+ resizerRef.current?.setAttribute('aria-valuenow', String(nextWidth))
+ onWidthChange(nextWidth, { persist })
+ return nextWidth
+ },
+ [onWidthChange, resolvedMaxWidth],
+ )
+
+ const handleResizePointerDown = useCallback(
+ (event) => {
+ event.preventDefault()
+ resizeCleanupRef.current?.()
+
+ const target = event.currentTarget
+ const pointerId = event.pointerId
+ const startX = event.clientX
+ const startWidth = widthRef.current
+ let latestWidth = startWidth
+ let cleanedUp = false
+ const canCapture =
+ pointerId != null && typeof target.setPointerCapture === 'function'
+ const listenerTarget = window
+
+ const handlePointerMove = (moveEvent) => {
+ latestWidth = applyWidth(startWidth + startX - moveEvent.clientX)
+ }
+
+ const cleanupResize = ({ persist = false } = {}) => {
+ if (cleanedUp) return
+ cleanedUp = true
+ listenerTarget.removeEventListener('pointermove', handlePointerMove)
+ listenerTarget.removeEventListener('pointerup', handlePointerUp)
+ listenerTarget.removeEventListener('pointercancel', handlePointerCancel)
+ target.removeEventListener('lostpointercapture', handlePointerCancel)
+ if (
+ canCapture &&
+ typeof target.releasePointerCapture === 'function' &&
+ (!target.hasPointerCapture || target.hasPointerCapture(pointerId))
+ ) {
+ try {
+ target.releasePointerCapture(pointerId)
+ } catch {
+ // Ignore stale pointer capture state.
+ }
+ }
+ if (persist) onWidthChange(latestWidth, { persist: true })
+ if (resizeCleanupRef.current === cleanupResize) {
+ resizeCleanupRef.current = null
+ }
+ }
+
+ const handlePointerUp = () => cleanupResize({ persist: true })
+ const handlePointerCancel = () => cleanupResize()
+
+ resizeCleanupRef.current = cleanupResize
+ listenerTarget.addEventListener('pointermove', handlePointerMove)
+ listenerTarget.addEventListener('pointerup', handlePointerUp)
+ listenerTarget.addEventListener('pointercancel', handlePointerCancel)
+ target.addEventListener('lostpointercapture', handlePointerCancel)
+ if (canCapture) {
+ try {
+ target.setPointerCapture(pointerId)
+ } catch {
+ // Window-level listeners keep resize working when capture is unavailable.
+ }
+ }
+ },
+ [applyWidth, onWidthChange],
+ )
+
+ const handleResizeKeyDown = useCallback(
+ (event) => {
+ if (event.key === 'ArrowLeft') {
+ event.preventDefault()
+ applyWidth((current) => current + LYRICS_SIDEBAR_WIDTH_STEP, {
+ persist: true,
+ })
+ } else if (event.key === 'ArrowRight') {
+ event.preventDefault()
+ applyWidth((current) => current - LYRICS_SIDEBAR_WIDTH_STEP, {
+ persist: true,
+ })
+ } else if (event.key === 'Home') {
+ event.preventDefault()
+ applyWidth(LYRICS_SIDEBAR_MIN_WIDTH, { persist: true })
+ } else if (event.key === 'End') {
+ event.preventDefault()
+ applyWidth(resolvedMaxWidth, { persist: true })
+ }
+ },
+ [applyWidth, resolvedMaxWidth],
+ )
+
+ if (!rendered) return null
+
+ return (
+
+ )
+}
+
+export default LyricsSidebar
diff --git a/ui/src/audioplayer/LyricsSidebar.test.jsx b/ui/src/audioplayer/LyricsSidebar.test.jsx
new file mode 100644
index 000000000..b5023ca4a
--- /dev/null
+++ b/ui/src/audioplayer/LyricsSidebar.test.jsx
@@ -0,0 +1,379 @@
+import React, { useCallback, useState } from 'react'
+import {
+ cleanup,
+ fireEvent,
+ render,
+ screen,
+ waitFor,
+} from '@testing-library/react'
+import { ThemeProvider, createTheme } from '@material-ui/core/styles'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import LyricsSidebar from './LyricsSidebar'
+import {
+ LYRICS_SIDEBAR_DEFAULT_WIDTH,
+ LYRICS_SIDEBAR_MAX_WIDTH,
+ LYRICS_SIDEBAR_MIN_WIDTH,
+ LYRICS_SIDEBAR_TRANSITION_MS,
+} from './lyricsSidebarWidth'
+
+const theme = createTheme({
+ palette: {
+ primary: {
+ main: '#35aa66',
+ },
+ background: {
+ default: '#101820',
+ paper: '#ffffff',
+ },
+ },
+})
+const lyric = {
+ synced: true,
+ line: [{ start: 0, end: 1000, value: 'Main line' }],
+}
+const originalPointerEvent = window.PointerEvent
+
+const ControlledLyricsSidebar = ({
+ initialWidth = LYRICS_SIDEBAR_DEFAULT_WIDTH,
+ maxWidth = LYRICS_SIDEBAR_MAX_WIDTH,
+ onWidthChange,
+ ...props
+}) => {
+ const [width, setWidth] = useState(initialWidth)
+ const handleWidthChange = useCallback(
+ (nextWidth, options) => {
+ setWidth(nextWidth)
+ onWidthChange?.(nextWidth, options)
+ },
+ [onWidthChange],
+ )
+
+ return (
+
+ )
+}
+
+const sidebarView = (props = {}) => (
+
+
+
+)
+
+const renderSidebar = (props) => render(sidebarView(props))
+
+describe('', () => {
+ afterEach(() => {
+ vi.useRealTimers()
+ cleanup()
+ localStorage.clear()
+ document.body.className = ''
+ document
+ .querySelectorAll('[data-lyrics-return-target]')
+ .forEach((node) => node.remove())
+ Object.defineProperty(window, 'PointerEvent', {
+ configurable: true,
+ writable: true,
+ value: originalPointerEvent,
+ })
+ })
+
+ it('renders as an embedded sidebar without global layout side effects', () => {
+ renderSidebar()
+
+ expect(document.body.className).toBe('')
+ const sidebar = screen.getByTestId('lyrics-sidebar')
+ expect(sidebar).toHaveStyle({
+ transform: 'translateX(0)',
+ opacity: '1',
+ })
+ expect(screen.getByTestId('lyrics-sidebar-resizer')).toBeInTheDocument()
+ })
+
+ it('keeps the sidebar mounted while sliding closed', () => {
+ vi.useFakeTimers()
+ const returnTarget = document.createElement('button')
+ returnTarget.dataset.lyricsReturnTarget = 'true'
+ document.body.appendChild(returnTarget)
+ const returnFocusRef = { current: returnTarget }
+ const { rerender } = renderSidebar({ returnFocusRef })
+ const sidebar = screen.getByTestId('lyrics-sidebar')
+ const resizer = screen.getByTestId('lyrics-sidebar-resizer')
+ expect(sidebar).not.toHaveAttribute('inert')
+ resizer.focus()
+
+ rerender(sidebarView({ visible: false, returnFocusRef }))
+
+ expect(sidebar).toHaveStyle({
+ transform: 'translateX(100%)',
+ opacity: '0',
+ })
+ expect(sidebar).toHaveAttribute('aria-hidden', 'true')
+ expect(sidebar).toHaveAttribute('inert')
+ expect(document.body.className).toBe('')
+ expect(document.activeElement).toBe(returnTarget)
+
+ vi.advanceTimersByTime(LYRICS_SIDEBAR_TRANSITION_MS)
+
+ expect(screen.queryByTestId('lyrics-sidebar')).toBeNull()
+ vi.useRealTimers()
+ })
+
+ it('restores focus if a breakpoint unmounts the open sidebar', () => {
+ const returnTarget = document.createElement('button')
+ returnTarget.dataset.lyricsReturnTarget = 'true'
+ document.body.appendChild(returnTarget)
+ const returnFocusRef = { current: returnTarget }
+ const { unmount } = renderSidebar({ returnFocusRef })
+ const resizer = screen.getByTestId('lyrics-sidebar-resizer')
+ resizer.focus()
+
+ unmount()
+
+ expect(document.activeElement).toBe(returnTarget)
+ })
+
+ it('keeps lyrics mounted but noninteractive while the queue obscures them', () => {
+ const returnTarget = document.createElement('button')
+ returnTarget.dataset.lyricsReturnTarget = 'true'
+ document.body.appendChild(returnTarget)
+ const returnFocusRef = { current: returnTarget }
+ const { rerender } = renderSidebar({ returnFocusRef })
+ const sidebar = screen.getByTestId('lyrics-sidebar')
+ const panel = screen.getByTestId('karaoke-lyrics-panel')
+ screen.getByTestId('lyrics-sidebar-resizer').focus()
+
+ rerender(
+ sidebarView({
+ obscuredByQueue: true,
+ returnFocusRef,
+ }),
+ )
+
+ expect(screen.getByTestId('lyrics-sidebar')).toBe(sidebar)
+ expect(screen.getByTestId('karaoke-lyrics-panel')).toBe(panel)
+ expect(sidebar).toHaveStyle({
+ transform: 'translateX(0)',
+ opacity: '1',
+ pointerEvents: 'none',
+ })
+ expect(sidebar).toHaveAttribute('aria-hidden', 'true')
+ expect(sidebar).toHaveAttribute('inert')
+ expect(document.activeElement).toBe(returnTarget)
+
+ rerender(sidebarView({ returnFocusRef }))
+
+ expect(screen.getByTestId('lyrics-sidebar')).toBe(sidebar)
+ expect(screen.getByTestId('karaoke-lyrics-panel')).toBe(panel)
+ expect(sidebar).toHaveStyle({ pointerEvents: 'auto' })
+ expect(sidebar).toHaveAttribute('aria-hidden', 'false')
+ expect(sidebar).not.toHaveAttribute('inert')
+ })
+
+ it('clamps controlled keyboard resizing to the available width', () => {
+ const onWidthChange = vi.fn()
+ renderSidebar({ initialWidth: 999, maxWidth: 435, onWidthChange })
+
+ const sidebar = screen.getByTestId('lyrics-sidebar')
+ const resizer = screen.getByTestId('lyrics-sidebar-resizer')
+
+ expect(sidebar).toHaveStyle({ width: '435px' })
+ expect(resizer).toHaveAttribute('aria-valuemax', '435')
+ expect(resizer).toHaveAttribute('aria-valuenow', '435')
+
+ fireEvent.keyDown(resizer, { key: 'Home' })
+
+ expect(sidebar).toHaveStyle({ width: `${LYRICS_SIDEBAR_MIN_WIDTH}px` })
+ expect(onWidthChange).toHaveBeenLastCalledWith(LYRICS_SIDEBAR_MIN_WIDTH, {
+ persist: true,
+ })
+
+ fireEvent.keyDown(resizer, { key: 'End' })
+
+ expect(sidebar).toHaveStyle({ width: '435px' })
+ expect(resizer).toHaveAttribute('aria-valuenow', '435')
+ expect(onWidthChange).toHaveBeenLastCalledWith(435, { persist: true })
+ })
+
+ it('clamps pointer resizing from the left separator', async () => {
+ Object.defineProperty(window, 'PointerEvent', {
+ configurable: true,
+ writable: true,
+ value: MouseEvent,
+ })
+ const onWidthChange = vi.fn()
+ renderSidebar({ maxWidth: 435, onWidthChange })
+
+ const sidebar = screen.getByTestId('lyrics-sidebar')
+ const resizer = screen.getByTestId('lyrics-sidebar-resizer')
+ resizer.setPointerCapture = vi.fn(() => {
+ throw new Error('pointer capture unavailable')
+ })
+ resizer.releasePointerCapture = vi.fn()
+
+ fireEvent.pointerDown(resizer, { clientX: 500 })
+ window.dispatchEvent(new MouseEvent('pointermove', { clientX: -100 }))
+ await waitFor(() => expect(sidebar).toHaveStyle({ width: '435px' }))
+ expect(resizer).toHaveAttribute('aria-valuemax', '435')
+ expect(resizer).toHaveAttribute('aria-valuenow', '435')
+ expect(onWidthChange).toHaveBeenLastCalledWith(435, { persist: false })
+
+ window.dispatchEvent(new MouseEvent('pointermove', { clientX: 1000 }))
+ await waitFor(() =>
+ expect(sidebar).toHaveStyle({ width: `${LYRICS_SIDEBAR_MIN_WIDTH}px` }),
+ )
+
+ window.dispatchEvent(new MouseEvent('pointerup'))
+ expect(onWidthChange).toHaveBeenLastCalledWith(LYRICS_SIDEBAR_MIN_WIDTH, {
+ persist: true,
+ })
+ })
+
+ it('keeps the live pointer width through an unrelated rerender', async () => {
+ Object.defineProperty(window, 'PointerEvent', {
+ configurable: true,
+ writable: true,
+ value: MouseEvent,
+ })
+ const { rerender } = renderSidebar()
+ const sidebar = screen.getByTestId('lyrics-sidebar')
+ const resizer = screen.getByTestId('lyrics-sidebar-resizer')
+
+ fireEvent.pointerDown(resizer, { clientX: 500 })
+ window.dispatchEvent(new MouseEvent('pointermove', { clientX: 450 }))
+ await waitFor(() => expect(sidebar).toHaveStyle({ width: '410px' }))
+ expect(resizer).toHaveAttribute('aria-valuenow', '410')
+
+ rerender(sidebarView({ labels: { title: 'Updated lyrics' } }))
+
+ expect(screen.getByTestId('lyrics-sidebar')).toHaveStyle({ width: '410px' })
+ expect(screen.getByTestId('lyrics-sidebar-resizer')).toHaveAttribute(
+ 'aria-valuenow',
+ '410',
+ )
+ window.dispatchEvent(new MouseEvent('pointercancel'))
+ })
+
+ it('cleans pointer resizing on cancellation and unmount', async () => {
+ Object.defineProperty(window, 'PointerEvent', {
+ configurable: true,
+ writable: true,
+ value: MouseEvent,
+ })
+ const onWidthChange = vi.fn()
+ const { unmount } = renderSidebar({ onWidthChange })
+
+ const sidebar = screen.getByTestId('lyrics-sidebar')
+ const resizer = screen.getByTestId('lyrics-sidebar-resizer')
+
+ fireEvent.pointerDown(resizer, { clientX: 500 })
+ window.dispatchEvent(new MouseEvent('pointermove', { clientX: -100 }))
+ await waitFor(() =>
+ expect(sidebar).toHaveStyle({ width: `${LYRICS_SIDEBAR_MAX_WIDTH}px` }),
+ )
+
+ window.dispatchEvent(new MouseEvent('pointercancel'))
+ const callsAfterCancel = onWidthChange.mock.calls.length
+
+ window.dispatchEvent(new MouseEvent('pointermove', { clientX: 1000 }))
+ expect(sidebar).toHaveStyle({ width: `${LYRICS_SIDEBAR_MAX_WIDTH}px` })
+ expect(onWidthChange).toHaveBeenCalledTimes(callsAfterCancel)
+ expect(onWidthChange).not.toHaveBeenCalledWith(expect.anything(), {
+ persist: true,
+ })
+
+ fireEvent.pointerDown(resizer, { clientX: 500 })
+ window.dispatchEvent(new MouseEvent('pointermove', { clientX: 1000 }))
+ await waitFor(() =>
+ expect(sidebar).toHaveStyle({ width: `${LYRICS_SIDEBAR_MIN_WIDTH}px` }),
+ )
+
+ unmount()
+ const callsBeforePointerUp = onWidthChange.mock.calls.length
+ window.dispatchEvent(new MouseEvent('pointerup'))
+
+ expect(onWidthChange).toHaveBeenCalledTimes(callsBeforePointerUp)
+ expect(onWidthChange).not.toHaveBeenCalledWith(expect.anything(), {
+ persist: true,
+ })
+ })
+
+ it('uses icon toggle buttons with pressed and disabled states', () => {
+ const onTogglePronunciation = vi.fn()
+ const onToggleTranslation = vi.fn()
+ renderSidebar({
+ showPronunciation: true,
+ showTranslation: false,
+ pronunciationEnabled: true,
+ translationEnabled: false,
+ onTogglePronunciation,
+ onToggleTranslation,
+ labels: {
+ title: 'Song words',
+ resize: 'Resize song words',
+ showTranslation: 'Reveal translation',
+ hidePronunciation: 'Conceal pronunciation',
+ },
+ })
+
+ const pronunciation = screen.getByTestId('toggle-pronunciation-button')
+ const translation = screen.getByTestId('toggle-translation-button')
+ expect(screen.getByLabelText('Song words')).toBeInTheDocument()
+ expect(screen.getByLabelText('Resize song words')).toBeInTheDocument()
+ expect(pronunciation).toHaveAttribute('aria-label', 'Conceal pronunciation')
+ expect(pronunciation).toHaveAttribute('aria-pressed', 'true')
+ expect(pronunciation.className).toContain('controlActive')
+ fireEvent.click(pronunciation)
+ expect(onTogglePronunciation).toHaveBeenCalledTimes(1)
+
+ expect(translation).toHaveAttribute('aria-label', 'Reveal translation')
+ expect(translation).toHaveAttribute('aria-pressed', 'false')
+ expect(translation).toBeDisabled()
+ expect(translation.className).not.toContain('controlActive')
+ fireEvent.click(translation)
+ expect(onToggleTranslation).not.toHaveBeenCalled()
+ })
+
+ it('stays mounted without re-entering when an open sidebar receives no lyrics', () => {
+ const { rerender } = renderSidebar()
+
+ const sidebar = screen.getByTestId('lyrics-sidebar')
+ expect(sidebar).toHaveStyle({ transform: 'translateX(0)' })
+ expect(screen.getByTestId('karaoke-lyrics-panel')).toBeInTheDocument()
+
+ rerender(
+ sidebarView({
+ mainLyric: null,
+ showTranslation: false,
+ showPronunciation: false,
+ translationEnabled: false,
+ pronunciationEnabled: false,
+ }),
+ )
+
+ expect(screen.getByTestId('lyrics-sidebar')).toHaveStyle({
+ transform: 'translateX(0)',
+ })
+ expect(screen.getByTestId('karaoke-lyrics-panel')).toBeInTheDocument()
+ expect(screen.getByTestId('lyrics-empty-state')).toHaveTextContent(
+ 'No lyrics available',
+ )
+ expect(screen.queryByTestId('lyrics-line-group')).toBeNull()
+ expect(screen.getByTestId('toggle-pronunciation-button')).toBeDisabled()
+ expect(screen.getByTestId('toggle-translation-button')).toBeDisabled()
+ })
+})
diff --git a/ui/src/audioplayer/MobileKaraokeLyricsPortal.jsx b/ui/src/audioplayer/MobileKaraokeLyricsPortal.jsx
new file mode 100644
index 000000000..34750f84e
--- /dev/null
+++ b/ui/src/audioplayer/MobileKaraokeLyricsPortal.jsx
@@ -0,0 +1,90 @@
+import React, { useEffect, useRef, useState } from 'react'
+import { createPortal } from 'react-dom'
+import { MOBILE_KARAOKE_LYRICS_TRANSITION_MS } from './lyricsKaraokeConstants'
+import useEnterExitTransition from './useEnterExitTransition'
+import useRestoreFocusOnExit from './useRestoreFocusOnExit'
+
+export const MOBILE_KARAOKE_LYRICS_HOST_SELECTOR =
+ '.react-jinke-music-player-mobile-cover'
+export const MOBILE_KARAOKE_LYRICS_ACTIVE_CLASS = 'nd-mobile-lyrics-active'
+export const MOBILE_KARAOKE_LYRICS_ENTERED_CLASS = 'nd-mobile-lyrics-entered'
+export const MOBILE_KARAOKE_LYRICS_LAYER_CLASS = 'nd-mobile-lyrics-layer'
+
+const resolveMobileLyricsHost = () => {
+ if (typeof document === 'undefined') return null
+ return document.querySelector(MOBILE_KARAOKE_LYRICS_HOST_SELECTOR)
+}
+
+const MobileKaraokeLyricsPortal = ({
+ active,
+ children,
+ obscured = false,
+ returnFocusRef,
+}) => {
+ const { rendered, entered } = useEnterExitTransition(
+ active,
+ MOBILE_KARAOKE_LYRICS_TRANSITION_MS,
+ )
+ const [host, setHost] = useState(() =>
+ active ? resolveMobileLyricsHost() : null,
+ )
+ const layerRef = useRef(null)
+ const interactive = entered && !obscured
+
+ useRestoreFocusOnExit({
+ surfaceRef: layerRef,
+ entered: interactive,
+ returnFocusRef,
+ surfaceKey: host,
+ })
+
+ useEffect(() => {
+ if (typeof document === 'undefined') {
+ setHost(null)
+ return undefined
+ }
+
+ if (!rendered) {
+ setHost(null)
+ return undefined
+ }
+
+ setHost(resolveMobileLyricsHost())
+
+ const observer = new MutationObserver(() => {
+ const nextHost = resolveMobileLyricsHost()
+ setHost((current) => (current === nextHost ? current : nextHost))
+ })
+ observer.observe(document.body, { childList: true, subtree: true })
+
+ return () => observer.disconnect()
+ }, [rendered])
+
+ useEffect(() => {
+ if (!host) return undefined
+
+ host.classList.toggle(MOBILE_KARAOKE_LYRICS_ACTIVE_CLASS, rendered)
+ host.classList.toggle(MOBILE_KARAOKE_LYRICS_ENTERED_CLASS, entered)
+ return () => {
+ host.classList.remove(MOBILE_KARAOKE_LYRICS_ACTIVE_CLASS)
+ host.classList.remove(MOBILE_KARAOKE_LYRICS_ENTERED_CLASS)
+ }
+ }, [entered, host, rendered])
+
+ if (!rendered || !host) return null
+ return createPortal(
+
+ {children}
+
,
+ host,
+ )
+}
+
+export default MobileKaraokeLyricsPortal
diff --git a/ui/src/audioplayer/MobileKaraokeLyricsPortal.test.jsx b/ui/src/audioplayer/MobileKaraokeLyricsPortal.test.jsx
new file mode 100644
index 000000000..2676db7f5
--- /dev/null
+++ b/ui/src/audioplayer/MobileKaraokeLyricsPortal.test.jsx
@@ -0,0 +1,347 @@
+import React from 'react'
+import {
+ act,
+ cleanup,
+ fireEvent,
+ render,
+ screen,
+ waitFor,
+ within,
+} from '@testing-library/react'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import MobileKaraokeLyricsPortal, {
+ MOBILE_KARAOKE_LYRICS_ACTIVE_CLASS,
+ MOBILE_KARAOKE_LYRICS_ENTERED_CLASS,
+ MOBILE_KARAOKE_LYRICS_HOST_SELECTOR,
+ MOBILE_KARAOKE_LYRICS_LAYER_CLASS,
+} from './MobileKaraokeLyricsPortal'
+import { MOBILE_KARAOKE_LYRICS_TRANSITION_MS } from './lyricsKaraokeConstants'
+import usePlayerLyrics from './usePlayerLyrics'
+
+const { defaultLyricsResponse, useEnhancedLyricsMock } = vi.hoisted(() => {
+ const defaultLyricsResponse = {
+ layers: {
+ main: {
+ synced: true,
+ line: [{ start: 0, end: 1000, value: 'Persistent panel lyrics' }],
+ },
+ translation: null,
+ pronunciation: null,
+ },
+ loading: false,
+ error: null,
+ retry: vi.fn(),
+ }
+ return {
+ defaultLyricsResponse,
+ useEnhancedLyricsMock: vi.fn(() => defaultLyricsResponse),
+ }
+})
+
+vi.mock('./useEnhancedLyrics', () => ({
+ default: useEnhancedLyricsMock,
+}))
+
+const createHost = () => {
+ const host = document.createElement('div')
+ host.className = MOBILE_KARAOKE_LYRICS_HOST_SELECTOR.slice(1)
+ document.body.appendChild(host)
+ return host
+}
+
+const portal = (text, active = true, returnFocusRef, obscured = false) => (
+
+ {text}
+
+)
+
+const MobileLyricsHarness = ({
+ trackId = 'track-1',
+ isRadio = false,
+ obscuredByQueue = false,
+}) => {
+ const { toolbarLyricsProps, mobileLyricsSurface } = usePlayerLyrics({
+ trackId,
+ trackUpdatedAt: 'now',
+ isRadio,
+ audioInstance: null,
+ isDesktop: false,
+ obscuredByQueue,
+ translate: (key) => key,
+ })
+ return (
+ <>
+
+ {mobileLyricsSurface}
+ >
+ )
+}
+
+describe('', () => {
+ afterEach(() => {
+ vi.useRealTimers()
+ useEnhancedLyricsMock.mockReset()
+ useEnhancedLyricsMock.mockImplementation(() => defaultLyricsResponse)
+ cleanup()
+ document.body.innerHTML = ''
+ })
+
+ it('mounts active lyrics into the mobile cover host and cleans up the class', () => {
+ vi.useFakeTimers()
+ const host = createHost()
+ const { rerender } = render(portal('Inline lyrics'))
+
+ expect(within(host).getByText('Inline lyrics')).toBeInTheDocument()
+ expect(host).toHaveClass(MOBILE_KARAOKE_LYRICS_ACTIVE_CLASS)
+ expect(host).toHaveClass(MOBILE_KARAOKE_LYRICS_ENTERED_CLASS)
+ expect(
+ host.querySelector(`.${MOBILE_KARAOKE_LYRICS_LAYER_CLASS}`),
+ ).toHaveAttribute('data-entered', 'true')
+ expect(
+ host.querySelector(`.${MOBILE_KARAOKE_LYRICS_LAYER_CLASS}`),
+ ).toHaveStyle({ pointerEvents: 'auto' })
+ expect(
+ host.querySelector(`.${MOBILE_KARAOKE_LYRICS_LAYER_CLASS}`),
+ ).not.toHaveAttribute('inert')
+
+ rerender(portal('Inline lyrics', false))
+
+ expect(within(host).getByText('Inline lyrics')).toBeInTheDocument()
+ expect(host).toHaveClass(MOBILE_KARAOKE_LYRICS_ACTIVE_CLASS)
+ expect(host).not.toHaveClass(MOBILE_KARAOKE_LYRICS_ENTERED_CLASS)
+ expect(
+ host.querySelector(`.${MOBILE_KARAOKE_LYRICS_LAYER_CLASS}`),
+ ).toHaveAttribute('data-entered', 'false')
+ expect(
+ host.querySelector(`.${MOBILE_KARAOKE_LYRICS_LAYER_CLASS}`),
+ ).toHaveStyle({ pointerEvents: 'none' })
+ expect(
+ host.querySelector(`.${MOBILE_KARAOKE_LYRICS_LAYER_CLASS}`),
+ ).toHaveAttribute('inert')
+
+ act(() => {
+ vi.advanceTimersByTime(MOBILE_KARAOKE_LYRICS_TRANSITION_MS)
+ })
+
+ expect(screen.queryByText('Inline lyrics')).not.toBeInTheDocument()
+ expect(host).not.toHaveClass(MOBILE_KARAOKE_LYRICS_ACTIVE_CLASS)
+ expect(host).not.toHaveClass(MOBILE_KARAOKE_LYRICS_ENTERED_CLASS)
+ })
+
+ it('makes the exiting layer inert and returns focus to the lyrics toggle', () => {
+ vi.useFakeTimers()
+ const host = createHost()
+ const returnTarget = document.createElement('button')
+ document.body.appendChild(returnTarget)
+ const returnFocusRef = { current: returnTarget }
+ const { rerender } = render(
+
+
+ ,
+ )
+ const layerControl = within(host).getByRole('button', {
+ name: 'Layer control',
+ })
+ layerControl.focus()
+
+ rerender(
+
+
+ ,
+ )
+
+ const layer = host.querySelector(`.${MOBILE_KARAOKE_LYRICS_LAYER_CLASS}`)
+ expect(layer).toHaveAttribute('aria-hidden', 'true')
+ expect(layer).toHaveAttribute('inert')
+ expect(document.activeElement).toBe(returnTarget)
+ })
+
+ it('keeps open lyrics mounted and inert while the queue obscures them', async () => {
+ const host = createHost()
+ const { rerender } = render()
+
+ fireEvent.click(screen.getByRole('button', { name: 'Toggle lyrics' }))
+ const layer = host.querySelector(`.${MOBILE_KARAOKE_LYRICS_LAYER_CLASS}`)
+ const panel = within(host).getByTestId('karaoke-lyrics-panel')
+ await waitFor(() => expect(layer).toHaveAttribute('aria-hidden', 'false'))
+
+ rerender()
+
+ expect(host).toHaveClass(MOBILE_KARAOKE_LYRICS_ACTIVE_CLASS)
+ expect(host).toHaveClass(MOBILE_KARAOKE_LYRICS_ENTERED_CLASS)
+ expect(host.querySelector(`.${MOBILE_KARAOKE_LYRICS_LAYER_CLASS}`)).toBe(
+ layer,
+ )
+ expect(within(host).getByTestId('karaoke-lyrics-panel')).toBe(panel)
+ expect(layer).toHaveAttribute('aria-hidden', 'true')
+ expect(layer).toHaveAttribute('inert')
+ expect(layer).toHaveStyle({ pointerEvents: 'none' })
+ expect(useEnhancedLyricsMock).toHaveBeenLastCalledWith(
+ expect.objectContaining({ requested: true }),
+ )
+
+ rerender()
+
+ expect(within(host).getByTestId('karaoke-lyrics-panel')).toBe(panel)
+ await waitFor(() => expect(layer).toHaveAttribute('aria-hidden', 'false'))
+ expect(layer).not.toHaveAttribute('inert')
+ expect(layer).toHaveStyle({ pointerEvents: 'auto' })
+ })
+
+ it('attaches when the mobile cover host appears after activation', async () => {
+ vi.useFakeTimers()
+
+ render(portal('Late lyrics'))
+
+ expect(screen.queryByText('Late lyrics')).not.toBeInTheDocument()
+
+ const host = createHost()
+
+ await waitFor(() =>
+ expect(within(host).getByText('Late lyrics')).toBeInTheDocument(),
+ )
+ expect(host).toHaveClass(MOBILE_KARAOKE_LYRICS_ACTIVE_CLASS)
+ })
+
+ it('moves active lyrics when the mobile cover host is replaced', async () => {
+ const firstHost = createHost()
+
+ render(portal('Persistent lyrics'))
+ expect(within(firstHost).getByText('Persistent lyrics')).toBeInTheDocument()
+
+ const secondHost = createHost()
+ firstHost.replaceWith(secondHost)
+
+ await waitFor(() =>
+ expect(
+ within(secondHost).getByText('Persistent lyrics'),
+ ).toBeInTheDocument(),
+ )
+ expect(firstHost).not.toHaveClass(MOBILE_KARAOKE_LYRICS_ACTIVE_CLASS)
+ expect(secondHost).toHaveClass(MOBILE_KARAOKE_LYRICS_ACTIVE_CLASS)
+ })
+
+ it('keeps panel contents rendered for the complete mobile exit', async () => {
+ vi.useFakeTimers()
+ const host = createHost()
+ const { rerender } = render()
+
+ fireEvent.click(screen.getByRole('button', { name: 'Toggle lyrics' }))
+ expect(
+ within(host).getByText('Persistent panel lyrics'),
+ ).toBeInTheDocument()
+
+ fireEvent.click(screen.getByRole('button', { name: 'Toggle lyrics' }))
+
+ expect(useEnhancedLyricsMock).toHaveBeenLastCalledWith(
+ expect.objectContaining({ requested: false }),
+ )
+ rerender()
+ const trackTwoRequests = useEnhancedLyricsMock.mock.calls
+ .filter(([options]) => options.trackId === 'track-2')
+ .map(([options]) => options.requested)
+ expect(trackTwoRequests.length).toBeGreaterThan(0)
+ expect(trackTwoRequests).not.toContain(true)
+ expect(
+ within(host).getByText('Persistent panel lyrics'),
+ ).toBeInTheDocument()
+ expect(
+ host.querySelector(`.${MOBILE_KARAOKE_LYRICS_LAYER_CLASS}`),
+ ).toHaveStyle({ pointerEvents: 'none' })
+
+ act(() => {
+ vi.advanceTimersByTime(MOBILE_KARAOKE_LYRICS_TRANSITION_MS)
+ })
+
+ expect(
+ screen.queryByText('Persistent panel lyrics'),
+ ).not.toBeInTheDocument()
+ })
+
+ it('centers working pronunciation and translation toggles below mobile lyrics', () => {
+ useEnhancedLyricsMock.mockImplementation(() => ({
+ ...defaultLyricsResponse,
+ layers: {
+ ...defaultLyricsResponse.layers,
+ pronunciation: {
+ synced: true,
+ line: [{ start: 0, end: 1000, value: 'Spoken lyrics' }],
+ },
+ translation: {
+ synced: true,
+ line: [{ start: 0, end: 1000, value: 'Translated lyrics' }],
+ },
+ },
+ }))
+ const host = createHost()
+ render()
+
+ fireEvent.click(screen.getByRole('button', { name: 'Toggle lyrics' }))
+
+ const controls = within(host).getByTestId('lyrics-mobile-layer-controls')
+ expect(controls).toHaveStyle({
+ bottom: '8px',
+ left: '50%',
+ transform: 'translateX(-50%)',
+ })
+
+ const pronunciation = within(host).getByTestId(
+ 'toggle-pronunciation-button',
+ )
+ const translation = within(host).getByTestId('toggle-translation-button')
+ expect(pronunciation).toHaveAttribute(
+ 'aria-label',
+ 'player.hideLyricsPronunciationText',
+ )
+ expect(pronunciation).toHaveAttribute('aria-pressed', 'true')
+ expect(translation).toHaveAttribute(
+ 'aria-label',
+ 'player.hideLyricsTranslationText',
+ )
+ expect(translation).toHaveAttribute('aria-pressed', 'true')
+
+ fireEvent.click(pronunciation)
+ fireEvent.click(translation)
+
+ expect(pronunciation).toHaveAttribute('aria-pressed', 'false')
+ expect(translation).toHaveAttribute('aria-pressed', 'false')
+ expect(within(host).queryByText('Spoken lyrics')).not.toBeInTheDocument()
+ expect(
+ within(host).queryByText('Translated lyrics'),
+ ).not.toBeInTheDocument()
+ })
+
+ it('keeps open lyrics closable after playback switches to radio', () => {
+ vi.useFakeTimers()
+ const host = createHost()
+ const { rerender } = render()
+ const toggle = screen.getByRole('button', { name: 'Toggle lyrics' })
+
+ fireEvent.click(toggle)
+ expect(
+ within(host).getByText('Persistent panel lyrics'),
+ ).toBeInTheDocument()
+
+ rerender()
+ expect(toggle).toBeEnabled()
+ fireEvent.click(toggle)
+
+ act(() => {
+ vi.advanceTimersByTime(MOBILE_KARAOKE_LYRICS_TRANSITION_MS)
+ })
+ expect(
+ screen.queryByText('Persistent panel lyrics'),
+ ).not.toBeInTheDocument()
+ expect(toggle).toBeDisabled()
+ })
+})
diff --git a/ui/src/audioplayer/Player.jsx b/ui/src/audioplayer/Player.jsx
index c3d795b26..f20828d44 100644
--- a/ui/src/audioplayer/Player.jsx
+++ b/ui/src/audioplayer/Player.jsx
@@ -34,6 +34,12 @@ import { keyMap } from '../hotkeys'
import keyHandlers from './keyHandlers'
import { calculateGain } from '../utils/calculateReplayGain'
import { detectBrowserProfile, decisionService } from '../transcode'
+import usePlayerLyrics from './usePlayerLyrics'
+import { useLyricsLayout } from './LyricsLayoutContext'
+import {
+ PLAYER_DESKTOP_MEDIA_QUERY,
+ PLAYER_MOBILE_MATCH_MEDIA_QUERY,
+} from './playerBreakpoints'
const Player = () => {
const theme = useCurrentTheme()
@@ -48,7 +54,8 @@ const Player = () => {
const currentTrackIdRef = useRef(null)
const stoppedRef = useRef(false)
const [audioInstance, setAudioInstance] = useState(null)
- const isDesktop = useMediaQuery('(min-width:810px)')
+ const [audioListsPanelVisible, setAudioListsPanelVisible] = useState(false)
+ const isDesktop = useMediaQuery(PLAYER_DESKTOP_MEDIA_QUERY)
const isMobilePlayer =
/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(
navigator.userAgent,
@@ -138,6 +145,33 @@ const Player = () => {
const gainInfo = useSelector((state) => state.replayGain)
const [context, setContext] = useState(null)
const [gainNode, setGainNode] = useState(null)
+ const { setDesktopLyricsProps } = useLyricsLayout()
+ const {
+ toolbarLyricsProps,
+ desktopLyricsProps,
+ mobileLyricsSurface,
+ useInlineMobileLyrics,
+ closeLyrics,
+ } = usePlayerLyrics({
+ trackId: playerState.current?.trackId || currentTrackId,
+ trackUpdatedAt:
+ playerState.current?.song?.updatedAt || playerState.current?.updatedAt,
+ isRadio: playerState.current?.isRadio || false,
+ audioInstance,
+ isDesktop,
+ obscuredByQueue: audioListsPanelVisible,
+ translate,
+ })
+
+ useEffect(() => {
+ if (!isDesktop) {
+ setDesktopLyricsProps(null)
+ return undefined
+ }
+
+ setDesktopLyricsProps(desktopLyricsProps)
+ return () => setDesktopLyricsProps(null)
+ }, [desktopLyricsProps, isDesktop, setDesktopLyricsProps])
useEffect(() => {
if (
@@ -213,12 +247,13 @@ const Player = () => {
clearPriorAudioLists: false,
showDestroy: true,
showDownload: false,
- showLyric: true,
+ showLyric: false,
showReload: false,
toggleMode: !isDesktop,
glassBg: false,
showThemeSwitch: false,
showMediaSession: true,
+ mobileMediaQuery: PLAYER_MOBILE_MATCH_MEDIA_QUERY,
restartCurrentOnPrev: true,
quietUpdate: true,
defaultPosition: {
@@ -251,12 +286,16 @@ const Player = () => {
(playerState.clear || playerState.playIndex === 0),
clearPriorAudioLists: playerState.clear,
extendsContent: (
-
+
),
defaultVolume: isMobilePlayer ? 1 : playerState.volume,
showMediaSession: !current.isRadio,
}
- }, [playerState, defaultOptions, isMobilePlayer])
+ }, [playerState, defaultOptions, isMobilePlayer, toolbarLyricsProps])
const onAudioListsChange = useCallback(
(_, audioLists, audioInfo) => dispatch(syncQueue(audioInfo, audioLists)),
@@ -365,11 +404,15 @@ const Player = () => {
[dispatch, dataProvider, currentTrackId],
)
- const onCoverClick = useCallback((mode, audioLists, audioInfo) => {
- if (mode === 'full' && audioInfo?.song?.albumId) {
- window.location.href = `#/album/${audioInfo.song.albumId}/show`
- }
- }, [])
+ const onCoverClick = useCallback(
+ (mode, audioLists, audioInfo) => {
+ if (useInlineMobileLyrics) return
+ if (mode === 'full' && audioInfo?.song?.albumId) {
+ window.location.href = `#/album/${audioInfo.song.albumId}/show`
+ }
+ },
+ [useInlineMobileLyrics],
+ )
const onAudioError = useCallback(
(error, currentPlayId, audioLists, audioInfo) => {
@@ -404,10 +447,11 @@ const Player = () => {
}
setHeartbeatTrackId(null)
setCurrentTrackId(null)
+ closeLyrics()
dispatch(clearQueue())
reject()
})
- }, [dispatch, currentTrackId])
+ }, [closeLyrics, dispatch, currentTrackId])
if (!visible) {
document.title = 'Navidrome'
@@ -459,6 +503,7 @@ const Player = () => {
{...options}
className={classes.player}
onAudioListsChange={onAudioListsChange}
+ onAudioListsPanelChange={setAudioListsPanelVisible}
onAudioVolumeChange={onAudioVolumeChange}
onAudioProgress={onAudioProgress}
onAudioPlay={onAudioPlay}
@@ -471,6 +516,7 @@ const Player = () => {
onBeforeDestroy={onBeforeDestroy}
getAudioInstance={setAudioInstance}
/>
+ {mobileLyricsSurface}
)
diff --git a/ui/src/audioplayer/PlayerToolbar.jsx b/ui/src/audioplayer/PlayerToolbar.jsx
index 4812141ab..e146bc238 100644
--- a/ui/src/audioplayer/PlayerToolbar.jsx
+++ b/ui/src/audioplayer/PlayerToolbar.jsx
@@ -4,11 +4,14 @@ import { useGetOne } from 'react-admin'
import { GlobalHotKeys } from 'react-hotkeys'
import IconButton from '@material-ui/core/IconButton'
import { useMediaQuery } from '@material-ui/core'
+import Tooltip from '@material-ui/core/Tooltip'
+import { RiFileMusicLine } from 'react-icons/ri'
import { RiSaveLine } from 'react-icons/ri'
import { LoveButton, useToggleLove } from '../common'
import { openSaveQueueDialog } from '../actions'
import { keyMap } from '../hotkeys'
import { makeStyles } from '@material-ui/core/styles'
+import { PLAYER_DESKTOP_MEDIA_QUERY } from './playerBreakpoints'
const useStyles = makeStyles((theme) => ({
toolbar: {
@@ -55,11 +58,21 @@ const useStyles = makeStyles((theme) => ({
},
}))
-const PlayerToolbar = ({ id, isRadio }) => {
+const PlayerToolbar = ({
+ id,
+ isRadio,
+ onToggleLyrics,
+ lyricsActive = false,
+ lyricsDisabled = false,
+ lyricsLoading = false,
+ lyricsLabel = 'Toggle lyrics',
+ lyricsLoadingLabel = 'Loading lyrics',
+ lyricsToggleRef,
+}) => {
const dispatch = useDispatch()
const { data, loading } = useGetOne('song', id, { enabled: !!id && !isRadio })
const [toggleLove, toggling] = useToggleLove('song', data)
- const isDesktop = useMediaQuery('(min-width:810px)')
+ const isDesktop = useMediaQuery(PLAYER_DESKTOP_MEDIA_QUERY)
const classes = useStyles()
const handlers = {
@@ -76,6 +89,7 @@ const PlayerToolbar = ({ id, isRadio }) => {
const buttonClass = isDesktop ? classes.button : classes.mobileButton
const listItemClass = isDesktop ? classes.toolbar : classes.mobileListItem
+ const lyricsUnavailable = lyricsDisabled || (lyricsLoading && !lyricsActive)
const saveQueueButton = (
{
/>
)
+ const currentLyricsLabel = lyricsLoading ? lyricsLoadingLabel : lyricsLabel
+ const lyricsButton = (
+
+
+
+
+
+
+
+ )
+
return (
<>
@@ -106,11 +144,13 @@ const PlayerToolbar = ({ id, isRadio }) => {
{saveQueueButton}
{loveButton}
+ {lyricsButton}
) : (
<>
{saveQueueButton}
{loveButton}
+ {lyricsButton}
>
)}
>
diff --git a/ui/src/audioplayer/PlayerToolbar.test.jsx b/ui/src/audioplayer/PlayerToolbar.test.jsx
index d0368b0f0..ef03b1cad 100644
--- a/ui/src/audioplayer/PlayerToolbar.test.jsx
+++ b/ui/src/audioplayer/PlayerToolbar.test.jsx
@@ -61,37 +61,22 @@ describe('', () => {
useMediaQuery.mockReturnValue(true) // isDesktop = true
})
- it('renders desktop toolbar with both buttons', () => {
+ it('renders desktop toolbar with all buttons', () => {
render()
- // Both buttons should be in a single list item
+ // All buttons should be in a single list item
const listItems = screen.getAllByRole('listitem')
expect(listItems).toHaveLength(1)
- // Verify both buttons are rendered
+ // Verify all buttons are rendered
expect(screen.getByTestId('save-queue-button')).toBeInTheDocument()
expect(screen.getByTestId('love-button')).toBeInTheDocument()
+ expect(screen.getByTestId('toggle-lyrics-button')).toBeInTheDocument()
// Verify desktop classes are applied
expect(listItems[0].className).toContain('toolbar')
})
- it('disables save queue button when isRadio is true', () => {
- render()
-
- const saveQueueButton = screen.getByTestId('save-queue-button')
- expect(saveQueueButton).toBeDisabled()
- })
-
- it('disables love button when conditions are met', () => {
- useGetOne.mockReturnValue({ data: mockSongData, loading: true })
-
- render()
-
- const loveButton = screen.getByTestId('love-button')
- expect(loveButton).toBeDisabled()
- })
-
it('opens save queue dialog when save button is clicked', () => {
render()
@@ -102,6 +87,32 @@ describe('', () => {
type: 'OPEN_SAVE_QUEUE_DIALOG',
})
})
+
+ it('toggles lyrics from an accessible toolbar button', () => {
+ const onToggleLyrics = vi.fn()
+ const lyricsToggleRef = React.createRef()
+ render(
+ ,
+ )
+
+ const lyricsButton = screen.getByLabelText('Toggle lyrics')
+ expect(lyricsButton).toHaveAttribute(
+ 'data-testid',
+ 'toggle-lyrics-button',
+ )
+ expect(lyricsButton).toHaveClass('MuiIconButton-colorPrimary')
+ expect(lyricsButton).toHaveAttribute('aria-pressed', 'true')
+ expect(lyricsToggleRef.current).toBe(lyricsButton)
+
+ fireEvent.click(lyricsButton)
+
+ expect(onToggleLyrics).toHaveBeenCalledTimes(1)
+ })
})
describe('Mobile layout', () => {
@@ -114,35 +125,57 @@ describe('', () => {
// Each button should be in its own list item
const listItems = screen.getAllByRole('listitem')
- expect(listItems).toHaveLength(2)
+ expect(listItems).toHaveLength(3)
- // Verify both buttons are rendered
+ // Verify all buttons are rendered
expect(screen.getByTestId('save-queue-button')).toBeInTheDocument()
expect(screen.getByTestId('love-button')).toBeInTheDocument()
+ expect(screen.getByTestId('toggle-lyrics-button')).toBeInTheDocument()
// Verify mobile classes are applied
expect(listItems[0].className).toContain('mobileListItem')
expect(listItems[1].className).toContain('mobileListItem')
+ expect(listItems[2].className).toContain('mobileListItem')
})
- it('disables save queue button when isRadio is true', () => {
- render()
+ it('disables lyrics toggle when lyrics are unavailable', () => {
+ render(
+ ,
+ )
- const saveQueueButton = screen.getByTestId('save-queue-button')
- expect(saveQueueButton).toBeDisabled()
+ expect(screen.getByTestId('toggle-lyrics-button')).toBeDisabled()
})
- it('disables love button when conditions are met', () => {
- useGetOne.mockReturnValue({ data: mockSongData, loading: true })
+ it('shows loading state on the lyrics toggle before lyrics are available', () => {
+ render(
+ ,
+ )
- render()
-
- const loveButton = screen.getByTestId('love-button')
- expect(loveButton).toBeDisabled()
+ const lyricsButton = screen.getByTestId('toggle-lyrics-button')
+ expect(lyricsButton).toBeDisabled()
+ expect(lyricsButton).toHaveAttribute('aria-label', 'Loading lyrics')
+ expect(lyricsButton).toHaveAttribute('aria-busy', 'true')
+ expect(lyricsButton).toHaveAttribute('aria-pressed', 'false')
})
})
describe('Common behavior', () => {
+ it('disables save queue button when isRadio is true', () => {
+ useMediaQuery.mockReturnValue(true)
+ render()
+
+ expect(screen.getByTestId('save-queue-button')).toBeDisabled()
+ })
+
+ it('disables love button when conditions are met', () => {
+ useMediaQuery.mockReturnValue(true)
+ useGetOne.mockReturnValue({ data: mockSongData, loading: true })
+
+ render()
+
+ expect(screen.getByTestId('love-button')).toBeDisabled()
+ })
+
it('renders global hotkeys in both layouts', () => {
// Test desktop layout
useMediaQuery.mockReturnValue(true)
diff --git a/ui/src/audioplayer/lyrics.js b/ui/src/audioplayer/lyrics.js
new file mode 100644
index 000000000..05193d271
--- /dev/null
+++ b/ui/src/audioplayer/lyrics.js
@@ -0,0 +1,508 @@
+const normalizeLanguageTag = (language) => {
+ const normalized = (language || '').trim().replace(/_/g, '-')
+ if (!normalized) return ''
+
+ try {
+ return Intl.getCanonicalLocales(normalized)[0].toLowerCase()
+ } catch {
+ return normalized.toLowerCase()
+ }
+}
+
+const LYRIC_KIND_MAIN = 'main'
+const LYRIC_KIND_TRANSLATION = 'translation'
+const LYRIC_KIND_PRONUNCIATION = 'pronunciation'
+
+const toTime = (value) => {
+ if (value == null || value === '') {
+ return null
+ }
+ const numeric = Number(value)
+ return Number.isFinite(numeric) ? numeric : null
+}
+
+const applyTimeOffset = (value, offset = 0) => {
+ const time = toTime(value)
+ return time == null ? null : time + offset
+}
+
+const toByteOffset = (value) => {
+ if (value == null || value === '') {
+ return null
+ }
+ const numeric = Number(value)
+ if (!Number.isInteger(numeric) || numeric < 0) {
+ return null
+ }
+ return numeric
+}
+
+const compareNullableTime = (a, b) => {
+ if (a == null && b == null) return 0
+ if (a == null) return 1
+ if (b == null) return -1
+ return a - b
+}
+
+const sortTokensByStart = (tokens) =>
+ tokens
+ .map((token, order) => ({ ...token, order }))
+ .sort((a, b) => {
+ const byStart = compareNullableTime(a.start, b.start)
+ if (byStart !== 0) return byStart
+ const byEnd = compareNullableTime(a.end, b.end)
+ if (byEnd !== 0) return byEnd
+ return a.order - b.order
+ })
+ .map(({ order, ...token }) => token)
+
+const languageMatch = (candidate, preferred) => {
+ if (!candidate || !preferred) return false
+ return (
+ candidate === preferred ||
+ candidate.startsWith(`${preferred}-`) ||
+ preferred.startsWith(`${candidate}-`)
+ )
+}
+
+const hasTimedCueLines = (lyric) =>
+ Array.isArray(lyric?.cueLine) &&
+ lyric.cueLine.some(
+ (cueLine) =>
+ toTime(cueLine?.start) != null ||
+ (Array.isArray(cueLine?.cue) &&
+ cueLine.cue.some((cue) => toTime(cue?.start) != null)),
+ )
+
+const hasTimedLines = (lyric) =>
+ Boolean(
+ lyric &&
+ lyric.synced &&
+ ((Array.isArray(lyric.line) &&
+ lyric.line.some((line) => toTime(line.start) != null)) ||
+ hasTimedCueLines(lyric)),
+ )
+
+const preferTimedLyrics = (lyrics) => {
+ const timed = lyrics.filter(hasTimedLines)
+ return timed.length > 0 ? timed : lyrics
+}
+
+const normalizeToken = (token, offset = 0) => {
+ if (!token) return null
+ const value = typeof token.value === 'string' ? token.value : ''
+ if (value.length === 0) return null
+ const byteStart = toByteOffset(token.byteStart)
+ const byteEnd = toByteOffset(token.byteEnd)
+ return {
+ start: applyTimeOffset(token.start, offset),
+ end: applyTimeOffset(token.end, offset),
+ value,
+ ...(byteStart != null ? { byteStart } : {}),
+ ...(byteEnd != null ? { byteEnd } : {}),
+ }
+}
+
+const utf8BytesForCodePoint = (codePoint) => {
+ if (codePoint <= 0x7f) return 1
+ if (codePoint <= 0x7ff) return 2
+ if (codePoint <= 0xffff) return 3
+ return 4
+}
+
+export const utf8ByteOffsetToCodeUnitIndex = (text, targetByteOffset) => {
+ if (typeof text !== 'string' || text.length === 0) return 0
+
+ const target = toByteOffset(targetByteOffset)
+ if (target == null || target <= 0) return 0
+
+ let byteOffset = 0
+ let index = 0
+ while (index < text.length) {
+ if (byteOffset >= target) return index
+ const codePoint = text.codePointAt(index)
+ byteOffset += utf8BytesForCodePoint(codePoint)
+ index += codePoint > 0xffff ? 2 : 1
+ }
+
+ return text.length
+}
+
+export const utf8ByteRangeToCodeUnitRange = (text, byteStart, byteEnd) => {
+ if (typeof text !== 'string') return null
+
+ const start = toByteOffset(byteStart)
+ const end = toByteOffset(byteEnd)
+ if (start == null || end == null || end < start) return null
+
+ const startIndex = utf8ByteOffsetToCodeUnitIndex(text, start)
+ const endIndex = utf8ByteOffsetToCodeUnitIndex(text, end + 1)
+ if (
+ startIndex >= endIndex ||
+ startIndex > text.length ||
+ endIndex > text.length
+ ) {
+ return null
+ }
+
+ return {
+ start: startIndex,
+ end: endIndex,
+ text: text.slice(startIndex, endIndex),
+ }
+}
+
+const buildAgentLookup = (structuredLyric) => {
+ const lookup = new Map()
+ const agents = Array.isArray(structuredLyric?.agents)
+ ? structuredLyric.agents
+ : []
+ for (const agent of agents) {
+ const id = typeof agent?.id === 'string' ? agent.id : ''
+ if (!id || lookup.has(id)) continue
+ lookup.set(id, {
+ id,
+ role: typeof agent?.role === 'string' ? agent.role : '',
+ name: typeof agent?.name === 'string' ? agent.name : '',
+ })
+ }
+ return lookup
+}
+
+const deriveUiRole = (agent) => {
+ if (!agent?.role || agent.role === 'main') return ''
+ return agent.role
+}
+
+const normalizeCueLine = (cueLine, fallbackIndex, agentLookup, offset = 0) => {
+ const index = Number.isFinite(Number(cueLine?.index))
+ ? Number(cueLine.index)
+ : fallbackIndex
+ const agentId = typeof cueLine?.agentId === 'string' ? cueLine.agentId : ''
+ const agent = agentId ? agentLookup.get(agentId) || null : null
+ const fallbackRole = typeof cueLine?.role === 'string' ? cueLine.role : ''
+ const tokens = sortTokensByStart(
+ Array.isArray(cueLine?.cue)
+ ? cueLine.cue.map((cue) => normalizeToken(cue, offset)).filter(Boolean)
+ : [],
+ )
+
+ return {
+ index,
+ start: applyTimeOffset(cueLine?.start, offset),
+ end: applyTimeOffset(cueLine?.end, offset),
+ value: typeof cueLine?.value === 'string' ? cueLine.value : '',
+ role: agent ? deriveUiRole(agent) : fallbackRole,
+ agentId,
+ agentRole: agent?.role || fallbackRole,
+ agentName: agent?.name || '',
+ tokens,
+ }
+}
+
+const normalizeLyricKind = (kind) => {
+ const normalized = (kind || '').toLowerCase().trim()
+ switch (normalized) {
+ case LYRIC_KIND_TRANSLATION:
+ return LYRIC_KIND_TRANSLATION
+ case LYRIC_KIND_PRONUNCIATION:
+ return LYRIC_KIND_PRONUNCIATION
+ default:
+ return LYRIC_KIND_MAIN
+ }
+}
+
+const lyricsInPreferredLanguage = (lyrics, preferredLanguage) => {
+ if (!Array.isArray(lyrics) || lyrics.length === 0) return null
+
+ const preferred = normalizeLanguageTag(preferredLanguage)
+ const preferredBase = preferred.split('-')[0]
+
+ const preferredMatches = lyrics.filter((lyric) =>
+ languageMatch(normalizeLanguageTag(lyric.lang), preferred),
+ )
+ if (preferredMatches.length > 0) return preferredMatches
+
+ const baseMatches = lyrics.filter((lyric) =>
+ languageMatch(normalizeLanguageTag(lyric.lang), preferredBase),
+ )
+ if (baseMatches.length > 0) return baseMatches
+
+ const englishMatches = lyrics.filter((lyric) =>
+ languageMatch(normalizeLanguageTag(lyric.lang), 'en'),
+ )
+ return englishMatches.length > 0 ? englishMatches : lyrics
+}
+
+const pickPreferredLyric = (lyrics, preferredLanguage) => {
+ const preferred = lyricsInPreferredLanguage(lyrics, preferredLanguage)
+ return preferred ? preferTimedLyrics(preferred)[0] || null : null
+}
+
+export const hasCueTiming = (structuredLyric) =>
+ hasTimedCueLines(structuredLyric)
+
+export const hasStructuredLyricContent = (structuredLyric) =>
+ Boolean(
+ structuredLyric &&
+ ((Array.isArray(structuredLyric.line) &&
+ structuredLyric.line.some(
+ (line) => typeof line?.value === 'string' && line.value.trim() !== '',
+ )) ||
+ hasCueTiming(structuredLyric)),
+ )
+
+export const getPreferredLyricLanguage = () => {
+ try {
+ if (typeof window !== 'undefined' && window.localStorage) {
+ const stored = window.localStorage.getItem('locale')
+ if (stored) return stored
+ }
+ } catch {
+ // Fall back to the browser language when storage access is restricted.
+ }
+ if (typeof navigator !== 'undefined' && navigator.language) {
+ return navigator.language
+ }
+ return 'en'
+}
+
+export const selectLyricLayers = (structuredLyrics, preferredLanguage) => {
+ if (!Array.isArray(structuredLyrics)) {
+ return { main: null, translation: null, pronunciation: null }
+ }
+
+ const available = structuredLyrics.filter(hasStructuredLyricContent)
+ if (available.length === 0) {
+ return { main: null, translation: null, pronunciation: null }
+ }
+
+ const grouped = {
+ [LYRIC_KIND_MAIN]: [],
+ [LYRIC_KIND_TRANSLATION]: [],
+ [LYRIC_KIND_PRONUNCIATION]: [],
+ }
+
+ for (const lyric of available) {
+ grouped[normalizeLyricKind(lyric?.kind)].push(lyric)
+ }
+
+ const mainCandidates = grouped[LYRIC_KIND_MAIN].length
+ ? grouped[LYRIC_KIND_MAIN]
+ : available
+
+ return {
+ main: pickPreferredLyric(mainCandidates, preferredLanguage),
+ translation: pickPreferredLyric(
+ grouped[LYRIC_KIND_TRANSLATION],
+ preferredLanguage,
+ ),
+ pronunciation: pickPreferredLyric(
+ grouped[LYRIC_KIND_PRONUNCIATION],
+ preferredLanguage,
+ ),
+ }
+}
+
+const buildBaseKaraokeLine = (line, index, offset = 0) => ({
+ index,
+ start: applyTimeOffset(line?.start, offset),
+ end: applyTimeOffset(line?.end, offset),
+ value: typeof line?.value === 'string' ? line.value : '',
+ tokens: [],
+ lanes: [],
+})
+
+const buildBaseKaraokeLines = (baseLines, offset = 0) =>
+ baseLines.map((line, index) => buildBaseKaraokeLine(line, index, offset))
+
+const minNullableTime = (...values) => {
+ const times = values.filter((value) => value != null)
+ return times.length > 0 ? Math.min(...times) : null
+}
+
+const maxNullableTime = (...values) => {
+ const times = values.filter((value) => value != null)
+ return times.length > 0 ? Math.max(...times) : null
+}
+
+const laneRoleRank = (line) => {
+ const role = (line?.agentRole || line?.role || '').toLowerCase()
+ switch (role) {
+ case '':
+ case 'main':
+ return 0
+ case 'voice':
+ return 1
+ case 'group':
+ case 'chorus':
+ case 'choir':
+ return 2
+ case 'bg':
+ case 'background':
+ case 'background vocals':
+ case 'background-vocals':
+ case 'backing':
+ case 'backing vocals':
+ case 'backing-vocals':
+ return 3
+ default:
+ return 1
+ }
+}
+
+const sortCueLineLanes = (lanes) =>
+ [...lanes].sort((a, b) => {
+ const byRole = laneRoleRank(a) - laneRoleRank(b)
+ if (byRole !== 0) return byRole
+ return a.order - b.order
+ })
+
+const buildLaneFromCueLine = (cueLine, laneIndex) => ({
+ key: `${cueLine.index}-${cueLine.agentId || 'default'}-${laneIndex}`,
+ index: cueLine.index,
+ start: cueLine.start,
+ end: cueLine.end,
+ value: cueLine.value,
+ role: cueLine.role,
+ agentId: cueLine.agentId,
+ agentName: cueLine.agentName,
+ agentRole: cueLine.agentRole,
+ tokens: cueLine.tokens,
+})
+
+const buildLineFromCueLineGroup = (index, group, baseLines, offset = 0) => {
+ const baseLine = buildBaseKaraokeLine(baseLines[index] || {}, index, offset)
+ const orderedGroup = sortCueLineLanes(group)
+ const lanes = orderedGroup.map(buildLaneFromCueLine)
+ const first = lanes[0] || {}
+ const tokens = sortTokensByStart(lanes.flatMap((lane) => lane.tokens || []))
+ const fallbackStart =
+ tokens.find((token) => token.start != null)?.start ?? null
+ const fallbackEnd =
+ [...tokens].reverse().find((token) => token.end != null)?.end ?? null
+ const laneStart = minNullableTime(
+ ...lanes.flatMap((lane) => [lane.start, lane.tokens?.[0]?.start]),
+ )
+ const laneEnd = maxNullableTime(
+ ...lanes.flatMap((lane) => [
+ lane.end,
+ [...(lane.tokens || [])].reverse().find((token) => token.end != null)
+ ?.end,
+ ]),
+ )
+ const fallbackValue = tokens
+ .map((token) => token.value)
+ .filter(Boolean)
+ .join(' ')
+ .replace(/\s+/g, ' ')
+ .trim()
+ const value = baseLine.value || first.value || fallbackValue
+
+ return {
+ ...baseLine,
+ index,
+ start: minNullableTime(
+ baseLine.start,
+ first.start,
+ laneStart,
+ fallbackStart,
+ ),
+ end: maxNullableTime(baseLine.end, first.end, laneEnd, fallbackEnd),
+ value,
+ agentId: first.agentId,
+ agentName: first.agentName,
+ agentRole: first.agentRole,
+ tokens,
+ lanes,
+ }
+}
+
+export const buildKaraokeLinesFromCueLines = (
+ rawCueLines,
+ baseLines,
+ agentLookup,
+ offset = 0,
+) => {
+ const normalizedCueLines = rawCueLines.map((cueLine, fallbackIndex) => {
+ const normalized = normalizeCueLine(
+ cueLine,
+ fallbackIndex,
+ agentLookup,
+ offset,
+ )
+ return {
+ ...normalized,
+ order: fallbackIndex,
+ tokens: normalized.tokens.map((token) => ({
+ ...token,
+ role: normalized.role,
+ agentId: normalized.agentId,
+ agentName: normalized.agentName,
+ agentRole: normalized.agentRole,
+ })),
+ }
+ })
+
+ const byIndex = new Map()
+ for (const cueLine of normalizedCueLines) {
+ if (!byIndex.has(cueLine.index)) byIndex.set(cueLine.index, [])
+ byIndex.get(cueLine.index).push(cueLine)
+ }
+
+ const indexes = new Set(baseLines.map((_line, index) => index))
+ byIndex.forEach((_group, index) => indexes.add(index))
+
+ return Array.from(indexes)
+ .sort((a, b) => a - b)
+ .map((index) => {
+ const group = byIndex.get(index) || []
+ if (group.length === 0) {
+ return buildBaseKaraokeLine(baseLines[index] || {}, index, offset)
+ }
+ return buildLineFromCueLineGroup(index, group, baseLines, offset)
+ })
+}
+
+export const buildKaraokeLines = (structuredLyric) => {
+ if (!structuredLyric) return []
+
+ const offset = toTime(structuredLyric.offset) ?? 0
+ const agentLookup = buildAgentLookup(structuredLyric)
+ const baseLines = Array.isArray(structuredLyric.line)
+ ? structuredLyric.line
+ : []
+ const rawCueLines = Array.isArray(structuredLyric.cueLine)
+ ? structuredLyric.cueLine
+ : []
+
+ const lines =
+ rawCueLines.length > 0
+ ? buildKaraokeLinesFromCueLines(
+ rawCueLines,
+ baseLines,
+ agentLookup,
+ offset,
+ )
+ : buildBaseKaraokeLines(baseLines, offset)
+
+ return lines
+ .map((line) => ({
+ ...line,
+ renderable: Boolean(line.value?.trim() || line.tokens.length > 0),
+ }))
+ .sort((a, b) => a.index - b.index)
+}
+
+export const hasUsableKaraokeTiming = (lines) =>
+ Array.isArray(lines) &&
+ lines.some(
+ (line) =>
+ line?.renderable !== false &&
+ (toTime(line?.start) != null ||
+ (Array.isArray(line?.tokens) &&
+ line.tokens.some(
+ (token) =>
+ toTime(token?.start) != null || toTime(token?.end) != null,
+ ))),
+ )
diff --git a/ui/src/audioplayer/lyrics.test.js b/ui/src/audioplayer/lyrics.test.js
new file mode 100644
index 000000000..208d82ac6
--- /dev/null
+++ b/ui/src/audioplayer/lyrics.test.js
@@ -0,0 +1,362 @@
+import { describe, expect, it, vi } from 'vitest'
+import {
+ buildKaraokeLines,
+ getPreferredLyricLanguage,
+ hasStructuredLyricContent,
+ selectLyricLayers,
+ utf8ByteRangeToCodeUnitRange,
+} from './lyrics'
+
+const timed = (value, start, end, extra = {}) => ({
+ ...extra,
+ start,
+ ...(end == null ? {} : { end }),
+ value,
+})
+
+const structured = (kind, lang, value) => ({
+ kind,
+ lang,
+ synced: true,
+ line: [timed(value, 0)],
+})
+
+const cueLine = (index, value, start, end, cue, extra = {}) => ({
+ index,
+ ...timed(value, start, end, extra),
+ cue,
+})
+
+describe('lyrics helpers', () => {
+ it('selects main, pronunciation, and translation layers by kind and language', () => {
+ const layers = selectLyricLayers(
+ [
+ structured('translation', 'es', 'Hola'),
+ structured('main', 'en-US', 'Hello'),
+ structured('pronunciation', 'en', 'heh-low'),
+ ],
+ 'en',
+ )
+
+ expect(layers.main.value || layers.main.line[0].value).toBe('Hello')
+ expect(layers.pronunciation.line[0].value).toBe('heh-low')
+ expect(layers.translation.line[0].value).toBe('Hola')
+ })
+
+ it('prefers cue-timed lyrics when line starts are omitted', () => {
+ const layers = selectLyricLayers(
+ [
+ {
+ kind: 'main',
+ lang: 'en',
+ synced: false,
+ line: [{ value: 'Plain lyrics' }],
+ },
+ {
+ kind: 'main',
+ lang: 'en',
+ synced: true,
+ line: [{ value: 'Cue-timed lyrics' }],
+ cueLine: [
+ {
+ index: 0,
+ value: 'Cue-timed lyrics',
+ cue: [{ start: 1000, value: 'Cue-timed lyrics' }],
+ },
+ ],
+ },
+ ],
+ 'en',
+ )
+
+ expect(layers.main.line[0].value).toBe('Cue-timed lyrics')
+ })
+
+ it('prefers the requested language before timed variants', () => {
+ const layers = selectLyricLayers(
+ [
+ {
+ kind: 'main',
+ lang: 'en',
+ synced: false,
+ line: [{ value: 'Preferred plain lyrics' }],
+ },
+ structured('main', 'ja', '別の言語'),
+ ],
+ 'en-US',
+ )
+
+ expect(layers.main.line[0].value).toBe('Preferred plain lyrics')
+ })
+
+ it('matches language tags with multiple underscore separators', () => {
+ const layers = selectLyricLayers(
+ [
+ structured('main', 'zh-Hans', '简体'),
+ structured('main', 'zh-Hant-TW', '繁體'),
+ ],
+ 'zh_Hant_TW',
+ )
+
+ expect(layers.main.line[0].value).toBe('繁體')
+ })
+
+ it('matches ISO 639-2 lyric languages to regional browser locales', () => {
+ const lyrics = [
+ structured('main', 'spa', 'Español'),
+ structured('main', 'eng', 'English'),
+ structured('main', 'por', 'Português'),
+ ]
+
+ expect(selectLyricLayers(lyrics, 'en-US').main.line[0].value).toBe(
+ 'English',
+ )
+ expect(selectLyricLayers(lyrics, 'pt-BR').main.line[0].value).toBe(
+ 'Português',
+ )
+ })
+
+ it('resolves UTF-8 byte ranges without confusing repeated words', () => {
+ const text = 'caf\u00e9 caf\u00e9'
+
+ expect(utf8ByteRangeToCodeUnitRange(text, 0, 4)).toMatchObject({
+ text: 'caf\u00e9',
+ })
+ expect(utf8ByteRangeToCodeUnitRange(text, 6, 10)).toMatchObject({
+ text: 'caf\u00e9',
+ start: 5,
+ })
+ })
+
+ it('applies structured lyric offsets to line and cue timing', () => {
+ const delayed = buildKaraokeLines({
+ synced: true,
+ offset: 500,
+ line: [timed('Delayed line', 1000, 3000)],
+ cueLine: [
+ cueLine(0, 'Delayed line', 1000, 3000, [
+ timed('Delayed ', 1000, 2000, { byteStart: 0, byteEnd: 7 }),
+ timed('line', 2000, 3000, { byteStart: 8, byteEnd: 11 }),
+ ]),
+ ],
+ })
+ const advanced = buildKaraokeLines({
+ synced: true,
+ offset: -250,
+ line: [timed('Advanced line', 1000)],
+ })
+
+ expect(delayed[0].start).toBe(1500)
+ expect(delayed[0].end).toBe(3500)
+ expect(delayed[0].tokens[0].start).toBe(1500)
+ expect(delayed[0].tokens[0].end).toBe(2500)
+ expect(advanced[0].start).toBe(750)
+ })
+
+ it('keeps base lyric lines that do not have word-level cue lines', () => {
+ const lines = buildKaraokeLines({
+ synced: true,
+ line: [
+ timed('Word timed', 1000),
+ timed('Plain timed line', 2000),
+ timed('More words', 3000),
+ ],
+ cueLine: [
+ cueLine(0, 'Word timed', 1000, null, [
+ timed('Word ', 1000, null, { byteStart: 0, byteEnd: 4 }),
+ timed('timed', 1500, null, { byteStart: 5, byteEnd: 9 }),
+ ]),
+ cueLine(2, 'More words', 3000, null, [
+ timed('More ', 3000, null, { byteStart: 0, byteEnd: 4 }),
+ timed('words', 3500, null, { byteStart: 5, byteEnd: 9 }),
+ ]),
+ ],
+ })
+
+ expect(lines.map((line) => line.value)).toEqual([
+ 'Word timed',
+ 'Plain timed line',
+ 'More words',
+ ])
+ expect(lines[1].tokens).toEqual([])
+ })
+
+ it('preserves repeated ELRC-style cue timing and trailing cue ends', () => {
+ const repeatedLine = (index, start) =>
+ cueLine(index, 'Hello world', start, start + 900, [
+ timed('Hello ', start + 100, start + 500, {
+ byteStart: 0,
+ byteEnd: 5,
+ }),
+ timed('world', start + 500, start + 900, {
+ byteStart: 6,
+ byteEnd: 10,
+ }),
+ ])
+ const lines = buildKaraokeLines({
+ synced: true,
+ line: [
+ timed('Hello world', 10000, 10900),
+ timed('Hello world', 30000, 30900),
+ ],
+ cueLine: [repeatedLine(0, 10000), repeatedLine(1, 30000)],
+ })
+
+ expect(lines).toHaveLength(2)
+ expect(lines[0].tokens[1].end).toBe(10900)
+ expect(lines[1].tokens[0].start).toBe(30100)
+ })
+
+ it('keeps multiline SRT, TTML, and plain text values intact', () => {
+ const lines = buildKaraokeLines({
+ synced: true,
+ line: [{ start: 1000, value: 'first line\nsecond line' }],
+ })
+
+ expect(lines[0].value).toBe('first line\nsecond line')
+ })
+
+ it('keeps adjacent TTML syllable cue tokens in order', () => {
+ const lines = buildKaraokeLines({
+ synced: true,
+ line: [timed('konni', 2000)],
+ cueLine: [
+ cueLine(0, 'konni', 2000, 2600, [
+ timed('ko', 2000, 2300, { byteStart: 0, byteEnd: 1 }),
+ timed('nni', 2300, 2600, { byteStart: 2, byteEnd: 4 }),
+ ]),
+ ],
+ })
+
+ expect(lines[0].tokens.map((token) => token.value)).toEqual(['ko', 'nni'])
+ })
+
+ it('keeps same-index agent cue lines as ordered voice lanes', () => {
+ const agentCue = (value, agentId, start, end) =>
+ cueLine(
+ 0,
+ value,
+ start,
+ end,
+ [timed(value, start, end, { byteStart: 0, byteEnd: value.length - 1 })],
+ { agentId },
+ )
+ const lines = buildKaraokeLines({
+ synced: true,
+ agents: [
+ { id: 'lead', role: 'main' },
+ { id: 'all', role: 'group' },
+ { id: 'lead-bg', role: 'bg' },
+ ],
+ line: [timed('Lead all echo', 1000, 4000)],
+ cueLine: [
+ agentCue('echo', 'lead-bg', 2000, 3000),
+ agentCue('all', 'all', 1500, 2500),
+ agentCue('Lead', 'lead', 1000, 2000),
+ ],
+ })
+
+ expect(lines).toHaveLength(1)
+ expect(lines[0].lanes).toHaveLength(3)
+ expect(lines[0].lanes.map((lane) => lane.agentRole)).toEqual([
+ 'main',
+ 'group',
+ 'bg',
+ ])
+ expect(lines[0].tokens.map((token) => token.value)).toEqual([
+ 'Lead',
+ 'all',
+ 'echo',
+ ])
+ })
+
+ it('uses whitespace-aware text when cue-only fallback values are needed', () => {
+ const lines = buildKaraokeLines({
+ synced: true,
+ cueLine: [
+ cueLine(0, '', 1000, 3000, [
+ timed('Lead', 1000, 2000),
+ timed('all', 2000, 3000),
+ timed('echo', 3000, 3500),
+ ]),
+ ],
+ })
+
+ expect(lines[0].value).toBe('Lead all echo')
+ })
+
+ it('preserves untimed lyric rows between surrounding timed rows', () => {
+ const lines = buildKaraokeLines({
+ synced: true,
+ line: [
+ { start: 1000, value: 'First verse' },
+ { value: '[instrumental]' },
+ { start: 2000, value: 'Second verse' },
+ ],
+ })
+
+ expect(lines.map((line) => line.value)).toEqual([
+ 'First verse',
+ '[instrumental]',
+ 'Second verse',
+ ])
+ })
+
+ it('preserves source display order independently from timestamps', () => {
+ const lines = buildKaraokeLines({
+ synced: true,
+ line: [
+ { start: 3000, value: 'Displayed first' },
+ { start: 1000, value: 'Displayed second' },
+ { start: 2000, value: 'Displayed third' },
+ ],
+ })
+
+ expect(lines.map((line) => line.value)).toEqual([
+ 'Displayed first',
+ 'Displayed second',
+ 'Displayed third',
+ ])
+ expect(lines.map((line) => line.start)).toEqual([3000, 1000, 2000])
+ })
+
+ it('preserves timed blank rows as non-renderable timing markers', () => {
+ const lines = buildKaraokeLines({
+ synced: true,
+ line: [
+ { start: 1000, value: 'Before pause' },
+ { start: 2000, value: '' },
+ { start: 4000, value: 'After pause' },
+ ],
+ })
+
+ expect(lines).toHaveLength(3)
+ expect(lines[1]).toMatchObject({
+ start: 2000,
+ value: '',
+ renderable: false,
+ })
+ })
+
+ it('falls back to the browser language when locale storage is unavailable', () => {
+ const storage = vi
+ .spyOn(Storage.prototype, 'getItem')
+ .mockImplementation(() => {
+ throw new DOMException('Access denied', 'SecurityError')
+ })
+
+ expect(getPreferredLyricLanguage()).toBe(navigator.language)
+ storage.mockRestore()
+ })
+
+ it('treats instrumental empty lyrics as no renderable content', () => {
+ expect(
+ hasStructuredLyricContent({
+ kind: 'main',
+ lang: 'en',
+ synced: false,
+ line: [],
+ }),
+ ).toBe(false)
+ })
+})
diff --git a/ui/src/audioplayer/lyricsKaraokeConstants.js b/ui/src/audioplayer/lyricsKaraokeConstants.js
new file mode 100644
index 000000000..ff4983cbd
--- /dev/null
+++ b/ui/src/audioplayer/lyricsKaraokeConstants.js
@@ -0,0 +1,113 @@
+export const KARAOKE_CLOCK_DRIFT_RESET_MS = 140
+export const KARAOKE_HIGHLIGHT_LEAD_MS = 120
+export const KARAOKE_ANIMATION_MS = 500
+export const KARAOKE_LINE_OPACITY_MS = 300
+export const KARAOKE_SCROLLBAR_VISIBLE_MS = 1400
+export const KARAOKE_MANUAL_SCROLL_PAUSE_MS = 2200
+export const KARAOKE_SCROLL_ANIMATION_MS = 300
+export const KARAOKE_LINE_LIFT_PX = 1.5
+const KARAOKE_EASE_IN_OUT_X1 = 0.77
+const KARAOKE_EASE_IN_OUT_Y1 = 0
+const KARAOKE_EASE_IN_OUT_X2 = 0.175
+const KARAOKE_EASE_IN_OUT_Y2 = 1
+const KARAOKE_OPACITY_EASING_X1 = 0.22
+const KARAOKE_OPACITY_EASING_Y1 = 1
+const KARAOKE_OPACITY_EASING_X2 = 0.36
+const KARAOKE_OPACITY_EASING_Y2 = 1
+export const KARAOKE_LINE_MOTION_EASING = `cubic-bezier(${KARAOKE_EASE_IN_OUT_X1}, ${KARAOKE_EASE_IN_OUT_Y1}, ${KARAOKE_EASE_IN_OUT_X2}, ${KARAOKE_EASE_IN_OUT_Y2})`
+export const KARAOKE_CHARACTER_LIFT_PX = KARAOKE_LINE_LIFT_PX
+export const KARAOKE_CHARACTER_RISE_MS = KARAOKE_ANIMATION_MS
+export const KARAOKE_CHARACTER_WAVE_SPAN_RATIO = 0.36
+export const KARAOKE_CHARACTER_WAVE_SPAN_MAX_MS = 140
+export const KARAOKE_IDLE_LAYER_OPACITY = 0.49
+export const KARAOKE_LINE_RELEASE_MS = KARAOKE_LINE_OPACITY_MS
+export const MOBILE_KARAOKE_LYRICS_TRANSITION_MS = 260
+export const KARAOKE_SCROLL_PRE_ROLL_MS = 320
+export const KARAOKE_DESKTOP_ACTIVE_LINE_ANCHOR_RATIO = 0.1
+export const KARAOKE_INLINE_ACTIVE_LINE_ANCHOR_RATIO = 0.42
+export const KARAOKE_SCROLL_SETTLE_PX = 2
+export const KARAOKE_AUX_LINE_HEIGHT = 1.18
+export const KARAOKE_EASING = `cubic-bezier(${KARAOKE_OPACITY_EASING_X1}, ${KARAOKE_OPACITY_EASING_Y1}, ${KARAOKE_OPACITY_EASING_X2}, ${KARAOKE_OPACITY_EASING_Y2})`
+export const TOKEN_FUTURE_ALPHA = 0.56
+export const TOKEN_ACTIVE_ALPHA = 1
+export const TOKEN_WIPE_SOFT_SPREAD_PCT = 34
+export const TOKEN_WIPE_EDGE_PCT = 12
+
+export const clamp = (value, min, max) => Math.min(max, Math.max(min, value))
+
+const cubicBezierCoordinate = (time, firstControl, secondControl) => {
+ const inverse = 1 - time
+ return (
+ 3 * inverse * inverse * time * firstControl +
+ 3 * inverse * time * time * secondControl +
+ time * time * time
+ )
+}
+
+const cubicBezierSlope = (time, firstControl, secondControl) => {
+ const inverse = 1 - time
+ return (
+ 3 * inverse * inverse * firstControl +
+ 6 * inverse * time * (secondControl - firstControl) +
+ 3 * time * time * (1 - secondControl)
+ )
+}
+
+const solveCubicBezierTime = (progress, firstControl, secondControl) => {
+ let time = progress
+ for (let iteration = 0; iteration < 6; iteration += 1) {
+ const error =
+ cubicBezierCoordinate(time, firstControl, secondControl) - progress
+ if (Math.abs(error) < 0.000001) return time
+ const slope = cubicBezierSlope(time, firstControl, secondControl)
+ if (Math.abs(slope) < 0.000001) break
+ time = clamp(time - error / slope, 0, 1)
+ }
+
+ let lower = 0
+ let upper = 1
+ for (let iteration = 0; iteration < 12; iteration += 1) {
+ time = (lower + upper) / 2
+ if (cubicBezierCoordinate(time, firstControl, secondControl) < progress) {
+ lower = time
+ } else {
+ upper = time
+ }
+ }
+ return time
+}
+
+export const easeKaraokeMotion = (value) => {
+ const progress = clamp(value, 0, 1)
+ if (progress === 0 || progress === 1) return progress
+ return cubicBezierCoordinate(
+ solveCubicBezierTime(
+ progress,
+ KARAOKE_EASE_IN_OUT_X1,
+ KARAOKE_EASE_IN_OUT_X2,
+ ),
+ KARAOKE_EASE_IN_OUT_Y1,
+ KARAOKE_EASE_IN_OUT_Y2,
+ )
+}
+
+export const easeKaraokeOpacity = (value) => {
+ const progress = clamp(value, 0, 1)
+ if (progress === 0 || progress === 1) return progress
+ return cubicBezierCoordinate(
+ solveCubicBezierTime(
+ progress,
+ KARAOKE_OPACITY_EASING_X1,
+ KARAOKE_OPACITY_EASING_X2,
+ ),
+ KARAOKE_OPACITY_EASING_Y1,
+ KARAOKE_OPACITY_EASING_Y2,
+ )
+}
+
+export const easeInOut = (value) => {
+ const progress = clamp(value, 0, 1)
+ return progress < 0.5
+ ? 2 * progress * progress
+ : 1 - (-2 * progress + 2) ** 2 / 2
+}
diff --git a/ui/src/audioplayer/lyricsKaraokeStyles.js b/ui/src/audioplayer/lyricsKaraokeStyles.js
new file mode 100644
index 000000000..6e751133f
--- /dev/null
+++ b/ui/src/audioplayer/lyricsKaraokeStyles.js
@@ -0,0 +1,45 @@
+const EMPHASIS_ROLES = new Set([
+ 'adlib',
+ 'backing',
+ 'backing vocals',
+ 'backing-vocals',
+ 'background',
+ 'background vocals',
+ 'background-vocals',
+ 'bg',
+ 'choir',
+ 'chorus',
+ 'group',
+ 'harmony',
+])
+
+const normalizeRole = (role) =>
+ String(role || '')
+ .trim()
+ .toLowerCase()
+
+export const isEmphasisRole = (token) =>
+ EMPHASIS_ROLES.has(normalizeRole(token?.role)) ||
+ EMPHASIS_ROLES.has(normalizeRole(token?.agentRole))
+
+export const buildEmphasisStyle = (token) =>
+ isEmphasisRole(token) ? { fontStyle: 'italic' } : undefined
+
+export const parseColorRGB = (color) => {
+ const value = String(color || '').trim()
+ const hex = value.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i)
+ if (hex) {
+ const raw = hex[1]
+ if (raw.length === 3) {
+ return raw.split('').map((part) => parseInt(part + part, 16))
+ }
+ return [
+ parseInt(raw.slice(0, 2), 16),
+ parseInt(raw.slice(2, 4), 16),
+ parseInt(raw.slice(4, 6), 16),
+ ]
+ }
+
+ const rgb = value.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/)
+ return rgb ? [parseInt(rgb[1]), parseInt(rgb[2]), parseInt(rgb[3])] : null
+}
diff --git a/ui/src/audioplayer/lyricsScroll.js b/ui/src/audioplayer/lyricsScroll.js
new file mode 100644
index 000000000..adf05d728
--- /dev/null
+++ b/ui/src/audioplayer/lyricsScroll.js
@@ -0,0 +1,73 @@
+import {
+ KARAOKE_SCROLL_ANIMATION_MS,
+ KARAOKE_SCROLL_SETTLE_PX,
+ clamp,
+ easeInOut,
+} from './lyricsKaraokeConstants'
+
+export const cancelScrollAnimation = (scrollAnimationRef) => {
+ const animation = scrollAnimationRef.current
+ if (animation?.frameId != null) {
+ window.cancelAnimationFrame(animation.frameId)
+ }
+ scrollAnimationRef.current = null
+}
+
+export const getScrollEndPadding = (body, anchorRatio) => {
+ const height = Number(body?.clientHeight)
+ if (!Number.isFinite(height) || height <= 0) return 0
+ return Math.round(height * (1 - anchorRatio))
+}
+
+export const getAnchoredScrollTop = (body, targetNode, anchorRatio) => {
+ const bodyRect = body.getBoundingClientRect()
+ const targetRect = targetNode.getBoundingClientRect()
+ const activeAnchorTop = body.clientHeight * anchorRatio
+ const deltaWithinBody = targetRect.top - bodyRect.top - activeAnchorTop
+ const maxTop = Math.max(0, body.scrollHeight - body.clientHeight)
+ return clamp(body.scrollTop + deltaWithinBody, 0, maxTop)
+}
+
+export const animateScrollTop = ({
+ body,
+ targetTop,
+ reducedMotion,
+ scrollAnimationRef,
+}) => {
+ cancelScrollAnimation(scrollAnimationRef)
+
+ const startTop = body.scrollTop
+ const maxTop = Math.max(0, body.scrollHeight - body.clientHeight)
+ const nextTargetTop = clamp(targetTop, 0, maxTop)
+ const distance = nextTargetTop - startTop
+ if (Math.abs(distance) < KARAOKE_SCROLL_SETTLE_PX) return
+
+ if (reducedMotion) {
+ body.scrollTop = nextTargetTop
+ return
+ }
+
+ const startedAt = performance.now()
+ const animation = { frameId: null }
+ const step = () => {
+ if (scrollAnimationRef.current !== animation) return
+
+ const progress = clamp(
+ (performance.now() - startedAt) / KARAOKE_SCROLL_ANIMATION_MS,
+ 0,
+ 1,
+ )
+ body.scrollTop =
+ progress >= 1 ? nextTargetTop : startTop + distance * easeInOut(progress)
+
+ if (progress < 1) {
+ animation.frameId = window.requestAnimationFrame(step)
+ return
+ }
+
+ scrollAnimationRef.current = null
+ }
+
+ animation.frameId = window.requestAnimationFrame(step)
+ scrollAnimationRef.current = animation
+}
diff --git a/ui/src/audioplayer/lyricsScroll.test.js b/ui/src/audioplayer/lyricsScroll.test.js
new file mode 100644
index 000000000..f75a44fe7
--- /dev/null
+++ b/ui/src/audioplayer/lyricsScroll.test.js
@@ -0,0 +1,157 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { KARAOKE_SCROLL_ANIMATION_MS } from './lyricsKaraokeConstants'
+import {
+ animateScrollTop,
+ cancelScrollAnimation,
+ getAnchoredScrollTop,
+ getScrollEndPadding,
+} from './lyricsScroll'
+
+const createScrollableBody = (scrollTop = 0) => ({
+ clientHeight: 200,
+ scrollHeight: 1000,
+ scrollTop,
+})
+
+const animate = (body, scrollAnimationRef, targetTop, reducedMotion = false) =>
+ animateScrollTop({
+ body,
+ targetTop,
+ reducedMotion,
+ scrollAnimationRef,
+ })
+
+describe('lyrics scroll helpers', () => {
+ let animationFrames
+ let now
+
+ beforeEach(() => {
+ animationFrames = []
+ now = 0
+ vi.spyOn(performance, 'now').mockImplementation(() => now)
+ vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => {
+ animationFrames.push(callback)
+ return animationFrames.length
+ })
+ vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {})
+ })
+
+ afterEach(() => {
+ vi.restoreAllMocks()
+ })
+
+ it('calculates end padding and clamps anchored targets', () => {
+ expect(getScrollEndPadding({ clientHeight: 500 }, 0.42)).toBe(290)
+ expect(getScrollEndPadding({ clientHeight: 200 }, 0.5)).toBe(100)
+ expect(getScrollEndPadding({ clientHeight: 0 }, 0.5)).toBe(0)
+ expect(getScrollEndPadding(null, 0.5)).toBe(0)
+ const body = {
+ ...createScrollableBody(120),
+ getBoundingClientRect: () => ({ top: 50 }),
+ }
+ const target = {
+ getBoundingClientRect: () => ({ top: 310 }),
+ }
+
+ // 120 + (310 - 50 - 200 * 0.4) = 300
+ expect(getAnchoredScrollTop(body, target, 0.4)).toBe(300)
+ const clampedBody = {
+ ...createScrollableBody(20),
+ getBoundingClientRect: () => ({ top: 100 }),
+ }
+ const above = {
+ getBoundingClientRect: () => ({ top: -500 }),
+ }
+ const below = {
+ getBoundingClientRect: () => ({ top: 5000 }),
+ }
+
+ expect(getAnchoredScrollTop(clampedBody, above, 0.4)).toBe(0)
+ expect(getAnchoredScrollTop(clampedBody, below, 0.4)).toBe(800)
+ })
+
+ it('stores a cancellable frame while animating scroll position', () => {
+ const body = createScrollableBody()
+ const scrollAnimationRef = { current: null }
+ window.requestAnimationFrame.mockImplementationOnce((callback) => {
+ animationFrames.push(callback)
+ return 0
+ })
+
+ animate(body, scrollAnimationRef, 400)
+
+ expect(scrollAnimationRef.current?.frameId).toBe(0)
+
+ cancelScrollAnimation(scrollAnimationRef)
+
+ expect(window.cancelAnimationFrame).toHaveBeenCalledWith(0)
+ expect(scrollAnimationRef.current).toBeNull()
+ })
+
+ it('updates scroll position immediately when reduced motion is enabled', () => {
+ const body = createScrollableBody()
+ const scrollAnimationRef = { current: null }
+
+ animate(body, scrollAnimationRef, 400, true)
+
+ expect(body.scrollTop).toBe(400)
+ expect(window.requestAnimationFrame).not.toHaveBeenCalled()
+ expect(scrollAnimationRef.current).toBeNull()
+ })
+
+ it('skips tiny scroll adjustments within the settle distance', () => {
+ const body = createScrollableBody()
+ const scrollAnimationRef = { current: null }
+
+ animate(body, scrollAnimationRef, 1)
+
+ expect(body.scrollTop).toBe(0)
+ expect(window.requestAnimationFrame).not.toHaveBeenCalled()
+ expect(scrollAnimationRef.current).toBeNull()
+ })
+
+ it('clears the current animation after natural completion', () => {
+ const body = createScrollableBody()
+ const scrollAnimationRef = { current: null }
+
+ animate(body, scrollAnimationRef, 400)
+
+ now = 1000
+ animationFrames[0]()
+
+ expect(body.scrollTop).toBe(400)
+ expect(scrollAnimationRef.current).toBeNull()
+ })
+
+ it('ignores stale frames after a newer scroll animation starts', () => {
+ const body = createScrollableBody()
+ const scrollAnimationRef = { current: null }
+
+ animate(body, scrollAnimationRef, 400)
+ const firstAnimation = scrollAnimationRef.current
+
+ animate(body, scrollAnimationRef, 500)
+ const secondAnimation = scrollAnimationRef.current
+
+ expect(secondAnimation).not.toBe(firstAnimation)
+
+ now = KARAOKE_SCROLL_ANIMATION_MS / 2
+ animationFrames[0]()
+
+ expect(body.scrollTop).toBe(0)
+ expect(scrollAnimationRef.current).toBe(secondAnimation)
+ expect(animationFrames).toHaveLength(2)
+
+ animationFrames[1]()
+
+ expect(body.scrollTop).toBe(250)
+ expect(scrollAnimationRef.current).toBe(secondAnimation)
+ expect(animationFrames).toHaveLength(3)
+
+ now = KARAOKE_SCROLL_ANIMATION_MS
+ animationFrames[2]()
+
+ expect(body.scrollTop).toBe(500)
+ expect(scrollAnimationRef.current).toBeNull()
+ })
+})
diff --git a/ui/src/audioplayer/lyricsSegments.js b/ui/src/audioplayer/lyricsSegments.js
new file mode 100644
index 000000000..81b6eb5c7
--- /dev/null
+++ b/ui/src/audioplayer/lyricsSegments.js
@@ -0,0 +1,146 @@
+import { utf8ByteRangeToCodeUnitRange } from './lyrics'
+
+export const buildSegmentsFromLine = (line) => {
+ if (!line || !Array.isArray(line.tokens) || line.tokens.length === 0) {
+ return [{ text: line?.value || '', token: null, tokenIndex: -1 }]
+ }
+
+ const text = line.value || ''
+ const exactSegments = (() => {
+ if (!text) return null
+
+ const rangedTokens = line.tokens
+ .map((token, tokenIndex) => ({
+ token,
+ tokenIndex,
+ range: utf8ByteRangeToCodeUnitRange(
+ text,
+ token?.byteStart,
+ token?.byteEnd,
+ ),
+ }))
+ .filter((entry) => entry.range != null)
+
+ if (
+ rangedTokens.length !== line.tokens.length ||
+ rangedTokens.length === 0
+ ) {
+ return null
+ }
+
+ rangedTokens.sort(
+ (a, b) =>
+ a.range.start - b.range.start ||
+ a.range.end - b.range.end ||
+ a.tokenIndex - b.tokenIndex,
+ )
+
+ const segments = []
+ let cursor = 0
+ for (const entry of rangedTokens) {
+ if (entry.range.start < cursor) return null
+ if (entry.range.start > cursor) {
+ segments.push({
+ text: text.slice(cursor, entry.range.start),
+ token: null,
+ tokenIndex: -1,
+ })
+ }
+ segments.push({
+ text: entry.range.text,
+ token: entry.token,
+ tokenIndex: entry.tokenIndex,
+ })
+ cursor = entry.range.end
+ }
+
+ if (cursor < text.length) {
+ segments.push({ text: text.slice(cursor), token: null, tokenIndex: -1 })
+ }
+
+ return segments
+ })()
+
+ if (exactSegments) return exactSegments
+
+ const matchedSegments = []
+ const fallbackSegments = []
+ let cursor = 0
+ let allMatched = text.length > 0
+ let anyMatched = false
+
+ const pushFallbackSeparatorIfNeeded = (nextTokenText) => {
+ if (fallbackSegments.length === 0) return
+ const prevText = fallbackSegments[fallbackSegments.length - 1].text || ''
+ if (!prevText || !nextTokenText) return
+ if (/\s$/.test(prevText) || /^\s/.test(nextTokenText)) return
+ if (/[A-Za-z0-9]$/.test(prevText) && /^[A-Za-z0-9]/.test(nextTokenText)) {
+ fallbackSegments.push({ text: ' ', token: null, tokenIndex: -1 })
+ }
+ }
+
+ for (let tokenIndex = 0; tokenIndex < line.tokens.length; tokenIndex += 1) {
+ const token = line.tokens[tokenIndex]
+ const tokenText = token.value || ''
+ if (!tokenText) continue
+
+ pushFallbackSeparatorIfNeeded(tokenText)
+ fallbackSegments.push({ text: tokenText, token, tokenIndex })
+
+ if (!text) {
+ allMatched = false
+ continue
+ }
+
+ const foundAt = text.indexOf(tokenText, cursor)
+ const lowerText = foundAt < 0 ? text.toLowerCase() : text
+ const lowerTokenText = foundAt < 0 ? tokenText.toLowerCase() : tokenText
+ const canReuseLowercaseOffsets =
+ lowerText.length === text.length &&
+ lowerTokenText.length === tokenText.length
+ const normalizedFoundAt =
+ foundAt >= 0
+ ? foundAt
+ : canReuseLowercaseOffsets
+ ? lowerText.indexOf(lowerTokenText, cursor)
+ : -1
+
+ if (normalizedFoundAt >= 0) {
+ anyMatched = true
+ if (normalizedFoundAt > cursor) {
+ matchedSegments.push({
+ text: text.slice(cursor, normalizedFoundAt),
+ token: null,
+ tokenIndex: -1,
+ })
+ }
+ const matchedTokenText = text.slice(
+ normalizedFoundAt,
+ normalizedFoundAt + tokenText.length,
+ )
+ matchedSegments.push({
+ text: matchedTokenText || tokenText,
+ token,
+ tokenIndex,
+ })
+ cursor = normalizedFoundAt + tokenText.length
+ } else {
+ allMatched = false
+ }
+ }
+
+ if (allMatched && anyMatched) {
+ if (cursor < text.length) {
+ matchedSegments.push({
+ text: text.slice(cursor),
+ token: null,
+ tokenIndex: -1,
+ })
+ }
+ return matchedSegments
+ }
+
+ if (fallbackSegments.length > 0) return fallbackSegments
+
+ return [{ text, token: null, tokenIndex: -1 }]
+}
diff --git a/ui/src/audioplayer/lyricsSegments.test.js b/ui/src/audioplayer/lyricsSegments.test.js
new file mode 100644
index 000000000..209b42e5c
--- /dev/null
+++ b/ui/src/audioplayer/lyricsSegments.test.js
@@ -0,0 +1,15 @@
+import { describe, expect, it } from 'vitest'
+import { buildSegmentsFromLine } from './lyricsSegments'
+
+describe('buildSegmentsFromLine', () => {
+ it('does not reuse lowercase offsets when Unicode case folding expands text', () => {
+ const segments = buildSegmentsFromLine({
+ value: '\u0130A',
+ tokens: [{ value: 'a' }],
+ })
+
+ expect(segments).toEqual([
+ { text: 'a', token: { value: 'a' }, tokenIndex: 0 },
+ ])
+ })
+})
diff --git a/ui/src/audioplayer/lyricsSidebarState.js b/ui/src/audioplayer/lyricsSidebarState.js
new file mode 100644
index 000000000..15322a366
--- /dev/null
+++ b/ui/src/audioplayer/lyricsSidebarState.js
@@ -0,0 +1,24 @@
+export const resolveLyricsSidebarState = ({
+ lyricsVisiblePreference,
+ translationPreference,
+ pronunciationPreference,
+ hasTranslationLyric,
+ hasPronunciationLyric,
+}) => ({
+ lyricsVisible: Boolean(lyricsVisiblePreference),
+ showTranslation: Boolean(
+ (translationPreference ?? true) && hasTranslationLyric,
+ ),
+ showPronunciation: Boolean(
+ (pronunciationPreference ?? true) && hasPronunciationLyric,
+ ),
+})
+
+export const toggleLayerPreference = (
+ previousPreference,
+ hasLayer,
+ defaultEnabled = true,
+) => {
+ if (!hasLayer) return false
+ return !(previousPreference ?? defaultEnabled)
+}
diff --git a/ui/src/audioplayer/lyricsSidebarState.test.js b/ui/src/audioplayer/lyricsSidebarState.test.js
new file mode 100644
index 000000000..db2f02360
--- /dev/null
+++ b/ui/src/audioplayer/lyricsSidebarState.test.js
@@ -0,0 +1,41 @@
+import { describe, expect, it } from 'vitest'
+import {
+ resolveLyricsSidebarState,
+ toggleLayerPreference,
+} from './lyricsSidebarState'
+
+describe('lyricsSidebarState', () => {
+ it('defaults available layers on and keeps preferences local', () => {
+ const resolve = (availability, preferences = {}) =>
+ resolveLyricsSidebarState({
+ lyricsVisiblePreference: true,
+ translationPreference: null,
+ pronunciationPreference: null,
+ ...availability,
+ ...preferences,
+ })
+
+ expect(
+ resolve({ hasTranslationLyric: true, hasPronunciationLyric: true }),
+ ).toEqual({
+ lyricsVisible: true,
+ showTranslation: true,
+ showPronunciation: true,
+ })
+ expect(
+ resolve(
+ { hasTranslationLyric: true, hasPronunciationLyric: false },
+ { translationPreference: false, pronunciationPreference: true },
+ ),
+ ).toEqual({
+ lyricsVisible: true,
+ showTranslation: false,
+ showPronunciation: false,
+ })
+
+ expect(toggleLayerPreference(null, true)).toBe(false)
+ expect(toggleLayerPreference(null, true, false)).toBe(true)
+ expect(toggleLayerPreference(false, true)).toBe(true)
+ expect(toggleLayerPreference(true, false)).toBe(false)
+ })
+})
diff --git a/ui/src/audioplayer/lyricsSidebarWidth.js b/ui/src/audioplayer/lyricsSidebarWidth.js
new file mode 100644
index 000000000..2d0152d69
--- /dev/null
+++ b/ui/src/audioplayer/lyricsSidebarWidth.js
@@ -0,0 +1,73 @@
+export const LYRICS_SIDEBAR_STORAGE_KEY = 'nd.lyricsSidebar.width.v1'
+export const LYRICS_SIDEBAR_DEFAULT_WIDTH = 360
+export const LYRICS_SIDEBAR_MIN_WIDTH = 300
+export const LYRICS_SIDEBAR_MAX_WIDTH = 520
+export const LYRICS_ROUTE_MIN_WIDTH = 320
+export const LYRICS_SIDEBAR_WIDTH_STEP = 20
+export const LYRICS_SIDEBAR_TRANSITION_MS = 260
+
+export const clampSidebarMaxWidth = (value) => {
+ const numeric = Number(value)
+ if (!Number.isFinite(numeric)) return LYRICS_SIDEBAR_MAX_WIDTH
+ return Math.round(
+ Math.min(
+ LYRICS_SIDEBAR_MAX_WIDTH,
+ Math.max(LYRICS_SIDEBAR_MIN_WIDTH, numeric),
+ ),
+ )
+}
+
+export const clampSidebarWidth = (
+ value,
+ maxWidth = LYRICS_SIDEBAR_MAX_WIDTH,
+) => {
+ const resolvedMaxWidth = clampSidebarMaxWidth(maxWidth)
+ const numeric = Number(value)
+ if (!Number.isFinite(numeric)) {
+ return Math.min(LYRICS_SIDEBAR_DEFAULT_WIDTH, resolvedMaxWidth)
+ }
+ return Math.round(
+ Math.min(resolvedMaxWidth, Math.max(LYRICS_SIDEBAR_MIN_WIDTH, numeric)),
+ )
+}
+
+export const getSidebarMaxWidth = (contentWidth) => {
+ const numeric = Number(contentWidth)
+ if (!Number.isFinite(numeric) || numeric <= 0) {
+ return LYRICS_SIDEBAR_MAX_WIDTH
+ }
+ return clampSidebarMaxWidth(numeric - LYRICS_ROUTE_MIN_WIDTH)
+}
+
+const hasLocalStorage = () => {
+ try {
+ return typeof window !== 'undefined' && Boolean(window.localStorage)
+ } catch {
+ return false
+ }
+}
+
+export const loadSidebarWidth = () => {
+ if (!hasLocalStorage()) return LYRICS_SIDEBAR_DEFAULT_WIDTH
+ try {
+ const stored = window.localStorage.getItem(LYRICS_SIDEBAR_STORAGE_KEY)
+ return stored == null
+ ? LYRICS_SIDEBAR_DEFAULT_WIDTH
+ : clampSidebarWidth(stored)
+ } catch {
+ return LYRICS_SIDEBAR_DEFAULT_WIDTH
+ }
+}
+
+export const saveSidebarWidth = (width) => {
+ const resolvedWidth = clampSidebarWidth(width)
+ if (!hasLocalStorage()) return
+ try {
+ window.localStorage.setItem(
+ LYRICS_SIDEBAR_STORAGE_KEY,
+ String(resolvedWidth),
+ )
+ } catch {
+ // Ignore storage failures.
+ }
+}
diff --git a/ui/src/audioplayer/lyricsTimeline.js b/ui/src/audioplayer/lyricsTimeline.js
new file mode 100644
index 000000000..325d6d494
--- /dev/null
+++ b/ui/src/audioplayer/lyricsTimeline.js
@@ -0,0 +1,312 @@
+import { KARAOKE_SCROLL_PRE_ROLL_MS } from './lyricsKaraokeConstants'
+
+export const finiteTime = (value) => {
+ if (value == null || value === '') return null
+ const number = Number(value)
+ return Number.isFinite(number) ? number : null
+}
+
+const upperBound = (items, time, getTime) => {
+ let low = 0
+ let high = items.length
+ while (low < high) {
+ const middle = (low + high) >>> 1
+ if (getTime(items[middle]) <= time) low = middle + 1
+ else high = middle
+ }
+ return low
+}
+
+export const sameIndexes = (left, right) =>
+ left.length === right.length &&
+ left.every((value, index) => value === right[index])
+
+const getPrimaryActiveIndex = (timeline, indexes) =>
+ indexes.reduce((primaryIndex, lineIndex) => {
+ if (primaryIndex < 0) return lineIndex
+ const primary = timeline?.windows?.[primaryIndex]
+ const candidate = timeline?.windows?.[lineIndex]
+ if ((candidate?.start ?? -Infinity) !== (primary?.start ?? -Infinity)) {
+ return (candidate?.start ?? -Infinity) > (primary?.start ?? -Infinity)
+ ? lineIndex
+ : primaryIndex
+ }
+ return lineIndex > primaryIndex ? lineIndex : primaryIndex
+ }, -1)
+
+const earliestTokenStart = (line) => {
+ const tokens = Array.isArray(line?.tokens) ? line.tokens : []
+ let earliest = null
+ for (const token of tokens) {
+ const start = finiteTime(token?.start)
+ if (start != null && (earliest == null || start < earliest))
+ earliest = start
+ }
+ return earliest
+}
+
+const latestExplicitTokenEnd = (line) => {
+ const tokens = Array.isArray(line?.tokens) ? line.tokens : []
+ let latest = null
+ for (const token of tokens) {
+ const end = finiteTime(token?.end)
+ if (end != null && (latest == null || end > latest)) latest = end
+ }
+ return latest
+}
+
+export const buildLyricsTimeline = (
+ lines,
+ { durationMs = null, fallbackLineDurationMs = 8000 } = {},
+) => {
+ const sourceLines = Array.isArray(lines) ? lines : []
+ const starts = sourceLines.map(
+ (line) => finiteTime(line?.start) ?? earliestTokenStart(line),
+ )
+ const nextTimedStarts = new Array(sourceLines.length).fill(null)
+ const timedEntries = starts
+ .map((start, lineIndex) => ({ start, lineIndex }))
+ .filter((entry) => entry.start != null)
+ .sort(
+ (left, right) =>
+ left.start - right.start || left.lineIndex - right.lineIndex,
+ )
+ let nextDistinctStart = null
+ for (let index = timedEntries.length - 1; index >= 0; ) {
+ const groupStart = timedEntries[index].start
+ let groupIndex = index
+ while (groupIndex >= 0 && timedEntries[groupIndex].start === groupStart) {
+ nextTimedStarts[timedEntries[groupIndex].lineIndex] = nextDistinctStart
+ groupIndex -= 1
+ }
+ nextDistinctStart = groupStart
+ index = groupIndex
+ }
+
+ const trackEnd = finiteTime(durationMs)
+ const windows = sourceLines.map((line, lineIndex) => {
+ const start = starts[lineIndex]
+ let end = finiteTime(line?.end) ?? latestExplicitTokenEnd(line)
+ if (end == null) end = nextTimedStarts[lineIndex]
+ if (end == null && start != null) {
+ end = start + fallbackLineDurationMs
+ if (trackEnd != null && trackEnd > start) {
+ end = Math.min(end, trackEnd)
+ }
+ }
+ const renderable = line?.renderable !== false
+ const intervalValid = start != null && end != null && end > start
+ const valid = renderable && intervalValid
+ return {
+ lineIndex,
+ start,
+ end,
+ renderable,
+ intervalValid,
+ valid,
+ preRollStart:
+ start == null ? null : Math.max(0, start - KARAOKE_SCROLL_PRE_ROLL_MS),
+ nextTimedStart: nextTimedStarts[lineIndex],
+ }
+ })
+
+ const events = windows
+ .filter((window) => window.valid)
+ .flatMap((window) => [
+ { time: window.start, type: 'start', lineIndex: window.lineIndex },
+ { time: window.end, type: 'end', lineIndex: window.lineIndex },
+ ])
+ .sort((left, right) => {
+ if (left.time !== right.time) return left.time - right.time
+ if (left.type !== right.type) return left.type === 'end' ? -1 : 1
+ return left.lineIndex - right.lineIndex
+ })
+
+ const checkpoints = []
+ const active = new Set()
+ events.forEach((event, index) => {
+ if (event.type === 'start') active.add(event.lineIndex)
+ else active.delete(event.lineIndex)
+ if ((index + 1) % 64 === 0) {
+ checkpoints.push({
+ eventIndex: index + 1,
+ time: event.time,
+ active: Array.from(active),
+ })
+ }
+ })
+
+ const scrollOrder = windows
+ .filter((window) => window.valid && window.preRollStart != null)
+ .sort(
+ (left, right) =>
+ left.preRollStart - right.preRollStart ||
+ left.start - right.start ||
+ left.lineIndex - right.lineIndex,
+ )
+
+ return { windows, events, checkpoints, scrollOrder }
+}
+
+export const getTimelineScrollTarget = (timeline, time) => {
+ const current = finiteTime(time) ?? 0
+ const order = timeline?.scrollOrder || []
+ const index = upperBound(order, current, (item) => item.preRollStart) - 1
+ return index >= 0 ? order[index].lineIndex : -1
+}
+
+export class LyricTimelineCursor {
+ constructor(timeline) {
+ this.timeline = timeline
+ this.eventIndex = 0
+ this.active = new Set()
+ this.lastTime = -Infinity
+ this.lastIndexes = []
+ this.result = {
+ indexes: this.lastIndexes,
+ changed: false,
+ primaryIndex: -1,
+ }
+ }
+
+ reset(timeline = this.timeline) {
+ this.timeline = timeline
+ this.eventIndex = 0
+ this.active.clear()
+ this.lastTime = -Infinity
+ this.lastIndexes = []
+ this.result.indexes = this.lastIndexes
+ this.result.changed = false
+ this.result.primaryIndex = -1
+ }
+
+ applyEvent(event) {
+ if (event.type === 'start') this.active.add(event.lineIndex)
+ else this.active.delete(event.lineIndex)
+ }
+
+ seek(time) {
+ const events = this.timeline?.events || []
+ const checkpoints = this.timeline?.checkpoints || []
+ const checkpointIndex =
+ upperBound(checkpoints, time, (item) => item.time) - 1
+ const checkpoint =
+ checkpointIndex >= 0 ? checkpoints[checkpointIndex] : null
+ this.active = new Set(checkpoint?.active || [])
+ this.eventIndex = checkpoint?.eventIndex || 0
+ while (
+ this.eventIndex < events.length &&
+ events[this.eventIndex].time <= time
+ ) {
+ this.applyEvent(events[this.eventIndex])
+ this.eventIndex += 1
+ }
+ }
+
+ update(time, forceSeek = false) {
+ const current = finiteTime(time) ?? 0
+ const events = this.timeline?.events || []
+ const previousEventIndex = this.eventIndex
+ if (forceSeek || current < this.lastTime) {
+ this.seek(current)
+ } else {
+ while (
+ this.eventIndex < events.length &&
+ events[this.eventIndex].time <= current
+ ) {
+ this.applyEvent(events[this.eventIndex])
+ this.eventIndex += 1
+ }
+ }
+ this.lastTime = current
+ if (!forceSeek && previousEventIndex === this.eventIndex) {
+ this.result.changed = false
+ return this.result
+ }
+ const indexes = Array.from(this.active).sort((a, b) => a - b)
+ const changed = !sameIndexes(indexes, this.lastIndexes)
+ this.lastIndexes = indexes
+ this.result.indexes = indexes
+ this.result.changed = changed
+ this.result.primaryIndex = getPrimaryActiveIndex(this.timeline, indexes)
+ return this.result
+ }
+}
+
+export const resolveKaraokeTokenWindows = (line, lineEndFallback = null) => {
+ const tokens = Array.isArray(line?.tokens) ? line.tokens : []
+ if (tokens.length === 0) return []
+
+ const lineStart = finiteTime(line?.start)
+ const lineEnd = finiteTime(line?.end) ?? finiteTime(lineEndFallback)
+ const hasLineWindow =
+ lineStart != null && lineEnd != null && lineEnd > lineStart
+ const tokenCount = tokens.length
+ const windows = new Array(tokenCount)
+
+ for (let tokenIndex = 0; tokenIndex < tokenCount; tokenIndex += 1) {
+ const token = tokens[tokenIndex]
+ const previousToken = tokenIndex > 0 ? tokens[tokenIndex - 1] : null
+ const nextToken =
+ tokenIndex + 1 < tokenCount ? tokens[tokenIndex + 1] : null
+ const estimatedStart = hasLineWindow
+ ? lineStart + ((lineEnd - lineStart) * tokenIndex) / tokenCount
+ : null
+ const estimatedEnd = hasLineWindow
+ ? lineStart + ((lineEnd - lineStart) * (tokenIndex + 1)) / tokenCount
+ : null
+ const previousEnd =
+ finiteTime(previousToken?.end) ?? finiteTime(previousToken?.start)
+
+ let start = finiteTime(token?.start)
+ if (start == null) start = previousEnd ?? estimatedStart ?? lineStart
+
+ let end = finiteTime(token?.end)
+ if (end == null) {
+ const nextStart = finiteTime(nextToken?.start)
+ const nextEstimatedStart =
+ hasLineWindow && tokenIndex + 1 < tokenCount
+ ? lineStart + ((lineEnd - lineStart) * (tokenIndex + 1)) / tokenCount
+ : null
+ end = nextStart ?? nextEstimatedStart ?? estimatedEnd ?? lineEnd
+ }
+
+ if (
+ tokenCount === 1 &&
+ hasLineWindow &&
+ (start == null || end == null || end <= start + 1)
+ ) {
+ start = lineStart
+ end = lineEnd
+ }
+ if (start != null && end != null && end < start) end = start
+
+ const previousWindow = tokenIndex > 0 ? windows[tokenIndex - 1] : null
+ const visualStart =
+ start != null && previousWindow?.end != null
+ ? Math.max(start, previousWindow.end)
+ : start
+ const visualEnd =
+ end != null && visualStart != null && end <= visualStart
+ ? visualStart + 1
+ : end
+ windows[tokenIndex] = {
+ start: visualStart,
+ end: visualEnd,
+ sourceStart: start,
+ sourceEnd: end,
+ }
+ }
+
+ return windows
+}
+
+export const tokenProgressAt = (window, time) => {
+ const current = finiteTime(time) ?? 0
+ if (!window || window.start == null || window.end == null) return 0
+ if (window.end <= window.start) return current >= window.start ? 1 : 0
+ return Math.max(
+ 0,
+ Math.min(1, (current - window.start) / (window.end - window.start)),
+ )
+}
diff --git a/ui/src/audioplayer/lyricsTimeline.test.js b/ui/src/audioplayer/lyricsTimeline.test.js
new file mode 100644
index 000000000..f357d60eb
--- /dev/null
+++ b/ui/src/audioplayer/lyricsTimeline.test.js
@@ -0,0 +1,196 @@
+import { describe, expect, it } from 'vitest'
+import {
+ buildLyricsTimeline,
+ getTimelineScrollTarget,
+ LyricTimelineCursor,
+ resolveKaraokeTokenWindows,
+ tokenProgressAt,
+} from './lyricsTimeline'
+import { KARAOKE_SCROLL_PRE_ROLL_MS } from './lyricsKaraokeConstants'
+
+describe('lyricsTimeline', () => {
+ it('supports overlapping active lines and backward seeks', () => {
+ const timeline = buildLyricsTimeline([
+ { start: 1000, end: 3000, tokens: [] },
+ { start: 2000, end: 4000, tokens: [] },
+ ])
+ const cursor = new LyricTimelineCursor(timeline)
+
+ expect(cursor.update(1500, true).indexes).toEqual([0])
+ expect(cursor.update(2500).indexes).toEqual([0, 1])
+ expect(cursor.update(3500).indexes).toEqual([1])
+ expect(cursor.update(1500).indexes).toEqual([0])
+ })
+
+ it('excludes zero and negative duration intervals', () => {
+ const timeline = buildLyricsTimeline([
+ { start: 1000, end: 1000, tokens: [] },
+ { start: 2000, end: 1500, tokens: [] },
+ { start: 3000, end: 4000, tokens: [] },
+ ])
+
+ expect(timeline.events).toEqual([
+ { time: 3000, type: 'start', lineIndex: 2 },
+ { time: 4000, type: 'end', lineIndex: 2 },
+ ])
+ })
+
+ it('uses the next timed line across untimed display rows', () => {
+ const timeline = buildLyricsTimeline([
+ { start: 1000, tokens: [] },
+ { value: 'untimed annotation', tokens: [] },
+ { start: 5000, end: 6000, tokens: [] },
+ ])
+
+ expect(timeline.windows[0].end).toBe(5000)
+ expect(timeline.windows[0].nextTimedStart).toBe(5000)
+ expect(timeline.windows[1].valid).toBe(false)
+ })
+
+ it('uses chronological timing independently from display order', () => {
+ const timeline = buildLyricsTimeline([
+ { start: 3000, end: 5000, value: 'Displayed first', tokens: [] },
+ { start: 1000, end: 4000, value: 'Displayed second', tokens: [] },
+ { start: 2000, value: 'Displayed third', tokens: [] },
+ ])
+ const cursor = new LyricTimelineCursor(timeline)
+
+ expect(timeline.windows[2].end).toBe(3000)
+ expect(cursor.update(2500, true)).toMatchObject({
+ indexes: [1, 2],
+ primaryIndex: 2,
+ })
+ expect(cursor.update(3500)).toMatchObject({
+ indexes: [0, 1],
+ primaryIndex: 0,
+ })
+ })
+
+ it('uses timed blank markers as boundaries without activating or scrolling to them', () => {
+ const timeline = buildLyricsTimeline([
+ {
+ start: 1000,
+ value: 'Before pause',
+ tokens: [],
+ renderable: true,
+ },
+ { start: 2000, value: '', tokens: [], renderable: false },
+ {
+ start: 4000,
+ end: 5000,
+ value: 'After pause',
+ tokens: [],
+ renderable: true,
+ },
+ ])
+ const cursor = new LyricTimelineCursor(timeline)
+
+ expect(timeline.windows[0].end).toBe(2000)
+ expect(timeline.windows[1]).toMatchObject({
+ renderable: false,
+ intervalValid: true,
+ valid: false,
+ })
+ expect(cursor.update(2500, true).indexes).toEqual([])
+ expect(timeline.scrollOrder.map((window) => window.lineIndex)).toEqual([
+ 0, 2,
+ ])
+ })
+
+ it('bounds a final open line when media duration is unavailable', () => {
+ const timeline = buildLyricsTimeline([{ start: 7000, tokens: [] }], {
+ fallbackLineDurationMs: 8000,
+ })
+
+ expect(timeline.windows[0]).toMatchObject({
+ start: 7000,
+ end: 15000,
+ valid: true,
+ })
+ })
+
+ it('keeps a start-only line active until the next timestamp', () => {
+ const timeline = buildLyricsTimeline([
+ { start: 1000, value: 'A lyric line', tokens: [] },
+ { start: 5000, end: 6000, tokens: [] },
+ ])
+ const cursor = new LyricTimelineCursor(timeline)
+
+ expect(timeline.windows[0].end).toBe(5000)
+ expect(cursor.update(4999, true).indexes).toEqual([0])
+ expect(cursor.update(5000).indexes).toEqual([1])
+ })
+
+ it('caps a final open line with track duration', () => {
+ const timeline = buildLyricsTimeline([{ start: 7000, tokens: [] }], {
+ durationMs: 10000,
+ })
+
+ expect(timeline.windows[0]).toMatchObject({
+ start: 7000,
+ end: 10000,
+ valid: true,
+ })
+ })
+
+ it('ignores a stale track duration that precedes the final line', () => {
+ const timeline = buildLyricsTimeline([{ start: 7000, tokens: [] }], {
+ durationMs: 5000,
+ fallbackLineDurationMs: 8000,
+ })
+
+ expect(timeline.windows[0]).toMatchObject({
+ start: 7000,
+ end: 15000,
+ valid: true,
+ })
+ })
+
+ it('seeks through checkpoints in timelines longer than 32 lines', () => {
+ const timeline = buildLyricsTimeline(
+ Array.from({ length: 40 }, (_, index) => ({
+ start: index * 1000,
+ end: (index + 1) * 1000,
+ tokens: [],
+ })),
+ )
+ const cursor = new LyricTimelineCursor(timeline)
+
+ expect(timeline.checkpoints.length).toBeGreaterThan(0)
+ expect(cursor.update(35500, true)).toMatchObject({
+ indexes: [35],
+ primaryIndex: 35,
+ })
+ })
+
+ it('moves the scroll target at pre-roll without changing active state', () => {
+ const timeline = buildLyricsTimeline([
+ { start: 1000, end: 1500, tokens: [] },
+ { start: 3000, end: 3500, tokens: [] },
+ ])
+
+ expect(getTimelineScrollTarget(timeline, 0)).toBe(-1)
+ expect(
+ getTimelineScrollTarget(timeline, 3000 - KARAOKE_SCROLL_PRE_ROLL_MS),
+ ).toBe(1)
+ })
+
+ it('precomputes token windows once and keeps progress deterministic', () => {
+ const line = {
+ start: 1000,
+ end: 3000,
+ tokens: [
+ { start: 1000, value: 'one' },
+ { start: 2000, end: 3000, value: 'two' },
+ ],
+ }
+ const windows = resolveKaraokeTokenWindows(line)
+
+ expect(windows).toEqual([
+ { start: 1000, end: 2000, sourceStart: 1000, sourceEnd: 2000 },
+ { start: 2000, end: 3000, sourceStart: 2000, sourceEnd: 3000 },
+ ])
+ expect(tokenProgressAt(windows[0], 1500)).toBe(0.5)
+ expect(tokenProgressAt(windows[0], 2500)).toBe(1)
+ })
+})
diff --git a/ui/src/audioplayer/playerBreakpoints.js b/ui/src/audioplayer/playerBreakpoints.js
new file mode 100644
index 000000000..a646b76fa
--- /dev/null
+++ b/ui/src/audioplayer/playerBreakpoints.js
@@ -0,0 +1,8 @@
+export const PLAYER_DESKTOP_MIN_WIDTH = 810
+export const PLAYER_MEDIA_QUERY_STEP = 0.05
+export const PLAYER_MOBILE_MAX_WIDTH =
+ PLAYER_DESKTOP_MIN_WIDTH - PLAYER_MEDIA_QUERY_STEP
+
+export const PLAYER_DESKTOP_MEDIA_QUERY = `(min-width:${PLAYER_DESKTOP_MIN_WIDTH}px)`
+export const PLAYER_MOBILE_MATCH_MEDIA_QUERY = `(max-width:${PLAYER_MOBILE_MAX_WIDTH}px)`
+export const PLAYER_MOBILE_MEDIA_QUERY = `@media screen and ${PLAYER_MOBILE_MATCH_MEDIA_QUERY}`
diff --git a/ui/src/audioplayer/playerBreakpoints.test.js b/ui/src/audioplayer/playerBreakpoints.test.js
new file mode 100644
index 000000000..4d40b56c8
--- /dev/null
+++ b/ui/src/audioplayer/playerBreakpoints.test.js
@@ -0,0 +1,28 @@
+import { describe, expect, it } from 'vitest'
+import {
+ PLAYER_DESKTOP_MEDIA_QUERY,
+ PLAYER_DESKTOP_MIN_WIDTH,
+ PLAYER_MOBILE_MATCH_MEDIA_QUERY,
+ PLAYER_MOBILE_MAX_WIDTH,
+ PLAYER_MOBILE_MEDIA_QUERY,
+ PLAYER_MEDIA_QUERY_STEP,
+} from './playerBreakpoints'
+
+describe('player breakpoints', () => {
+ it('keeps compact and desktop player ranges from overlapping', () => {
+ expect(PLAYER_DESKTOP_MIN_WIDTH).toBe(810)
+ expect(PLAYER_MOBILE_MAX_WIDTH).toBe(809.95)
+ expect(PLAYER_DESKTOP_MIN_WIDTH - PLAYER_MOBILE_MAX_WIDTH).toBeCloseTo(
+ PLAYER_MEDIA_QUERY_STEP,
+ )
+ expect(PLAYER_DESKTOP_MEDIA_QUERY).toBe(
+ `(min-width:${PLAYER_DESKTOP_MIN_WIDTH}px)`,
+ )
+ expect(PLAYER_MOBILE_MEDIA_QUERY).toBe(
+ `@media screen and (max-width:${PLAYER_MOBILE_MAX_WIDTH}px)`,
+ )
+ expect(PLAYER_MOBILE_MATCH_MEDIA_QUERY).toBe(
+ `(max-width:${PLAYER_MOBILE_MAX_WIDTH}px)`,
+ )
+ })
+})
diff --git a/ui/src/audioplayer/styles.js b/ui/src/audioplayer/styles.js
index 30a14d4db..dae045807 100644
--- a/ui/src/audioplayer/styles.js
+++ b/ui/src/audioplayer/styles.js
@@ -1,4 +1,8 @@
import { makeStyles } from '@material-ui/core/styles'
+import { MOBILE_KARAOKE_LYRICS_TRANSITION_MS } from './lyricsKaraokeConstants'
+import { PLAYER_MOBILE_MEDIA_QUERY } from './playerBreakpoints'
+
+const MOBILE_LYRICS_TRANSITION = `opacity ${MOBILE_KARAOKE_LYRICS_TRANSITION_MS}ms cubic-bezier(0.22, 1, 0.36, 1), transform ${MOBILE_KARAOKE_LYRICS_TRANSITION_MS}ms cubic-bezier(0.22, 1, 0.36, 1)`
const useStyle = makeStyles(
(theme) => ({
@@ -27,12 +31,12 @@ const useStyle = makeStyles(
},
player: {
display: (props) => (props.visible ? 'block' : 'none'),
- '@media screen and (max-width:810px)': {
+ [PLAYER_MOBILE_MEDIA_QUERY]: {
'& .sound-operation': {
display: 'none',
},
},
- '@media (prefers-reduced-motion)': {
+ '@media (prefers-reduced-motion: reduce)': {
'& .music-player-panel .panel-content div.img-rotate': {
animation: 'none',
},
@@ -42,7 +46,10 @@ const useStyle = makeStyles(
flexDirection: 'column',
},
'& .play-mode-title': {
- 'pointer-events': 'none',
+ pointerEvents: 'none',
+ },
+ '& .audio-lists-panel': {
+ zIndex: theme.zIndex.appBar,
},
'& .music-player-panel .panel-content div.img-rotate': {
// Customize desktop player when cover animation is disabled
@@ -62,11 +69,56 @@ const useStyle = makeStyles(
// Fix cover display when image is not square
aspectRatio: '1/1',
display: 'flex',
+ position: 'relative',
+ },
+ '& .react-jinke-music-player-mobile .react-jinke-music-player-mobile-cover.nd-mobile-lyrics-active':
+ {
+ width: '100%',
+ maxWidth: '100%',
+ height: '100%',
+ aspectRatio: 'auto',
+ borderRadius: 0,
+ alignItems: 'stretch',
+ justifyContent: 'stretch',
+ },
+ '& .react-jinke-music-player-mobile .react-jinke-music-player-mobile-cover .nd-mobile-lyrics-layer':
+ {
+ position: 'absolute',
+ inset: 0,
+ opacity: 0,
+ transform: 'scale(0.985)',
+ transition: MOBILE_LYRICS_TRANSITION,
+ '@media (prefers-reduced-motion: reduce)': {
+ transition: 'opacity 120ms linear',
+ transform: 'none',
+ },
+ },
+ '& .react-jinke-music-player-mobile .react-jinke-music-player-mobile-cover .nd-mobile-lyrics-layer[data-entered="true"]':
+ {
+ opacity: 1,
+ transform: 'scale(1)',
+ '@media (prefers-reduced-motion: reduce)': {
+ transform: 'none',
+ },
},
'& .react-jinke-music-player-mobile .react-jinke-music-player-mobile-cover img.cover':
{
animationDuration: (props) => !props.enableCoverAnimation && '0s',
objectFit: 'contain', // Fix cover display when image is not square
+ transition: MOBILE_LYRICS_TRANSITION,
+ '@media (prefers-reduced-motion: reduce)': {
+ transition: 'opacity 120ms linear',
+ transform: 'none',
+ },
+ },
+ '& .react-jinke-music-player-mobile .react-jinke-music-player-mobile-cover.nd-mobile-lyrics-active img.cover':
+ {
+ opacity: 0,
+ transform: 'scale(0.96)',
+ pointerEvents: 'none',
+ '@media (prefers-reduced-motion: reduce)': {
+ transform: 'none',
+ },
},
// Hide old singer display
'& .react-jinke-music-player-mobile .react-jinke-music-player-mobile-singer':
diff --git a/ui/src/audioplayer/useEnhancedLyrics.js b/ui/src/audioplayer/useEnhancedLyrics.js
new file mode 100644
index 000000000..8577e9280
--- /dev/null
+++ b/ui/src/audioplayer/useEnhancedLyrics.js
@@ -0,0 +1,266 @@
+import { useCallback, useEffect, useMemo, useState } from 'react'
+import subsonic from '../subsonic'
+import { getPreferredLyricLanguage, selectLyricLayers } from './lyrics'
+
+export const emptyLyricLayers = Object.freeze({
+ main: null,
+ translation: null,
+ pronunciation: null,
+})
+
+const MAX_LYRIC_CACHE_ENTRIES = 75
+const NEGATIVE_CACHE_TTL_MS = 30_000
+const COOLDOWN_PATTERN = /(?:cooldown active for|retry after)\s+(\d+)s/gi
+
+const cache = new Map()
+const inFlight = new Map()
+
+const normalizeLyricLayers = (layers) => ({
+ main: layers?.main || null,
+ translation: layers?.translation || null,
+ pronunciation: layers?.pronunciation || null,
+})
+
+const readStructuredLyrics = (response) =>
+ response?.json?.['subsonic-response']?.lyricsList?.structuredLyrics || []
+
+const retryDelayFromError = (error) => {
+ const serverMessage =
+ error?.body?.['subsonic-response']?.error?.message || error?.message || ''
+ const matches = [...String(serverMessage).matchAll(COOLDOWN_PATTERN)]
+ if (matches.length === 0) return null
+ const seconds = Math.max(...matches.map((match) => Number(match[1])))
+ return Number.isFinite(seconds) && seconds > 0 ? seconds * 1000 : null
+}
+
+const buildCacheKey = ({ trackId, preferredLanguage, updatedAt }) =>
+ [trackId || '', updatedAt || '', preferredLanguage || ''].join('\u0000')
+
+const rememberLyrics = (cacheKey, layers, expiresAt = null) => {
+ cache.delete(cacheKey)
+ cache.set(cacheKey, { layers, expiresAt })
+ while (cache.size > MAX_LYRIC_CACHE_ENTRIES) {
+ const oldestCacheKey = cache.keys().next().value
+ cache.delete(oldestCacheKey)
+ }
+}
+
+const readCachedLyrics = (cacheKey) => {
+ const cached = cache.get(cacheKey)
+ if (!cached) return null
+ if (cached.expiresAt != null && cached.expiresAt <= Date.now()) {
+ cache.delete(cacheKey)
+ return null
+ }
+ cache.delete(cacheKey)
+ cache.set(cacheKey, cached)
+ return cached.layers
+}
+
+const createLyricsRequest = ({ trackId, preferredLanguage, cacheKey }) => {
+ const controller = new AbortController()
+ const entry = {
+ controller,
+ consumers: 0,
+ settled: false,
+ promise: null,
+ }
+ entry.promise = subsonic
+ .getLyricsBySongId(trackId, { signal: controller.signal })
+ .then((response) => {
+ const selected = normalizeLyricLayers(
+ selectLyricLayers(readStructuredLyrics(response), preferredLanguage),
+ )
+ const hasAnyLayer = Boolean(
+ selected.main || selected.translation || selected.pronunciation,
+ )
+ rememberLyrics(
+ cacheKey,
+ selected,
+ hasAnyLayer ? null : Date.now() + NEGATIVE_CACHE_TTL_MS,
+ )
+ return selected
+ })
+ .finally(() => {
+ entry.settled = true
+ if (inFlight.get(cacheKey) === entry) inFlight.delete(cacheKey)
+ })
+ inFlight.set(cacheKey, entry)
+ return entry
+}
+
+const acquireLyricsRequest = ({ trackId, preferredLanguage, cacheKey }) => {
+ const existing = inFlight.get(cacheKey)
+ const entry =
+ existing && !existing.controller.signal.aborted
+ ? existing
+ : createLyricsRequest({ trackId, preferredLanguage, cacheKey })
+ entry.consumers += 1
+ let released = false
+ return {
+ promise: entry.promise,
+ release: () => {
+ if (released) return
+ released = true
+ entry.consumers = Math.max(0, entry.consumers - 1)
+ if (!entry.settled && entry.consumers === 0) {
+ if (inFlight.get(cacheKey) === entry) inFlight.delete(cacheKey)
+ entry.controller.abort()
+ }
+ },
+ }
+}
+
+const useEnhancedLyrics = ({
+ trackId,
+ updatedAt,
+ disabled = false,
+ requested = true,
+}) => {
+ const preferredLanguage = getPreferredLyricLanguage()
+ const cacheKey = useMemo(
+ () => buildCacheKey({ trackId, preferredLanguage, updatedAt }),
+ [preferredLanguage, trackId, updatedAt],
+ )
+ const [state, setState] = useState(() => ({
+ cacheKey,
+ layers: emptyLyricLayers,
+ loading: false,
+ error: null,
+ retryAt: null,
+ }))
+ const [requestGeneration, setRequestGeneration] = useState(0)
+ const [, setCountdownTick] = useState(0)
+
+ const retry = useCallback(() => {
+ cache.delete(cacheKey)
+ setRequestGeneration((current) => current + 1)
+ }, [cacheKey])
+
+ useEffect(() => {
+ if (!trackId || disabled || !requested) {
+ setState({
+ cacheKey,
+ layers: emptyLyricLayers,
+ loading: false,
+ error: null,
+ retryAt: null,
+ })
+ return undefined
+ }
+
+ const cached = readCachedLyrics(cacheKey)
+ if (cached) {
+ setState({
+ cacheKey,
+ layers: cached,
+ loading: false,
+ error: null,
+ retryAt: null,
+ })
+ return undefined
+ }
+
+ let active = true
+ const request = acquireLyricsRequest({
+ trackId,
+ preferredLanguage,
+ cacheKey,
+ })
+ setState({
+ cacheKey,
+ layers: emptyLyricLayers,
+ loading: true,
+ error: null,
+ retryAt: null,
+ })
+
+ request.promise
+ .then((layers) => {
+ if (!active) return
+ setState({
+ cacheKey,
+ layers,
+ loading: false,
+ error: null,
+ retryAt: null,
+ })
+ })
+ .catch((error) => {
+ if (!active || error?.name === 'AbortError') return
+ cache.delete(cacheKey)
+ setState({
+ cacheKey,
+ layers: emptyLyricLayers,
+ loading: false,
+ error,
+ retryAt: (() => {
+ const delay = retryDelayFromError(error)
+ return delay == null ? null : Date.now() + delay
+ })(),
+ })
+ })
+
+ return () => {
+ active = false
+ request.release()
+ }
+ }, [
+ cacheKey,
+ disabled,
+ preferredLanguage,
+ requestGeneration,
+ requested,
+ trackId,
+ ])
+
+ useEffect(() => {
+ if (
+ state.cacheKey !== cacheKey ||
+ state.retryAt == null ||
+ disabled ||
+ !requested
+ ) {
+ return undefined
+ }
+
+ const retryDelay = Math.max(0, state.retryAt - Date.now())
+ const retryTimer = window.setTimeout(retry, retryDelay)
+ const countdownTimer = window.setInterval(
+ () => setCountdownTick((current) => current + 1),
+ 1_000,
+ )
+ return () => {
+ window.clearTimeout(retryTimer)
+ window.clearInterval(countdownTimer)
+ }
+ }, [cacheKey, disabled, requested, retry, state.cacheKey, state.retryAt])
+
+ if (state.cacheKey !== cacheKey) {
+ return {
+ layers: emptyLyricLayers,
+ loading: Boolean(trackId && !disabled && requested),
+ error: null,
+ retryAfterSeconds: null,
+ retry,
+ }
+ }
+ return {
+ layers: state.layers,
+ loading: state.loading,
+ error: state.error,
+ retryAfterSeconds:
+ state.retryAt == null
+ ? null
+ : Math.max(1, Math.ceil((state.retryAt - Date.now()) / 1_000)),
+ retry,
+ }
+}
+
+export const clearEnhancedLyricsCache = () => {
+ cache.clear()
+ inFlight.forEach((entry) => entry.controller.abort())
+ inFlight.clear()
+}
+
+export default useEnhancedLyrics
diff --git a/ui/src/audioplayer/useEnhancedLyrics.test.js b/ui/src/audioplayer/useEnhancedLyrics.test.js
new file mode 100644
index 000000000..9ad835cc2
--- /dev/null
+++ b/ui/src/audioplayer/useEnhancedLyrics.test.js
@@ -0,0 +1,233 @@
+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 useEnhancedLyrics, {
+ clearEnhancedLyricsCache,
+ emptyLyricLayers,
+} from './useEnhancedLyrics'
+
+vi.mock('../subsonic', () => ({
+ default: {
+ getLyricsBySongId: vi.fn(),
+ },
+}))
+
+const responseFor = (value, lang = 'en') => ({
+ json: {
+ 'subsonic-response': {
+ lyricsList: {
+ structuredLyrics: [
+ {
+ kind: 'main',
+ lang,
+ synced: true,
+ line: [{ start: 0, value }],
+ },
+ ],
+ },
+ },
+ },
+})
+
+const createDeferred = () => {
+ let resolve
+ let reject
+ const promise = new Promise((resolvePromise, rejectPromise) => {
+ resolve = resolvePromise
+ reject = rejectPromise
+ })
+ return { promise, resolve, reject }
+}
+
+const renderLyrics = (initialProps) =>
+ renderHook((props) => useEnhancedLyrics(props), { initialProps })
+
+const expectLyric = (result, value) =>
+ waitFor(() => expect(result.current.layers.main?.line[0].value).toBe(value))
+
+describe('useEnhancedLyrics', () => {
+ beforeEach(() => {
+ localStorage.setItem('locale', 'en')
+ clearEnhancedLyricsCache()
+ subsonic.getLyricsBySongId.mockReset()
+ })
+
+ afterEach(() => {
+ vi.useRealTimers()
+ localStorage.clear()
+ clearEnhancedLyricsCache()
+ })
+
+ it('fetches enhanced structured lyrics and caches them by track id', async () => {
+ subsonic.getLyricsBySongId
+ .mockResolvedValueOnce(responseFor('Track one'))
+ .mockResolvedValueOnce(responseFor('Track two'))
+
+ const { result, rerender } = renderLyrics({ trackId: 'song-1' })
+
+ await expectLyric(result, 'Track one')
+ expect(subsonic.getLyricsBySongId).toHaveBeenCalledWith(
+ 'song-1',
+ expect.objectContaining({ signal: expect.any(Object) }),
+ )
+
+ rerender({ trackId: 'song-2' })
+ await expectLyric(result, 'Track two')
+
+ rerender({ trackId: 'song-1' })
+ await expectLyric(result, 'Track one')
+ expect(subsonic.getLyricsBySongId).toHaveBeenCalledTimes(2)
+ })
+
+ it('clears previous lyrics while loading an uncached track', async () => {
+ const nextRequest = createDeferred()
+ subsonic.getLyricsBySongId
+ .mockResolvedValueOnce(responseFor('Track one'))
+ .mockReturnValueOnce(nextRequest.promise)
+
+ const { result, rerender } = renderLyrics({ trackId: 'song-1' })
+
+ await expectLyric(result, 'Track one')
+
+ rerender({ trackId: 'song-2' })
+
+ expect(result.current.loading).toBe(true)
+ expect(result.current.layers).toBe(emptyLyricLayers)
+
+ nextRequest.resolve(responseFor('Track two'))
+ await expectLyric(result, 'Track two')
+ })
+
+ it('stays empty when disabled or not requested', () => {
+ const { result } = renderLyrics({
+ trackId: 'song-1',
+ disabled: true,
+ requested: false,
+ })
+
+ expect(result.current.layers).toBe(emptyLyricLayers)
+ expect(result.current.loading).toBe(false)
+ expect(subsonic.getLyricsBySongId).not.toHaveBeenCalled()
+ })
+
+ it('resets layers and retries the same lyrics identity after an error', async () => {
+ const error = new Error('lyrics failed')
+ subsonic.getLyricsBySongId
+ .mockRejectedValueOnce(error)
+ .mockResolvedValueOnce(responseFor('Recovered lyrics'))
+
+ const { result } = renderLyrics({ trackId: 'song-error' })
+
+ await waitFor(() => expect(result.current.error).toBe(error))
+ expect(result.current.layers).toBe(emptyLyricLayers)
+ expect(result.current.loading).toBe(false)
+
+ act(() => {
+ result.current.retry()
+ })
+
+ await expectLyric(result, 'Recovered lyrics')
+ expect(subsonic.getLyricsBySongId).toHaveBeenCalledTimes(2)
+ })
+
+ it('reports a cooldown countdown and retries the current song when it expires', async () => {
+ vi.useFakeTimers()
+ vi.setSystemTime(new Date('2026-08-23T06:00:00Z'))
+ const cooldownError = new Error('better lyrics API cooldown active for 2s')
+ subsonic.getLyricsBySongId
+ .mockRejectedValueOnce(cooldownError)
+ .mockResolvedValueOnce(responseFor('Recovered after cooldown'))
+
+ const { result } = renderLyrics({ trackId: 'rate-limited-song' })
+ await act(async () => {
+ await Promise.resolve()
+ })
+
+ expect(result.current.error).toBe(cooldownError)
+ expect(result.current.retryAfterSeconds).toBe(2)
+
+ await act(async () => {
+ vi.advanceTimersByTime(2_000)
+ await Promise.resolve()
+ })
+
+ expect(subsonic.getLyricsBySongId).toHaveBeenCalledTimes(2)
+ expect(result.current.layers.main?.line[0].value).toBe(
+ 'Recovered after cooldown',
+ )
+ })
+
+ it('keeps cached lyrics separate by preferred language', async () => {
+ subsonic.getLyricsBySongId
+ .mockResolvedValueOnce(responseFor('English lyrics', 'en'))
+ .mockResolvedValueOnce(responseFor('Japanese lyrics', 'ja'))
+
+ const { result, rerender } = renderLyrics({ trackId: 'song-1' })
+
+ await expectLyric(result, 'English lyrics')
+
+ localStorage.setItem('locale', 'ja')
+ rerender({ trackId: 'song-1' })
+
+ await expectLyric(result, 'Japanese lyrics')
+ expect(subsonic.getLyricsBySongId).toHaveBeenCalledTimes(2)
+ })
+
+ it('invalidates cache entries when the track update identity changes', async () => {
+ subsonic.getLyricsBySongId
+ .mockResolvedValueOnce(responseFor('Old lyrics'))
+ .mockResolvedValueOnce(responseFor('Updated lyrics'))
+
+ const { result, rerender } = renderLyrics({
+ trackId: 'song-1',
+ updatedAt: 'old',
+ })
+
+ await expectLyric(result, 'Old lyrics')
+ rerender({ trackId: 'song-1', updatedAt: 'new' })
+ await expectLyric(result, 'Updated lyrics')
+ expect(subsonic.getLyricsBySongId).toHaveBeenCalledTimes(2)
+ })
+
+ it('deduplicates concurrent consumers and aborts only after the last one leaves', () => {
+ let signal
+ subsonic.getLyricsBySongId.mockImplementation((_trackId, options) => {
+ signal = options.signal
+ return new Promise((_resolve, reject) => {
+ signal.addEventListener('abort', () => {
+ reject(new DOMException('Aborted', 'AbortError'))
+ })
+ })
+ })
+
+ const first = renderLyrics({ trackId: 'shared-song' })
+ const second = renderLyrics({ trackId: 'shared-song' })
+
+ expect(subsonic.getLyricsBySongId).toHaveBeenCalledTimes(1)
+ first.unmount()
+ expect(signal.aborted).toBe(false)
+ second.unmount()
+ expect(signal.aborted).toBe(true)
+ })
+
+ it('starts a fresh request after an abandoned request is aborted', () => {
+ const signals = []
+ subsonic.getLyricsBySongId.mockImplementation((_trackId, options) => {
+ signals.push(options.signal)
+ return new Promise((_resolve, reject) => {
+ options.signal.addEventListener('abort', () => {
+ reject(new DOMException('Aborted', 'AbortError'))
+ })
+ })
+ })
+
+ const first = renderLyrics({ trackId: 'retry-song' })
+ first.unmount()
+ expect(signals[0].aborted).toBe(true)
+
+ const second = renderLyrics({ trackId: 'retry-song' })
+ expect(subsonic.getLyricsBySongId).toHaveBeenCalledTimes(2)
+ second.unmount()
+ })
+})
diff --git a/ui/src/audioplayer/useEnterExitTransition.js b/ui/src/audioplayer/useEnterExitTransition.js
new file mode 100644
index 000000000..cdabf58f8
--- /dev/null
+++ b/ui/src/audioplayer/useEnterExitTransition.js
@@ -0,0 +1,22 @@
+import { useEffect, useState } from 'react'
+
+const useEnterExitTransition = (active, transitionMs) => {
+ const [rendered, setRendered] = useState(active)
+ const [entered, setEntered] = useState(active)
+
+ useEffect(() => {
+ if (active) {
+ setRendered(true)
+ const frameId = window.requestAnimationFrame(() => setEntered(true))
+ return () => window.cancelAnimationFrame(frameId)
+ }
+
+ setEntered(false)
+ const timerId = window.setTimeout(() => setRendered(false), transitionMs)
+ return () => window.clearTimeout(timerId)
+ }, [active, transitionMs])
+
+ return { rendered, entered }
+}
+
+export default useEnterExitTransition
diff --git a/ui/src/audioplayer/useLyricsTimeline.js b/ui/src/audioplayer/useLyricsTimeline.js
new file mode 100644
index 000000000..5316e8703
--- /dev/null
+++ b/ui/src/audioplayer/useLyricsTimeline.js
@@ -0,0 +1,776 @@
+import {
+ useCallback,
+ useEffect,
+ useLayoutEffect,
+ useMemo,
+ useRef,
+ useState,
+} from 'react'
+import {
+ KARAOKE_CHARACTER_LIFT_PX,
+ KARAOKE_CHARACTER_RISE_MS,
+ KARAOKE_CHARACTER_WAVE_SPAN_MAX_MS,
+ KARAOKE_CHARACTER_WAVE_SPAN_RATIO,
+ KARAOKE_CLOCK_DRIFT_RESET_MS,
+ KARAOKE_HIGHLIGHT_LEAD_MS,
+ KARAOKE_LINE_RELEASE_MS,
+ easeKaraokeMotion,
+ easeKaraokeOpacity,
+} from './lyricsKaraokeConstants'
+import {
+ buildLyricsTimeline,
+ getTimelineScrollTarget,
+ LyricTimelineCursor,
+ sameIndexes,
+ tokenProgressAt,
+} from './lyricsTimeline'
+
+const mediaTimeMs = (audio) => {
+ const seconds = Number(audio?.currentTime)
+ return Number.isFinite(seconds) && seconds >= 0 ? seconds * 1000 : 0
+}
+
+const mediaDurationMs = (audio) => {
+ const seconds = Number(audio?.duration)
+ return Number.isFinite(seconds) && seconds > 0 ? seconds * 1000 : null
+}
+
+const setProgress = (record, value) => {
+ const next = Math.max(0, Math.min(1, value))
+ if (record.progress != null && Math.abs(record.progress - next) < 0.001)
+ return
+ record.progress = next
+ record.node.style.setProperty('--lyrics-progress', String(next))
+}
+
+const setCharacterTransform = (node, localProgress) => {
+ const lift = Math.max(
+ 0,
+ Math.min(
+ KARAOKE_CHARACTER_LIFT_PX,
+ KARAOKE_CHARACTER_LIFT_PX * easeKaraokeMotion(localProgress),
+ ),
+ )
+ if (lift < 0.00005) {
+ if (node.style.transform) node.style.removeProperty('transform')
+ return
+ }
+ const nextTransform = `translate3d(0, -${lift.toFixed(4)}px, 0)`
+ if (node.style.transform !== nextTransform) {
+ node.style.transform = nextTransform
+ }
+}
+
+const resetCharacterLift = (record) => {
+ const characters = record.characters || []
+ if (!characters.length) return
+ if (record.characterMotionState === 'future') return
+
+ characters.forEach(({ node }) => setCharacterTransform(node, 0))
+ record.characterMotionState = 'future'
+}
+
+const settleCharacterLift = (record) => {
+ const characters = record.characters || []
+ if (!characters.length) return
+ if (record.characterMotionState === 'settled') return
+
+ characters.forEach(({ node }) => setCharacterTransform(node, 1))
+ record.characterMotionState = 'settled'
+}
+
+const setCharacterLiftAtTime = (record, time) => {
+ const characters = record.characters || []
+ if (!characters.length) return
+
+ const current = Number(time)
+ if (!Number.isFinite(current) || current <= record.characterMotionStart) {
+ resetCharacterLift(record)
+ return
+ }
+ if (current >= record.characterMotionEnd) {
+ settleCharacterLift(record)
+ return
+ }
+
+ characters.forEach(({ node, start }) => {
+ const localProgress = Math.max(
+ 0,
+ Math.min(1, (current - start) / KARAOKE_CHARACTER_RISE_MS),
+ )
+ setCharacterTransform(node, localProgress)
+ })
+ record.characterMotionState = 'moving'
+}
+
+const getCharacterTiming = (window, characterCount) => {
+ const start = Number(window?.start)
+ const duration = Number(window?.end) - Number(window?.start)
+ const safeStart = Number.isFinite(start) ? start : 0
+ const safeDuration = Number.isFinite(duration) ? Math.max(0, duration) : 0
+ const waveSpan =
+ characterCount <= 1
+ ? 0
+ : Math.min(
+ safeDuration * KARAOKE_CHARACTER_WAVE_SPAN_RATIO,
+ KARAOKE_CHARACTER_WAVE_SPAN_MAX_MS,
+ )
+ return {
+ start: safeStart,
+ waveSpan,
+ end: safeStart + waveSpan + KARAOKE_CHARACTER_RISE_MS,
+ }
+}
+
+const setCharacterGradient = (record) => {
+ const tokenRect = record.node.getBoundingClientRect()
+ const tokenWidth = tokenRect.width || record.node.offsetWidth || 1
+ if (!record.presentation?.gradient) return
+
+ const measurements = record.characters.map((character) => ({
+ character,
+ offset: character.node.getBoundingClientRect().left - tokenRect.left,
+ }))
+
+ measurements.forEach(({ character, offset }) => {
+ const { node } = character
+ if (
+ character.gradientWidth === tokenWidth &&
+ character.gradientOffset === offset
+ ) {
+ return
+ }
+ character.gradientWidth = tokenWidth
+ character.gradientOffset = offset
+ node.style.color = 'transparent'
+ node.style.webkitTextFillColor = 'transparent'
+ node.style.backgroundImage = record.presentation.gradient
+ node.style.backgroundSize = `${tokenWidth}px 100%`
+ node.style.backgroundPosition = `${-offset}px 0`
+ node.style.backgroundRepeat = 'no-repeat'
+ node.style.backgroundClip = 'text'
+ node.style.webkitBackgroundClip = 'text'
+ })
+}
+
+const setTokenOpacity = (record, value) => {
+ const next = Math.max(0, Math.min(1, value))
+ if (record.opacity != null && Math.abs(record.opacity - next) < 0.001) return
+ record.opacity = next
+ record.node.style.opacity = String(next)
+}
+
+const setTokenActiveAlpha = (record, value) => {
+ const next = Math.max(0, Math.min(1, value))
+ if (record.activeAlpha != null && Math.abs(record.activeAlpha - next) < 0.001)
+ return
+ record.activeAlpha = next
+ record.node.style.setProperty('--lyrics-token-active-alpha', String(next))
+}
+
+const setGradientTokenColor = (record) => {
+ const paintNode = record.paintNode || record.node
+ const paintsOnChild = paintNode !== record.node
+
+ record.node.style.color = 'transparent'
+ record.node.style.webkitTextFillColor = 'transparent'
+ record.node.style.backgroundImage = paintsOnChild
+ ? 'none'
+ : record.presentation.gradient
+ record.node.style.backgroundSize = paintsOnChild ? 'auto' : '100% 100%'
+ record.node.style.backgroundClip = paintsOnChild ? 'border-box' : 'text'
+ record.node.style.webkitBackgroundClip = paintsOnChild ? 'border-box' : 'text'
+
+ if (!paintsOnChild) return
+
+ paintNode.style.color = 'transparent'
+ paintNode.style.webkitTextFillColor = 'transparent'
+ paintNode.style.backgroundImage = record.characters.length
+ ? 'none'
+ : record.presentation.gradient
+ paintNode.style.backgroundSize = '100% 100%'
+ paintNode.style.backgroundPosition = '0 0'
+ paintNode.style.backgroundRepeat = 'no-repeat'
+ paintNode.style.backgroundClip = 'text'
+ paintNode.style.webkitBackgroundClip = 'text'
+}
+
+const getInactiveTokenAlpha = (presentation = {}) =>
+ Math.min(1, Math.max(0, presentation.futureAlpha ?? 0.34))
+
+const getActiveTokenAlpha = (presentation = {}) =>
+ Math.min(1, Math.max(0, presentation.activeAlpha ?? 1))
+
+const isGradientTokenState = (state) =>
+ state === 'future' ||
+ state === 'active' ||
+ state === 'completed' ||
+ state === 'release' ||
+ state === 'inactive-past'
+
+const applyTokenState = (record, state, progress = 0) => {
+ const previousState = record.state
+ record.state = state
+ record.node.dataset.lyricsState = state
+ const presentation = record.presentation || {}
+
+ if (!isGradientTokenState(previousState)) {
+ setGradientTokenColor(record)
+ }
+
+ const isFuture = state === 'future'
+ const isPast = state === 'inactive-past'
+ const nextProgress = isFuture ? 0 : state === 'active' ? progress : 1
+ setProgress(record, nextProgress)
+ setTokenOpacity(record, 1)
+ setTokenActiveAlpha(
+ record,
+ isPast
+ ? getInactiveTokenAlpha(presentation)
+ : getActiveTokenAlpha(presentation),
+ )
+}
+
+const setTokenReleasePresentation = (record, progress) => {
+ const presentation = record.presentation || {}
+ const nextProgress = Math.max(0, Math.min(1, progress))
+ const easedProgress = easeKaraokeOpacity(nextProgress)
+ if (record.state !== 'release') {
+ applyTokenState(record, 'completed', 1)
+ record.state = 'release'
+ record.node.dataset.lyricsState = 'release'
+ }
+ const activeAlpha = getActiveTokenAlpha(presentation)
+ const targetAlpha = getInactiveTokenAlpha(presentation)
+ setTokenOpacity(record, 1)
+ setTokenActiveAlpha(
+ record,
+ activeAlpha + (targetAlpha - activeAlpha) * easedProgress,
+ )
+ settleCharacterLift(record)
+}
+
+const resetToken = (record, state = 'future') => {
+ applyTokenState(record, state, 0)
+ if (state === 'future') resetCharacterLift(record)
+ else settleCharacterLift(record)
+}
+
+const setTokenPresentation = (record, time) => {
+ const progress = tokenProgressAt(record.window, time)
+ const state =
+ progress <= 0 ? 'future' : progress >= 1 ? 'completed' : 'active'
+ if (record.state !== state || state === 'active') {
+ applyTokenState(record, state, progress)
+ }
+ setCharacterLiftAtTime(record, time)
+}
+
+const canListenToMedia = (audio) =>
+ Boolean(
+ audio &&
+ typeof audio.addEventListener === 'function' &&
+ typeof audio.removeEventListener === 'function',
+ )
+
+const useLyricsTimeline = ({
+ lines,
+ audioInstance,
+ visible,
+ reducedMotion,
+}) => {
+ const [durationMs, setDurationMs] = useState(() =>
+ mediaDurationMs(audioInstance),
+ )
+
+ useEffect(() => {
+ const updateDuration = () => setDurationMs(mediaDurationMs(audioInstance))
+ updateDuration()
+ if (!canListenToMedia(audioInstance)) return undefined
+ audioInstance.addEventListener('loadedmetadata', updateDuration)
+ audioInstance.addEventListener('durationchange', updateDuration)
+ return () => {
+ audioInstance.removeEventListener('loadedmetadata', updateDuration)
+ audioInstance.removeEventListener('durationchange', updateDuration)
+ }
+ }, [audioInstance])
+
+ const timeline = useMemo(
+ () => buildLyricsTimeline(lines, { durationMs }),
+ [durationMs, lines],
+ )
+ const cursorRef = useRef(new LyricTimelineCursor(timeline))
+ const lineNodesRef = useRef(new Map())
+ const tokenRecordsRef = useRef(new Map())
+ const tokenRecordsByNodeRef = useRef(new WeakMap())
+ const tokenResizeObserverRef = useRef(null)
+ const lineTokenKeysRef = useRef(new Map())
+ const releaseIndexesRef = useRef(new Set())
+ const frameRef = useRef(0)
+ const resetInterpolationRef = useRef(null)
+ const lastPublishedIndexesRef = useRef([])
+ const lastPublishedPrimaryIndexRef = useRef(-1)
+ const lastScrollTargetRef = useRef(-1)
+ const lastAppliedTimeRef = useRef(0)
+ const [activeState, setActiveState] = useState(() => ({
+ indexes: [],
+ primaryIndex: -1,
+ }))
+ const [scrollTargetIndex, setScrollTargetIndex] = useState(-1)
+
+ useLayoutEffect(() => {
+ const ResizeObserverConstructor = window.ResizeObserver
+ if (!ResizeObserverConstructor || reducedMotion) return undefined
+
+ const observer = new ResizeObserverConstructor((entries) => {
+ entries.forEach(({ target }) => {
+ const record = tokenRecordsByNodeRef.current.get(target)
+ if (record) setCharacterGradient(record)
+ })
+ })
+ tokenResizeObserverRef.current = observer
+ tokenRecordsRef.current.forEach((record) => {
+ if (record.characters.length) observer.observe(record.node)
+ })
+ return () => {
+ observer.disconnect()
+ tokenResizeObserverRef.current = null
+ }
+ }, [reducedMotion])
+
+ const setLineState = useCallback((lineIndex, phase) => {
+ const node = lineNodesRef.current.get(lineIndex)
+ if (!node) return
+ node.dataset.active = phase === 'active' ? 'true' : 'false'
+ node.dataset.lifecycle = phase
+ node.dataset.highlightActive = phase === 'active' ? 'true' : 'false'
+ node.dataset.raised = phase === 'idle' ? 'false' : 'true'
+ }, [])
+
+ const resetLineTokens = useCallback((lineIndex, state = 'future') => {
+ lineTokenKeysRef.current.get(lineIndex)?.forEach((key) => {
+ const record = tokenRecordsRef.current.get(key)
+ if (record) resetToken(record, state)
+ })
+ }, [])
+
+ const updateLineTokens = useCallback((lineIndex, time) => {
+ lineTokenKeysRef.current.get(lineIndex)?.forEach((key) => {
+ const record = tokenRecordsRef.current.get(key)
+ if (record) setTokenPresentation(record, time)
+ })
+ }, [])
+
+ const updateLineReleaseTokens = useCallback((lineIndex, progress) => {
+ lineTokenKeysRef.current.get(lineIndex)?.forEach((key) => {
+ const record = tokenRecordsRef.current.get(key)
+ if (record) setTokenReleasePresentation(record, progress)
+ })
+ }, [])
+
+ const publishActiveState = useCallback((indexes, nextPrimaryIndex) => {
+ if (
+ sameIndexes(lastPublishedIndexesRef.current, indexes) &&
+ lastPublishedPrimaryIndexRef.current === nextPrimaryIndex
+ ) {
+ return
+ }
+ lastPublishedIndexesRef.current = indexes
+ lastPublishedPrimaryIndexRef.current = nextPrimaryIndex
+ setActiveState({ indexes, primaryIndex: nextPrimaryIndex })
+ }, [])
+
+ const publishScrollTarget = useCallback(
+ (time) => {
+ const nextIndex = getTimelineScrollTarget(timeline, time)
+ if (nextIndex === lastScrollTargetRef.current) return
+ lastScrollTargetRef.current = nextIndex
+ setScrollTargetIndex(nextIndex)
+ },
+ [timeline],
+ )
+
+ const apply = useCallback(
+ (time, forceSeek = false) => {
+ const current = Number.isFinite(Number(time))
+ ? Math.max(0, Number(time))
+ : 0
+ const lead = reducedMotion ? 0 : KARAOKE_HIGHLIGHT_LEAD_MS
+ lastAppliedTimeRef.current = current
+ const cursor = cursorRef.current
+ const previousIndexes = cursor.lastIndexes
+ const result = cursor.update(current, forceSeek)
+
+ if (forceSeek) {
+ releaseIndexesRef.current.clear()
+ timeline.windows.forEach((window) => {
+ if (!window.valid || current < window.start) {
+ setLineState(window.lineIndex, 'idle')
+ resetLineTokens(window.lineIndex, 'future')
+ return
+ }
+ if (current >= window.end) {
+ if (current < window.end + KARAOKE_LINE_RELEASE_MS) {
+ releaseIndexesRef.current.add(window.lineIndex)
+ setLineState(window.lineIndex, 'release')
+ updateLineReleaseTokens(
+ window.lineIndex,
+ (current - window.end) / KARAOKE_LINE_RELEASE_MS,
+ )
+ } else {
+ setLineState(window.lineIndex, 'past')
+ resetLineTokens(window.lineIndex, 'inactive-past')
+ }
+ }
+ })
+ } else if (result.changed) {
+ previousIndexes.forEach((lineIndex) => {
+ if (cursor.active.has(lineIndex)) return
+ const window = timeline.windows[lineIndex]
+ if (
+ window?.valid &&
+ current >= window.end &&
+ current < window.end + KARAOKE_LINE_RELEASE_MS
+ ) {
+ releaseIndexesRef.current.add(lineIndex)
+ setLineState(lineIndex, 'release')
+ updateLineReleaseTokens(lineIndex, 0)
+ } else {
+ setLineState(lineIndex, 'past')
+ resetLineTokens(lineIndex, 'inactive-past')
+ }
+ })
+ }
+
+ result.indexes.forEach((lineIndex) => {
+ releaseIndexesRef.current.delete(lineIndex)
+ setLineState(lineIndex, 'active')
+ updateLineTokens(lineIndex, current + lead)
+ })
+
+ releaseIndexesRef.current.forEach((lineIndex) => {
+ const window = timeline.windows[lineIndex]
+ if (
+ !window?.valid ||
+ current >= window.end + KARAOKE_LINE_RELEASE_MS ||
+ current < window.end
+ ) {
+ releaseIndexesRef.current.delete(lineIndex)
+ const isPast = current >= (window?.end ?? Infinity)
+ setLineState(lineIndex, isPast ? 'past' : 'idle')
+ resetLineTokens(lineIndex, isPast ? 'inactive-past' : 'future')
+ return
+ }
+ updateLineReleaseTokens(
+ lineIndex,
+ (current - window.end) / KARAOKE_LINE_RELEASE_MS,
+ )
+ })
+
+ if (result.changed || forceSeek) {
+ publishActiveState(result.indexes, result.primaryIndex)
+ }
+ publishScrollTarget(current)
+ return result
+ },
+ [
+ publishActiveState,
+ publishScrollTarget,
+ reducedMotion,
+ resetLineTokens,
+ setLineState,
+ timeline.windows,
+ updateLineReleaseTokens,
+ updateLineTokens,
+ ],
+ )
+
+ const registerLine = useCallback(
+ (lineIndex, node) => {
+ if (!node) {
+ lineNodesRef.current.delete(lineIndex)
+ return
+ }
+ lineNodesRef.current.set(lineIndex, node)
+ const window = timeline.windows[lineIndex]
+ const time = lastAppliedTimeRef.current
+ if (cursorRef.current.active.has(lineIndex)) {
+ setLineState(lineIndex, 'active')
+ } else if (
+ window?.valid &&
+ time >= window.end &&
+ time < window.end + KARAOKE_LINE_RELEASE_MS
+ ) {
+ setLineState(lineIndex, 'release')
+ } else if (window?.valid && time >= window.end) {
+ setLineState(lineIndex, 'past')
+ } else {
+ setLineState(lineIndex, 'idle')
+ }
+ },
+ [setLineState, timeline.windows],
+ )
+
+ const registerToken = useCallback(
+ (key, descriptor, node) => {
+ const existing = tokenRecordsRef.current.get(key)
+ if (!node) {
+ if (existing) {
+ tokenResizeObserverRef.current?.unobserve(existing.node)
+ tokenRecordsByNodeRef.current.delete(existing.node)
+ tokenRecordsRef.current.delete(key)
+ const keys = lineTokenKeysRef.current.get(existing.lineIndex)
+ keys?.delete(key)
+ if (keys?.size === 0) {
+ lineTokenKeysRef.current.delete(existing.lineIndex)
+ }
+ }
+ return
+ }
+
+ const characterNodes = reducedMotion
+ ? []
+ : Array.from(node.querySelectorAll('[data-lyrics-character="true"]'))
+ const characterCount = characterNodes.length
+ const characterTiming = getCharacterTiming(
+ descriptor.window,
+ characterCount,
+ )
+ const characters = characterNodes.map((characterNode, index) => ({
+ node: characterNode,
+ start:
+ characterCount <= 1
+ ? characterTiming.start
+ : characterTiming.start +
+ (index / (characterCount - 1)) * characterTiming.waveSpan,
+ }))
+ const record = {
+ key,
+ node,
+ paintNode: node.querySelector('[data-lyrics-wave-text="true"]'),
+ lineIndex: descriptor.lineIndex,
+ window: descriptor.window,
+ presentation: descriptor.presentation,
+ characters,
+ characterMotionStart: characterTiming.start,
+ characterMotionEnd: characterTiming.end,
+ characterMotionState: null,
+ progress: null,
+ opacity: null,
+ activeAlpha: null,
+ state: null,
+ }
+ setCharacterGradient(record)
+ tokenRecordsByNodeRef.current.set(node, record)
+ if (characters.length) tokenResizeObserverRef.current?.observe(node)
+ tokenRecordsRef.current.set(key, record)
+ if (!lineTokenKeysRef.current.has(record.lineIndex)) {
+ lineTokenKeysRef.current.set(record.lineIndex, new Set())
+ }
+ lineTokenKeysRef.current.get(record.lineIndex).add(key)
+
+ if (cursorRef.current.active.has(record.lineIndex)) {
+ setTokenPresentation(
+ record,
+ lastAppliedTimeRef.current +
+ (reducedMotion ? 0 : KARAOKE_HIGHLIGHT_LEAD_MS),
+ )
+ } else {
+ const lineWindow = timeline.windows[record.lineIndex]
+ if (
+ lineWindow?.valid &&
+ lastAppliedTimeRef.current >= lineWindow.end &&
+ lastAppliedTimeRef.current < lineWindow.end + KARAOKE_LINE_RELEASE_MS
+ ) {
+ setTokenReleasePresentation(
+ record,
+ (lastAppliedTimeRef.current - lineWindow.end) /
+ KARAOKE_LINE_RELEASE_MS,
+ )
+ } else {
+ resetToken(
+ record,
+ lineWindow?.valid && lastAppliedTimeRef.current >= lineWindow.end
+ ? 'inactive-past'
+ : 'future',
+ )
+ }
+ }
+ },
+ [reducedMotion, timeline.windows],
+ )
+
+ const getLineNode = useCallback(
+ (lineIndex) => lineNodesRef.current.get(lineIndex) || null,
+ [],
+ )
+
+ useLayoutEffect(() => {
+ lineNodesRef.current.forEach((node) => {
+ node.dataset.active = 'false'
+ node.dataset.lifecycle = 'idle'
+ node.dataset.highlightActive = 'false'
+ node.dataset.raised = 'false'
+ })
+ tokenRecordsRef.current.forEach((record) => resetToken(record))
+ cursorRef.current = new LyricTimelineCursor(timeline)
+ releaseIndexesRef.current.clear()
+ lastPublishedIndexesRef.current = []
+ lastPublishedPrimaryIndexRef.current = -1
+ lastScrollTargetRef.current = -1
+ setActiveState({ indexes: [], primaryIndex: -1 })
+ setScrollTargetIndex(-1)
+ apply(mediaTimeMs(audioInstance), true)
+ }, [apply, audioInstance, timeline])
+
+ useEffect(() => {
+ if (
+ !audioInstance ||
+ !timeline.events.length ||
+ !canListenToMedia(audioInstance)
+ ) {
+ return undefined
+ }
+ const seek = () => {
+ resetInterpolationRef.current?.()
+ apply(mediaTimeMs(audioInstance), true)
+ }
+ audioInstance.addEventListener('seeking', seek)
+ audioInstance.addEventListener('seeked', seek)
+ return () => {
+ audioInstance.removeEventListener('seeking', seek)
+ audioInstance.removeEventListener('seeked', seek)
+ }
+ }, [apply, audioInstance, timeline.events.length])
+
+ useEffect(() => {
+ if (!audioInstance || !timeline.events.length || !visible) return undefined
+ if (!canListenToMedia(audioInstance)) {
+ apply(mediaTimeMs(audioInstance), true)
+ return undefined
+ }
+
+ let cancelled = false
+ let anchorAudioMs = mediaTimeMs(audioInstance)
+ let anchorPerfMs = performance.now()
+ let lastFrameTime = anchorAudioMs
+
+ const stop = () => {
+ if (frameRef.current) window.cancelAnimationFrame(frameRef.current)
+ frameRef.current = 0
+ }
+
+ const resetAnchor = () => {
+ anchorAudioMs = mediaTimeMs(audioInstance)
+ anchorPerfMs = performance.now()
+ lastFrameTime = anchorAudioMs
+ }
+ resetInterpolationRef.current = resetAnchor
+
+ const readInterpolatedTime = () => {
+ const observed = mediaTimeMs(audioInstance)
+ const now = performance.now()
+ const rate = Number(audioInstance.playbackRate)
+ const canInterpolate =
+ !audioInstance.paused &&
+ !audioInstance.seeking &&
+ Number.isFinite(rate) &&
+ rate > 0
+ if (!canInterpolate) {
+ anchorAudioMs = observed
+ anchorPerfMs = now
+ lastFrameTime = observed
+ return observed
+ }
+ const predicted = anchorAudioMs + (now - anchorPerfMs) * rate
+ const drift = observed - predicted
+ let current =
+ Math.abs(drift) > KARAOKE_CLOCK_DRIFT_RESET_MS ? observed : predicted
+ if (Math.abs(drift) > KARAOKE_CLOCK_DRIFT_RESET_MS) {
+ anchorAudioMs = observed
+ anchorPerfMs = now
+ }
+ const backwards = lastFrameTime - current
+ if (backwards > 0) {
+ current = lastFrameTime
+ }
+ lastFrameTime = Math.max(0, current)
+ return lastFrameTime
+ }
+
+ const tick = () => {
+ if (
+ cancelled ||
+ audioInstance.paused ||
+ window.document.visibilityState !== 'visible'
+ ) {
+ stop()
+ return
+ }
+ apply(readInterpolatedTime())
+ frameRef.current = window.requestAnimationFrame(tick)
+ }
+
+ const start = () => {
+ if (
+ frameRef.current ||
+ audioInstance.paused ||
+ window.document.visibilityState !== 'visible'
+ ) {
+ return
+ }
+ resetAnchor()
+ frameRef.current = window.requestAnimationFrame(tick)
+ }
+
+ const syncAndStop = () => {
+ stop()
+ apply(mediaTimeMs(audioInstance), true)
+ }
+
+ const visibility = () => {
+ if (window.document.visibilityState !== 'visible') {
+ stop()
+ return
+ }
+ apply(mediaTimeMs(audioInstance), true)
+ start()
+ }
+
+ audioInstance.addEventListener('play', start)
+ audioInstance.addEventListener('pause', syncAndStop)
+ window.document.addEventListener('visibilitychange', visibility)
+ apply(mediaTimeMs(audioInstance), true)
+ start()
+
+ return () => {
+ cancelled = true
+ stop()
+ if (resetInterpolationRef.current === resetAnchor) {
+ resetInterpolationRef.current = null
+ }
+ audioInstance.removeEventListener('play', start)
+ audioInstance.removeEventListener('pause', syncAndStop)
+ window.document.removeEventListener('visibilitychange', visibility)
+ }
+ }, [apply, audioInstance, timeline.events.length, visible])
+
+ const syncNow = useCallback(
+ (time = mediaTimeMs(audioInstance), forceSeek = true) =>
+ apply(time, forceSeek),
+ [apply, audioInstance],
+ )
+
+ return {
+ activeIndexes: activeState.indexes,
+ primaryIndex: activeState.primaryIndex,
+ scrollTargetIndex,
+ registerLine,
+ registerToken,
+ getLineNode,
+ syncNow,
+ timeline,
+ }
+}
+
+export default useLyricsTimeline
diff --git a/ui/src/audioplayer/useLyricsTimeline.test.js b/ui/src/audioplayer/useLyricsTimeline.test.js
new file mode 100644
index 000000000..54a70aa88
--- /dev/null
+++ b/ui/src/audioplayer/useLyricsTimeline.test.js
@@ -0,0 +1,646 @@
+import { act } from '@testing-library/react'
+import { renderHook } from '@testing-library/react-hooks'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import {
+ KARAOKE_CHARACTER_LIFT_PX,
+ KARAOKE_CHARACTER_RISE_MS,
+ KARAOKE_CHARACTER_WAVE_SPAN_MAX_MS,
+ KARAOKE_CHARACTER_WAVE_SPAN_RATIO,
+ KARAOKE_CLOCK_DRIFT_RESET_MS,
+ KARAOKE_HIGHLIGHT_LEAD_MS,
+ KARAOKE_LINE_RELEASE_MS,
+ easeKaraokeOpacity,
+} from './lyricsKaraokeConstants'
+import useLyricsTimeline from './useLyricsTimeline'
+
+const createAudio = ({
+ currentTime = 0,
+ duration = 10,
+ paused = true,
+ playbackRate = 1,
+} = {}) => {
+ const target = new EventTarget()
+ target.currentTime = currentTime
+ target.duration = duration
+ target.paused = paused
+ target.playbackRate = playbackRate
+ target.seeking = false
+ return target
+}
+
+const lines = [
+ {
+ start: 0,
+ end: 1000,
+ tokens: [
+ { start: 0, end: 500, value: 'first' },
+ { start: 500, end: 1000, value: 'second' },
+ ],
+ },
+ { start: 1000, end: 2000, tokens: [] },
+]
+
+const presentation = {
+ rgb: [255, 255, 255],
+ futureAlpha: 0.34,
+ activeAlpha: 1,
+ futureColor: 'rgba(255, 255, 255, 0.34)',
+ doneColor: 'rgba(255, 255, 255, 1)',
+ gradient: 'linear-gradient(90deg, white, transparent)',
+ useCrossfade: false,
+}
+
+const fullCharacterLift = `translate3d(0, -${KARAOKE_CHARACTER_LIFT_PX.toFixed(
+ 4,
+)}px, 0)`
+
+const createTokenNode = (text = '') => {
+ const tokenNode = document.createElement('span')
+ Array.from(text).forEach((character) => {
+ const node = document.createElement('span')
+ node.dataset.lyricsCharacter = 'true'
+ node.textContent = character
+ tokenNode.appendChild(node)
+ })
+ return tokenNode
+}
+
+const registerToken = (result, key, window, text = '') => {
+ const tokenNode = createTokenNode(text)
+ act(() => {
+ result.current.registerToken(
+ key,
+ { lineIndex: 0, window, presentation },
+ tokenNode,
+ )
+ })
+ return tokenNode
+}
+
+const syncNow = (result, time) => {
+ act(() => {
+ result.current.syncNow(time, true)
+ })
+}
+
+const renderTimeline = ({
+ audio = createAudio(),
+ sourceLines = lines,
+ reducedMotion = false,
+} = {}) =>
+ renderHook(() =>
+ useLyricsTimeline({
+ lines: sourceLines,
+ audioInstance: audio,
+ visible: true,
+ reducedMotion,
+ }),
+ )
+
+describe('useLyricsTimeline', () => {
+ beforeEach(() => {
+ vi.spyOn(window, 'requestAnimationFrame').mockImplementation(() => 1)
+ vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {})
+ })
+
+ afterEach(() => {
+ vi.restoreAllMocks()
+ })
+
+ it('synchronizes paused media without starting an animation loop', () => {
+ const audio = createAudio({ currentTime: 0.25, paused: true })
+ const { result } = renderTimeline({ audio })
+ const lineNode = document.createElement('div')
+ const tokenNode = createTokenNode()
+
+ act(() => {
+ result.current.registerLine(0, lineNode)
+ result.current.registerToken(
+ '0:main:0',
+ {
+ lineIndex: 0,
+ window: { start: 0, end: 500 },
+ presentation,
+ },
+ tokenNode,
+ )
+ })
+
+ expect(result.current.activeIndexes).toEqual([0])
+ expect(lineNode.dataset.active).toBe('true')
+ expect(tokenNode.dataset.lyricsState).toBe('active')
+ expect(
+ Number(tokenNode.style.getPropertyValue('--lyrics-progress')),
+ ).toBeCloseTo(0.74, 2)
+ expect(window.requestAnimationFrame).not.toHaveBeenCalled()
+ })
+
+ it('recomputes token state immediately after seeking backward', () => {
+ const audio = createAudio({ currentTime: 0.75, paused: true })
+ const { result } = renderTimeline({ audio, reducedMotion: true })
+ const first = document.createElement('span')
+ const second = document.createElement('span')
+
+ act(() => {
+ result.current.registerToken(
+ '0:first',
+ {
+ lineIndex: 0,
+ window: { start: 0, end: 500 },
+ presentation,
+ },
+ first,
+ )
+ result.current.registerToken(
+ '0:second',
+ {
+ lineIndex: 0,
+ window: { start: 500, end: 1000 },
+ presentation,
+ },
+ second,
+ )
+ })
+
+ expect(first.dataset.lyricsState).toBe('completed')
+ expect(second.dataset.lyricsState).toBe('active')
+
+ act(() => {
+ audio.currentTime = 0.1
+ audio.dispatchEvent(new Event('seeking'))
+ })
+
+ expect(first.dataset.lyricsState).toBe('active')
+ expect(second.dataset.lyricsState).toBe('future')
+ })
+
+ it('tracks overlapping line intervals as an active set', () => {
+ const audio = createAudio({ currentTime: 2.5, paused: true })
+ const overlappingLines = [
+ { start: 1000, end: 4000, tokens: [] },
+ { start: 2000, end: 3000, tokens: [] },
+ { start: 5000, end: 6000, tokens: [] },
+ ]
+ const { result } = renderTimeline({ audio, sourceLines: overlappingLines })
+
+ expect(result.current.activeIndexes).toEqual([0, 1])
+ expect(result.current.primaryIndex).toBe(1)
+ })
+
+ it('publishes the most recently started line when display order differs', () => {
+ const audio = createAudio({ currentTime: 2.5, paused: true })
+ const displayOrderLines = [
+ { start: 2000, end: 4000, tokens: [] },
+ { start: 1000, end: 4000, tokens: [] },
+ ]
+ const { result } = renderTimeline({ audio, sourceLines: displayOrderLines })
+
+ expect(result.current.activeIndexes).toEqual([0, 1])
+ expect(result.current.primaryIndex).toBe(0)
+ })
+
+ it('keeps the same gradient paint when an active word completes', () => {
+ const audio = createAudio({ currentTime: 0.25, paused: true })
+ const { result } = renderTimeline({ audio })
+ const tokenNode = registerToken(
+ result,
+ '0:stable-completion',
+ { start: 0, end: 500 },
+ 'first',
+ )
+
+ const activeBackground = tokenNode.style.backgroundImage
+ expect(tokenNode.dataset.lyricsState).toBe('active')
+ expect(tokenNode.style.color).toBe('transparent')
+
+ syncNow(result, 600)
+
+ expect(tokenNode.dataset.lyricsState).toBe('completed')
+ expect(tokenNode.style.backgroundImage).toBe(activeBackground)
+ expect(tokenNode.style.color).toBe('transparent')
+ expect(tokenNode.style.webkitTextFillColor).toBe('transparent')
+ tokenNode
+ .querySelectorAll('[data-lyrics-character="true"]')
+ .forEach((character) =>
+ expect(character.style.transform).toBe(fullCharacterLift),
+ )
+ })
+
+ it('keeps gradient paint and opacity continuous when release becomes past', () => {
+ const audio = createAudio({ currentTime: 0.25, paused: true })
+ const { result } = renderTimeline({ audio })
+ const tokenNode = registerToken(
+ result,
+ '0:no-release-blink',
+ { start: 0, end: 500 },
+ 'first',
+ )
+
+ const releaseEnd = 1000 + KARAOKE_LINE_RELEASE_MS
+ syncNow(result, releaseEnd - 1)
+ const gradient = tokenNode.style.backgroundImage
+ const releaseAlpha = Number(
+ tokenNode.style.getPropertyValue('--lyrics-token-active-alpha'),
+ )
+ expect(tokenNode.dataset.lyricsState).toBe('release')
+ expect(tokenNode.style.color).toBe('transparent')
+ expect(tokenNode.style.opacity).toBe('1')
+
+ syncNow(result, releaseEnd)
+
+ const pastAlpha = Number(
+ tokenNode.style.getPropertyValue('--lyrics-token-active-alpha'),
+ )
+ expect(tokenNode.dataset.lyricsState).toBe('inactive-past')
+ expect(tokenNode.style.backgroundImage).toBe(gradient)
+ expect(tokenNode.style.color).toBe('transparent')
+ expect(tokenNode.style.webkitTextFillColor).toBe('transparent')
+ expect(tokenNode.style.opacity).toBe('1')
+ expect(Math.abs(pastAlpha - releaseAlpha)).toBeLessThan(0.01)
+ expect(pastAlpha).toBeCloseTo(presentation.futureAlpha, 5)
+ expect(tokenNode.style.getPropertyValue('--lyrics-progress')).toBe('1')
+ })
+
+ it('uses the shared opacity easing throughout line release', () => {
+ const audio = createAudio({ currentTime: 0.25, paused: true })
+ const { result } = renderTimeline({ audio })
+ const lineNode = document.createElement('div')
+ act(() => result.current.registerLine(0, lineNode))
+ const tokenNode = registerToken(
+ result,
+ '0:eased-release',
+ { start: 0, end: 500 },
+ 'first',
+ )
+
+ syncNow(result, 1000 + KARAOKE_LINE_RELEASE_MS / 2)
+
+ expect(lineNode).toHaveAttribute('data-lifecycle', 'release')
+ expect(lineNode).toHaveAttribute('data-active', 'false')
+ const expectedAlpha =
+ presentation.activeAlpha +
+ (presentation.futureAlpha - presentation.activeAlpha) *
+ easeKaraokeOpacity(0.5)
+ expect(
+ Number(tokenNode.style.getPropertyValue('--lyrics-token-active-alpha')),
+ ).toBeCloseTo(expectedAlpha, 5)
+ })
+
+ it('completes long-token character waves while highlighting continues', () => {
+ const audio = createAudio({ currentTime: 0, duration: 6, paused: true })
+ const longLines = [
+ {
+ start: 0,
+ end: 5000,
+ tokens: [{ start: 500, end: 4500, value: 'super' }],
+ },
+ ]
+ const { result } = renderTimeline({ audio, sourceLines: longLines })
+ const tokenNode = registerToken(
+ result,
+ '0:long-word',
+ { start: 500, end: 4500 },
+ 'super',
+ )
+
+ const characters = Array.from(
+ tokenNode.querySelectorAll('[data-lyrics-character="true"]'),
+ )
+ expect(characters[0].style.backgroundImage).toBe(
+ tokenNode.style.backgroundImage,
+ )
+ const tokenStart = 500 - KARAOKE_HIGHLIGHT_LEAD_MS
+ syncNow(result, tokenStart)
+ characters.forEach((character) =>
+ expect(character.style.transform).toBe(''),
+ )
+
+ syncNow(result, tokenStart + KARAOKE_CHARACTER_WAVE_SPAN_MAX_MS / 2)
+ expect(characters[0].style.transform).not.toBe(fullCharacterLift)
+ expect(characters[0].style.transform).not.toBe('')
+ expect(characters[4].style.transform).toBe('')
+
+ syncNow(result, tokenStart + KARAOKE_CHARACTER_RISE_MS)
+ expect(characters[0].style.transform).toBe(fullCharacterLift)
+ expect(characters[4].style.transform).toMatch(
+ /^translate3d\(0, -\d+\.\d{4}px, 0\)$/,
+ )
+ expect(characters[4].style.transform).not.toBe(fullCharacterLift)
+
+ const completeWaveAt =
+ tokenStart +
+ KARAOKE_CHARACTER_WAVE_SPAN_MAX_MS +
+ KARAOKE_CHARACTER_RISE_MS
+ syncNow(result, completeWaveAt)
+ characters.forEach((character) =>
+ expect(character.style.transform).toBe(fullCharacterLift),
+ )
+ expect(
+ Number(tokenNode.style.getPropertyValue('--lyrics-progress')),
+ ).toBeCloseTo(
+ (KARAOKE_CHARACTER_WAVE_SPAN_MAX_MS + KARAOKE_CHARACTER_RISE_MS) / 4000,
+ 5,
+ )
+ })
+
+ it('finishes short-token motion after highlighting without dropping the lift', () => {
+ const audio = createAudio({ currentTime: 0, duration: 2, paused: true })
+ const shortLines = [
+ {
+ start: 0,
+ end: 1200,
+ tokens: [{ start: 500, end: 680, value: 'go' }],
+ },
+ ]
+ const { result } = renderTimeline({ audio, sourceLines: shortLines })
+ const tokenNode = registerToken(
+ result,
+ '0:short-word',
+ { start: 500, end: 680 },
+ 'go',
+ )
+
+ const characters = tokenNode.querySelectorAll(
+ '[data-lyrics-character="true"]',
+ )
+ const tokenStart = 500 - KARAOKE_HIGHLIGHT_LEAD_MS
+ const waveSpan = 180 * KARAOKE_CHARACTER_WAVE_SPAN_RATIO
+ syncNow(result, tokenStart)
+ expect(characters[0].style.transform).toBe('')
+ expect(characters[1].style.transform).toBe('')
+
+ syncNow(result, 680 - KARAOKE_HIGHLIGHT_LEAD_MS)
+ expect(tokenNode.style.getPropertyValue('--lyrics-progress')).toBe('1')
+ expect(characters[0].style.transform).not.toBe('')
+ expect(characters[1].style.transform).not.toBe(fullCharacterLift)
+
+ syncNow(result, tokenStart + waveSpan + KARAOKE_CHARACTER_RISE_MS)
+ expect(characters[0].style.transform).toBe(fullCharacterLift)
+ expect(characters[1].style.transform).toBe(fullCharacterLift)
+ })
+
+ it('gives a single character the same fixed rise duration', () => {
+ const audio = createAudio({ currentTime: 0, duration: 5, paused: true })
+ const sourceLines = [
+ {
+ start: 0,
+ end: 5000,
+ tokens: [{ start: 120, end: 4120, value: 'I' }],
+ },
+ ]
+ const { result } = renderTimeline({ audio, sourceLines })
+ const tokenNode = registerToken(
+ result,
+ '0:single-character',
+ { start: 120, end: 4120 },
+ 'I',
+ )
+ const character = tokenNode.querySelector('[data-lyrics-character="true"]')
+ const tokenStart = 120 - KARAOKE_HIGHLIGHT_LEAD_MS
+
+ syncNow(result, tokenStart)
+ expect(character.style.transform).toBe('')
+ syncNow(result, tokenStart + KARAOKE_CHARACTER_RISE_MS / 2)
+ expect(character.style.transform).toMatch(
+ /^translate3d\(0, -\d+\.\d{4}px, 0\)$/,
+ )
+ expect(character.style.transform).not.toBe(fullCharacterLift)
+ syncNow(result, tokenStart + KARAOKE_CHARACTER_RISE_MS - 20)
+ expect(character.style.transform).not.toBe(fullCharacterLift)
+ syncNow(result, tokenStart + KARAOKE_CHARACTER_RISE_MS)
+ expect(character.style.transform).toBe(fullCharacterLift)
+ expect(
+ Number(tokenNode.style.getPropertyValue('--lyrics-progress')),
+ ).toBeCloseTo(KARAOKE_CHARACTER_RISE_MS / 4000, 5)
+ })
+
+ it('forces an unfinished character wave to its raised release state', () => {
+ const audio = createAudio({ currentTime: 0, duration: 2, paused: true })
+ const sourceLines = [
+ {
+ start: 0,
+ end: 600,
+ tokens: [{ start: 500, end: 550, value: 'go' }],
+ },
+ ]
+ const { result } = renderTimeline({ audio, sourceLines })
+ const tokenNode = registerToken(
+ result,
+ '0:release-wave',
+ { start: 500, end: 550 },
+ 'go',
+ )
+ const characters = tokenNode.querySelectorAll(
+ '[data-lyrics-character="true"]',
+ )
+
+ syncNow(result, 500 - KARAOKE_HIGHLIGHT_LEAD_MS)
+ expect(characters[1].style.transform).toBe('')
+
+ syncNow(result, 600)
+ expect(tokenNode.dataset.lyricsState).toBe('release')
+ characters.forEach((character) =>
+ expect(character.style.transform).toBe(fullCharacterLift),
+ )
+ })
+
+ it('keeps interpolated playback time monotonic between coarse media updates', () => {
+ let frameCallback = null
+ let now = 0
+ window.requestAnimationFrame.mockImplementation((callback) => {
+ frameCallback = callback
+ return 1
+ })
+ vi.spyOn(performance, 'now').mockImplementation(() => now)
+
+ const audio = createAudio({ currentTime: 0.2, paused: false })
+ const { result } = renderTimeline({ audio })
+ const tokenNode = document.createElement('span')
+ act(() => {
+ result.current.registerToken(
+ '0:monotonic',
+ {
+ lineIndex: 0,
+ window: { start: 0, end: 1000 },
+ presentation,
+ },
+ tokenNode,
+ )
+ })
+
+ now = 100
+ act(() => frameCallback())
+ const firstProgress = Number(
+ tokenNode.style.getPropertyValue('--lyrics-progress'),
+ )
+ now = 180
+ act(() => frameCallback())
+ const secondProgress = Number(
+ tokenNode.style.getPropertyValue('--lyrics-progress'),
+ )
+
+ expect(secondProgress).toBeGreaterThanOrEqual(firstProgress)
+ })
+
+ it('drives character motion from playback-rate-adjusted media time', () => {
+ let frameCallback = null
+ let now = 0
+ window.requestAnimationFrame.mockImplementation((callback) => {
+ frameCallback = callback
+ return 1
+ })
+ vi.spyOn(performance, 'now').mockImplementation(() => now)
+
+ const audio = createAudio({
+ currentTime: 0.38,
+ duration: 2,
+ paused: false,
+ playbackRate: 2,
+ })
+ const sourceLines = [
+ {
+ start: 0,
+ end: 1200,
+ tokens: [{ start: 500, end: 1000, value: 'go' }],
+ },
+ ]
+ const { result } = renderTimeline({ audio, sourceLines })
+ const tokenNode = registerToken(
+ result,
+ '0:playback-rate-wave',
+ { start: 500, end: 1000 },
+ 'go',
+ )
+ const character = tokenNode.querySelector('[data-lyrics-character="true"]')
+
+ expect(character.style.transform).toBe('')
+ now = KARAOKE_CLOCK_DRIFT_RESET_MS / 4
+ act(() => frameCallback())
+ expect(character.style.transform).toMatch(
+ /^translate3d\(0, -\d+\.\d{4}px, 0\)$/,
+ )
+ expect(character.style.transform).not.toBe(fullCharacterLift)
+ })
+
+ it('reconstructs settled character motion after visibility recovery', () => {
+ let visibility = 'visible'
+ vi.spyOn(window.document, 'visibilityState', 'get').mockImplementation(
+ () => visibility,
+ )
+ const audio = createAudio({
+ currentTime: 0.38,
+ duration: 2,
+ paused: false,
+ })
+ const sourceLines = [
+ {
+ start: 0,
+ end: 1200,
+ tokens: [{ start: 500, end: 1000, value: 'go' }],
+ },
+ ]
+ const { result } = renderTimeline({ audio, sourceLines })
+ const tokenNode = registerToken(
+ result,
+ '0:visibility-wave',
+ { start: 500, end: 1000 },
+ 'go',
+ )
+ const characters = tokenNode.querySelectorAll(
+ '[data-lyrics-character="true"]',
+ )
+
+ visibility = 'hidden'
+ act(() => {
+ window.document.dispatchEvent(new Event('visibilitychange'))
+ })
+ const characterMotionEnd =
+ 500 +
+ Math.min(
+ 500 * KARAOKE_CHARACTER_WAVE_SPAN_RATIO,
+ KARAOKE_CHARACTER_WAVE_SPAN_MAX_MS,
+ ) +
+ KARAOKE_CHARACTER_RISE_MS
+ audio.currentTime = (characterMotionEnd - KARAOKE_HIGHLIGHT_LEAD_MS) / 1000
+ visibility = 'visible'
+ act(() => {
+ window.document.dispatchEvent(new Event('visibilitychange'))
+ })
+
+ characters.forEach((character) =>
+ expect(character.style.transform).toBe(fullCharacterLift),
+ )
+ })
+
+ it('resets monotonic interpolation after an explicit backward seek', () => {
+ let frameCallback = null
+ let now = 0
+ window.requestAnimationFrame.mockImplementation((callback) => {
+ frameCallback = callback
+ return 1
+ })
+ vi.spyOn(performance, 'now').mockImplementation(() => now)
+
+ const audio = createAudio({ currentTime: 0.8, paused: false })
+ const { result } = renderTimeline({ audio })
+ const tokenNode = registerToken(result, '0:backward-seek', {
+ start: 0,
+ end: 1000,
+ })
+
+ now = 100
+ act(() => frameCallback())
+ const beforeSeek = Number(
+ tokenNode.style.getPropertyValue('--lyrics-progress'),
+ )
+
+ audio.currentTime = 0.1
+ act(() => {
+ audio.dispatchEvent(new Event('seeked'))
+ })
+ now = 120
+ act(() => frameCallback())
+
+ const afterSeek = Number(
+ tokenNode.style.getPropertyValue('--lyrics-progress'),
+ )
+ expect(afterSeek).toBeLessThan(beforeSeek)
+ expect(afterSeek).toBeCloseTo(0.24, 2)
+ })
+
+ it('starts and stops requestAnimationFrame with playback visibility', () => {
+ const audio = createAudio({ currentTime: 0.25, paused: true })
+ renderTimeline({ audio })
+
+ expect(window.requestAnimationFrame).not.toHaveBeenCalled()
+
+ act(() => {
+ audio.paused = false
+ audio.dispatchEvent(new Event('play'))
+ })
+ expect(window.requestAnimationFrame).toHaveBeenCalledTimes(1)
+
+ act(() => {
+ audio.paused = true
+ audio.dispatchEvent(new Event('pause'))
+ })
+ expect(window.cancelAnimationFrame).toHaveBeenCalled()
+ })
+
+ it('rebuilds open-line timing when media duration changes', () => {
+ const audio = createAudio({ duration: 10, paused: true })
+ const { result } = renderTimeline({
+ audio,
+ sourceLines: [{ start: 7000, tokens: [] }],
+ })
+
+ expect(result.current.timeline.windows[0].end).toBe(10000)
+
+ act(() => {
+ audio.duration = 20
+ audio.dispatchEvent(new Event('durationchange'))
+ })
+
+ expect(result.current.timeline.windows[0].end).toBe(15000)
+ })
+})
diff --git a/ui/src/audioplayer/usePlayerLyrics.jsx b/ui/src/audioplayer/usePlayerLyrics.jsx
new file mode 100644
index 000000000..9c09e8cb2
--- /dev/null
+++ b/ui/src/audioplayer/usePlayerLyrics.jsx
@@ -0,0 +1,216 @@
+import React, { useCallback, useMemo, useRef, useState } from 'react'
+import LyricsLayerControls from './LyricsLayerControls'
+import LyricsPanel from './LyricsPanel'
+import MobileKaraokeLyricsPortal from './MobileKaraokeLyricsPortal'
+import { hasStructuredLyricContent } from './lyrics'
+import {
+ resolveLyricsSidebarState,
+ toggleLayerPreference,
+} from './lyricsSidebarState'
+import useEnhancedLyrics from './useEnhancedLyrics'
+
+const usePlayerLyrics = ({
+ trackId,
+ trackUpdatedAt,
+ isRadio,
+ audioInstance,
+ isDesktop,
+ obscuredByQueue = false,
+ translate,
+}) => {
+ const [lyricsVisiblePreference, setLyricsVisiblePreference] = useState(false)
+ const [translationPreference, setTranslationPreference] = useState(null)
+ const [pronunciationPreference, setPronunciationPreference] = useState(null)
+ const lyricsToggleRef = useRef(null)
+
+ const {
+ layers: lyricLayers,
+ loading: lyricsLoading,
+ error: lyricsError,
+ retryAfterSeconds: lyricsRetryAfterSeconds,
+ retry: retryLyrics,
+ } = useEnhancedLyrics({
+ trackId,
+ updatedAt: trackUpdatedAt,
+ disabled: isRadio,
+ requested: lyricsVisiblePreference,
+ })
+
+ const hasTranslationLyric = hasStructuredLyricContent(lyricLayers.translation)
+ const hasPronunciationLyric = hasStructuredLyricContent(
+ lyricLayers.pronunciation,
+ )
+ const { lyricsVisible, showTranslation, showPronunciation } =
+ resolveLyricsSidebarState({
+ lyricsVisiblePreference,
+ translationPreference,
+ pronunciationPreference,
+ hasTranslationLyric,
+ hasPronunciationLyric,
+ })
+ const lyricsToggleDisabled = !lyricsVisiblePreference && (isRadio || !trackId)
+ const useInlineMobileLyrics = lyricsVisible && !isDesktop
+
+ const labels = useMemo(
+ () => ({
+ title: translate('player.lyricsTitleText'),
+ toggle: translate('player.toggleLyricText'),
+ loading: translate('player.lyricsLoadingText'),
+ unavailable: translate('player.lyricsUnavailableText'),
+ rateLimited:
+ lyricsRetryAfterSeconds == null
+ ? null
+ : translate('player.lyricsRateLimitedText', {
+ smart_count: lyricsRetryAfterSeconds,
+ }),
+ empty: translate('player.emptyLyricText'),
+ resize: translate('player.resizeLyricsSidebarText'),
+ showTranslation: translate('player.showLyricsTranslationText'),
+ hideTranslation: translate('player.hideLyricsTranslationText'),
+ showPronunciation: translate('player.showLyricsPronunciationText'),
+ hidePronunciation: translate('player.hideLyricsPronunciationText'),
+ }),
+ [lyricsRetryAfterSeconds, translate],
+ )
+
+ const toggleLyrics = useCallback(() => {
+ const next = !lyricsVisiblePreference
+ setLyricsVisiblePreference(next)
+ if (next) {
+ if (lyricsError) retryLyrics()
+ }
+ }, [lyricsError, lyricsVisiblePreference, retryLyrics])
+
+ const closeLyrics = useCallback(() => {
+ setLyricsVisiblePreference(false)
+ }, [])
+
+ const toggleTranslation = useCallback(() => {
+ setTranslationPreference((current) =>
+ toggleLayerPreference(current, hasTranslationLyric),
+ )
+ }, [hasTranslationLyric])
+
+ const togglePronunciation = useCallback(() => {
+ setPronunciationPreference((current) =>
+ toggleLayerPreference(current, hasPronunciationLyric, true),
+ )
+ }, [hasPronunciationLyric])
+
+ const toolbarLyricsProps = useMemo(
+ () => ({
+ onToggleLyrics: toggleLyrics,
+ lyricsActive: lyricsVisible,
+ lyricsDisabled: lyricsToggleDisabled,
+ lyricsLoading,
+ lyricsLabel: labels.toggle,
+ lyricsLoadingLabel: labels.loading,
+ lyricsToggleRef,
+ }),
+ [labels, lyricsLoading, lyricsToggleDisabled, lyricsVisible, toggleLyrics],
+ )
+
+ const desktopLyricsProps = useMemo(
+ () => ({
+ visible: isDesktop && lyricsVisible,
+ mainLyric: lyricLayers.main,
+ translationLyric: lyricLayers.translation,
+ pronunciationLyric: lyricLayers.pronunciation,
+ showTranslation,
+ showPronunciation,
+ translationEnabled: hasTranslationLyric,
+ pronunciationEnabled: hasPronunciationLyric,
+ onToggleTranslation: toggleTranslation,
+ onTogglePronunciation: togglePronunciation,
+ audioInstance,
+ loading: lyricsLoading,
+ error: lyricsError,
+ errorMessage: labels.rateLimited,
+ labels,
+ obscuredByQueue,
+ returnFocusRef: lyricsToggleRef,
+ }),
+ [
+ audioInstance,
+ hasPronunciationLyric,
+ hasTranslationLyric,
+ isDesktop,
+ labels,
+ lyricLayers.main,
+ lyricLayers.pronunciation,
+ lyricLayers.translation,
+ lyricsError,
+ lyricsLoading,
+ lyricsVisible,
+ obscuredByQueue,
+ showPronunciation,
+ showTranslation,
+ togglePronunciation,
+ toggleTranslation,
+ ],
+ )
+
+ const mobileLyricsSurface = useMemo(
+ () => (
+
+
+
+
+ ),
+ [
+ audioInstance,
+ hasPronunciationLyric,
+ hasTranslationLyric,
+ lyricLayers.main,
+ lyricLayers.pronunciation,
+ lyricLayers.translation,
+ lyricsError,
+ lyricsLoading,
+ labels,
+ obscuredByQueue,
+ showPronunciation,
+ showTranslation,
+ togglePronunciation,
+ toggleTranslation,
+ useInlineMobileLyrics,
+ ],
+ )
+
+ return {
+ toolbarLyricsProps,
+ desktopLyricsProps,
+ mobileLyricsSurface,
+ useInlineMobileLyrics,
+ closeLyrics,
+ }
+}
+
+export default usePlayerLyrics
diff --git a/ui/src/audioplayer/usePlayerLyrics.test.jsx b/ui/src/audioplayer/usePlayerLyrics.test.jsx
new file mode 100644
index 000000000..0c46c13b4
--- /dev/null
+++ b/ui/src/audioplayer/usePlayerLyrics.test.jsx
@@ -0,0 +1,82 @@
+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 { clearEnhancedLyricsCache } from './useEnhancedLyrics'
+import usePlayerLyrics from './usePlayerLyrics'
+
+vi.mock('../subsonic', () => ({
+ default: {
+ getLyricsBySongId: vi.fn(),
+ },
+}))
+
+const translate = (key) => key
+
+describe('usePlayerLyrics', () => {
+ beforeEach(() => {
+ localStorage.setItem('locale', 'en')
+ clearEnhancedLyricsCache()
+ subsonic.getLyricsBySongId.mockReset()
+ })
+
+ afterEach(() => {
+ localStorage.clear()
+ clearEnhancedLyricsCache()
+ })
+
+ it('loads lyrics for a Subsonic queue entry without embedded lyrics', async () => {
+ const current = {
+ trackId: 'song-1',
+ song: {
+ id: 'song-1',
+ title: 'Subsonic song',
+ artist: 'Artist',
+ },
+ }
+ subsonic.getLyricsBySongId.mockResolvedValue({
+ json: {
+ 'subsonic-response': {
+ lyricsList: {
+ structuredLyrics: [
+ {
+ kind: 'main',
+ lang: 'en',
+ synced: true,
+ line: [{ start: 0, value: 'Loaded by track ID' }],
+ },
+ ],
+ },
+ },
+ },
+ })
+
+ const { result } = renderHook(() =>
+ usePlayerLyrics({
+ trackId: current.trackId,
+ trackUpdatedAt: current.song.updatedAt,
+ isRadio: false,
+ audioInstance: null,
+ isDesktop: true,
+ translate,
+ }),
+ )
+
+ expect(current.song).not.toHaveProperty('lyrics')
+ expect(subsonic.getLyricsBySongId).not.toHaveBeenCalled()
+
+ act(() => result.current.toolbarLyricsProps.onToggleLyrics())
+
+ await waitFor(() =>
+ expect(result.current.desktopLyricsProps.mainLyric?.line[0].value).toBe(
+ 'Loaded by track ID',
+ ),
+ )
+ expect(subsonic.getLyricsBySongId).toHaveBeenCalledWith(
+ 'song-1',
+ expect.objectContaining({ signal: expect.any(Object) }),
+ )
+ expect(result.current.toolbarLyricsProps.lyricsActive).toBe(true)
+ expect(result.current.desktopLyricsProps.visible).toBe(true)
+ })
+})
diff --git a/ui/src/audioplayer/useRestoreFocusOnExit.js b/ui/src/audioplayer/useRestoreFocusOnExit.js
new file mode 100644
index 000000000..83b9cf170
--- /dev/null
+++ b/ui/src/audioplayer/useRestoreFocusOnExit.js
@@ -0,0 +1,38 @@
+import { useLayoutEffect } from 'react'
+
+const restoreFocusFromSurface = (surface, returnFocusRef) => {
+ if (!surface || typeof document === 'undefined') return
+
+ const activeElement = document.activeElement
+ if (!activeElement || !surface.contains(activeElement)) return
+
+ const returnTarget = returnFocusRef?.current
+ const canReturnFocus =
+ returnTarget?.isConnected &&
+ !returnTarget.disabled &&
+ returnTarget.getAttribute?.('aria-disabled') !== 'true' &&
+ typeof returnTarget.focus === 'function'
+
+ if (canReturnFocus) {
+ returnTarget.focus()
+ return
+ }
+
+ activeElement.blur?.()
+}
+
+const useRestoreFocusOnExit = ({
+ surfaceRef,
+ entered,
+ returnFocusRef,
+ surfaceKey,
+}) => {
+ useLayoutEffect(() => {
+ const surface = surfaceRef.current
+ if (!entered) restoreFocusFromSurface(surface, returnFocusRef)
+
+ return () => restoreFocusFromSurface(surface, returnFocusRef)
+ }, [entered, returnFocusRef, surfaceKey, surfaceRef])
+}
+
+export default useRestoreFocusOnExit
diff --git a/ui/src/i18n/en.json b/ui/src/i18n/en.json
index f694ea75f..e6ea01a00 100644
--- a/ui/src/i18n/en.json
+++ b/ui/src/i18n/en.json
@@ -709,6 +709,15 @@
"reloadText": "Reload",
"volumeText": "Volume",
"toggleLyricText": "Toggle lyrics",
+ "lyricsTitleText": "Lyrics",
+ "lyricsLoadingText": "Loading lyrics",
+ "lyricsUnavailableText": "Lyrics unavailable",
+ "lyricsRateLimitedText": "Remote lyrics are rate-limited. Retrying in %{smart_count} second. |||| Remote lyrics are rate-limited. Retrying in %{smart_count} seconds.",
+ "resizeLyricsSidebarText": "Resize lyrics sidebar",
+ "showLyricsTranslationText": "Show translation",
+ "hideLyricsTranslationText": "Hide translation",
+ "showLyricsPronunciationText": "Show pronunciation",
+ "hideLyricsPronunciationText": "Hide pronunciation",
"toggleMiniModeText": "Minimize",
"destroyText": "Destroy",
"downloadText": "Download",
diff --git a/ui/src/layout/Layout.jsx b/ui/src/layout/Layout.jsx
index 44cf9b42c..9b54c5aff 100644
--- a/ui/src/layout/Layout.jsx
+++ b/ui/src/layout/Layout.jsx
@@ -8,6 +8,7 @@ import AppBar from './AppBar'
import Notification from './Notification'
import useCurrentTheme from '../themes/useCurrentTheme'
import { useSearchRefocus } from '../common'
+import LyricsLayoutFrame from './LyricsLayoutFrame'
const useStyles = makeStyles({
root: { paddingBottom: (props) => (props.addPadding ? '80px' : 0) },
@@ -33,7 +34,9 @@ const Layout = (props) => {
appBar={AppBar}
theme={theme}
notification={Notification}
- />
+ >
+ {props.children}
+
)
}
diff --git a/ui/src/layout/LyricsLayoutFrame.jsx b/ui/src/layout/LyricsLayoutFrame.jsx
new file mode 100644
index 000000000..8164b37ec
--- /dev/null
+++ b/ui/src/layout/LyricsLayoutFrame.jsx
@@ -0,0 +1,171 @@
+import React, { useCallback, useEffect, useRef, useState } from 'react'
+import { makeStyles } from '@material-ui/core/styles'
+import { setSidebarVisibility } from 'react-admin'
+import { useDispatch, useSelector } from 'react-redux'
+import LyricsSidebar from '../audioplayer/LyricsSidebar'
+import { useLyricsLayout } from '../audioplayer/LyricsLayoutContext'
+import {
+ LYRICS_ROUTE_MIN_WIDTH,
+ LYRICS_SIDEBAR_MIN_WIDTH,
+ clampSidebarWidth,
+ getSidebarMaxWidth,
+ loadSidebarWidth,
+ saveSidebarWidth,
+} from '../audioplayer/lyricsSidebarWidth'
+
+const MIN_CONTENT_WITH_LYRICS =
+ LYRICS_ROUTE_MIN_WIDTH + LYRICS_SIDEBAR_MIN_WIDTH
+
+const useStyles = makeStyles(
+ () => ({
+ frame: {
+ minWidth: 0,
+ },
+ }),
+ { name: 'NDLyricsLayoutFrame' },
+)
+
+const LyricsLayoutFrame = ({ children }) => {
+ const { desktopLyricsProps } = useLyricsLayout()
+ const hasVisibleLyrics = Boolean(desktopLyricsProps?.visible)
+ const [preferredSidebarWidth, setPreferredSidebarWidth] =
+ useState(loadSidebarWidth)
+ const [contentWidth, setContentWidth] = useState(null)
+ const frameRef = useRef(null)
+ const autoCollapsedRef = useRef(false)
+ const manualSidebarOverrideRef = useRef(false)
+ const pendingSidebarStateRef = useRef(null)
+ const previousSidebarOpenRef = useRef(null)
+ const dispatch = useDispatch()
+ const sidebarOpen = useSelector(
+ (state) => state.admin?.ui?.sidebarOpen ?? false,
+ )
+ const classes = useStyles()
+ const sidebarMaxWidth = getSidebarMaxWidth(contentWidth)
+ const sidebarWidth = clampSidebarWidth(preferredSidebarWidth, sidebarMaxWidth)
+
+ useEffect(() => {
+ const parent = frameRef.current?.parentElement
+ if (!parent) return undefined
+
+ const updateContentWidth = (nextWidth) => {
+ const numeric = Number(nextWidth)
+ if (!Number.isFinite(numeric) || numeric <= 0) return
+ const rounded = Math.round(numeric)
+ setContentWidth((current) => (current === rounded ? current : rounded))
+ }
+
+ const measureParent = () => {
+ updateContentWidth(parent.clientWidth)
+ }
+
+ measureParent()
+ const ResizeObserverConstructor = window.ResizeObserver
+ if (!ResizeObserverConstructor) {
+ window.addEventListener('resize', measureParent)
+ return () => window.removeEventListener('resize', measureParent)
+ }
+
+ const resizeObserver = new ResizeObserverConstructor((entries) => {
+ const entry = entries.find((candidate) => candidate.target === parent)
+ const borderBoxSize = Array.isArray(entry?.borderBoxSize)
+ ? entry.borderBoxSize[0]
+ : entry?.borderBoxSize
+ updateContentWidth(
+ borderBoxSize?.inlineSize ||
+ entry?.target?.clientWidth ||
+ entry?.contentRect?.width,
+ )
+ })
+ resizeObserver.observe(parent)
+ return () => resizeObserver.disconnect()
+ }, [])
+
+ useEffect(() => {
+ if (hasVisibleLyrics) {
+ setPreferredSidebarWidth(loadSidebarWidth())
+ }
+ }, [hasVisibleLyrics])
+
+ useEffect(() => {
+ const previousSidebarOpen = previousSidebarOpenRef.current
+ previousSidebarOpenRef.current = sidebarOpen
+ if (previousSidebarOpen == null || previousSidebarOpen === sidebarOpen) {
+ return
+ }
+
+ if (pendingSidebarStateRef.current === sidebarOpen) {
+ pendingSidebarStateRef.current = null
+ return
+ }
+
+ if (hasVisibleLyrics) {
+ manualSidebarOverrideRef.current = true
+ autoCollapsedRef.current = false
+ }
+ }, [hasVisibleLyrics, sidebarOpen])
+
+ useEffect(() => {
+ if (!hasVisibleLyrics) {
+ if (
+ autoCollapsedRef.current &&
+ !manualSidebarOverrideRef.current &&
+ !sidebarOpen
+ ) {
+ autoCollapsedRef.current = false
+ pendingSidebarStateRef.current = true
+ dispatch(setSidebarVisibility(true))
+ }
+ manualSidebarOverrideRef.current = false
+ return
+ }
+
+ if (
+ manualSidebarOverrideRef.current ||
+ !Number.isFinite(contentWidth) ||
+ contentWidth <= 0
+ ) {
+ return
+ }
+
+ if (sidebarOpen && contentWidth < MIN_CONTENT_WITH_LYRICS) {
+ autoCollapsedRef.current = true
+ pendingSidebarStateRef.current = false
+ dispatch(setSidebarVisibility(false))
+ return
+ }
+ }, [contentWidth, dispatch, hasVisibleLyrics, sidebarOpen])
+
+ const handleSidebarWidthChange = useCallback(
+ (nextWidth, { persist = false } = {}) => {
+ const resolvedWidth = clampSidebarWidth(nextWidth, sidebarMaxWidth)
+ setPreferredSidebarWidth(resolvedWidth)
+ if (persist) saveSidebarWidth(resolvedWidth)
+ },
+ [sidebarMaxWidth],
+ )
+
+ return (
+
+ {children}
+ {desktopLyricsProps && (
+
+ )}
+
+ )
+}
+
+export default LyricsLayoutFrame
diff --git a/ui/src/layout/LyricsLayoutFrame.test.jsx b/ui/src/layout/LyricsLayoutFrame.test.jsx
new file mode 100644
index 000000000..632acf6b6
--- /dev/null
+++ b/ui/src/layout/LyricsLayoutFrame.test.jsx
@@ -0,0 +1,370 @@
+import React, { useEffect } from 'react'
+import {
+ act,
+ cleanup,
+ fireEvent,
+ render,
+ screen,
+ waitFor,
+} from '@testing-library/react'
+import { ThemeProvider, createTheme } from '@material-ui/core/styles'
+import { setSidebarVisibility } from 'react-admin'
+import { Provider } from 'react-redux'
+import { createStore } from 'redux'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import {
+ LyricsLayoutProvider,
+ useLyricsLayout,
+} from '../audioplayer/LyricsLayoutContext'
+import {
+ LYRICS_SIDEBAR_STORAGE_KEY,
+ LYRICS_SIDEBAR_TRANSITION_MS,
+} from '../audioplayer/lyricsSidebarWidth'
+import LyricsLayoutFrame from './LyricsLayoutFrame'
+
+const theme = createTheme({
+ palette: {
+ primary: { main: '#35aa66' },
+ background: { default: '#101820' },
+ },
+})
+
+const lyric = {
+ synced: true,
+ line: [{ start: 0, end: 1000, value: 'Main line' }],
+}
+
+const lyricsProps = {
+ visible: true,
+ mainLyric: lyric,
+ showTranslation: false,
+ showPronunciation: false,
+ translationEnabled: false,
+ pronunciationEnabled: false,
+ onToggleTranslation: vi.fn(),
+ onTogglePronunciation: vi.fn(),
+}
+
+const originalResizeObserver = window.ResizeObserver
+const resizeObservers = []
+
+class TestResizeObserver {
+ constructor(callback) {
+ this.callback = callback
+ this.target = null
+ this.disconnected = false
+ resizeObservers.push(this)
+ }
+
+ observe(target) {
+ this.target = target
+ }
+
+ disconnect() {
+ this.disconnected = true
+ }
+}
+
+const notifyContentWidth = (width) => {
+ act(() => {
+ resizeObservers.forEach((observer) => {
+ if (observer.disconnected || !observer.target) return
+ observer.callback([
+ {
+ target: observer.target,
+ contentRect: { width },
+ borderBoxSize: [{ inlineSize: width }],
+ },
+ ])
+ })
+ })
+}
+
+const sidebarVisibilityActionType = setSidebarVisibility(true).type
+
+const createLayoutStore = (sidebarOpen = false) =>
+ createStore(
+ (state, action) => {
+ if (action.type !== sidebarVisibilityActionType) return state
+ return {
+ ...state,
+ admin: {
+ ...state.admin,
+ ui: {
+ ...state.admin.ui,
+ sidebarOpen: action.payload,
+ },
+ },
+ }
+ },
+ { admin: { ui: { sidebarOpen } } },
+ )
+
+const PublishLyricsProps = ({ props }) => {
+ const { setDesktopLyricsProps } = useLyricsLayout()
+
+ useEffect(() => {
+ setDesktopLyricsProps(props)
+ return () => setDesktopLyricsProps(null)
+ }, [props, setDesktopLyricsProps])
+
+ return null
+}
+
+const frame = (props, store) => (
+
+
+
+
+
+ Albums grid
+
+
+
+
+)
+
+const renderFrame = (props, { sidebarOpen = false, store } = {}) => {
+ window.ResizeObserver = TestResizeObserver
+ const layoutStore = store || createLayoutStore(sidebarOpen)
+ const view = render(frame(props, layoutStore))
+ return {
+ ...view,
+ store: layoutStore,
+ rerenderFrame: (nextProps) => view.rerender(frame(nextProps, layoutStore)),
+ }
+}
+
+describe('', () => {
+ afterEach(() => {
+ vi.useRealTimers()
+ cleanup()
+ localStorage.clear()
+ resizeObservers.splice(0)
+ window.ResizeObserver = originalResizeObserver
+ })
+
+ it('reserves a native right-hand lyrics pane beside route content', async () => {
+ renderFrame(lyricsProps)
+
+ const sidebar = await screen.findByTestId('lyrics-sidebar')
+ const frame = screen.getByTestId('lyrics-layout-frame')
+ const content = screen.getByTestId('route-content')
+
+ expect(frame).toHaveAttribute('data-lyrics-sidebar-visible', 'true')
+ expect(frame).toHaveStyle({ marginRight: '360px' })
+ expect(frame).toContainElement(content)
+ expect(frame).toContainElement(sidebar)
+ expect(window.getComputedStyle(sidebar).position).toBe('fixed')
+ })
+
+ it('caps a persisted width against the measured route reserve without overwriting it', async () => {
+ localStorage.setItem(LYRICS_SIDEBAR_STORAGE_KEY, '520')
+ renderFrame(lyricsProps)
+ const sidebar = await screen.findByTestId('lyrics-sidebar')
+ const resizer = screen.getByTestId('lyrics-sidebar-resizer')
+
+ notifyContentWidth(755)
+
+ expect(screen.getByTestId('lyrics-layout-frame')).toHaveStyle({
+ marginRight: '435px',
+ })
+ expect(sidebar).toHaveStyle({ width: '435px' })
+ expect(resizer).toHaveAttribute('aria-valuemax', '435')
+ expect(resizer).toHaveAttribute('aria-valuenow', '435')
+ expect(localStorage.getItem(LYRICS_SIDEBAR_STORAGE_KEY)).toBe('520')
+
+ notifyContentWidth(840)
+
+ expect(screen.getByTestId('lyrics-layout-frame')).toHaveStyle({
+ marginRight: '520px',
+ })
+ expect(sidebar).toHaveStyle({ width: '520px' })
+ expect(resizer).toHaveAttribute('aria-valuemax', '520')
+ expect(resizer).toHaveAttribute('aria-valuenow', '520')
+ expect(localStorage.getItem(LYRICS_SIDEBAR_STORAGE_KEY)).toBe('520')
+ })
+
+ it('keeps the layout width reserved while the queue obscures lyrics', async () => {
+ localStorage.setItem(LYRICS_SIDEBAR_STORAGE_KEY, '520')
+ renderFrame({ ...lyricsProps, obscuredByQueue: true })
+ const sidebar = await screen.findByTestId('lyrics-sidebar')
+
+ notifyContentWidth(755)
+
+ expect(screen.getByTestId('lyrics-layout-frame')).toHaveStyle({
+ marginRight: '435px',
+ })
+ expect(sidebar).toHaveStyle({
+ width: '435px',
+ transform: 'translateX(0)',
+ pointerEvents: 'none',
+ })
+ expect(sidebar).toHaveAttribute('aria-hidden', 'true')
+ expect(sidebar).toHaveAttribute('inert')
+ expect(screen.getByTestId('karaoke-lyrics-panel')).toBeInTheDocument()
+ })
+
+ it('collapses an open app menu only when the minimum pane cannot fit', async () => {
+ localStorage.setItem(LYRICS_SIDEBAR_STORAGE_KEY, '520')
+ const { rerenderFrame, store } = renderFrame(lyricsProps, {
+ sidebarOpen: true,
+ })
+ await screen.findByTestId('lyrics-sidebar')
+
+ notifyContentWidth(570)
+
+ await waitFor(() =>
+ expect(store.getState().admin.ui.sidebarOpen).toBe(false),
+ )
+ expect(screen.getByTestId('lyrics-layout-frame')).toHaveStyle({
+ marginRight: '300px',
+ })
+ expect(screen.getByTestId('lyrics-sidebar-resizer')).toHaveAttribute(
+ 'aria-valuemax',
+ '300',
+ )
+
+ notifyContentWidth(755)
+
+ expect(screen.getByTestId('lyrics-layout-frame')).toHaveStyle({
+ marginRight: '435px',
+ })
+ expect(store.getState().admin.ui.sidebarOpen).toBe(false)
+
+ rerenderFrame({ ...lyricsProps, visible: false })
+
+ await waitFor(() =>
+ expect(store.getState().admin.ui.sidebarOpen).toBe(true),
+ )
+ })
+
+ it('keeps the app menu open at the 960px and 1280px layout caps', async () => {
+ localStorage.setItem(LYRICS_SIDEBAR_STORAGE_KEY, '520')
+ const { store } = renderFrame(lyricsProps, { sidebarOpen: true })
+ await screen.findByTestId('lyrics-sidebar')
+
+ notifyContentWidth(720)
+
+ expect(store.getState().admin.ui.sidebarOpen).toBe(true)
+ expect(screen.getByTestId('lyrics-layout-frame')).toHaveStyle({
+ marginRight: '400px',
+ })
+ expect(screen.getByTestId('lyrics-sidebar-resizer')).toHaveAttribute(
+ 'aria-valuemax',
+ '400',
+ )
+
+ notifyContentWidth(1040)
+
+ expect(screen.getByTestId('lyrics-layout-frame')).toHaveStyle({
+ marginRight: '520px',
+ })
+ expect(screen.getByTestId('lyrics-sidebar-resizer')).toHaveAttribute(
+ 'aria-valuemax',
+ '520',
+ )
+ })
+
+ it('keeps an automatic collapse until lyrics close when space returns', async () => {
+ const { rerenderFrame, store } = renderFrame(lyricsProps, {
+ sidebarOpen: true,
+ })
+ await screen.findByTestId('lyrics-sidebar')
+ notifyContentWidth(570)
+ await waitFor(() =>
+ expect(store.getState().admin.ui.sidebarOpen).toBe(false),
+ )
+
+ notifyContentWidth(1040)
+
+ expect(store.getState().admin.ui.sidebarOpen).toBe(false)
+
+ rerenderFrame({ ...lyricsProps, visible: false })
+
+ await waitFor(() =>
+ expect(store.getState().admin.ui.sidebarOpen).toBe(true),
+ )
+ })
+
+ it('does not restore the menu after a manual override', async () => {
+ const { rerenderFrame, store } = renderFrame(lyricsProps, {
+ sidebarOpen: true,
+ })
+ await screen.findByTestId('lyrics-sidebar')
+ notifyContentWidth(570)
+ await waitFor(() =>
+ expect(store.getState().admin.ui.sidebarOpen).toBe(false),
+ )
+
+ act(() => {
+ store.dispatch(setSidebarVisibility(true))
+ })
+ await waitFor(() =>
+ expect(store.getState().admin.ui.sidebarOpen).toBe(true),
+ )
+ act(() => {
+ store.dispatch(setSidebarVisibility(false))
+ })
+ await waitFor(() =>
+ expect(store.getState().admin.ui.sidebarOpen).toBe(false),
+ )
+
+ rerenderFrame({ ...lyricsProps, visible: false })
+
+ expect(store.getState().admin.ui.sidebarOpen).toBe(false)
+ })
+
+ it('persists deliberate keyboard resizing at the measured cap', async () => {
+ localStorage.setItem(LYRICS_SIDEBAR_STORAGE_KEY, '520')
+ renderFrame(lyricsProps)
+ await screen.findByTestId('lyrics-sidebar')
+ notifyContentWidth(755)
+ const resizer = screen.getByTestId('lyrics-sidebar-resizer')
+
+ fireEvent.keyDown(resizer, { key: 'Home' })
+
+ expect(screen.getByTestId('lyrics-layout-frame')).toHaveStyle({
+ marginRight: '300px',
+ })
+ expect(localStorage.getItem(LYRICS_SIDEBAR_STORAGE_KEY)).toBe('300')
+
+ fireEvent.keyDown(resizer, { key: 'End' })
+
+ expect(screen.getByTestId('lyrics-layout-frame')).toHaveStyle({
+ marginRight: '435px',
+ })
+ expect(resizer).toHaveAttribute('aria-valuenow', '435')
+ expect(localStorage.getItem(LYRICS_SIDEBAR_STORAGE_KEY)).toBe('435')
+ })
+
+ it('releases the layout frame after the sidebar exit transition', async () => {
+ vi.useFakeTimers()
+ const { rerenderFrame } = renderFrame(lyricsProps)
+
+ await screen.findByTestId('lyrics-sidebar')
+
+ rerenderFrame({ ...lyricsProps, visible: false })
+
+ expect(screen.getByTestId('lyrics-layout-frame')).toHaveAttribute(
+ 'data-lyrics-sidebar-visible',
+ 'false',
+ )
+ expect(screen.getByTestId('lyrics-layout-frame')).toHaveStyle({
+ marginRight: '0px',
+ })
+ expect(screen.getByTestId('lyrics-sidebar')).toHaveStyle({
+ transform: 'translateX(100%)',
+ })
+
+ act(() => {
+ vi.advanceTimersByTime(LYRICS_SIDEBAR_TRANSITION_MS)
+ })
+
+ expect(screen.getByTestId('lyrics-layout-frame')).toHaveAttribute(
+ 'data-lyrics-sidebar-visible',
+ 'false',
+ )
+ expect(screen.queryByTestId('lyrics-sidebar')).toBeNull()
+ })
+})
diff --git a/ui/src/layout/Menu.jsx b/ui/src/layout/Menu.jsx
index 45f40b26d..899c2e869 100644
--- a/ui/src/layout/Menu.jsx
+++ b/ui/src/layout/Menu.jsx
@@ -11,6 +11,10 @@ import albumLists from '../album/albumLists'
import PlaylistsSubMenu from './PlaylistsSubMenu'
import LibrarySelector from '../common/LibrarySelector'
import config from '../config'
+import {
+ APP_SIDEBAR_CLOSED_WIDTH,
+ APP_SIDEBAR_OPEN_WIDTH,
+} from './layoutWidths'
const useStyles = makeStyles((theme) => ({
root: {
@@ -23,10 +27,10 @@ const useStyles = makeStyles((theme) => ({
paddingBottom: (props) => (props.addPadding ? '80px' : '20px'),
},
open: {
- width: 240,
+ width: APP_SIDEBAR_OPEN_WIDTH,
},
closed: {
- width: 55,
+ width: APP_SIDEBAR_CLOSED_WIDTH,
},
active: {
color: theme.palette.text.primary,
diff --git a/ui/src/layout/layoutWidths.js b/ui/src/layout/layoutWidths.js
new file mode 100644
index 000000000..4587891f6
--- /dev/null
+++ b/ui/src/layout/layoutWidths.js
@@ -0,0 +1,2 @@
+export const APP_SIDEBAR_OPEN_WIDTH = 240
+export const APP_SIDEBAR_CLOSED_WIDTH = 55
diff --git a/ui/src/subsonic/index.js b/ui/src/subsonic/index.js
index 9f0ad28d7..8fb0be528 100644
--- a/ui/src/subsonic/index.js
+++ b/ui/src/subsonic/index.js
@@ -131,6 +131,26 @@ const getTopSongs = (artist, count = 50) => {
return httpClient(url('getTopSongs', null, { artist, count }))
}
+const requireSuccessfulLyricsResponse = (response) => {
+ const subsonicResponse = response?.json?.['subsonic-response']
+ if (subsonicResponse?.status !== 'failed') return response
+
+ const error = new Error(
+ subsonicResponse.error?.message || 'Lyrics request failed',
+ )
+ error.body = response.json
+ error.code = subsonicResponse.error?.code
+ throw error
+}
+
+const getLyricsBySongId = (id, requestOptions) => {
+ const requestUrl = url('getLyricsBySongId', id, { enhanced: true })
+ const request = requestOptions
+ ? httpClient(requestUrl, requestOptions)
+ : httpClient(requestUrl)
+ return request.then(requireSuccessfulLyricsResponse)
+}
+
const streamUrl = (id, options) => {
return baseUrl(
url('stream', id, {
@@ -160,4 +180,5 @@ export default {
getArtistInfo,
getTopSongs,
getSimilarSongs2,
+ getLyricsBySongId,
}
diff --git a/ui/src/subsonic/index.test.js b/ui/src/subsonic/index.test.js
index 32c699a41..d50e686b8 100644
--- a/ui/src/subsonic/index.test.js
+++ b/ui/src/subsonic/index.test.js
@@ -1,27 +1,56 @@
-import { vi } from 'vitest'
+import { afterEach, vi } from 'vitest'
import config from '../config'
+import { httpClient } from '../dataProvider'
import subsonic from './index'
+vi.mock('../dataProvider', () => ({
+ httpClient: vi.fn((target) => Promise.resolve({ target })),
+ clientUniqueId: 'test-client-id',
+ clientUniqueIdHeader: 'x-test-client-id',
+}))
+
+const originalLocalStorageDescriptor = Object.getOwnPropertyDescriptor(
+ window,
+ 'localStorage',
+)
+
+const mockSubsonicLocalStorage = () => {
+ const localStorageMock = {
+ getItem: vi.fn((key) => {
+ const values = {
+ username: 'testuser',
+ 'subsonic-token': 'testtoken',
+ 'subsonic-salt': 'testsalt',
+ }
+ return values[key] || null
+ }),
+ setItem: vi.fn(),
+ clear: vi.fn(),
+ }
+
+ Object.defineProperty(window, 'localStorage', {
+ configurable: true,
+ value: localStorageMock,
+ })
+}
+
+afterEach(() => {
+ if (originalLocalStorageDescriptor) {
+ Object.defineProperty(
+ window,
+ 'localStorage',
+ originalLocalStorageDescriptor,
+ )
+ }
+})
+
describe('getCoverArtUrl', () => {
beforeEach(() => {
// Mock window.location
delete window.location
window.location = { href: 'http://localhost:3000/app' }
- // Mock localStorage values required by subsonic
- const localStorageMock = {
- getItem: vi.fn((key) => {
- const values = {
- username: 'testuser',
- 'subsonic-token': 'testtoken',
- 'subsonic-salt': 'testsalt',
- }
- return values[key] || null
- }),
- setItem: vi.fn(),
- clear: vi.fn(),
- }
- Object.defineProperty(window, 'localStorage', { value: localStorageMock })
+ mockSubsonicLocalStorage()
})
it('should return playlist cover art URL for records with sync property', () => {
@@ -163,19 +192,45 @@ describe('getCoverArtUrl', () => {
})
})
+describe('getLyricsBySongId', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockSubsonicLocalStorage()
+ })
+
+ it('requests enhanced structured lyrics for the song id', async () => {
+ await subsonic.getLyricsBySongId('song-123')
+
+ expect(httpClient).toHaveBeenCalledTimes(1)
+ const requestedUrl = httpClient.mock.calls[0][0]
+ expect(requestedUrl).toContain('/rest/getLyricsBySongId?')
+ expect(requestedUrl).toContain('id=song-123')
+ expect(requestedUrl).toContain('enhanced=true')
+ })
+
+ it('rejects a failed Subsonic lyrics response with its server message', async () => {
+ httpClient.mockResolvedValueOnce({
+ json: {
+ 'subsonic-response': {
+ status: 'failed',
+ error: {
+ code: 0,
+ message:
+ 'Internal Server Error: better lyrics API cooldown active for 30s',
+ },
+ },
+ },
+ })
+
+ await expect(subsonic.getLyricsBySongId('song-123')).rejects.toThrow(
+ 'better lyrics API cooldown active for 30s',
+ )
+ })
+})
+
describe('getDiscCoverArtUrl', () => {
beforeEach(() => {
- const localStorageMock = {
- getItem: vi.fn((key) => {
- const values = {
- username: 'testuser',
- 'subsonic-token': 'testtoken',
- 'subsonic-salt': 'testsalt',
- }
- return values[key] || null
- }),
- }
- Object.defineProperty(window, 'localStorage', { value: localStorageMock })
+ mockSubsonicLocalStorage()
})
it('should construct URL with dc-albumId:discNumber format, size, and cache param', () => {
@@ -215,18 +270,7 @@ describe('getDiscCoverArtUrl', () => {
describe('getAvatarUrl', () => {
beforeEach(() => {
- // Mock localStorage values required by subsonic
- const localStorageMock = {
- getItem: vi.fn((key) => {
- const values = {
- username: 'testuser',
- 'subsonic-token': 'testtoken',
- 'subsonic-salt': 'testsalt',
- }
- return values[key] || null
- }),
- }
- Object.defineProperty(window, 'localStorage', { value: localStorageMock })
+ mockSubsonicLocalStorage()
})
it('should include username parameter', () => {
@@ -238,17 +282,7 @@ describe('getAvatarUrl', () => {
describe('reportPlayback', () => {
beforeEach(() => {
- const localStorageMock = {
- getItem: vi.fn((key) => {
- const values = {
- username: 'testuser',
- 'subsonic-token': 'testtoken',
- 'subsonic-salt': 'testsalt',
- }
- return values[key] || null
- }),
- }
- Object.defineProperty(window, 'localStorage', { value: localStorageMock })
+ mockSubsonicLocalStorage()
})
it('should construct reportPlayback URL with correct parameters', () => {
diff --git a/ui/src/themes/nutball.css.js b/ui/src/themes/nutball.css.js
index aaf1c0e16..a1428461e 100644
--- a/ui/src/themes/nutball.css.js
+++ b/ui/src/themes/nutball.css.js
@@ -1,3 +1,5 @@
+import { PLAYER_MOBILE_MAX_WIDTH } from '../audioplayer/playerBreakpoints'
+
const stylesheet = `
html {
scrollbar-width: none;
@@ -136,7 +138,7 @@ button[data-testid="save-queue-button"] {
stroke: #fff;
height: .8em;
}
-@media screen and (max-width: 810px) {
+@media screen and (max-width: ${PLAYER_MOBILE_MAX_WIDTH}px) {
.react-jinke-music-player-main .music-player-panel .panel-content .player-content .play-sounds .sounds-icon {
margin-left: 5px;
margin-right: 0;