diff --git a/ui/src/actions/player.js b/ui/src/actions/player.js
index f55102207..9056abeb6 100644
--- a/ui/src/actions/player.js
+++ b/ui/src/actions/player.js
@@ -9,7 +9,6 @@ export const PLAYER_SET_VOLUME = 'PLAYER_SET_VOLUME'
export const PLAYER_SET_MODE = 'PLAYER_SET_MODE'
export const TRANSCODING_SET_PROFILE = 'TRANSCODING_SET_PROFILE'
export const PLAYER_REFRESH_QUEUE = 'PLAYER_REFRESH_QUEUE'
-export const PLAYER_UPDATE_LYRIC = 'PLAYER_UPDATE_LYRIC'
export const setTrack = (data) => ({
type: PLAYER_SET_TRACK,
@@ -115,8 +114,3 @@ export const refreshQueue = (resolvedUrls) => ({
type: PLAYER_REFRESH_QUEUE,
data: resolvedUrls,
})
-
-export const updateQueueLyric = (trackId, lyric) => ({
- type: PLAYER_UPDATE_LYRIC,
- data: { trackId, lyric },
-})
diff --git a/ui/src/audioplayer/KaraokeLyricsOverlay.jsx b/ui/src/audioplayer/KaraokeLyricsOverlay.jsx
deleted file mode 100644
index aefb0127e..000000000
--- a/ui/src/audioplayer/KaraokeLyricsOverlay.jsx
+++ /dev/null
@@ -1,1745 +0,0 @@
-import IconButton from '@material-ui/core/IconButton'
-import Popover from '@material-ui/core/Popover'
-import Slider from '@material-ui/core/Slider'
-import { makeStyles, useTheme } from '@material-ui/core/styles'
-import Tooltip from '@material-ui/core/Tooltip'
-import Typography from '@material-ui/core/Typography'
-import CloseIcon from '@material-ui/icons/Close'
-import RestoreIcon from '@material-ui/icons/Restore'
-import TuneIcon from '@material-ui/icons/Tune'
-import clsx from 'clsx'
-import React, {
- memo,
- useCallback,
- useEffect,
- useMemo,
- useRef,
- useState,
-} from 'react'
-import {
- buildHighlightedAuxLine,
- buildHighlightedMainLine,
- buildKaraokeLines,
- getActiveKaraokeState,
- hasUsableKaraokeTiming,
- hasStructuredLyricContent,
- resolveKaraokeTokenWindow,
- resolveLayerLineForMain,
- utf8ByteRangeToCodeUnitRange,
-} from './lyrics'
-
-const KARAOKE_RENDER_LEAD_MS = 80
-const KARAOKE_CLOCK_DRIFT_RESET_MS = 140
-const KARAOKE_CLOCK_RESET_THRESHOLD_MS = 320
-const KARAOKE_MONOTONIC_JITTER_MS = 60
-const KARAOKE_RENDER_UPDATE_EPSILON_MS = 6
-const KARAOKE_WORD_SETTLE_MS = 96
-const KARAOKE_ANIMATION_MS = 150
-const KARAOKE_DEFAULT_HEIGHT_PX = 300
-const KARAOKE_MIN_HEIGHT_PX = 150
-const KARAOKE_MAX_HEIGHT_RATIO = 0.72
-const KARAOKE_MAX_HEIGHT_PX = 760
-const KARAOKE_CENTER_SPACER_RATIO = 0.5
-const KARAOKE_CENTER_SPACER_MIN_PX = 132
-const KARAOKE_DEFAULT_LINE_HEIGHT = 1.3
-const KARAOKE_MIN_LINE_HEIGHT = 1
-const KARAOKE_MAX_LINE_HEIGHT = 2.2
-const KARAOKE_LINE_HEIGHT_STEP = 0.02
-const KARAOKE_GROUP_SPACING_BASE_PX = 14
-const KARAOKE_AUX_LINE_HEIGHT = 1.2
-const KARAOKE_MAIN_INACTIVE_FONT_FACTOR = 0.8
-const KARAOKE_AUX_INACTIVE_FONT_FACTOR = 0.88
-
-const TOKEN_DONE_ALPHA = 1
-const TOKEN_FUTURE_ALPHA = 0.34
-const TOKEN_ACTIVE_ALPHA = 1
-const TOKEN_WIPE_SOFT_SPREAD_PCT = 12
-const TOKEN_WIPE_EDGE_PCT = 8
-
-const COLOR_PRESETS = [
- { key: 'white', label: 'White', value: 'rgba(255, 255, 255, 0.92)' },
- { key: 'black', label: 'Black', value: 'rgba(0, 0, 0, 0.87)' },
- { key: 'blue', label: 'Blue', value: 'rgba(120, 160, 220, 0.75)' },
- { key: 'green', label: 'Green', value: 'rgba(100, 200, 130, 0.7)' },
- { key: 'pink', label: 'Pink', value: 'rgba(240, 140, 170, 0.75)' },
- { key: 'purple', label: 'Purple', value: 'rgba(180, 140, 240, 0.75)' },
- { key: 'orange', label: 'Orange', value: 'rgba(240, 180, 100, 0.75)' },
- { key: 'cyan', label: 'Cyan', value: 'rgba(100, 210, 220, 0.75)' },
- { key: 'yellow', label: 'Yellow', value: 'rgba(240, 230, 110, 0.75)' },
-]
-
-const DEFAULT_LYRICS_SETTINGS = {
- lineHeight: KARAOKE_DEFAULT_LINE_HEIGHT,
- overlayHeight: KARAOKE_DEFAULT_HEIGHT_PX,
- tr: { fontSize: 18, colorKey: 'blue' },
- main: { fontSize: 30, colorKey: 'white' },
- pr: { fontSize: 18, colorKey: 'green' },
-}
-
-const SETTINGS_STORAGE_KEY = 'karaoke-lyrics-settings'
-
-const createDefaultLyricsSettings = (isDark = true) => ({
- lineHeight: KARAOKE_DEFAULT_LINE_HEIGHT,
- overlayHeight: KARAOKE_DEFAULT_HEIGHT_PX,
- tr: { ...DEFAULT_LYRICS_SETTINGS.tr },
- main: { ...DEFAULT_LYRICS_SETTINGS.main, colorKey: isDark ? 'white' : 'black' },
- pr: { ...DEFAULT_LYRICS_SETTINGS.pr },
-})
-
-const clampLineHeight = (value) => {
- const numeric = Number(value)
- if (!Number.isFinite(numeric)) {
- return KARAOKE_DEFAULT_LINE_HEIGHT
- }
- return clamp(numeric, KARAOKE_MIN_LINE_HEIGHT, KARAOKE_MAX_LINE_HEIGHT)
-}
-
-const clampOverlayHeightPreference = (value) => {
- const numeric = Number(value)
- if (!Number.isFinite(numeric)) {
- return KARAOKE_DEFAULT_HEIGHT_PX
- }
- return clamp(numeric, KARAOKE_MIN_HEIGHT_PX, KARAOKE_MAX_HEIGHT_PX)
-}
-
-const normalizeLyricsSettings = (settings) => ({
- lineHeight: clampLineHeight(settings?.lineHeight),
- overlayHeight: clampOverlayHeightPreference(settings?.overlayHeight),
- tr: { ...DEFAULT_LYRICS_SETTINGS.tr, ...settings?.tr },
- main: { ...DEFAULT_LYRICS_SETTINGS.main, ...settings?.main },
- pr: { ...DEFAULT_LYRICS_SETTINGS.pr, ...settings?.pr },
-})
-
-const loadLyricsSettings = () => {
- try {
- const raw = localStorage.getItem(SETTINGS_STORAGE_KEY)
- if (raw) {
- return normalizeLyricsSettings(JSON.parse(raw))
- }
- } catch {
- /* ignore */
- }
- return normalizeLyricsSettings()
-}
-
-const saveLyricsSettings = (settings) => {
- try {
- localStorage.setItem(
- SETTINGS_STORAGE_KEY,
- JSON.stringify(normalizeLyricsSettings(settings)),
- )
- } catch {
- /* ignore */
- }
-}
-
-const getColorValue = (colorKey) =>
- COLOR_PRESETS.find((c) => c.key === colorKey)?.value || COLOR_PRESETS[0].value
-
-const hexToRgba = (hex, alpha) => {
- const m = (hex || '').match(/#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})/i)
- if (m) return `rgba(${parseInt(m[1], 16)}, ${parseInt(m[2], 16)}, ${parseInt(m[3], 16)}, ${alpha})`
- const rm = (hex || '').match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/)
- if (rm) return `rgba(${rm[1]}, ${rm[2]}, ${rm[3]}, ${alpha})`
- return `rgba(48, 48, 48, ${alpha})`
-}
-
-const useStyles = makeStyles((theme) => {
- const isDark = theme.palette.type === 'dark'
- const overlayBg = hexToRgba(theme.palette.background.default, 0.85)
- const primaryMain = theme.palette.primary.main
- const primaryRgb = (() => {
- const m = (primaryMain || '').match(/#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})/i)
- if (m) return [parseInt(m[1], 16), parseInt(m[2], 16), parseInt(m[3], 16)]
- const rm = (primaryMain || '').match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/)
- if (rm) return [parseInt(rm[1]), parseInt(rm[2]), parseInt(rm[3])]
- return [144, 202, 249]
- })()
- const textPrimary = isDark ? 'rgba(255, 255, 255, 0.92)' : 'rgba(0, 0, 0, 0.87)'
- const textSecondary = isDark ? 'rgba(255, 255, 255, 0.55)' : 'rgba(0, 0, 0, 0.54)'
- const borderSubtle = isDark ? 'rgba(255, 255, 255, 0.12)' : 'rgba(0, 0, 0, 0.12)'
-
- return ({
- overlay: {
- position: 'fixed',
- left: '50%',
- bottom: 100,
- transform: 'translateX(-50%)',
- zIndex: 1400,
- width: 'min(1000px, calc(100vw - 32px))',
- minHeight: KARAOKE_MIN_HEIGHT_PX,
- background: overlayBg,
- borderRadius: 12,
- border: `1px solid ${borderSubtle}`,
- boxShadow: '0 18px 48px rgba(0, 0, 0, 0.42)',
- backdropFilter: 'blur(20px)',
- color: textPrimary,
- display: 'flex',
- flexDirection: 'column',
- overflow: 'hidden',
- '@media (max-width:810px)': {
- bottom: 78,
- width: 'calc(100vw - 12px)',
- borderRadius: 12,
- minHeight: 180,
- maxHeight: '65vh',
- },
- },
- overlayInline: {
- position: 'absolute',
- inset: 0,
- width: '100%',
- height: '100%',
- minHeight: 0,
- maxHeight: '100%',
- transform: 'none',
- borderRadius: 'inherit',
- border: 'none',
- boxShadow: 'none',
- background: 'transparent',
- backdropFilter: 'blur(16px)',
- WebkitBackdropFilter: 'blur(16px)',
- zIndex: 1,
- },
- resizeHandle: {
- height: 14,
- cursor: 'ns-resize',
- flexShrink: 0,
- position: 'relative',
- '&::after': {
- content: '""',
- position: 'absolute',
- left: '50%',
- top: 4,
- transform: 'translateX(-50%)',
- width: 56,
- height: 3,
- borderRadius: 999,
- background: `rgba(${primaryRgb.join(', ')}, 0.22)`,
- },
- '@media (max-width:810px)': {
- display: 'none',
- },
- },
- header: {
- display: 'flex',
- alignItems: 'center',
- justifyContent: 'space-between',
- gap: theme.spacing(1),
- padding: theme.spacing(0.3, 1.3, 0.4, 1.3),
- },
- headerInline: {
- padding: theme.spacing(0.25, 0.65, 0.35, 0.65),
- gap: theme.spacing(0.65),
- },
- headerLeft: {
- display: 'flex',
- alignItems: 'center',
- gap: theme.spacing(1),
- minWidth: 0,
- },
- languageBadges: {
- display: 'flex',
- alignItems: 'center',
- gap: theme.spacing(0.5),
- flexWrap: 'wrap',
- minWidth: 0,
- },
- languageBadge: {
- display: 'inline-flex',
- alignItems: 'center',
- justifyContent: 'center',
- gap: theme.spacing(0.35),
- padding: theme.spacing(0.2, 0.7),
- borderRadius: 999,
- border: `1px solid ${borderSubtle}`,
- background: isDark ? 'rgba(15, 23, 42, 0.42)' : 'rgba(0, 0, 0, 0.06)',
- color: isDark ? 'rgba(226, 232, 240, 0.8)' : 'rgba(0, 0, 0, 0.6)',
- fontSize: 10,
- lineHeight: 1,
- letterSpacing: '0.04em',
- whiteSpace: 'nowrap',
- transition: `all ${KARAOKE_ANIMATION_MS}ms ease-in-out`,
- userSelect: 'none',
- },
- languageBadgeToggle: {
- cursor: 'pointer',
- '&:hover': {
- borderColor: `rgba(${primaryRgb.join(', ')}, 0.35)`,
- background: isDark ? 'rgba(15, 23, 42, 0.56)' : 'rgba(0, 0, 0, 0.1)',
- },
- },
- languageBadgeActive: {
- borderColor: `rgba(${primaryRgb.join(', ')}, 0.46)`,
- background: `rgba(${primaryRgb.join(', ')}, 0.18)`,
- color: isDark ? 'rgba(248, 250, 252, 0.94)' : 'rgba(0, 0, 0, 0.87)',
- },
- languageBadgeLabel: {
- fontWeight: 700,
- textTransform: 'uppercase',
- opacity: 0.78,
- },
- languageBadgeValue: {
- opacity: 0.9,
- },
- closeButton: {
- color: textSecondary,
- },
- lineGroup: {
- display: 'flex',
- flexDirection: 'column',
- alignItems: 'center',
- gap: theme.spacing(0.35),
- },
- inlineTr: {
- margin: 0,
- display: 'inline-block',
- maxWidth: '100%',
- textAlign: 'center',
- fontWeight: 400,
- lineHeight: KARAOKE_AUX_LINE_HEIGHT,
- letterSpacing: '0.01em',
- transition: `opacity ${KARAOKE_ANIMATION_MS}ms ease-in-out, color ${KARAOKE_ANIMATION_MS}ms ease-in-out, font-size 280ms ease-in-out, max-width 280ms ease-in-out`,
- },
- inlinePr: {
- margin: 0,
- display: 'inline-flex',
- alignItems: 'center',
- justifyContent: 'center',
- flexWrap: 'wrap',
- alignSelf: 'center',
- width: 'fit-content',
- maxWidth: '100%',
- boxSizing: 'border-box',
- textAlign: 'center',
- fontWeight: 400,
- lineHeight: 1,
- letterSpacing: '0.01em',
- transition: `opacity ${KARAOKE_ANIMATION_MS}ms ease-in-out, color ${KARAOKE_ANIMATION_MS}ms ease-in-out, font-size 280ms ease-in-out, max-width 280ms ease-in-out`,
- padding: theme.spacing(0.15, 0.9),
- borderRadius: 999,
- background: isDark ? 'rgba(255, 255, 255, 0.08)' : 'rgba(0, 0, 0, 0.05)',
- border: `1px solid ${borderSubtle}`,
- },
- bodyWrapper: {
- position: 'relative',
- flex: 1,
- overflow: 'hidden',
- },
- body: {
- padding: theme.spacing(0.5, 2, 1.4, 2),
- overflowY: 'auto',
- overflowX: 'hidden',
- height: '100%',
- overscrollBehavior: 'contain',
- scrollbarWidth: 'none',
- msOverflowStyle: 'none',
- maskImage: 'linear-gradient(to bottom, transparent 0%, black 8%, black 92%, transparent 100%)',
- WebkitMaskImage: 'linear-gradient(to bottom, transparent 0%, black 8%, black 92%, transparent 100%)',
- '&::-webkit-scrollbar': {
- display: 'none',
- width: 0,
- height: 0,
- },
- '@media (max-width:810px)': {
- padding: theme.spacing(0.35, 1.2, 1.2, 1.2),
- },
- },
- bodyInline: {
- padding: theme.spacing(0.25, 0.8, 0.85, 0.8),
- },
- lines: {
- display: 'flex',
- flexDirection: 'column',
- gap: theme.spacing(1.24),
- paddingBottom: theme.spacing(1),
- },
- line: {
- margin: 0,
- display: 'inline-block',
- maxWidth: '100%',
- fontWeight: 600,
- lineHeight: 1.24,
- letterSpacing: '0.01em',
- textAlign: 'center',
- color: isDark ? 'rgba(255, 255, 255, 0.62)' : 'rgba(0, 0, 0, 0.52)',
- transition: `opacity ${KARAOKE_ANIMATION_MS}ms ease-in-out, color ${KARAOKE_ANIMATION_MS}ms ease-in-out, font-size 280ms ease-in-out, max-width 280ms ease-in-out`,
- },
- token: {
- display: 'inline-block',
- whiteSpace: 'pre-wrap',
- transition: `color ${KARAOKE_ANIMATION_MS}ms ease-in-out, text-shadow ${KARAOKE_ANIMATION_MS}ms ease-in-out`,
- },
- settingsButton: {
- color: textSecondary,
- padding: 4,
- '&:hover': {
- color: textPrimary,
- },
- },
- settingsPanel: {
- background: isDark ? 'rgba(12, 14, 20, 0.96)' : 'rgba(255, 255, 255, 0.96)',
- border: `1px solid ${borderSubtle}`,
- borderRadius: 10,
- padding: theme.spacing(1.5, 2),
- width: 278,
- backdropFilter: 'blur(12px)',
- },
- settingsHeader: {
- display: 'flex',
- alignItems: 'center',
- justifyContent: 'space-between',
- gap: theme.spacing(1),
- marginBottom: theme.spacing(1.25),
- },
- settingsSection: {
- marginBottom: theme.spacing(1.2),
- '&:last-child': {
- marginBottom: 0,
- },
- },
- settingsTitle: {
- fontSize: 11,
- fontWeight: 700,
- letterSpacing: '0.08em',
- textTransform: 'uppercase',
- color: isDark ? 'rgba(255, 255, 255, 0.78)' : 'rgba(0, 0, 0, 0.72)',
- },
- settingsLabel: {
- fontSize: 10,
- fontWeight: 600,
- letterSpacing: '0.1em',
- textTransform: 'uppercase',
- color: isDark ? 'rgba(255, 255, 255, 0.55)' : 'rgba(0, 0, 0, 0.5)',
- marginBottom: 4,
- },
- settingsRow: {
- display: 'flex',
- alignItems: 'center',
- gap: theme.spacing(1),
- },
- settingsSlider: {
- flex: 1,
- color: `rgba(${primaryRgb.join(', ')}, 0.6)`,
- '& .MuiSlider-thumb': {
- width: 12,
- height: 12,
- },
- '& .MuiSlider-rail': {
- opacity: 0.3,
- },
- },
- settingsSliderValue: {
- fontSize: 11,
- color: isDark ? 'rgba(255, 255, 255, 0.5)' : 'rgba(0, 0, 0, 0.45)',
- minWidth: 22,
- textAlign: 'right',
- },
- settingsControlLabel: {
- fontSize: 10,
- letterSpacing: '0.06em',
- textTransform: 'uppercase',
- color: isDark ? 'rgba(255, 255, 255, 0.45)' : 'rgba(0, 0, 0, 0.42)',
- minWidth: 72,
- whiteSpace: 'nowrap',
- },
- resetButton: {
- color: textSecondary,
- padding: 4,
- '&:hover': {
- color: textPrimary,
- },
- },
- colorDots: {
- display: 'flex',
- gap: 5,
- marginTop: 4,
- },
- colorDot: {
- width: 16,
- height: 16,
- borderRadius: '50%',
- border: '2px solid transparent',
- cursor: 'pointer',
- transition: 'border-color 120ms ease, transform 120ms ease',
- '&:hover': {
- transform: 'scale(1.2)',
- },
- },
- colorDotActive: {
- borderColor: isDark ? 'rgba(255, 255, 255, 0.85)' : 'rgba(0, 0, 0, 0.7)',
- },
-})})
-
-const clamp = (v, min, max) => Math.max(min, Math.min(max, v))
-const lerp = (from, to, t) => from + (to - from) * t
-const formatLineHeight = (value) => clampLineHeight(value).toFixed(2)
-const getLineGapPx = (lineHeight) =>
- `${Math.round(clampLineHeight(lineHeight) * KARAOKE_GROUP_SPACING_BASE_PX)}px`
-
-const normalizeForComparison = (text) =>
- (text || '').replace(/[\s\p{P}]/gu, '').toLowerCase()
-
-const shouldShowAuxLine = (mainLine, auxLine) => {
- if (!auxLine || !auxLine.value) return false
- return (
- normalizeForComparison(auxLine.value) !==
- normalizeForComparison(mainLine.value)
- )
-}
-
-const buildLanguageBadges = ({
- mainLyric,
- translationLyric,
- pronunciationLyric,
- showTranslation,
- showPronunciation,
- translationEnabled,
- pronunciationEnabled,
-}) =>
- [
- {
- key: 'main',
- label: 'Main',
- lang: mainLyric?.lang,
- active: true,
- toggleable: false,
- },
- pronunciationEnabled && {
- key: 'pr',
- label: 'PR',
- lang: pronunciationLyric?.lang,
- active: showPronunciation,
- toggleable: true,
- tooltip: showPronunciation ? 'Hide pronunciation' : 'Show pronunciation',
- },
- translationEnabled && {
- key: 'tr',
- label: 'TR',
- lang: translationLyric?.lang,
- active: showTranslation,
- toggleable: true,
- tooltip: showTranslation ? 'Hide translation' : 'Show translation',
- },
- ].filter((badge) => badge && badge.lang)
-
-const SettingsSection = ({ label, layer, settings, onChange, classes }) => {
- const s = settings[layer]
- return (
-
-
{label}
-
-
- onChange({ ...settings, [layer]: { ...s, fontSize: val } })
- }
- />
- {s.fontSize}
-
-
- {COLOR_PRESETS.map((preset) => (
-
- onChange({ ...settings, [layer]: { ...s, colorKey: preset.key } })
- }
- />
- ))}
-
-
- )
-}
-
-const LineHeightSetting = ({ settings, onChange, classes }) => (
-
-
Spacing
-
-
Line height
-
- onChange({
- ...settings,
- lineHeight: clampLineHeight(Array.isArray(val) ? val[0] : val),
- })
- }
- />
-
- {formatLineHeight(settings.lineHeight)}
-
-
-
-)
-
-const LyricsSettingsPopover = ({ settings, onChange, onReset }) => {
- const classes = useStyles()
- const [anchorEl, setAnchorEl] = useState(null)
-
- const handleToggle = useCallback((e) => {
- e.stopPropagation()
- setAnchorEl((prev) => (prev ? null : e.currentTarget))
- }, [])
-
- const handleClose = useCallback(() => setAnchorEl(null), [])
-
- return (
- <>
-
-
-
-
-
-
-
-
-
- Appearance
-
-
-
-
-
-
-
-
-
-
-
-
-
- >
- )
-}
-
-const easeInOut = (v) => {
- const clamped = clamp(v, 0, 1)
- return clamped < 0.5 ? 2 * clamped * clamped : 1 - (-2 * clamped + 2) ** 2 / 2
-}
-
-const getMaxHeightPx = () => {
- if (typeof window === 'undefined') {
- return KARAOKE_MAX_HEIGHT_PX
- }
- return Math.min(
- Math.floor(window.innerHeight * KARAOKE_MAX_HEIGHT_RATIO),
- KARAOKE_MAX_HEIGHT_PX,
- )
-}
-
-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 normalizedFoundAt =
- foundAt >= 0
- ? foundAt
- : text.toLowerCase().indexOf(tokenText.toLowerCase(), cursor)
-
- 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 }]
-}
-
-const getLineRenderWindow = (line, nextLineStart) => {
- let start = Number.isFinite(Number(line?.start)) ? Number(line.start) : null
- let end = Number.isFinite(Number(line?.end)) ? Number(line.end) : null
- const fallbackEnd = Number.isFinite(Number(nextLineStart))
- ? Number(nextLineStart)
- : null
-
- if (end == null) {
- end = fallbackEnd
- }
-
- const tokens = Array.isArray(line?.tokens) ? line.tokens : []
- if (tokens.length > 0) {
- const firstWindow = resolveKaraokeTokenWindow(line, 0, nextLineStart)
- const lastWindow = resolveKaraokeTokenWindow(
- line,
- tokens.length - 1,
- nextLineStart,
- )
-
- if (
- firstWindow.start != null &&
- (start == null || firstWindow.start < start)
- ) {
- start = firstWindow.start
- }
- if (lastWindow.end != null && (end == null || lastWindow.end > end)) {
- end = lastWindow.end
- }
- }
-
- return { start, end }
-}
-
-const shouldSkipLineFrame = (
- prevPlaybackMs,
- nextPlaybackMs,
- line,
- nextLineStart,
-) => {
- if (prevPlaybackMs === nextPlaybackMs) {
- return true
- }
-
- const { start, end } = getLineRenderWindow(line, nextLineStart)
-
- if (start != null) {
- const activationStart = start - 220
- if (prevPlaybackMs < activationStart && nextPlaybackMs < activationStart) {
- return true
- }
- }
-
- if (end != null) {
- const settleEnd = end + KARAOKE_WORD_SETTLE_MS + 160
- if (prevPlaybackMs > settleEnd && nextPlaybackMs > settleEnd) {
- return true
- }
- }
-
- return false
-}
-
-const areLineStylesEqual = (prevStyle, nextStyle) => {
- const a = prevStyle || {}
- const b = nextStyle || {}
- return (
- a.opacity === b.opacity &&
- a.color === b.color &&
- a.fontSize === b.fontSize &&
- a.fontWeight === b.fontWeight &&
- a.lineHeight === b.lineHeight &&
- a.maxWidth === b.maxWidth
- )
-}
-
-const parseColorRGB = (rgba) => {
- const m = (rgba || '').match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/)
- return m ? [parseInt(m[1]), parseInt(m[2]), parseInt(m[3])] : [255, 255, 255]
-}
-
-const buildTokenWipeStyle = ({
- fillProgress,
- highlightAlpha,
- futureAlpha,
- rgb,
-}) => {
- const [r, g, b] = rgb || [255, 255, 255]
- const fillPct = clamp(fillProgress, 0, 1) * 100
- const doneColor = `rgba(${r}, ${g}, ${b}, ${clamp(highlightAlpha, TOKEN_DONE_ALPHA, TOKEN_ACTIVE_ALPHA)})`
- const futureColor = `rgba(${r}, ${g}, ${b}, ${futureAlpha})`
-
- if (fillPct <= 0) {
- return { color: futureColor, textShadow: 'none' }
- }
-
- const edgeStart = clamp(fillPct - TOKEN_WIPE_EDGE_PCT, 0, 100)
- const softEnd = clamp(fillPct + TOKEN_WIPE_SOFT_SPREAD_PCT, 0, 100)
- return {
- color: 'transparent',
- WebkitTextFillColor: 'transparent',
- backgroundImage: `linear-gradient(90deg, ${doneColor} 0%, ${doneColor} ${edgeStart}%, ${doneColor} ${fillPct}%, ${futureColor} ${softEnd}%, ${futureColor} 100%)`,
- backgroundClip: 'text',
- WebkitBackgroundClip: 'text',
- textShadow: 'none',
- }
-}
-
-const KaraokeLineRow = memo(
- ({
- line,
- nextLineStart,
- renderPlaybackMs,
- className,
- style,
- tokenClassName,
- highlightTokens = true,
- }) => {
- const segments = buildSegmentsFromLine(line)
- const tokenRGB = useMemo(
- () => (style?.color ? parseColorRGB(style.color) : [255, 255, 255]),
- [style?.color],
- )
-
- return (
-
- {segments.map((segment, idx) => {
- if (!segment.token) {
- return {segment.text}
- }
-
- if (!highlightTokens) {
- return {segment.text}
- }
-
- const { start: tokenStart, end: tokenEnd } =
- resolveKaraokeTokenWindow(line, segment.tokenIndex, nextLineStart)
-
- const isDone = tokenEnd != null ? renderPlaybackMs >= tokenEnd : false
- const isActive =
- !isDone && tokenStart != null && renderPlaybackMs >= tokenStart
-
- const progress =
- isDone ||
- tokenStart == null ||
- tokenEnd == null ||
- tokenEnd <= tokenStart
- ? isDone
- ? 1
- : 0
- : clamp(
- (renderPlaybackMs - tokenStart) / (tokenEnd - tokenStart),
- 0,
- 1,
- )
-
- const justEnded =
- tokenEnd != null &&
- renderPlaybackMs > tokenEnd &&
- renderPlaybackMs <= tokenEnd + KARAOKE_WORD_SETTLE_MS
-
- const settleProgress =
- justEnded && tokenEnd != null
- ? clamp(
- (renderPlaybackMs - tokenEnd) / KARAOKE_WORD_SETTLE_MS,
- 0,
- 1,
- )
- : 0
-
- let alpha = TOKEN_FUTURE_ALPHA
- if (isDone) {
- alpha = TOKEN_DONE_ALPHA
- } else if (isActive) {
- alpha = lerp(
- TOKEN_FUTURE_ALPHA,
- TOKEN_ACTIVE_ALPHA,
- easeInOut(progress),
- )
- }
- if (justEnded) {
- alpha = lerp(
- TOKEN_ACTIVE_ALPHA,
- TOKEN_DONE_ALPHA,
- easeInOut(settleProgress),
- )
- }
- alpha = clamp(alpha, TOKEN_FUTURE_ALPHA, TOKEN_ACTIVE_ALPHA)
- const fillProgress = isDone ? 1 : isActive ? progress : 0
- const isBgRole = segment.token?.role === 'bg'
-
- return (
-
- {segment.text}
-
- )
- })}
-
- )
- },
- (prevProps, nextProps) => {
- if (
- prevProps.line !== nextProps.line ||
- prevProps.nextLineStart !== nextProps.nextLineStart ||
- prevProps.className !== nextProps.className ||
- prevProps.tokenClassName !== nextProps.tokenClassName ||
- prevProps.highlightTokens !== nextProps.highlightTokens ||
- !areLineStylesEqual(prevProps.style, nextProps.style)
- ) {
- return false
- }
-
- return shouldSkipLineFrame(
- prevProps.renderPlaybackMs,
- nextProps.renderPlaybackMs,
- nextProps.line,
- nextProps.nextLineStart,
- )
- },
-)
-
-KaraokeLineRow.displayName = 'KaraokeLineRow'
-
-const KaraokeLyricsOverlay = ({
- visible,
- mainLyric,
- translationLyric,
- pronunciationLyric,
- showTranslation,
- showPronunciation,
- translationEnabled,
- pronunciationEnabled,
- onToggleTranslation,
- onTogglePronunciation,
- audioInstance,
- onClose,
- inline = false,
-}) => {
- const classes = useStyles()
- const theme = useTheme()
- const isDark = theme.palette.type === 'dark'
- const [playbackMs, setPlaybackMs] = useState(0)
- const [maxHeightPx, setMaxHeightPx] = useState(getMaxHeightPx())
- const [bodyViewportHeight, setBodyViewportHeight] = useState(0)
- const [isCompact, setIsCompact] = useState(
- typeof window !== 'undefined' ? window.innerWidth <= 810 : false,
- )
- const [lyricsSettings, setLyricsSettings] = useState(loadLyricsSettings)
-
- const handleSettingsChange = useCallback((next) => {
- const normalized = normalizeLyricsSettings(next)
- setLyricsSettings(normalized)
- saveLyricsSettings(normalized)
- }, [])
-
- const handleResetAppearance = useCallback(() => {
- const defaults = createDefaultLyricsSettings(isDark)
- setLyricsSettings(defaults)
- saveLyricsSettings(defaults)
- }, [isDark])
-
- const bodyRef = useRef(null)
- const activeLineRef = useRef(null)
-
- const mainLines = useMemo(() => buildKaraokeLines(mainLyric), [mainLyric])
- const translationLines = useMemo(
- () => buildKaraokeLines(translationLyric),
- [translationLyric],
- )
- const pronunciationLines = useMemo(
- () => buildKaraokeLines(pronunciationLyric),
- [pronunciationLyric],
- )
- const overlayHeight = clamp(
- lyricsSettings.overlayHeight,
- KARAOKE_MIN_HEIGHT_PX,
- maxHeightPx,
- )
-
- useEffect(() => {
- const onResize = () => {
- const nextMaxHeight = getMaxHeightPx()
- setIsCompact(window.innerWidth <= 810)
- setMaxHeightPx(nextMaxHeight)
- }
-
- onResize()
- window.addEventListener('resize', onResize)
- return () => window.removeEventListener('resize', onResize)
- }, [])
-
- useEffect(() => {
- setLyricsSettings((prev) => {
- const currentColor = prev.main.colorKey
- const shouldSwap =
- (isDark && currentColor === 'black') ||
- (!isDark && currentColor === 'white')
- if (!shouldSwap) return prev
- const newColorKey = isDark ? 'white' : 'black'
- const updated = {
- ...prev,
- main: { ...prev.main, colorKey: newColorKey },
- }
- saveLyricsSettings(updated)
- return updated
- })
- }, [isDark])
-
- useEffect(() => {
- const body = bodyRef.current
- if (!body) {
- return undefined
- }
-
- const updateViewportHeight = () => {
- setBodyViewportHeight(body.clientHeight || 0)
- }
-
- updateViewportHeight()
-
- if (typeof ResizeObserver !== 'undefined') {
- const observer = new ResizeObserver(updateViewportHeight)
- observer.observe(body)
- return () => observer.disconnect()
- }
-
- window.addEventListener('resize', updateViewportHeight)
- return () => window.removeEventListener('resize', updateViewportHeight)
- }, [overlayHeight, isCompact, showTranslation, showPronunciation, visible])
-
- const onResizeStart = useCallback(
- (event) => {
- if (isCompact) {
- return
- }
-
- event.preventDefault()
- const startY = event.clientY
- const startHeight = overlayHeight
-
- const onMove = (moveEvent) => {
- const delta = startY - moveEvent.clientY
- handleSettingsChange({
- ...lyricsSettings,
- overlayHeight: clamp(
- startHeight + delta,
- KARAOKE_MIN_HEIGHT_PX,
- maxHeightPx,
- ),
- })
- }
-
- const onUp = () => {
- window.removeEventListener('mousemove', onMove)
- window.removeEventListener('mouseup', onUp)
- }
-
- window.addEventListener('mousemove', onMove)
- window.addEventListener('mouseup', onUp)
- },
- [
- handleSettingsChange,
- isCompact,
- lyricsSettings,
- maxHeightPx,
- overlayHeight,
- ],
- )
-
- useEffect(() => {
- if (!visible || !audioInstance) {
- setPlaybackMs(0)
- return
- }
-
- let rafId = 0
- let cancelled = false
- let anchorAudioMs = 0
- let anchorPerfMs = 0
- let lastRenderMs = 0
-
- const readPlaybackMs = () => {
- const seconds = Number(audioInstance.currentTime)
- if (!Number.isFinite(seconds) || seconds < 0) {
- return 0
- }
- return seconds * 1000
- }
-
- const resetAnchor = (perfNow, observedMs) => {
- anchorAudioMs = observedMs
- anchorPerfMs = perfNow
- }
-
- const tick = () => {
- if (cancelled) {
- return
- }
-
- const observedMs = readPlaybackMs()
- const perfNow = performance.now()
- const playbackRate = Number(audioInstance.playbackRate)
- const canInterpolate =
- !audioInstance.paused &&
- !audioInstance.seeking &&
- Number.isFinite(playbackRate) &&
- playbackRate > 0
-
- let nowMs = observedMs
-
- if (!canInterpolate) {
- resetAnchor(perfNow, observedMs)
- } else if (anchorPerfMs === 0) {
- resetAnchor(perfNow, observedMs)
- } else {
- const predicted =
- anchorAudioMs + (perfNow - anchorPerfMs) * playbackRate
- const drift = observedMs - predicted
- if (Math.abs(drift) > KARAOKE_CLOCK_DRIFT_RESET_MS) {
- nowMs = observedMs
- resetAnchor(perfNow, observedMs)
- } else {
- nowMs = predicted
- }
- }
-
- const backwardsDrift = lastRenderMs - nowMs
- if (canInterpolate && backwardsDrift > 0) {
- nowMs = lastRenderMs
- }
-
- if (canInterpolate && backwardsDrift > KARAOKE_CLOCK_RESET_THRESHOLD_MS) {
- resetAnchor(perfNow, observedMs)
- } else if (
- !canInterpolate &&
- backwardsDrift > 0 &&
- backwardsDrift <= KARAOKE_MONOTONIC_JITTER_MS
- ) {
- nowMs = lastRenderMs
- }
-
- nowMs = Math.max(0, nowMs)
- lastRenderMs = nowMs
-
- setPlaybackMs((prev) =>
- Math.abs(prev - nowMs) >= KARAOKE_RENDER_UPDATE_EPSILON_MS
- ? nowMs
- : prev,
- )
- rafId = window.requestAnimationFrame(tick)
- }
-
- const initialMs = readPlaybackMs()
- resetAnchor(performance.now(), initialMs)
- lastRenderMs = initialMs
- setPlaybackMs(initialMs)
- rafId = window.requestAnimationFrame(tick)
-
- return () => {
- cancelled = true
- if (rafId) {
- window.cancelAnimationFrame(rafId)
- }
- }
- }, [audioInstance, visible])
-
- const renderPlaybackMs = playbackMs + KARAOKE_RENDER_LEAD_MS
- const hasTimedMainLines = useMemo(
- () => hasUsableKaraokeTiming(mainLines),
- [mainLines],
- )
-
- const { lineIndex } = useMemo(
- () =>
- hasTimedMainLines
- ? getActiveKaraokeState(mainLines, renderPlaybackMs)
- : { lineIndex: -1, tokenIndex: -1 },
- [hasTimedMainLines, mainLines, renderPlaybackMs],
- )
-
- const activeIndex = hasTimedMainLines && lineIndex >= 0 ? lineIndex : -1
- const lineHeight = lyricsSettings.lineHeight
- const lineGap = getLineGapPx(lineHeight)
- const languageBadges = buildLanguageBadges({
- mainLyric,
- translationLyric,
- pronunciationLyric,
- showTranslation,
- showPronunciation,
- translationEnabled,
- pronunciationEnabled,
- })
-
- const trByMainIndex = useMemo(() => {
- if (!showTranslation || translationLines.length === 0) return {}
- const map = {}
- for (let i = 0; i < mainLines.length; i++) {
- const { line } = resolveLayerLineForMain(mainLines, translationLines, i)
- if (line) map[i] = line
- }
- return map
- }, [mainLines, translationLines, showTranslation])
-
- const prByMainIndex = useMemo(() => {
- if (!showPronunciation || pronunciationLines.length === 0) return {}
- const map = {}
- for (let i = 0; i < mainLines.length; i++) {
- const { line } = resolveLayerLineForMain(mainLines, pronunciationLines, i)
- if (line) map[i] = line
- }
- return map
- }, [mainLines, pronunciationLines, showPronunciation])
-
- const hasTranslationLine = showTranslation && translationLines.length > 0
- const hasPronunciationLine =
- showPronunciation && pronunciationLines.length > 0
- const measuredViewportHeight = bodyRef.current?.clientHeight || 0
- const estimatedViewportHeight =
- measuredViewportHeight > 0
- ? measuredViewportHeight
- : bodyViewportHeight > 0
- ? bodyViewportHeight
- : isCompact
- ? 260
- : Math.max(220, overlayHeight - 170)
- const centerSpacerPx = Math.max(
- hasTimedMainLines ? KARAOKE_CENTER_SPACER_MIN_PX : 0,
- hasTimedMainLines
- ? Math.floor(estimatedViewportHeight * KARAOKE_CENTER_SPACER_RATIO)
- : 0,
- )
-
- useEffect(() => {
- if (!visible || !hasTimedMainLines) {
- return
- }
-
- let animFrameId = null
- let scrollAnimId = null
-
- animFrameId = window.requestAnimationFrame(() => {
- const body = bodyRef.current
- const activeNode = activeLineRef.current
- if (!body || !activeNode) {
- return
- }
-
- const bodyRect = body.getBoundingClientRect()
- const activeRect = activeNode.getBoundingClientRect()
- const deltaWithinBody =
- activeRect.top -
- bodyRect.top -
- (body.clientHeight - activeRect.height) / 2
- const maxTop = Math.max(0, body.scrollHeight - body.clientHeight)
- const targetTop = clamp(body.scrollTop + deltaWithinBody, 0, maxTop)
- const distance = targetTop - body.scrollTop
-
- if (Math.abs(distance) < 2) {
- return
- }
-
- const startTop = body.scrollTop
- const duration = 400
- const startTime = performance.now()
-
- const easeOutCubic = (t) => 1 - Math.pow(1 - t, 3)
-
- const step = (now) => {
- const elapsed = now - startTime
- const progress = Math.min(elapsed / duration, 1)
- const eased = easeOutCubic(progress)
- body.scrollTop = startTop + distance * eased
- if (progress < 1) {
- scrollAnimId = window.requestAnimationFrame(step)
- }
- }
-
- scrollAnimId = window.requestAnimationFrame(step)
- })
-
- return () => {
- if (animFrameId) window.cancelAnimationFrame(animFrameId)
- if (scrollAnimId) window.cancelAnimationFrame(scrollAnimId)
- }
- }, [
- centerSpacerPx,
- hasTimedMainLines,
- hasPronunciationLine,
- hasTranslationLine,
- lineIndex,
- overlayHeight,
- visible,
- ])
-
- if (
- !visible ||
- !hasStructuredLyricContent(mainLyric) ||
- mainLines.length === 0
- ) {
- return null
- }
-
- const getMainLineStyle = (idx) => {
- const [r, g, b] = parseColorRGB(getColorValue(lyricsSettings.main.colorKey))
- if (!hasTimedMainLines) {
- return {
- opacity: 1,
- color: `rgba(${r}, ${g}, ${b}, 0.98)`,
- fontSize: lyricsSettings.main.fontSize,
- lineHeight,
- }
- }
-
- const delta = idx - activeIndex
- const isActive = delta === 0
- let opacity = isActive ? 1 : delta < 0 ? 0.6 : 0.72
- const color = isActive
- ? `rgba(${r}, ${g}, ${b}, 0.98)`
- : delta < 0
- ? `rgba(${r}, ${g}, ${b}, 0.4)`
- : `rgba(${r}, ${g}, ${b}, 0.54)`
-
- if (delta > 1) {
- const level = clamp(delta, 1, 6)
- opacity = Math.max(0.36, 0.74 - level * 0.08)
- }
-
- if (delta < -1) {
- const level = clamp(Math.abs(delta), 1, 6)
- opacity = Math.max(0.28, 0.62 - level * 0.08)
- }
-
- const baseFontSize = lyricsSettings.main.fontSize
- const fontSize = isActive
- ? baseFontSize
- : Math.round(baseFontSize * KARAOKE_MAIN_INACTIVE_FONT_FACTOR)
-
- return {
- opacity,
- color,
- fontSize,
- lineHeight,
- maxWidth: isActive
- ? '100%'
- : `${Math.round(KARAOKE_MAIN_INACTIVE_FONT_FACTOR * 100)}%`,
- }
- }
-
- const getAuxLineStyle = (idx, layerKey) => {
- const [r, g, b] = parseColorRGB(
- getColorValue(lyricsSettings[layerKey].colorKey),
- )
- const baseFontSize = lyricsSettings[layerKey].fontSize
- if (!hasTimedMainLines) {
- return {
- opacity: 0.94,
- fontSize: baseFontSize,
- color: `rgba(${r}, ${g}, ${b}, 0.94)`,
- lineHeight: KARAOKE_AUX_LINE_HEIGHT,
- }
- }
-
- const delta = idx - activeIndex
- const isActive = delta === 0
-
- let opacity = isActive ? 0.94 : delta < 0 ? 0.5 : 0.62
- const color = isActive
- ? `rgba(${r}, ${g}, ${b}, 0.94)`
- : delta < 0
- ? `rgba(${r}, ${g}, ${b}, 0.42)`
- : `rgba(${r}, ${g}, ${b}, 0.56)`
-
- if (delta > 1) {
- const level = clamp(delta, 1, 6)
- opacity = Math.max(0.28, 0.64 - level * 0.08)
- }
-
- if (delta < -1) {
- const level = clamp(Math.abs(delta), 1, 6)
- opacity = Math.max(0.22, 0.5 - level * 0.08)
- }
-
- const fontSize = isActive
- ? baseFontSize
- : Math.round(baseFontSize * KARAOKE_AUX_INACTIVE_FONT_FACTOR)
-
- return {
- opacity,
- fontSize,
- color,
- lineHeight: KARAOKE_AUX_LINE_HEIGHT,
- maxWidth: isActive
- ? '100%'
- : `${Math.round(KARAOKE_AUX_INACTIVE_FONT_FACTOR * 100)}%`,
- }
- }
-
- const overlayStyle = inline
- ? undefined
- : isCompact
- ? undefined
- : {
- height: overlayHeight,
- maxHeight: maxHeightPx,
- }
-
- return (
-
event.stopPropagation() : undefined}
- >
- {!inline && (
-
- )}
-
-
-
-
- {languageBadges.map((badge) => {
- const badgeEl = (
-
{
- if (e.key === 'Enter' || e.key === ' ') {
- e.preventDefault()
- ;(badge.key === 'tr'
- ? onToggleTranslation
- : onTogglePronunciation)()
- }
- }
- : undefined
- }
- >
-
- {badge.label}
-
- {badge.lang}
-
- )
- return badge.toggleable ? (
-
- {badgeEl}
-
- ) : badgeEl
- })}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {mainLines.map((line, idx) => {
- const trLine = trByMainIndex[idx]
- const prLine = prByMainIndex[idx]
- const mainNextLineStart = mainLines[idx + 1]?.start ?? null
- const highlightedMainLine = buildHighlightedMainLine(
- line,
- mainNextLineStart,
- )
- const highlightedTrLine = buildHighlightedAuxLine(
- line,
- trLine,
- mainNextLineStart,
- )
- const highlightedPrLine = buildHighlightedAuxLine(
- line,
- prLine,
- mainNextLineStart,
- )
- const showTr = shouldShowAuxLine(line, trLine)
- const showPr = shouldShowAuxLine(line, prLine)
- const lineStyle = getMainLineStyle(idx)
- const trStyle = getAuxLineStyle(idx, 'tr')
- const prStyle = getAuxLineStyle(idx, 'pr')
- return (
-
{
- if (audioInstance && line.start != null) {
- audioInstance.currentTime = line.start / 1000
- }
- }}
- >
-
- {showPr && (
-
- )}
- {showTr && (
-
- )}
-
- )
- })}
-
-
-
-
-
- )
-}
-
-export default KaraokeLyricsOverlay
diff --git a/ui/src/audioplayer/KaraokeLyricsOverlay.test.jsx b/ui/src/audioplayer/KaraokeLyricsOverlay.test.jsx
deleted file mode 100644
index dba354363..000000000
--- a/ui/src/audioplayer/KaraokeLyricsOverlay.test.jsx
+++ /dev/null
@@ -1,514 +0,0 @@
-import React from 'react'
-import {
- cleanup,
- fireEvent,
- render,
- screen,
- waitFor,
-} from '@testing-library/react'
-import KaraokeLyricsOverlay from './KaraokeLyricsOverlay'
-
-const DEFAULT_LINE_HEIGHT_TEXT = '1.30'
-const NEXT_LINE_HEIGHT_TEXT = '1.32'
-
-const audioInstance = {
- currentTime: 0,
- paused: true,
- seeking: false,
- playbackRate: 1,
-}
-
-const buildLyric = (kind, lang, value) => ({
- kind,
- lang,
- synced: true,
- line: [{ start: 1000, value }],
-})
-
-const renderOverlay = (props = {}) =>
- render(
-
{}}
- onTogglePronunciation={() => {}}
- audioInstance={audioInstance}
- onClose={() => {}}
- {...props}
- />,
- )
-
-describe(' behavior', () => {
- beforeEach(() => {
- localStorage.clear()
- window.innerWidth = 1200
- window.innerHeight = 900
- vi.spyOn(window, 'requestAnimationFrame').mockImplementation(() => 1)
- vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {})
- })
-
- afterEach(() => {
- vi.restoreAllMocks()
- cleanup()
- })
-
- it('shows tooltips for translation, pronunciation, and appearance controls', async () => {
- renderOverlay()
-
- fireEvent.mouseOver(screen.getByTestId('lyrics-language-badge-tr'))
- expect(await screen.findByText('Show translation')).toBeInTheDocument()
-
- fireEvent.mouseOver(screen.getByTestId('lyrics-language-badge-pr'))
- expect(await screen.findByText('Hide pronunciation')).toBeInTheDocument()
-
- fireEvent.mouseOver(screen.getByTestId('lyrics-settings-button'))
- expect(await screen.findByText('Appearance')).toBeInTheDocument()
- })
-
- it('renders inline mode without the desktop resize handle', () => {
- renderOverlay({ inline: true })
-
- expect(screen.getByTestId('karaoke-lyrics-overlay')).toHaveAttribute(
- 'data-inline',
- 'true',
- )
- expect(screen.queryByTestId('lyrics-resize-handle')).not.toBeInTheDocument()
- })
-
- it('renders the appearance popup with Main label and default line height for older settings', async () => {
- localStorage.setItem(
- 'karaoke-lyrics-settings',
- JSON.stringify({
- tr: { fontSize: 16, colorKey: 'blue' },
- main: { fontSize: 26, colorKey: 'white' },
- pr: { fontSize: 15, colorKey: 'green' },
- }),
- )
-
- renderOverlay()
-
- fireEvent.click(screen.getByTestId('lyrics-settings-button'))
-
- expect(await screen.findByText('Appearance')).toBeInTheDocument()
- expect(screen.getByText('Main', { selector: 'div' })).toBeInTheDocument()
- expect(screen.queryByText('Default')).not.toBeInTheDocument()
- expect(screen.getByTestId('lyrics-reset-appearance')).toBeInTheDocument()
- expect(screen.getByTestId('lyrics-line-height-value')).toHaveTextContent(
- DEFAULT_LINE_HEIGHT_TEXT,
- )
- })
-
- it('renders the lyric group in main, pronunciation, translation order with layer badges', () => {
- renderOverlay({
- showTranslation: true,
- showPronunciation: true,
- })
-
- const mainLine = screen.getByText('こんにちは')
- const pronunciationLine = screen.getByText('konnichiwa')
- const translationLine = screen.getByText('Hello')
-
- expect(
- mainLine.compareDocumentPosition(pronunciationLine) &
- Node.DOCUMENT_POSITION_FOLLOWING,
- ).toBeTruthy()
- expect(
- pronunciationLine.compareDocumentPosition(translationLine) &
- Node.DOCUMENT_POSITION_FOLLOWING,
- ).toBeTruthy()
-
- expect(screen.getByTestId('lyrics-language-badge-main')).toHaveTextContent(
- 'Mainja',
- )
- expect(screen.getByTestId('lyrics-language-badge-pr')).toHaveTextContent(
- 'PRja-Latn',
- )
- expect(screen.getByTestId('lyrics-language-badge-tr')).toHaveTextContent(
- 'TRen',
- )
- })
-
- it('renders line-timed rows as whole-line spans without synthetic token splits', () => {
- renderOverlay({
- mainLyric: {
- kind: 'main',
- lang: 'en',
- synced: true,
- line: [
- { start: 1000, end: 2400, value: 'Batter up, batter up, batter up' },
- ],
- },
- translationLyric: {
- kind: 'translation',
- lang: 'ja',
- synced: true,
- line: [
- {
- start: 1000,
- end: 2400,
- value: 'バッターアップ、バッターアップ、バッターアップ',
- },
- ],
- },
- pronunciationLyric: {
- kind: 'pronunciation',
- lang: 'ja-Latn',
- synced: true,
- line: [
- {
- start: 1000,
- end: 2400,
- value: 'Battaa appu, battaa appu, battaa appu',
- },
- ],
- },
- showTranslation: true,
- showPronunciation: true,
- })
-
- const mainLine = screen.getByText(
- 'Batter up, batter up, batter up',
- ).parentElement
- const pronunciationLine = screen.getByText(
- 'Battaa appu, battaa appu, battaa appu',
- ).parentElement
- const translationLine = screen.getByText(
- 'バッターアップ、バッターアップ、バッターアップ',
- ).parentElement
-
- expect(mainLine.querySelectorAll('span')).toHaveLength(1)
- expect(pronunciationLine.querySelectorAll('span')).toHaveLength(1)
- expect(translationLine.querySelectorAll('span')).toHaveLength(1)
- })
-
- it('uses cue byte offsets to segment repeated words in the karaoke line', () => {
- renderOverlay({
- mainLyric: {
- kind: 'main',
- lang: 'en',
- synced: true,
- line: [{ start: 0, end: 2400, value: 'Oh love love me tonight' }],
- cueLine: [
- {
- index: 0,
- start: 0,
- end: 2400,
- value: 'Oh love love me tonight',
- cue: [
- { start: 0, end: 300, value: 'Oh', byteStart: 0, byteEnd: 1 },
- {
- start: 900,
- end: 1300,
- value: 'love',
- byteStart: 8,
- byteEnd: 11,
- },
- {
- start: 1300,
- end: 1600,
- value: 'me',
- byteStart: 13,
- byteEnd: 14,
- },
- {
- start: 1600,
- end: 2400,
- value: 'tonight',
- byteStart: 16,
- byteEnd: 22,
- },
- ],
- },
- ],
- },
- translationLyric: null,
- pronunciationLyric: null,
- showTranslation: false,
- showPronunciation: false,
- translationEnabled: false,
- pronunciationEnabled: false,
- audioInstance: {
- ...audioInstance,
- currentTime: 1.0,
- },
- })
-
- const mainLine = screen.getByText('Oh').parentElement
- const segments = Array.from(mainLine.querySelectorAll('span')).map(
- (span) => span.textContent,
- )
-
- expect(segments).toEqual([
- 'Oh',
- ' love ',
- 'love',
- ' ',
- 'me',
- ' ',
- 'tonight',
- ])
- })
-
- it('uses cue byte offsets to preserve explicit space cues in multibyte karaoke lines', () => {
- renderOverlay({
- mainLyric: {
- kind: 'main',
- lang: 'ko',
- synced: true,
- line: [{ start: 0, end: 900, value: '눈을 뜬 순간' }],
- cueLine: [
- {
- index: 0,
- start: 0,
- end: 900,
- value: '눈을 뜬 순간',
- cue: [
- { start: 0, end: 150, value: '눈을', byteStart: 0, byteEnd: 5 },
- { start: 150, end: 250, value: ' ', byteStart: 6, byteEnd: 6 },
- { start: 250, end: 450, value: '뜬', byteStart: 7, byteEnd: 9 },
- { start: 450, end: 550, value: ' ', byteStart: 10, byteEnd: 10 },
- { start: 550, end: 900, value: '순간', byteStart: 11, byteEnd: 16 },
- ],
- },
- ],
- },
- translationLyric: null,
- pronunciationLyric: null,
- showTranslation: false,
- showPronunciation: false,
- translationEnabled: false,
- pronunciationEnabled: false,
- audioInstance: {
- ...audioInstance,
- currentTime: 0.3,
- },
- })
-
- const mainLine = screen.getByText('눈을').parentElement
- const segments = Array.from(mainLine.querySelectorAll('span')).map(
- (span) => span.textContent,
- )
-
- expect(segments).toEqual(['눈을', ' ', '뜬', ' ', '순간'])
- })
-
- it('highlights line-timed pronunciation and translation rows with the active main line', () => {
- renderOverlay({
- mainLyric: {
- kind: 'main',
- lang: 'en',
- synced: true,
- line: [
- { start: 1000, end: 1800, value: 'Line one' },
- { start: 2500, end: 3300, value: 'Line two' },
- ],
- },
- translationLyric: {
- kind: 'translation',
- lang: 'ja',
- synced: true,
- line: [
- { start: 1000, end: 1800, value: '一行目' },
- { start: 2500, end: 3300, value: '二行目' },
- ],
- },
- pronunciationLyric: {
- kind: 'pronunciation',
- lang: 'ja-Latn',
- synced: true,
- line: [
- { start: 1000, end: 1800, value: 'ichigyoume' },
- { start: 2500, end: 3300, value: 'nigyoume' },
- ],
- },
- showTranslation: true,
- showPronunciation: true,
- audioInstance: {
- ...audioInstance,
- currentTime: 1.2,
- },
- })
-
- const activePronunciation = screen.getByText('ichigyoume').parentElement
- const inactivePronunciation = screen.getByText('nigyoume').parentElement
- const activeTranslation = screen.getByText('一行目').parentElement
- const inactiveTranslation = screen.getByText('二行目').parentElement
-
- expect(parseFloat(activePronunciation.style.opacity)).toBeGreaterThan(
- parseFloat(inactivePronunciation.style.opacity),
- )
- expect(parseFloat(activeTranslation.style.opacity)).toBeGreaterThan(
- parseFloat(inactiveTranslation.style.opacity),
- )
- })
-
- it('pre-wraps inactive main lines so the active line keeps the same wrap shape', () => {
- renderOverlay({
- mainLyric: {
- kind: 'main',
- lang: 'en',
- synced: true,
- line: [
- { start: 1000, end: 1800, value: 'First line that is getting focus' },
- { start: 2500, end: 3300, value: 'Second line waiting below' },
- ],
- },
- translationLyric: null,
- pronunciationLyric: null,
- showTranslation: false,
- showPronunciation: false,
- translationEnabled: false,
- pronunciationEnabled: false,
- audioInstance: {
- ...audioInstance,
- currentTime: 1.2,
- },
- })
-
- const activeLine = screen.getByText('First line that is getting focus')
- .parentElement
- const inactiveLine = screen.getByText('Second line waiting below')
- .parentElement
-
- expect(parseFloat(activeLine.style.fontSize)).toBeGreaterThan(
- parseFloat(inactiveLine.style.fontSize),
- )
- expect(activeLine.style.maxWidth).toBe('100%')
- expect(inactiveLine.style.maxWidth).toBe('80%')
- })
-
- it('centers pronunciation text inside the pill container', () => {
- renderOverlay({
- showTranslation: false,
- showPronunciation: true,
- })
-
- const pronunciationLine = screen.getByText('konnichiwa').parentElement
- const styles = window.getComputedStyle(pronunciationLine)
-
- expect(styles.display).toBe('inline-flex')
- expect(styles.justifyContent).toBe('center')
- expect(styles.alignItems).toBe('center')
- })
-
- it('renders untimed text lyrics in manual reading mode without a pinned active line', () => {
- renderOverlay({
- mainLyric: {
- kind: 'main',
- lang: 'en',
- synced: false,
- line: [{ value: 'First plain line' }, { value: 'Second plain line' }],
- },
- translationLyric: null,
- pronunciationLyric: null,
- showTranslation: false,
- showPronunciation: false,
- translationEnabled: false,
- pronunciationEnabled: false,
- })
-
- const firstLine = screen.getByText('First plain line').parentElement
- const secondLine = screen.getByText('Second plain line').parentElement
-
- expect(firstLine.style.opacity).toBe('1')
- expect(secondLine.style.opacity).toBe('1')
- expect(firstLine.style.color).toBe(secondLine.style.color)
- })
-
- it('persists line height changes, keeps aux line spacing fixed, and stores overlay height', async () => {
- renderOverlay({
- mainLyric: buildLyric('main', 'en', 'Hello world'),
- translationLyric: buildLyric('translation', 'es', 'Hola'),
- pronunciationLyric: buildLyric('pronunciation', 'en-Latn', 'heh-loh'),
- showTranslation: true,
- showPronunciation: true,
- translationEnabled: true,
- pronunciationEnabled: true,
- })
-
- const overlay = screen.getByTestId('karaoke-lyrics-overlay')
- const mainLine = screen.getByText('Hello world').parentElement
- const pronunciationLine = screen.getByText('heh-loh').parentElement
- expect(mainLine).toHaveStyle(`line-height: ${DEFAULT_LINE_HEIGHT_TEXT}`)
- expect(pronunciationLine).toHaveStyle('line-height: 1.2')
-
- fireEvent.click(screen.getByTestId('lyrics-settings-button'))
-
- const slider = screen.getByRole('slider', { name: 'Line height' })
- slider.focus()
- fireEvent.keyDown(slider, { key: 'ArrowRight' })
-
- await waitFor(() =>
- expect(screen.getByTestId('lyrics-line-height-value')).toHaveTextContent(
- NEXT_LINE_HEIGHT_TEXT,
- ),
- )
-
- await waitFor(() =>
- expect(mainLine).toHaveStyle(`line-height: ${NEXT_LINE_HEIGHT_TEXT}`),
- )
- expect(pronunciationLine).toHaveStyle('line-height: 1.2')
-
- fireEvent.mouseDown(screen.getByTestId('lyrics-resize-handle'), {
- clientY: 400,
- })
- fireEvent.mouseMove(window, { clientY: 360 })
- fireEvent.mouseUp(window)
-
- await waitFor(() => expect(overlay).toHaveStyle('height: 340px'))
-
- const stored = JSON.parse(localStorage.getItem('karaoke-lyrics-settings'))
- expect(stored.lineHeight).toBeCloseTo(1.32, 2)
- expect(stored.overlayHeight).toBe(340)
- })
-
- it('resets appearance back to the default spacing and overlay height', async () => {
- localStorage.setItem(
- 'karaoke-lyrics-settings',
- JSON.stringify({
- lineHeight: 1.8,
- overlayHeight: 420,
- tr: { fontSize: 16, colorKey: 'yellow' },
- main: { fontSize: 28, colorKey: 'cyan' },
- pr: { fontSize: 15, colorKey: 'pink' },
- }),
- )
-
- renderOverlay({
- mainLyric: buildLyric('main', 'en', 'Hello world'),
- translationLyric: null,
- pronunciationLyric: null,
- showPronunciation: false,
- translationEnabled: false,
- pronunciationEnabled: false,
- })
-
- const overlay = screen.getByTestId('karaoke-lyrics-overlay')
- const mainLine = screen.getByText('Hello world').parentElement
- expect(overlay).toHaveStyle('height: 420px')
- expect(mainLine).toHaveStyle('line-height: 1.8')
-
- fireEvent.click(screen.getByTestId('lyrics-settings-button'))
- fireEvent.click(screen.getByTestId('lyrics-reset-appearance'))
-
- await waitFor(() =>
- expect(screen.getByTestId('lyrics-line-height-value')).toHaveTextContent(
- DEFAULT_LINE_HEIGHT_TEXT,
- ),
- )
- await waitFor(() => expect(overlay).toHaveStyle('height: 300px'))
- await waitFor(() =>
- expect(mainLine).toHaveStyle(`line-height: ${DEFAULT_LINE_HEIGHT_TEXT}`),
- )
-
- const stored = JSON.parse(localStorage.getItem('karaoke-lyrics-settings'))
- expect(stored.lineHeight).toBeCloseTo(1.3, 2)
- expect(stored.overlayHeight).toBe(300)
- })
-})
diff --git a/ui/src/audioplayer/MobileKaraokeLyricsPortal.jsx b/ui/src/audioplayer/MobileKaraokeLyricsPortal.jsx
deleted file mode 100644
index 636107184..000000000
--- a/ui/src/audioplayer/MobileKaraokeLyricsPortal.jsx
+++ /dev/null
@@ -1,65 +0,0 @@
-import React, { useEffect, useState } from 'react'
-import { createPortal } from 'react-dom'
-
-export const MOBILE_KARAOKE_LYRICS_HOST_SELECTOR =
- '.react-jinke-music-player-mobile-cover'
-export const MOBILE_KARAOKE_LYRICS_ACTIVE_CLASS = 'nd-mobile-lyrics-active'
-
-const resolveMobileLyricsHost = () => {
- if (typeof document === 'undefined') {
- return null
- }
- return document.querySelector(MOBILE_KARAOKE_LYRICS_HOST_SELECTOR)
-}
-
-const MobileKaraokeLyricsPortal = ({ active, children }) => {
- const [host, setHost] = useState(() =>
- active ? resolveMobileLyricsHost() : null,
- )
-
- useEffect(() => {
- if (typeof document === 'undefined') {
- setHost(null)
- return undefined
- }
-
- if (!active) {
- setHost(null)
- return undefined
- }
-
- const syncHost = () => {
- setHost(resolveMobileLyricsHost())
- }
-
- syncHost()
-
- const observer = new MutationObserver(syncHost)
- observer.observe(document.body, {
- childList: true,
- subtree: true,
- })
-
- return () => observer.disconnect()
- }, [active])
-
- useEffect(() => {
- if (!host) {
- return undefined
- }
-
- host.classList.toggle(MOBILE_KARAOKE_LYRICS_ACTIVE_CLASS, active)
-
- return () => {
- host.classList.remove(MOBILE_KARAOKE_LYRICS_ACTIVE_CLASS)
- }
- }, [active, host])
-
- if (!active || !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
deleted file mode 100644
index 8b237e184..000000000
--- a/ui/src/audioplayer/MobileKaraokeLyricsPortal.test.jsx
+++ /dev/null
@@ -1,55 +0,0 @@
-import React from 'react'
-import { cleanup, render, screen, waitFor } from '@testing-library/react'
-import MobileKaraokeLyricsPortal, {
- MOBILE_KARAOKE_LYRICS_ACTIVE_CLASS,
-} from './MobileKaraokeLyricsPortal'
-
-const HOST_CLASS = 'react-jinke-music-player-mobile-cover'
-
-describe('', () => {
- afterEach(() => {
- cleanup()
- document.body.innerHTML = ''
- })
-
- it('renders lyrics into the mobile cover host and toggles the active class', () => {
- const host = document.createElement('div')
- host.className = HOST_CLASS
- document.body.appendChild(host)
-
- const { rerender } = render(
-
- Lyrics
- ,
- )
-
- expect(host).toContainElement(screen.getByTestId('mobile-inline-lyrics'))
- expect(host).toHaveClass(MOBILE_KARAOKE_LYRICS_ACTIVE_CLASS)
-
- rerender(
-
- Lyrics
- ,
- )
-
- expect(screen.queryByTestId('mobile-inline-lyrics')).not.toBeInTheDocument()
- expect(host).not.toHaveClass(MOBILE_KARAOKE_LYRICS_ACTIVE_CLASS)
- })
-
- it('attaches when the mobile cover host appears after mount', async () => {
- render(
-
- Lyrics
- ,
- )
-
- const host = document.createElement('div')
- host.className = HOST_CLASS
- document.body.appendChild(host)
-
- await waitFor(() =>
- expect(host).toContainElement(screen.getByTestId('mobile-inline-lyrics')),
- )
- expect(host).toHaveClass(MOBILE_KARAOKE_LYRICS_ACTIVE_CLASS)
- })
-})
diff --git a/ui/src/audioplayer/Player.jsx b/ui/src/audioplayer/Player.jsx
index f1538c1d6..e2070deea 100644
--- a/ui/src/audioplayer/Player.jsx
+++ b/ui/src/audioplayer/Player.jsx
@@ -23,7 +23,6 @@ import {
refreshQueue,
setPlayMode,
setTranscodingProfile,
- updateQueueLyric,
setVolume,
syncQueue,
} from '../actions'
@@ -35,30 +34,6 @@ import { keyMap } from '../hotkeys'
import keyHandlers from './keyHandlers'
import { calculateGain } from '../utils/calculateReplayGain'
import { detectBrowserProfile, decisionService } from '../transcode'
-import {
- getPreferredLyricLanguage,
- hasStructuredLyricContent,
- selectLyricLayers,
- structuredLyricToLrc,
-} from './lyrics'
-import {
- resolveLyricsOverlayState,
- togglePronunciationPreference,
-} from './lyricsOverlayState'
-import KaraokeLyricsOverlay from './KaraokeLyricsOverlay'
-import MobileKaraokeLyricsPortal from './MobileKaraokeLyricsPortal'
-
-const emptyLyricLayers = {
- main: null,
- translation: null,
- pronunciation: null,
-}
-
-const normalizeLyricLayers = (layers) => ({
- main: layers?.main || null,
- translation: layers?.translation || null,
- pronunciation: layers?.pronunciation || null,
-})
const Player = () => {
const theme = useCurrentTheme()
@@ -163,83 +138,6 @@ const Player = () => {
const gainInfo = useSelector((state) => state.replayGain)
const [context, setContext] = useState(null)
const [gainNode, setGainNode] = useState(null)
- const lyricCacheRef = useRef(new Map())
- const lyricRequestIdRef = useRef(0)
- const playerRef = useRef(null)
- const [karaokeVisiblePreference, setKaraokeVisiblePreference] =
- useState(false)
- const [selectedLyricLayers, setSelectedLyricLayers] =
- useState(emptyLyricLayers)
- const [translationPreference, setTranslationPreference] = useState(false)
- const [pronunciationPreference, setPronunciationPreference] = useState(null)
- const currentTrackId = playerState.current?.trackId
- const currentTrackIsRadio = playerState.current?.isRadio
- const selectedStructuredLyric = selectedLyricLayers.main
- const hasKaraokeLyric = hasStructuredLyricContent(selectedStructuredLyric)
- const hasTranslationLyric = hasStructuredLyricContent(
- selectedLyricLayers.translation,
- )
- const hasPronunciationLyric = hasStructuredLyricContent(
- selectedLyricLayers.pronunciation,
- )
- const { karaokeVisible, showTranslation, showPronunciation } =
- resolveLyricsOverlayState({
- karaokeVisiblePreference,
- translationPreference,
- pronunciationPreference,
- hasKaraokeLyric,
- hasTranslationLyric,
- hasPronunciationLyric,
- })
- const useInlineMobileLyrics = karaokeVisible && !isDesktop
-
- const applyLyricToRuntimePlayer = useCallback((trackId, lyric) => {
- if (!trackId) {
- return
- }
-
- const player = playerRef.current
- if (!player || typeof player.setState !== 'function') {
- return
- }
-
- player.setState((prevState) => {
- const prevLists = Array.isArray(prevState.audioLists)
- ? prevState.audioLists
- : []
- let changed = false
- const audioLists = prevLists.map((item) => {
- if (item.trackId !== trackId) {
- return item
- }
- if (item.lyric === lyric) {
- return item
- }
- changed = true
- return {
- ...item,
- lyric,
- }
- })
-
- const currentItem = audioLists.find(
- (item) => item.musicSrc === prevState.musicSrc,
- )
- const currentLyric =
- typeof currentItem?.lyric === 'string'
- ? currentItem.lyric
- : prevState.lyric
-
- if (!changed && currentLyric === prevState.lyric) {
- return null
- }
-
- return {
- audioLists,
- lyric: currentLyric,
- }
- })
- }, [])
useEffect(() => {
if (
@@ -304,88 +202,6 @@ const Player = () => {
}
}, [playerState, audioInstance])
- useEffect(() => {
- if (!currentTrackId || currentTrackIsRadio) {
- setSelectedLyricLayers(emptyLyricLayers)
- return
- }
-
- const cached = lyricCacheRef.current.get(currentTrackId)
- let layers = emptyLyricLayers
- if (cached && typeof cached !== 'string') {
- if (cached.layers) {
- layers = normalizeLyricLayers(cached.layers)
- } else if (cached.structuredLyric) {
- layers = normalizeLyricLayers({
- main: cached.structuredLyric,
- })
- }
- }
- setSelectedLyricLayers(layers)
- }, [currentTrackId, currentTrackIsRadio])
-
- useEffect(() => {
- lyricRequestIdRef.current += 1
- const requestId = lyricRequestIdRef.current
-
- if (!currentTrackId || currentTrackIsRadio) {
- return
- }
-
- const cached = lyricCacheRef.current.get(currentTrackId)
- if (cached !== undefined) {
- const cachedLyric =
- typeof cached === 'string' ? cached : cached?.lrc || ''
- const cachedLayers =
- typeof cached === 'string'
- ? emptyLyricLayers
- : cached?.layers
- ? normalizeLyricLayers(cached.layers)
- : normalizeLyricLayers({ main: cached?.structuredLyric })
-
- setSelectedLyricLayers(cachedLayers)
- if (cachedLyric) {
- dispatch(updateQueueLyric(currentTrackId, cachedLyric))
- applyLyricToRuntimePlayer(currentTrackId, cachedLyric)
- }
- return
- }
-
- subsonic
- .getLyricsBySongId(currentTrackId)
- .then((resp) => {
- if (lyricRequestIdRef.current !== requestId) {
- return
- }
-
- const structuredLyrics =
- resp?.json?.['subsonic-response']?.lyricsList?.structuredLyrics || []
- const layers = selectLyricLayers(
- structuredLyrics,
- getPreferredLyricLanguage(),
- )
- const lyric = layers.main ? structuredLyricToLrc(layers.main) : ''
- lyricCacheRef.current.set(currentTrackId, {
- lrc: lyric,
- layers,
- })
- setSelectedLyricLayers(layers)
-
- if (lyric !== '') {
- dispatch(updateQueueLyric(currentTrackId, lyric))
- applyLyricToRuntimePlayer(currentTrackId, lyric)
- }
- })
- .catch(() => {
- if (lyricRequestIdRef.current !== requestId) {
- return
- }
- setSelectedLyricLayers(emptyLyricLayers)
- // Do not cache network/request failures as empty lyrics, so we can retry.
- lyricCacheRef.current.delete(currentTrackId)
- })
- }, [dispatch, currentTrackId, currentTrackIsRadio, applyLyricToRuntimePlayer])
-
const defaultOptions = useMemo(
() => ({
theme: playerTheme,
@@ -397,7 +213,7 @@ const Player = () => {
clearPriorAudioLists: false,
showDestroy: true,
showDownload: false,
- showLyric: false,
+ showLyric: true,
showReload: false,
toggleMode: !isDesktop,
glassBg: false,
@@ -435,26 +251,12 @@ const Player = () => {
(playerState.clear || playerState.playIndex === 0),
clearPriorAudioLists: playerState.clear,
extendsContent: (
-
- setKaraokeVisiblePreference((visible) => !visible)
- }
- lyricsActive={karaokeVisible}
- lyricsDisabled={!hasKaraokeLyric}
- />
+
),
defaultVolume: isMobilePlayer ? 1 : playerState.volume,
showMediaSession: !current.isRadio,
}
- }, [
- playerState,
- defaultOptions,
- isMobilePlayer,
- karaokeVisible,
- hasKaraokeLyric,
- ])
+ }, [playerState, defaultOptions, isMobilePlayer])
const onAudioListsChange = useCallback(
(_, audioLists, audioInfo) => dispatch(syncQueue(audioInfo, audioLists)),
@@ -576,13 +378,10 @@ const Player = () => {
)
const onCoverClick = useCallback((mode, audioLists, audioInfo) => {
- if (!isDesktop && karaokeVisible) {
- return
- }
if (mode === 'full' && audioInfo?.song?.albumId) {
window.location.href = `#/album/${audioInfo.song.albumId}/show`
}
- }, [isDesktop, karaokeVisible])
+ }, [])
const onAudioError = useCallback(
(error, currentPlayId, audioLists, audioInfo) => {
@@ -640,7 +439,6 @@ const Player = () => {
return (
{
onBeforeDestroy={onBeforeDestroy}
getAudioInstance={setAudioInstance}
/>
- {isDesktop && (
-
- setTranslationPreference((previous) =>
- hasTranslationLyric ? !previous : false,
- )
- }
- onTogglePronunciation={() =>
- setPronunciationPreference((previous) =>
- togglePronunciationPreference(previous, hasPronunciationLyric),
- )
- }
- audioInstance={audioInstance}
- onClose={() => setKaraokeVisiblePreference(false)}
- />
- )}
-
-
- setTranslationPreference((previous) =>
- hasTranslationLyric ? !previous : false,
- )
- }
- onTogglePronunciation={() =>
- setPronunciationPreference((previous) =>
- togglePronunciationPreference(previous, hasPronunciationLyric),
- )
- }
- audioInstance={audioInstance}
- onClose={() => setKaraokeVisiblePreference(false)}
- />
-
)
diff --git a/ui/src/audioplayer/Player.lyricsState.test.jsx b/ui/src/audioplayer/Player.lyricsState.test.jsx
deleted file mode 100644
index c47abea76..000000000
--- a/ui/src/audioplayer/Player.lyricsState.test.jsx
+++ /dev/null
@@ -1,77 +0,0 @@
-import {
- resolveLyricsOverlayState,
- togglePronunciationPreference,
-} from './lyricsOverlayState'
-
-describe('Player lyrics state helpers', () => {
- it('keeps the lyrics window preference across track changes in the session', () => {
- const visibleOnCurrentTrack = resolveLyricsOverlayState({
- karaokeVisiblePreference: true,
- translationPreference: false,
- pronunciationPreference: null,
- hasKaraokeLyric: true,
- hasTranslationLyric: true,
- hasPronunciationLyric: true,
- })
- expect(visibleOnCurrentTrack.karaokeVisible).toBe(true)
-
- const hiddenForTrackWithoutLyrics = resolveLyricsOverlayState({
- karaokeVisiblePreference: true,
- translationPreference: false,
- pronunciationPreference: null,
- hasKaraokeLyric: false,
- hasTranslationLyric: false,
- hasPronunciationLyric: false,
- })
- expect(hiddenForTrackWithoutLyrics.karaokeVisible).toBe(false)
-
- const restoredOnNextLyricsTrack = resolveLyricsOverlayState({
- karaokeVisiblePreference: true,
- translationPreference: false,
- pronunciationPreference: null,
- hasKaraokeLyric: true,
- hasTranslationLyric: false,
- hasPronunciationLyric: false,
- })
- expect(restoredOnNextLyricsTrack.karaokeVisible).toBe(true)
- })
-
- it('restores translation and pronunciation preferences after tracks without those layers', () => {
- const initialState = resolveLyricsOverlayState({
- karaokeVisiblePreference: false,
- translationPreference: false,
- pronunciationPreference: null,
- hasKaraokeLyric: true,
- hasTranslationLyric: true,
- hasPronunciationLyric: true,
- })
- expect(initialState.showTranslation).toBe(false)
- expect(initialState.showPronunciation).toBe(true)
-
- const translationPreference = true
- const pronunciationPreference = togglePronunciationPreference(null, true)
- expect(pronunciationPreference).toBe(false)
-
- const hiddenOnTrackWithoutAuxLayers = resolveLyricsOverlayState({
- karaokeVisiblePreference: false,
- translationPreference,
- pronunciationPreference,
- hasKaraokeLyric: true,
- hasTranslationLyric: false,
- hasPronunciationLyric: false,
- })
- expect(hiddenOnTrackWithoutAuxLayers.showTranslation).toBe(false)
- expect(hiddenOnTrackWithoutAuxLayers.showPronunciation).toBe(false)
-
- const restoredOnTrackWithAuxLayers = resolveLyricsOverlayState({
- karaokeVisiblePreference: false,
- translationPreference,
- pronunciationPreference,
- hasKaraokeLyric: true,
- hasTranslationLyric: true,
- hasPronunciationLyric: true,
- })
- expect(restoredOnTrackWithAuxLayers.showTranslation).toBe(true)
- expect(restoredOnTrackWithAuxLayers.showPronunciation).toBe(false)
- })
-})
diff --git a/ui/src/audioplayer/PlayerToolbar.jsx b/ui/src/audioplayer/PlayerToolbar.jsx
index 8487b0655..4812141ab 100644
--- a/ui/src/audioplayer/PlayerToolbar.jsx
+++ b/ui/src/audioplayer/PlayerToolbar.jsx
@@ -4,9 +4,7 @@ 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 { RiSaveLine } from 'react-icons/ri'
-import { RiFileMusicLine } from 'react-icons/ri'
import { LoveButton, useToggleLove } from '../common'
import { openSaveQueueDialog } from '../actions'
import { keyMap } from '../hotkeys'
@@ -57,13 +55,7 @@ const useStyles = makeStyles((theme) => ({
},
}))
-const PlayerToolbar = ({
- id,
- isRadio,
- onToggleLyrics,
- lyricsActive = false,
- lyricsDisabled = false,
-}) => {
+const PlayerToolbar = ({ id, isRadio }) => {
const dispatch = useDispatch()
const { data, loading } = useGetOne('song', id, { enabled: !!id && !isRadio })
const [toggleLove, toggling] = useToggleLove('song', data)
@@ -107,25 +99,6 @@ const PlayerToolbar = ({
/>
)
- const toggleLyricsButton = (
-
-
-
-
-
-
-
- )
-
return (
<>
@@ -133,13 +106,11 @@ const PlayerToolbar = ({
{saveQueueButton}
{loveButton}
- {toggleLyricsButton}
) : (
<>
{saveQueueButton}
{loveButton}
- {toggleLyricsButton}
>
)}
>
diff --git a/ui/src/audioplayer/PlayerToolbar.test.jsx b/ui/src/audioplayer/PlayerToolbar.test.jsx
index 3041001eb..d0368b0f0 100644
--- a/ui/src/audioplayer/PlayerToolbar.test.jsx
+++ b/ui/src/audioplayer/PlayerToolbar.test.jsx
@@ -71,7 +71,6 @@ describe('', () => {
// Verify both 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')
@@ -103,14 +102,6 @@ describe('', () => {
type: 'OPEN_SAVE_QUEUE_DIALOG',
})
})
-
- it('triggers lyric toggle callback when lyrics button is clicked', () => {
- const onToggleLyrics = vi.fn()
- render()
-
- fireEvent.click(screen.getByTestId('toggle-lyrics-button'))
- expect(onToggleLyrics).toHaveBeenCalledTimes(1)
- })
})
describe('Mobile layout', () => {
@@ -123,12 +114,11 @@ describe('', () => {
// Each button should be in its own list item
const listItems = screen.getAllByRole('listitem')
- expect(listItems).toHaveLength(3)
+ expect(listItems).toHaveLength(2)
// Verify both 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')
@@ -150,13 +140,6 @@ describe('', () => {
const loveButton = screen.getByTestId('love-button')
expect(loveButton).toBeDisabled()
})
-
- it('disables lyrics button when lyrics are unavailable', () => {
- render()
-
- const lyricsButton = screen.getByTestId('toggle-lyrics-button')
- expect(lyricsButton).toBeDisabled()
- })
})
describe('Common behavior', () => {
diff --git a/ui/src/audioplayer/lyrics.js b/ui/src/audioplayer/lyrics.js
deleted file mode 100644
index 98c638ab3..000000000
--- a/ui/src/audioplayer/lyrics.js
+++ /dev/null
@@ -1,725 +0,0 @@
-const normalizeLanguageTag = (language) =>
- (language || '').toLowerCase().replace('_', '-')
-
-// Roughly one 60fps frame; keeps line/token switching stable near tight boundaries.
-const KARAOKE_SWITCH_EPSILON_MS = 50
-const LYRIC_KIND_MAIN = 'main'
-const LYRIC_KIND_TRANSLATION = 'translation'
-const LYRIC_KIND_PRONUNCIATION = 'pronunciation'
-
-const padTime = (value) => {
- const str = value.toString()
- return str.length === 1 ? `0${str}` : str
-}
-
-const toTime = (value) => {
- if (value == null || value === '') {
- return null
- }
- const numeric = Number(value)
- return Number.isFinite(numeric) ? numeric : null
-}
-
-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 hasTimedLines = (lyric) =>
- lyric &&
- lyric.synced &&
- Array.isArray(lyric.line) &&
- lyric.line.some((line) => Number.isFinite(Number(line.start)))
-
-const preferTimedLyrics = (lyrics) => {
- const timed = lyrics.filter(hasTimedLines)
- return timed.length > 0 ? timed : lyrics
-}
-
-const normalizeToken = (token) => {
- 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: toTime(token.start),
- end: toTime(token.end),
- 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) => {
- 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(normalizeToken).filter(Boolean)
- : [],
- )
-
- return {
- index,
- start: toTime(cueLine?.start),
- end: toTime(cueLine?.end),
- 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 pickLyricByLanguage = (lyrics, preferredLanguage) => {
- if (!Array.isArray(lyrics) || lyrics.length === 0) {
- return null
- }
-
- const preferred = normalizeLanguageTag(preferredLanguage)
- const preferredBase = preferred.split('-')[0]
-
- return (
- lyrics.find((lyric) =>
- languageMatch(normalizeLanguageTag(lyric.lang), preferred),
- ) ||
- lyrics.find((lyric) =>
- languageMatch(normalizeLanguageTag(lyric.lang), preferredBase),
- ) ||
- lyrics.find((lyric) =>
- languageMatch(normalizeLanguageTag(lyric.lang), 'en'),
- ) ||
- lyrics[0]
- )
-}
-
-const lineTimeWindow = (lines, index) => {
- const line = lines[index]
- if (!line) {
- return { start: null, end: null }
- }
-
- const start = toTime(line.start)
- const end = toTime(line.end) ?? toTime(lines[index + 1]?.start)
- return { start, end }
-}
-
-export const hasCueTiming = (structuredLyric) =>
- Boolean(
- structuredLyric &&
- Array.isArray(structuredLyric.cueLine) &&
- structuredLyric.cueLine.some(
- (cueLine) =>
- Array.isArray(cueLine?.cue) &&
- cueLine.cue.some((cue) => Number.isFinite(Number(cue?.start))),
- ),
- )
-
-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 = () => {
- if (typeof window !== 'undefined' && window.localStorage) {
- const stored = window.localStorage.getItem('locale')
- if (stored) {
- return stored
- }
- }
- 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: pickLyricByLanguage(
- preferTimedLyrics(mainCandidates),
- preferredLanguage,
- ),
- translation: pickLyricByLanguage(
- preferTimedLyrics(grouped[LYRIC_KIND_TRANSLATION]),
- preferredLanguage,
- ),
- pronunciation: pickLyricByLanguage(
- preferTimedLyrics(grouped[LYRIC_KIND_PRONUNCIATION]),
- preferredLanguage,
- ),
- }
-}
-
-export const pickStructuredLyric = (structuredLyrics, preferredLanguage) =>
- selectLyricLayers(structuredLyrics, preferredLanguage).main
-
-export const structuredLyricToLrc = (structuredLyric) => {
- if (!structuredLyric || !Array.isArray(structuredLyric.line)) {
- return ''
- }
-
- let lyricText = ''
- for (const line of structuredLyric.line) {
- const start = Number(line.start)
- if (!Number.isFinite(start) || start < 0) {
- continue
- }
-
- let time = Math.floor(start / 10)
- const ms = time % 100
- time = Math.floor(time / 100)
- const sec = time % 60
- time = Math.floor(time / 60)
- const min = time % 60
-
- lyricText += `[${padTime(min)}:${padTime(sec)}.${padTime(ms)}] ${line.value || ''}\n`
- }
- return lyricText
-}
-
-export const structuredLyricsToLrc = (structuredLyrics, preferredLanguage) => {
- const selected = pickStructuredLyric(structuredLyrics, preferredLanguage)
- if (!selected) {
- return ''
- }
- return structuredLyricToLrc(selected)
-}
-
-const buildBaseKaraokeLines = (baseLines) =>
- baseLines.map((line, index) => ({
- index,
- start: toTime(line.start),
- end: toTime(line.end),
- value: typeof line.value === 'string' ? line.value : '',
- tokens: [],
- }))
-
-export const buildKaraokeLinesFromCueLines = (
- rawCueLines,
- baseLines,
- agentLookup,
-) => {
- const normalizedCueLines = rawCueLines.map((cueLine, fallbackIndex) => {
- const normalized = normalizeCueLine(cueLine, fallbackIndex, agentLookup)
- return {
- ...normalized,
- 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)
- }
-
- return Array.from(byIndex.entries()).map(([index, group]) => {
- const first = group[0]
- const baseLine = baseLines[index] || {}
- const tokens = sortTokensByStart(group.flatMap((cueLine) => cueLine.tokens))
- const fallbackStart =
- tokens.find((token) => token.start != null)?.start ?? null
- const fallbackEnd =
- [...tokens].reverse().find((token) => token.end != null)?.end ?? null
- const value =
- first.value ||
- (typeof baseLine.value === 'string' ? baseLine.value : '') ||
- tokens.map((token) => token.value).join('')
-
- return {
- index,
- start: first.start ?? toTime(baseLine.start) ?? fallbackStart,
- end: first.end ?? toTime(baseLine.end) ?? fallbackEnd,
- value,
- agentId: first.agentId,
- agentName: first.agentName,
- agentRole: first.agentRole,
- tokens,
- }
- })
-}
-
-export const buildKaraokeLines = (structuredLyric) => {
- if (!structuredLyric) {
- return []
- }
-
- 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)
- : buildBaseKaraokeLines(baseLines)
-
- const normalized = lines
- .filter((line) => line.value || line.tokens.length > 0)
- .sort((a, b) => {
- if (a.start == null && b.start == null) {
- return a.index - b.index
- }
- if (a.start == null) {
- return 1
- }
- if (b.start == null) {
- return -1
- }
- if (a.start !== b.start) {
- return a.start - b.start
- }
- return a.index - b.index
- })
-
- for (let i = 0; i < normalized.length; i += 1) {
- if (normalized[i].end == null) {
- const nextStart = normalized[i + 1]?.start
- if (nextStart != null) {
- normalized[i].end = nextStart
- }
- }
- }
-
- return normalized
-}
-
-export const resolveKaraokeTokenWindow = (
- line,
- tokenIndex,
- lineEndFallback = null,
-) => {
- const tokens = Array.isArray(line?.tokens) ? line.tokens : []
- const token = tokens[tokenIndex]
- if (!token) {
- return { start: null, end: null }
- }
-
- const prevToken = tokenIndex > 0 ? tokens[tokenIndex - 1] : null
- const nextToken =
- tokenIndex + 1 < tokens.length ? tokens[tokenIndex + 1] : null
-
- const lineStart = toTime(line?.start)
- const lineEnd = toTime(line?.end) ?? toTime(lineEndFallback)
- const tokenCount = tokens.length
- const hasLineWindow =
- lineStart != null &&
- lineEnd != null &&
- Number.isFinite(lineStart) &&
- Number.isFinite(lineEnd) &&
- lineEnd > lineStart
- const estimatedStart =
- hasLineWindow && tokenCount > 0
- ? lineStart + ((lineEnd - lineStart) * tokenIndex) / tokenCount
- : null
- const estimatedEnd =
- hasLineWindow && tokenCount > 0
- ? lineStart + ((lineEnd - lineStart) * (tokenIndex + 1)) / tokenCount
- : null
-
- let explicitStartCount = 0
- let explicitEndCount = 0
- const uniqueStarts = new Set()
- const uniqueEnds = new Set()
-
- for (let i = 0; i < tokenCount; i += 1) {
- const explicitStart = toTime(tokens[i]?.start)
- if (explicitStart != null) {
- explicitStartCount += 1
- uniqueStarts.add(explicitStart)
- }
-
- const explicitEnd = toTime(tokens[i]?.end)
- if (explicitEnd != null) {
- explicitEndCount += 1
- uniqueEnds.add(explicitEnd)
- }
- }
-
- const collapsedStarts =
- explicitStartCount > 1 && uniqueStarts.size <= Math.max(1, tokenCount / 4)
- const collapsedEnds =
- explicitEndCount > 1 && uniqueEnds.size <= Math.max(1, tokenCount / 4)
- const shouldForceEstimated =
- hasLineWindow && tokenCount > 1 && (collapsedStarts || collapsedEnds)
-
- if (shouldForceEstimated) {
- return {
- start: estimatedStart,
- end: estimatedEnd,
- }
- }
- const prevEnd = toTime(prevToken?.end) ?? toTime(prevToken?.start)
-
- let start = toTime(token.start)
- if (start == null) {
- start = prevEnd ?? estimatedStart ?? lineStart
- }
-
- let end = toTime(token.end)
- if (end == null) {
- const nextDirectStart = toTime(nextToken?.start)
- const nextEstimatedStart =
- hasLineWindow && tokenIndex + 1 < tokenCount
- ? lineStart + ((lineEnd - lineStart) * (tokenIndex + 1)) / tokenCount
- : null
- end = nextDirectStart ?? 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
- }
-
- return { start, end }
-}
-
-export const getActiveKaraokeState = (lines, currentTimeMs) => {
- if (!Array.isArray(lines) || lines.length === 0) {
- return { lineIndex: -1, tokenIndex: -1 }
- }
-
- const current = Number.isFinite(Number(currentTimeMs))
- ? Number(currentTimeMs)
- : 0
- let lineIndex = 0
- for (let i = 0; i < lines.length; i += 1) {
- const lineStart = toTime(lines[i]?.start)
- if (lineStart == null || lineStart <= current + KARAOKE_SWITCH_EPSILON_MS) {
- lineIndex = i
- continue
- }
- break
- }
-
- for (let i = lineIndex; i >= 0; i -= 1) {
- const lineStart = toTime(lines[i]?.start)
- const lineEnd = toTime(lines[i]?.end) ?? toTime(lines[i + 1]?.start)
- if (lineStart != null && current + KARAOKE_SWITCH_EPSILON_MS < lineStart) {
- continue
- }
- if (lineEnd == null || current <= lineEnd + KARAOKE_SWITCH_EPSILON_MS) {
- lineIndex = i
- break
- }
- }
-
- const activeLine = lines[lineIndex] || null
- const tokens = Array.isArray(activeLine?.tokens) ? activeLine.tokens : []
- let tokenIndex = -1
- for (let i = 0; i < tokens.length; i += 1) {
- const { start: tokenStart, end: tokenEnd } = resolveKaraokeTokenWindow(
- activeLine,
- i,
- lines[lineIndex + 1]?.start,
- )
- if (
- tokenStart == null ||
- tokenStart <= current + KARAOKE_SWITCH_EPSILON_MS
- ) {
- tokenIndex = i
- if (tokenEnd != null && current <= tokenEnd + KARAOKE_SWITCH_EPSILON_MS) {
- break
- }
- continue
- }
- break
- }
-
- return { lineIndex, tokenIndex }
-}
-
-export const hasUsableKaraokeTiming = (lines) =>
- Array.isArray(lines) &&
- lines.some(
- (line) =>
- toTime(line?.start) != null ||
- (Array.isArray(line?.tokens) &&
- line.tokens.some(
- (token) => toTime(token?.start) != null || toTime(token?.end) != null,
- )),
- )
-
-export const findLayerLineIndexForMain = (mainLines, layerLines, mainIndex) => {
- if (
- !Array.isArray(mainLines) ||
- !Array.isArray(layerLines) ||
- mainLines.length === 0 ||
- layerLines.length === 0 ||
- mainIndex < 0 ||
- mainIndex >= mainLines.length
- ) {
- return -1
- }
-
- const { start: mainStart, end: mainEnd } = lineTimeWindow(
- mainLines,
- mainIndex,
- )
-
- if (mainStart == null) {
- return -1
- }
- const mainWindowEnd = mainEnd ?? mainStart
- const mainWindowDuration = Math.max(0, mainWindowEnd - mainStart)
- const maxDelta = Math.max(550, Math.min(1400, mainWindowDuration + 420))
-
- let bestIdx = -1
- let bestScore = Number.POSITIVE_INFINITY
-
- for (let i = 0; i < layerLines.length; i += 1) {
- const { start, end } = lineTimeWindow(layerLines, i)
-
- if (start != null && end != null) {
- const overlap = Math.min(end, mainEnd ?? end) - Math.max(start, mainStart)
- if (overlap >= 0) {
- const score = Math.abs(start - mainStart) + Math.abs(i - mainIndex) * 30
- if (score < bestScore) {
- bestScore = score
- bestIdx = i
- }
- continue
- }
- }
-
- if (start != null) {
- if (Math.abs(start - mainStart) > maxDelta) {
- continue
- }
- const score = Math.abs(start - mainStart) + Math.abs(i - mainIndex) * 45
- if (score < bestScore) {
- bestScore = score
- bestIdx = i
- }
- }
- }
-
- return bestIdx
-}
-
-export const resolveLayerLineForMain = (mainLines, layerLines, mainIndex) => {
- const index = findLayerLineIndexForMain(mainLines, layerLines, mainIndex)
- return {
- index,
- line: index >= 0 ? layerLines[index] : null,
- }
-}
-
-export const buildHighlightedMainLine = (line) => line
-
-export const buildHighlightedAuxLine = (_referenceLine, auxiliaryLine) =>
- auxiliaryLine ?? null
diff --git a/ui/src/audioplayer/lyrics.test.js b/ui/src/audioplayer/lyrics.test.js
deleted file mode 100644
index 1abea57a5..000000000
--- a/ui/src/audioplayer/lyrics.test.js
+++ /dev/null
@@ -1,786 +0,0 @@
-import {
- buildHighlightedAuxLine,
- buildHighlightedMainLine,
- buildKaraokeLines,
- buildKaraokeLinesFromCueLines,
- findLayerLineIndexForMain,
- getActiveKaraokeState,
- getPreferredLyricLanguage,
- hasUsableKaraokeTiming,
- hasStructuredLyricContent,
- pickStructuredLyric,
- resolveKaraokeTokenWindow,
- resolveLayerLineForMain,
- selectLyricLayers,
- structuredLyricsToLrc,
- structuredLyricToLrc,
- utf8ByteOffsetToCodeUnitIndex,
- utf8ByteRangeToCodeUnitRange,
-} from './lyrics'
-
-describe('lyrics helpers', () => {
- beforeEach(() => {
- localStorage.clear()
- })
-
- it('prefers a lyric track that matches the locale', () => {
- const selected = pickStructuredLyric(
- [
- {
- lang: 'eng',
- synced: true,
- line: [{ start: 1000, value: 'English line' }],
- },
- {
- lang: 'pt-BR',
- synced: true,
- line: [{ start: 1000, value: 'Linha em portugues' }],
- },
- ],
- 'pt-BR',
- )
-
- expect(selected.lang).toBe('pt-BR')
- })
-
- it('falls back to english when preferred locale is not available', () => {
- const selected = pickStructuredLyric(
- [
- {
- lang: 'eng',
- synced: true,
- line: [{ start: 1000, value: 'English line' }],
- },
- {
- lang: 'deu',
- synced: true,
- line: [{ start: 1000, value: 'Deutsche Zeile' }],
- },
- ],
- 'pt-BR',
- )
-
- expect(selected.lang).toBe('eng')
- })
-
- it('falls back to first synced track when english is missing', () => {
- const selected = pickStructuredLyric(
- [
- {
- lang: 'jpn',
- synced: true,
- line: [{ start: 1000, value: 'Nihongo' }],
- },
- {
- lang: 'deu',
- synced: true,
- line: [{ start: 1000, value: 'Deutsch' }],
- },
- ],
- 'pt-BR',
- )
-
- expect(selected.lang).toBe('jpn')
- })
-
- it('selects translation and pronunciation layers by kind', () => {
- const layers = selectLyricLayers(
- [
- {
- kind: 'main',
- lang: 'ja',
- synced: true,
- line: [{ start: 1000, value: 'こんにちは' }],
- },
- {
- kind: 'translation',
- lang: 'es',
- synced: true,
- line: [{ start: 1000, value: 'Hola' }],
- },
- {
- kind: 'pronunciation',
- lang: 'ja-Latn',
- synced: true,
- line: [{ start: 1000, value: 'konnichiwa' }],
- },
- ],
- 'es-MX',
- )
-
- expect(layers.main.lang).toBe('ja')
- expect(layers.translation.lang).toBe('es')
- expect(layers.pronunciation.lang).toBe('ja-Latn')
- })
-
- it('treats missing kind as main for backward compatibility', () => {
- const layers = selectLyricLayers(
- [
- {
- lang: 'eng',
- synced: true,
- line: [{ start: 1000, value: 'Main' }],
- },
- ],
- 'eng',
- )
-
- expect(layers.main.lang).toBe('eng')
- expect(layers.translation).toBeNull()
- expect(layers.pronunciation).toBeNull()
- })
-
- it('falls back to unsynced lyric content when no timed track exists', () => {
- const layers = selectLyricLayers(
- [
- {
- lang: 'eng',
- synced: false,
- line: [{ value: 'Plain embedded lyric' }],
- },
- ],
- 'eng',
- )
-
- expect(layers.main).toEqual({
- lang: 'eng',
- synced: false,
- line: [{ value: 'Plain embedded lyric' }],
- })
- })
-
- it('still prefers timed lyrics when both timed and untimed tracks exist', () => {
- const layers = selectLyricLayers(
- [
- {
- lang: 'eng',
- synced: false,
- line: [{ value: 'Plain lyric' }],
- },
- {
- lang: 'eng',
- synced: true,
- line: [{ start: 1000, value: 'Timed lyric' }],
- },
- ],
- 'eng',
- )
-
- expect(layers.main).toEqual({
- lang: 'eng',
- synced: true,
- line: [{ start: 1000, value: 'Timed lyric' }],
- })
- })
-
- it('matches layer line by timing for the active main line', () => {
- const mainLines = [
- { index: 0, start: 1000, end: 1800, value: 'Line A', tokens: [] },
- { index: 1, start: 2000, end: 2800, value: 'Line B', tokens: [] },
- ]
- const layerLines = [
- { index: 0, start: 900, end: 1750, value: 'A2', tokens: [] },
- { index: 1, start: 2050, end: 2900, value: 'B2', tokens: [] },
- ]
-
- expect(findLayerLineIndexForMain(mainLines, layerLines, 1)).toBe(1)
- expect(resolveLayerLineForMain(mainLines, layerLines, 0).line.value).toBe(
- 'A2',
- )
- })
-
- it('matches metadata layers by nearest timing even when indexes differ', () => {
- const mainLines = [
- { index: 0, start: 1000, end: 1800, value: 'Line A', tokens: [] },
- { index: 1, start: 2000, end: 2800, value: 'Line B', tokens: [] },
- { index: 2, start: 3000, end: 3800, value: 'Line C', tokens: [] },
- ]
- const layerLines = [
- { index: 2, start: 3020, end: 3820, value: 'C2', tokens: [] },
- { index: 0, start: 980, end: 1760, value: 'A2', tokens: [] },
- { index: 1, start: 2010, end: 2810, value: 'B2', tokens: [] },
- ]
-
- expect(findLayerLineIndexForMain(mainLines, layerLines, 1)).toBe(2)
- expect(resolveLayerLineForMain(mainLines, layerLines, 2).line.value).toBe(
- 'C2',
- )
- })
-
- it('keeps translation lines line-level when they do not have real cue timing', () => {
- const mainLine = {
- index: 0,
- start: 1000,
- end: 2200,
- value: '불을 질러라',
- tokens: [
- { start: 1000, end: 1300, value: '불을 ' },
- { start: 1300, end: 1650, value: '질' },
- { start: 1650, end: 2200, value: '러라' },
- ],
- }
- const translationLine = {
- index: 0,
- start: 1000,
- end: 2200,
- value: 'Set it on fire',
- tokens: [],
- }
-
- const highlighted = buildHighlightedAuxLine(mainLine, translationLine, 2600)
-
- expect(highlighted).toBe(translationLine)
- expect(highlighted.tokens).toEqual([])
- })
-
- it('keeps pronunciation lines line-level when they do not have real cue timing', () => {
- const mainLine = {
- index: 0,
- start: 1000,
- end: 2200,
- value: 'You もっと強く 素早く 吹き飛ばせ',
- tokens: [],
- }
- const pronunciationLine = {
- index: 0,
- start: 1000,
- end: 2200,
- value: 'You motto tsuyoku subayaku fukitobase',
- tokens: [],
- }
-
- const highlighted = buildHighlightedAuxLine(
- mainLine,
- pronunciationLine,
- 2600,
- )
-
- expect(highlighted).toBe(pronunciationLine)
- expect(highlighted.tokens).toEqual([])
- })
-
- it('keeps main lines line-level when they do not have real cue timing', () => {
- const line = {
- index: 0,
- start: 1000,
- end: 2200,
- value: 'Youもっと強く 素早く 吹き飛ばせ',
- tokens: [],
- }
-
- const highlighted = buildHighlightedMainLine(line, 2600)
-
- expect(highlighted).toBe(line)
- expect(highlighted.tokens).toEqual([])
- })
-
- it('keeps auxiliary lines line-level when end time is missing and they lack cues', () => {
- const mainLine = {
- index: 0,
- start: 1000,
- end: null,
- value: 'Hello there',
- tokens: [],
- }
- const translationLine = {
- index: 0,
- start: 1000,
- end: null,
- value: 'Bonjour toi',
- tokens: [],
- }
-
- const highlighted = buildHighlightedAuxLine(mainLine, translationLine, 2400)
-
- expect(highlighted).toBe(translationLine)
- expect(highlighted.tokens).toEqual([])
- })
-
- it('keeps main lines line-level when end time is missing and they lack cues', () => {
- const line = {
- index: 0,
- start: 1000,
- end: null,
- value: 'One more time',
- tokens: [],
- }
-
- const highlighted = buildHighlightedMainLine(line, 2400)
-
- expect(highlighted).toBe(line)
- expect(highlighted.tokens).toEqual([])
- })
-
- it('returns no layer match when the nearest line is too far in time', () => {
- const mainLines = [
- { index: 0, start: 1000, end: 1800, value: 'Line A', tokens: [] },
- { index: 1, start: 2000, end: 2800, value: 'Line B', tokens: [] },
- ]
- const layerLines = [
- { index: 0, start: 60000, end: 60800, value: 'Far line', tokens: [] },
- ]
-
- expect(findLayerLineIndexForMain(mainLines, layerLines, 1)).toBe(-1)
- expect(resolveLayerLineForMain(mainLines, layerLines, 1).line).toBeNull()
- })
-
- it('converts a structured lyric track to LRC', () => {
- const lrc = structuredLyricToLrc({
- lang: 'eng',
- synced: true,
- line: [
- { start: 18800, value: "We're no strangers to love" },
- { start: 22801, value: 'You know the rules and so do I' },
- ],
- })
-
- expect(lrc).toBe(
- "[00:18.80] We're no strangers to love\n[00:22.80] You know the rules and so do I\n",
- )
- })
-
- it('returns empty text when no synced lyrics are available', () => {
- const lrc = structuredLyricsToLrc(
- [{ lang: 'eng', synced: false, line: [{ value: 'Unsynced line' }] }],
- 'eng',
- )
-
- expect(lrc).toBe('')
- })
-
- it('reads preferred language from localStorage first', () => {
- localStorage.setItem('locale', 'pt-BR')
- expect(getPreferredLyricLanguage()).toBe('pt-BR')
- })
-
- it('builds karaoke lines from agent-based cueLine payload', () => {
- const lines = buildKaraokeLines({
- lang: 'eng',
- synced: true,
- line: [{ start: 1000, end: 3000, value: 'Hello world' }],
- agents: [
- { id: 'lead', role: 'main', name: 'Lead Vocal' },
- { id: 'backing', role: 'bg' },
- ],
- cueLine: [
- {
- index: 0,
- start: 1000,
- end: 3000,
- value: 'Hello world',
- agentId: 'lead',
- cue: [{ start: 1000, end: 1500, value: 'Hello' }],
- },
- {
- index: 0,
- start: 1000,
- end: 3000,
- value: 'Hello world',
- agentId: 'backing',
- cue: [{ start: 2000, end: 2500, value: 'world' }],
- },
- ],
- })
-
- expect(lines).toEqual([
- {
- agentId: 'lead',
- agentName: 'Lead Vocal',
- agentRole: 'main',
- index: 0,
- start: 1000,
- end: 3000,
- value: 'Hello world',
- tokens: [
- {
- start: 1000,
- end: 1500,
- value: 'Hello',
- role: '',
- agentId: 'lead',
- agentName: 'Lead Vocal',
- agentRole: 'main',
- },
- {
- start: 2000,
- end: 2500,
- value: 'world',
- role: 'bg',
- agentId: 'backing',
- agentName: '',
- agentRole: 'bg',
- },
- ],
- },
- ])
- })
-
- it('builds grouped karaoke lines directly from cue lines', () => {
- const agentLookup = new Map([
- ['lead', { id: 'lead', role: 'main', name: 'Lead Vocal' }],
- ['backing', { id: 'backing', role: 'bg', name: '' }],
- ])
-
- const lines = buildKaraokeLinesFromCueLines(
- [
- {
- index: 0,
- start: 1000,
- end: 3000,
- value: 'Hello world',
- agentId: 'lead',
- cue: [{ start: 1000, end: 1500, value: 'Hello' }],
- },
- {
- index: 0,
- start: 1000,
- end: 3000,
- value: 'Hello world',
- agentId: 'backing',
- cue: [{ start: 2000, end: 2500, value: 'world' }],
- },
- ],
- [{ start: 1000, end: 3000, value: 'Hello world' }],
- agentLookup,
- )
-
- expect(lines).toEqual([
- {
- agentId: 'lead',
- agentName: 'Lead Vocal',
- agentRole: 'main',
- index: 0,
- start: 1000,
- end: 3000,
- value: 'Hello world',
- tokens: [
- {
- start: 1000,
- end: 1500,
- value: 'Hello',
- role: '',
- agentId: 'lead',
- agentName: 'Lead Vocal',
- agentRole: 'main',
- },
- {
- start: 2000,
- end: 2500,
- value: 'world',
- role: 'bg',
- agentId: 'backing',
- agentName: '',
- agentRole: 'bg',
- },
- ],
- },
- ])
- })
-
- it('preserves cue byte offsets on karaoke tokens', () => {
- const lines = buildKaraokeLines({
- lang: 'eng',
- synced: true,
- line: [{ start: 0, end: 2400, value: 'Oh love love me tonight' }],
- cueLine: [
- {
- index: 0,
- start: 0,
- end: 2400,
- value: 'Oh love love me tonight',
- cue: [
- { start: 0, end: 300, value: 'Oh', byteStart: 0, byteEnd: 1 },
- { start: 900, end: 1300, value: 'love', byteStart: 8, byteEnd: 11 },
- { start: 1300, end: 1600, value: 'me', byteStart: 13, byteEnd: 14 },
- {
- start: 1600,
- end: 2400,
- value: 'tonight',
- byteStart: 16,
- byteEnd: 22,
- },
- ],
- },
- ],
- })
-
- expect(
- lines[0].tokens.map((token) => [
- token.value,
- token.byteStart,
- token.byteEnd,
- ]),
- ).toEqual([
- ['Oh', 0, 1],
- ['love', 8, 11],
- ['me', 13, 14],
- ['tonight', 16, 22],
- ])
- })
-
- it('preserves whitespace-only cues for exact byte-range rendering', () => {
- const lines = buildKaraokeLines({
- lang: 'kor',
- synced: true,
- line: [{ start: 0, end: 900, value: '눈을 뜬 순간' }],
- cueLine: [
- {
- index: 0,
- start: 0,
- end: 900,
- value: '눈을 뜬 순간',
- cue: [
- { start: 0, end: 150, value: '눈을', byteStart: 0, byteEnd: 5 },
- { start: 150, end: 250, value: ' ', byteStart: 6, byteEnd: 6 },
- { start: 250, end: 450, value: '뜬', byteStart: 7, byteEnd: 9 },
- { start: 450, end: 550, value: ' ', byteStart: 10, byteEnd: 10 },
- { start: 550, end: 900, value: '순간', byteStart: 11, byteEnd: 16 },
- ],
- },
- ],
- })
-
- expect(
- lines[0].tokens.map((token) => [
- token.value,
- token.byteStart,
- token.byteEnd,
- ]),
- ).toEqual([
- ['눈을', 0, 5],
- [' ', 6, 6],
- ['뜬', 7, 9],
- [' ', 10, 10],
- ['순간', 11, 16],
- ])
- })
-
- it('maps UTF-8 byte offsets to string ranges for multibyte lyrics', () => {
- const text = '눈을 뜬 순간'
-
- expect(utf8ByteOffsetToCodeUnitIndex(text, 0)).toBe(0)
- expect(utf8ByteOffsetToCodeUnitIndex(text, 3)).toBe(1)
- expect(utf8ByteOffsetToCodeUnitIndex(text, 7)).toBe(3)
- expect(utf8ByteRangeToCodeUnitRange(text, 11, 16)).toEqual({
- start: 5,
- end: 7,
- text: '순간',
- })
- })
-
- it('falls back to legacy cueLine role values when agents are absent', () => {
- const lines = buildKaraokeLines({
- lang: 'eng',
- synced: true,
- line: [{ start: 1000, end: 3000, value: 'Hello world' }],
- cueLine: [
- {
- index: 0,
- start: 1000,
- end: 3000,
- value: 'Hello world',
- role: 'bg',
- cue: [{ start: 1000, end: 1500, value: 'Hello' }],
- },
- ],
- })
-
- expect(lines[0].tokens[0].role).toBe('bg')
- expect(lines[0].tokens[0].agentId).toBe('')
- expect(lines[0].tokens[0].agentName).toBe('')
- })
-
- it('sorts token timing by start to keep playback stable', () => {
- const lines = buildKaraokeLines({
- lang: 'eng',
- synced: true,
- line: [{ start: 1000, end: 3000, value: 'Hello world' }],
- cueLine: [
- {
- index: 0,
- start: 1000,
- end: 3000,
- value: 'Hello world',
- role: '',
- cue: [
- { start: 2000, end: 2500, value: 'world' },
- { start: 1000, end: 1500, value: 'Hello' },
- ],
- },
- ],
- })
-
- expect(lines[0].tokens.map((token) => token.value)).toEqual([
- 'Hello',
- 'world',
- ])
- })
-
- it('keeps a single full-line token unchanged instead of expanding it synthetically', () => {
- const lines = buildKaraokeLines({
- lang: 'ko-Latn',
- synced: true,
- line: [{ start: 1000, end: 2000, value: 'Da-la-lun, dun' }],
- cueLine: [
- {
- index: 0,
- start: 1000,
- end: 2000,
- value: 'Da-la-lun, dun',
- role: '',
- cue: [{ start: 1000, end: 2000, value: 'Da-la-lun, dun' }],
- },
- ],
- })
-
- expect(lines).toHaveLength(1)
- expect(lines[0].tokens).toHaveLength(1)
- expect(lines[0].tokens[0].value).toBe('Da-la-lun, dun')
-
- const firstWindow = resolveKaraokeTokenWindow(lines[0], 0)
-
- expect(firstWindow.start).toBeCloseTo(1000)
- expect(firstWindow.end).toBeCloseTo(2000)
- })
-
- it('detects active line and token for karaoke timing', () => {
- const state = getActiveKaraokeState(
- [
- {
- index: 0,
- start: 1000,
- end: 3000,
- value: 'Hello world',
- tokens: [
- { start: 1000, end: 1500, value: 'Hello', role: '' },
- { start: 2000, end: 2500, value: 'world', role: '' },
- ],
- },
- {
- index: 1,
- start: 3500,
- end: 5000,
- value: 'Second line',
- tokens: [],
- },
- ],
- 2200,
- )
-
- expect(state).toEqual({ lineIndex: 0, tokenIndex: 1 })
- })
-
- it('resolves token window fallback boundaries from neighboring tokens', () => {
- const line = {
- start: 1000,
- end: 3000,
- value: 'Hello world',
- tokens: [
- { start: 1200, value: 'Hello', role: '' },
- { start: 1800, value: 'world', role: '' },
- ],
- }
-
- expect(resolveKaraokeTokenWindow(line, 0)).toEqual({
- start: 1200,
- end: 1800,
- })
- expect(resolveKaraokeTokenWindow(line, 1)).toEqual({
- start: 1800,
- end: 3000,
- })
- })
-
- it('infers sequential token windows when token timings are missing', () => {
- const line = {
- start: 1000,
- end: 2000,
- value: 'A B C',
- tokens: [
- { value: 'A', role: '' },
- { value: 'B', role: '' },
- { value: 'C', role: '' },
- ],
- }
-
- const first = resolveKaraokeTokenWindow(line, 0)
- const second = resolveKaraokeTokenWindow(line, 1)
- const third = resolveKaraokeTokenWindow(line, 2)
-
- expect(first.start).toBeCloseTo(1000)
- expect(first.end).toBeCloseTo(1333.3333333333333)
-
- expect(second.start).toBeCloseTo(1333.3333333333333)
- expect(second.end).toBeCloseTo(1666.6666666666667)
-
- expect(third.start).toBeCloseTo(1666.6666666666667)
- expect(third.end).toBeCloseTo(2000)
- })
-
- it('falls back to sequential windows when token timings are collapsed', () => {
- const line = {
- start: 1000,
- end: 2000,
- value: 'A B C',
- tokens: [
- { start: 1000, end: 2000, value: 'A', role: '' },
- { start: 1000, end: 2000, value: 'B', role: '' },
- { start: 1000, end: 2000, value: 'C', role: '' },
- ],
- }
-
- const first = resolveKaraokeTokenWindow(line, 0)
- const second = resolveKaraokeTokenWindow(line, 1)
- const third = resolveKaraokeTokenWindow(line, 2)
-
- expect(first.start).toBeCloseTo(1000)
- expect(first.end).toBeCloseTo(1333.3333333333333)
- expect(second.start).toBeCloseTo(1333.3333333333333)
- expect(second.end).toBeCloseTo(1666.6666666666667)
- expect(third.start).toBeCloseTo(1666.6666666666667)
- expect(third.end).toBeCloseTo(2000)
- })
-
- it('keeps token selection stable near tight token boundaries', () => {
- const state = getActiveKaraokeState(
- [
- {
- index: 0,
- start: 1000,
- end: 2000,
- value: 'A B',
- tokens: [
- { start: 1000, end: 1100, value: 'A', role: '' },
- { start: 1110, end: 1300, value: 'B', role: '' },
- ],
- },
- ],
- 1108,
- )
-
- expect(state).toEqual({ lineIndex: 0, tokenIndex: 0 })
- })
-
- it('reports structured lyric content when token timing exists', () => {
- expect(
- hasStructuredLyricContent({
- cueLine: [{ cue: [{ start: 100, value: 'a' }] }],
- }),
- ).toBe(true)
- })
-
- it('detects when built karaoke lines have no usable timing', () => {
- expect(
- hasUsableKaraokeTiming([
- { index: 0, value: 'First line', tokens: [] },
- { index: 1, value: 'Second line', tokens: [] },
- ]),
- ).toBe(false)
-
- expect(
- hasUsableKaraokeTiming([
- { index: 0, start: 1000, value: 'Timed line', tokens: [] },
- ]),
- ).toBe(true)
- })
-})
diff --git a/ui/src/audioplayer/lyricsOverlayState.js b/ui/src/audioplayer/lyricsOverlayState.js
deleted file mode 100644
index e8ff0e0a8..000000000
--- a/ui/src/audioplayer/lyricsOverlayState.js
+++ /dev/null
@@ -1,27 +0,0 @@
-export const resolveLyricsOverlayState = ({
- karaokeVisiblePreference,
- translationPreference,
- pronunciationPreference,
- hasKaraokeLyric,
- hasTranslationLyric,
- hasPronunciationLyric,
-}) => ({
- karaokeVisible: karaokeVisiblePreference && hasKaraokeLyric,
- showTranslation: translationPreference && hasTranslationLyric,
- showPronunciation:
- (pronunciationPreference == null
- ? hasPronunciationLyric
- : pronunciationPreference) && hasPronunciationLyric,
-})
-
-export const togglePronunciationPreference = (
- previousPreference,
- hasPronunciationLyric,
-) => {
- if (!hasPronunciationLyric) {
- return false
- }
- const currentPreference =
- previousPreference == null ? hasPronunciationLyric : previousPreference
- return !currentPreference
-}
diff --git a/ui/src/audioplayer/styles.js b/ui/src/audioplayer/styles.js
index 30ccf7afb..30a14d4db 100644
--- a/ui/src/audioplayer/styles.js
+++ b/ui/src/audioplayer/styles.js
@@ -62,30 +62,12 @@ 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: 'none',
- height: 'clamp(280px, 42vh, 460px)',
- aspectRatio: 'auto',
- borderRadius: 12,
- border: 'none',
- boxShadow: 'none',
- background: 'transparent',
- cursor: 'default',
},
'& .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
},
- '& .react-jinke-music-player-mobile .react-jinke-music-player-mobile-cover.nd-mobile-lyrics-active img.cover':
- {
- opacity: 0,
- pointerEvents: 'none',
- },
// Hide old singer display
'& .react-jinke-music-player-mobile .react-jinke-music-player-mobile-singer':
{
diff --git a/ui/src/reducers/playerReducer.js b/ui/src/reducers/playerReducer.js
index 449dcd294..d6ab7484b 100644
--- a/ui/src/reducers/playerReducer.js
+++ b/ui/src/reducers/playerReducer.js
@@ -7,7 +7,6 @@ import {
PLAYER_CURRENT,
PLAYER_PLAY_NEXT,
PLAYER_PLAY_TRACKS,
- PLAYER_UPDATE_LYRIC,
PLAYER_SET_TRACK,
PLAYER_SET_VOLUME,
PLAYER_SYNC_QUEUE,
@@ -61,25 +60,21 @@ const mapToAudioLists = (item) => {
let lyricText = ''
if (lyrics) {
- try {
- const structured = JSON.parse(lyrics)
- for (const structuredLyric of structured) {
- if (structuredLyric.synced) {
- for (const line of structuredLyric.line) {
- let time = Math.floor(line.start / 10)
- const ms = time % 100
- time = Math.floor(time / 100)
- const sec = time % 60
- time = Math.floor(time / 60)
- const min = time % 60
+ const structured = JSON.parse(lyrics)
+ for (const structuredLyric of structured) {
+ if (structuredLyric.synced) {
+ for (const line of structuredLyric.line) {
+ let time = Math.floor(line.start / 10)
+ const ms = time % 100
+ time = Math.floor(time / 100)
+ const sec = time % 60
+ time = Math.floor(time / 60)
+ const min = time % 60
- ms.toString()
- lyricText += `[${pad(min)}:${pad(sec)}.${pad(ms)}] ${line.value}\n`
- }
+ ms.toString()
+ lyricText += `[${pad(min)}:${pad(sec)}.${pad(ms)}] ${line.value}\n`
}
}
- } catch {
- lyricText = ''
}
}
@@ -213,45 +208,6 @@ const reduceMode = (state, { data: { mode } }) => {
}
}
-const reduceUpdateLyric = (state, { data: { trackId, lyric } }) => {
- if (!trackId) {
- return state
- }
-
- let changed = false
- const queue = state.queue.map((item) => {
- if (item.trackId !== trackId) {
- return item
- }
- if (item.lyric === lyric) {
- return item
- }
- changed = true
- return {
- ...item,
- lyric,
- }
- })
-
- if (!changed) {
- return state
- }
-
- const current =
- state.current?.trackId === trackId
- ? {
- ...state.current,
- lyric,
- }
- : state.current
-
- return {
- ...state,
- queue,
- current,
- }
-}
-
export const playerReducer = (previousState = initialState, payload) => {
const { type } = payload
switch (type) {
@@ -289,8 +245,6 @@ export const playerReducer = (previousState = initialState, payload) => {
previousState.savedPlayIndex >= 0 ? previousState.savedPlayIndex : 0,
}
}
- case PLAYER_UPDATE_LYRIC:
- return reduceUpdateLyric(previousState, payload)
default:
return previousState
}
diff --git a/ui/src/reducers/playerReducer.test.js b/ui/src/reducers/playerReducer.test.js
index 43f24ec55..110ce8c53 100644
--- a/ui/src/reducers/playerReducer.test.js
+++ b/ui/src/reducers/playerReducer.test.js
@@ -1,24 +1,11 @@
-import { describe, expect, it, vi } from 'vitest'
+import { describe, it, expect } from 'vitest'
import { playerReducer } from './playerReducer'
import {
+ PLAYER_SYNC_QUEUE,
PLAYER_CURRENT,
PLAYER_REFRESH_QUEUE,
- PLAYER_SET_TRACK,
- PLAYER_SYNC_QUEUE,
- PLAYER_UPDATE_LYRIC,
} from '../actions'
-vi.mock('uuid', () => ({
- v4: () => 'test-uuid',
-}))
-
-vi.mock('../subsonic', () => ({
- default: {
- streamUrl: vi.fn((id) => `/rest/stream?id=${id}`),
- getCoverArtUrl: vi.fn(() => '/rest/getCoverArt?id=test'),
- },
-}))
-
describe('playerReducer', () => {
describe('pending track selection survives SYNC_QUEUE and premature CURRENT', () => {
// Simulates the real sequence when clicking a new song while one is playing:
@@ -67,6 +54,8 @@ describe('playerReducer', () => {
})
it('CURRENT for old track preserves pending playIndex', () => {
+ // After SYNC_QUEUE, queue has new UUIDs. The old track's UUID (zzz)
+ // is at index 2, but playIndex is 0. This is a premature callback.
const stateAfterSync = {
...stateAfterPlayTracks,
queue: [
@@ -82,7 +71,7 @@ describe('playerReducer', () => {
const result = playerReducer(stateAfterSync, action)
expect(result.playIndex).toBe(0)
expect(result.clear).toBe(true)
- expect(result.savedPlayIndex).toBe(2)
+ expect(result.savedPlayIndex).toBe(2) // preserved from before
})
it('CURRENT for correct track consumes pending playIndex', () => {
@@ -94,6 +83,7 @@ describe('playerReducer', () => {
{ trackId: 's3', uuid: 'zzz', name: 'Song 3' },
],
}
+ // Player switched to Song 1 (uuid 'xxx', index 0 == playIndex)
const action = {
type: PLAYER_CURRENT,
data: { uuid: 'xxx', name: 'Song 1', volume: 1 },
@@ -234,80 +224,4 @@ describe('playerReducer', () => {
expect(result.playIndex).toBe(0)
})
})
-
- it('maps embedded synced lyrics to LRC text', () => {
- const lyrics = JSON.stringify([
- {
- lang: 'eng',
- synced: true,
- line: [{ start: 1000, value: 'Line one' }],
- },
- {
- lang: 'eng',
- synced: false,
- line: [{ value: 'Unsynced line' }],
- },
- ])
-
- const state = playerReducer(undefined, {
- type: PLAYER_SET_TRACK,
- data: {
- id: 'song-1',
- title: 'Test Song',
- artist: 'Test Artist',
- album: 'Test Album',
- duration: 60,
- lyrics,
- },
- })
-
- expect(state.queue).toHaveLength(1)
- expect(state.queue[0].lyric).toBe('[00:01.00] Line one\n')
- })
-
- it('updates queue lyric by track id', () => {
- const initial = playerReducer(undefined, {
- type: PLAYER_SET_TRACK,
- data: {
- id: 'song-1',
- title: 'Test Song',
- artist: 'Test Artist',
- album: 'Test Album',
- duration: 60,
- },
- })
-
- const updated = playerReducer(initial, {
- type: PLAYER_UPDATE_LYRIC,
- data: {
- trackId: 'song-1',
- lyric: '[00:01.00] Updated lyric\n',
- },
- })
-
- expect(updated.queue[0].lyric).toBe('[00:01.00] Updated lyric\n')
- })
-
- it('returns same state when lyric update does not match any track', () => {
- const initial = playerReducer(undefined, {
- type: PLAYER_SET_TRACK,
- data: {
- id: 'song-1',
- title: 'Test Song',
- artist: 'Test Artist',
- album: 'Test Album',
- duration: 60,
- },
- })
-
- const updated = playerReducer(initial, {
- type: PLAYER_UPDATE_LYRIC,
- data: {
- trackId: 'missing-track',
- lyric: '[00:01.00] Updated lyric\n',
- },
- })
-
- expect(updated).toBe(initial)
- })
})
diff --git a/ui/src/subsonic/index.js b/ui/src/subsonic/index.js
index ae9a47a30..7d93972e0 100644
--- a/ui/src/subsonic/index.js
+++ b/ui/src/subsonic/index.js
@@ -129,10 +129,6 @@ const getTopSongs = (artist, count = 50) => {
return httpClient(url('getTopSongs', null, { artist, count }))
}
-const getLyricsBySongId = (id) => {
- return httpClient(url('getLyricsBySongId', id, { enhanced: true }))
-}
-
const streamUrl = (id, options) => {
return baseUrl(
url('stream', id, {
@@ -162,5 +158,4 @@ export default {
getArtistInfo,
getTopSongs,
getSimilarSongs2,
- getLyricsBySongId,
}
diff --git a/ui/src/subsonic/index.test.js b/ui/src/subsonic/index.test.js
index b433c3402..ad4764c24 100644
--- a/ui/src/subsonic/index.test.js
+++ b/ui/src/subsonic/index.test.js
@@ -1,14 +1,7 @@
import { vi } from 'vitest'
import config from '../config'
-import { httpClient } from '../dataProvider'
import subsonic from './index'
-vi.mock('../dataProvider', () => ({
- httpClient: vi.fn(() => Promise.resolve({})),
- clientUniqueId: 'test-client-id',
- clientUniqueIdHeader: 'X-ND-Client-Unique-Id',
-}))
-
describe('getCoverArtUrl', () => {
beforeEach(() => {
// Mock window.location
@@ -202,33 +195,6 @@ describe('getAvatarUrl', () => {
})
})
-describe('getLyricsBySongId', () => {
- beforeEach(() => {
- vi.clearAllMocks()
- 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 })
- })
-
- it('calls the getLyricsBySongId endpoint with enhanced=true', async () => {
- await subsonic.getLyricsBySongId('song-1')
-
- expect(httpClient).toHaveBeenCalledTimes(1)
- const calledUrl = httpClient.mock.calls[0][0]
- expect(calledUrl).toContain('/rest/getLyricsBySongId?')
- expect(calledUrl).toContain('id=song-1')
- expect(calledUrl).toContain('enhanced=true')
- })
-})
-
describe('reportPlayback', () => {
beforeEach(() => {
const localStorageMock = {