From 101ebc4738107d59120065349b9827f7f84cebba Mon Sep 17 00:00:00 2001 From: ranokay Date: Sun, 10 May 2026 00:41:24 +0300 Subject: [PATCH] chore: split lyrics UI changes --- ui/src/actions/player.js | 6 - ui/src/audioplayer/KaraokeLyricsOverlay.jsx | 1745 ----------------- .../audioplayer/KaraokeLyricsOverlay.test.jsx | 514 ----- .../audioplayer/MobileKaraokeLyricsPortal.jsx | 65 - .../MobileKaraokeLyricsPortal.test.jsx | 55 - ui/src/audioplayer/Player.jsx | 259 +-- .../audioplayer/Player.lyricsState.test.jsx | 77 - ui/src/audioplayer/PlayerToolbar.jsx | 31 +- ui/src/audioplayer/PlayerToolbar.test.jsx | 19 +- ui/src/audioplayer/lyrics.js | 725 ------- ui/src/audioplayer/lyrics.test.js | 786 -------- ui/src/audioplayer/lyricsOverlayState.js | 27 - ui/src/audioplayer/styles.js | 18 - ui/src/reducers/playerReducer.js | 70 +- ui/src/reducers/playerReducer.test.js | 98 +- ui/src/subsonic/index.js | 5 - ui/src/subsonic/index.test.js | 34 - 17 files changed, 24 insertions(+), 4510 deletions(-) delete mode 100644 ui/src/audioplayer/KaraokeLyricsOverlay.jsx delete mode 100644 ui/src/audioplayer/KaraokeLyricsOverlay.test.jsx delete mode 100644 ui/src/audioplayer/MobileKaraokeLyricsPortal.jsx delete mode 100644 ui/src/audioplayer/MobileKaraokeLyricsPortal.test.jsx delete mode 100644 ui/src/audioplayer/Player.lyricsState.test.jsx delete mode 100644 ui/src/audioplayer/lyrics.js delete mode 100644 ui/src/audioplayer/lyrics.test.js delete mode 100644 ui/src/audioplayer/lyricsOverlayState.js 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 = {