From 73d94962e01543b6a2340efdecea6e6cfe81320b Mon Sep 17 00:00:00 2001 From: ranokay Date: Fri, 27 Mar 2026 12:41:27 +0200 Subject: [PATCH] feat(lyrics): refine karaoke overlay timing and state --- core/lyrics/lyrics_test.go | 4 +- core/lyrics/sources_test.go | 15 +- model/lyrics.go | 12 - model/lyrics_test.go | 16 +- ui/src/audioplayer/KaraokeLyricsOverlay.jsx | 535 ++++++++++++++---- .../audioplayer/KaraokeLyricsOverlay.test.jsx | 344 +++++++++++ ui/src/audioplayer/Player.jsx | 51 +- .../audioplayer/Player.lyricsState.test.jsx | 77 +++ ui/src/audioplayer/lyrics.js | 90 +-- ui/src/audioplayer/lyrics.test.js | 134 ++++- ui/src/audioplayer/lyricsOverlayState.js | 27 + 11 files changed, 1070 insertions(+), 235 deletions(-) create mode 100644 ui/src/audioplayer/KaraokeLyricsOverlay.test.jsx create mode 100644 ui/src/audioplayer/Player.lyricsState.test.jsx create mode 100644 ui/src/audioplayer/lyricsOverlayState.js diff --git a/core/lyrics/lyrics_test.go b/core/lyrics/lyrics_test.go index 58e8ba82b..917c530ac 100644 --- a/core/lyrics/lyrics_test.go +++ b/core/lyrics/lyrics_test.go @@ -52,15 +52,17 @@ var _ = Describe("sources", func() { Line: []model.Line{ { Start: gg.P(int64(1000)), - End: gg.P(int64(1500)), + End: gg.P(int64(3000)), Value: "Lead words", Cue: []model.Cue{ { Start: gg.P(int64(1000)), + End: gg.P(int64(1500)), Value: "Lead ", }, { Start: gg.P(int64(1500)), + End: gg.P(int64(3000)), Value: "words", }, }, diff --git a/core/lyrics/sources_test.go b/core/lyrics/sources_test.go index a110390d8..a86c84cd0 100644 --- a/core/lyrics/sources_test.go +++ b/core/lyrics/sources_test.go @@ -102,22 +102,26 @@ var _ = Describe("sources", func() { // Line 1: has inline markers → Cue array populated Expect(lyrics[0].Line[0].Start).To(Equal(gg.P(int64(1000)))) + Expect(lyrics[0].Line[0].End).To(Equal(gg.P(int64(3000)))) Expect(lyrics[0].Line[0].Value).To(Equal("Some lyrics here")) Expect(lyrics[0].Line[0].Cue).To(HaveLen(3)) Expect(*lyrics[0].Line[0].Cue[0].Start).To(Equal(int64(1000))) Expect(lyrics[0].Line[0].Cue[0].Value).To(Equal("Some ")) - Expect(lyrics[0].Line[0].Cue[0].End).To(BeNil()) + Expect(lyrics[0].Line[0].Cue[0].End).To(Equal(gg.P(int64(1500)))) Expect(*lyrics[0].Line[0].Cue[1].Start).To(Equal(int64(1500))) Expect(lyrics[0].Line[0].Cue[1].Value).To(Equal("lyrics ")) - Expect(lyrics[0].Line[0].Cue[1].End).To(BeNil()) + Expect(lyrics[0].Line[0].Cue[1].End).To(Equal(gg.P(int64(2000)))) Expect(*lyrics[0].Line[0].Cue[2].Start).To(Equal(int64(2000))) Expect(lyrics[0].Line[0].Cue[2].Value).To(Equal("here")) - Expect(lyrics[0].Line[0].Cue[2].End).To(BeNil()) + Expect(lyrics[0].Line[0].Cue[2].End).To(Equal(gg.P(int64(3000)))) // Line 2: has inline markers Expect(lyrics[0].Line[1].Start).To(Equal(gg.P(int64(3000)))) + Expect(lyrics[0].Line[1].End).To(Equal(gg.P(int64(5000)))) Expect(lyrics[0].Line[1].Value).To(Equal("More words")) Expect(lyrics[0].Line[1].Cue).To(HaveLen(2)) + Expect(lyrics[0].Line[1].Cue[0].End).To(Equal(gg.P(int64(3500)))) + Expect(lyrics[0].Line[1].Cue[1].End).To(Equal(gg.P(int64(5000)))) // Line 3: plain line, no cues Expect(lyrics[0].Line[2].Start).To(Equal(gg.P(int64(5000)))) @@ -138,14 +142,15 @@ var _ = Describe("sources", func() { Expect(lyrics[0].Line).To(HaveLen(2)) Expect(lyrics[0].Line[0].Start).To(Equal(gg.P(int64(1000)))) + Expect(lyrics[0].Line[0].End).To(Equal(gg.P(int64(3000)))) Expect(lyrics[0].Line[0].Value).To(Equal("Lead words")) Expect(lyrics[0].Line[0].Cue).To(HaveLen(2)) Expect(*lyrics[0].Line[0].Cue[0].Start).To(Equal(int64(1000))) Expect(lyrics[0].Line[0].Cue[0].Value).To(Equal("Lead ")) - Expect(lyrics[0].Line[0].Cue[0].End).To(BeNil()) + Expect(lyrics[0].Line[0].Cue[0].End).To(Equal(gg.P(int64(1500)))) Expect(*lyrics[0].Line[0].Cue[1].Start).To(Equal(int64(1500))) Expect(lyrics[0].Line[0].Cue[1].Value).To(Equal("words")) - Expect(lyrics[0].Line[0].Cue[1].End).To(BeNil()) + Expect(lyrics[0].Line[0].Cue[1].End).To(Equal(gg.P(int64(3000)))) Expect(lyrics[0].Line[1].Start).To(Equal(gg.P(int64(3000)))) Expect(lyrics[0].Line[1].Value).To(Equal("Fallback line")) diff --git a/model/lyrics.go b/model/lyrics.go index 725c3aa94..ec0df9f34 100644 --- a/model/lyrics.go +++ b/model/lyrics.go @@ -417,18 +417,6 @@ func normalizeCueLine(line Line, fallbackEnd *int64) Line { return line } - hasAnyEnd := false - for i := range line.Cue { - if line.Cue[i].End != nil { - hasAnyEnd = true - break - } - } - if !hasAnyEnd { - line.Cue = clearCueEnds(line.Cue) - return NormalizeLineTiming(line) - } - for i := range line.Cue { if line.Cue[i].End != nil { continue diff --git a/model/lyrics_test.go b/model/lyrics_test.go index 9aad7d968..6f189f024 100644 --- a/model/lyrics_test.go +++ b/model/lyrics_test.go @@ -127,20 +127,24 @@ var _ = Describe("ToLyrics", func() { line0 := lyrics.Line[0] Expect(line0.Start).To(Equal(&t1000)) + Expect(line0.End).To(Equal(&t3000)) Expect(line0.Value).To(Equal("Some lyrics here")) Expect(line0.Cue).To(Equal([]Cue{ - {Start: &t1000, Value: "Some "}, - {Start: &t1500, Value: "lyrics "}, - {Start: &t2000, Value: "here"}, + {Start: &t1000, End: &t1500, Value: "Some "}, + {Start: &t1500, End: &t2000, Value: "lyrics "}, + {Start: &t2000, End: &t3000, Value: "here"}, })) line1 := lyrics.Line[1] Expect(line1.Start).To(Equal(&t3000)) + Expect(line1.End).To(Equal(&t3500)) Expect(line1.Value).To(Equal("More words")) Expect(line1.Cue).To(Equal([]Cue{ {Start: &t3000, Value: "More "}, {Start: &t3500, Value: "words"}, })) + + Expect(line1.Cue[1].End).To(BeNil()) }) It("should ignore Enhanced LRC markers and return plain lines when no markers present", func() { @@ -159,12 +163,14 @@ var _ = Describe("ToLyrics", func() { Expect(lyrics.Line).To(HaveLen(3)) t1000, t1500, t5000, t5500 := int64(1000), int64(1500), int64(5000), int64(5500) + t3000 := int64(3000) Expect(lyrics.Line[0].Cue).To(Equal([]Cue{ - {Start: &t1000, Value: "Some "}, - {Start: &t1500, Value: "lyrics"}, + {Start: &t1000, End: &t1500, Value: "Some "}, + {Start: &t1500, End: &t3000, Value: "lyrics"}, })) Expect(lyrics.Line[0].Value).To(Equal("Some lyrics")) + Expect(lyrics.Line[0].End).To(Equal(&t3000)) Expect(lyrics.Line[1].Cue).To(BeNil()) Expect(lyrics.Line[1].Value).To(Equal("Plain line")) diff --git a/ui/src/audioplayer/KaraokeLyricsOverlay.jsx b/ui/src/audioplayer/KaraokeLyricsOverlay.jsx index a44e50bf6..cd1484e41 100644 --- a/ui/src/audioplayer/KaraokeLyricsOverlay.jsx +++ b/ui/src/audioplayer/KaraokeLyricsOverlay.jsx @@ -3,8 +3,10 @@ import IconButton from '@material-ui/core/IconButton' import Popover from '@material-ui/core/Popover' import Slider from '@material-ui/core/Slider' import { makeStyles } 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, { @@ -16,8 +18,11 @@ import React, { useState, } from 'react' import { + buildHighlightedAuxLine, + buildHighlightedMainLine, buildKaraokeLines, getActiveKaraokeState, + hasUsableKaraokeTiming, hasStructuredLyricContent, resolveKaraokeTokenWindow, resolveLayerLineForMain, @@ -36,6 +41,12 @@ 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 TOKEN_DONE_ALPHA = 1 const TOKEN_FUTURE_ALPHA = 0.34 @@ -55,33 +66,65 @@ const COLOR_PRESETS = [ ] const DEFAULT_LYRICS_SETTINGS = { - tr: { fontSize: 14, colorKey: 'blue' }, - main: { fontSize: 24, colorKey: 'white' }, - pr: { fontSize: 14, colorKey: 'green' }, + 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 = () => ({ + lineHeight: KARAOKE_DEFAULT_LINE_HEIGHT, + overlayHeight: KARAOKE_DEFAULT_HEIGHT_PX, + tr: { ...DEFAULT_LYRICS_SETTINGS.tr }, + main: { ...DEFAULT_LYRICS_SETTINGS.main }, + 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) { - const parsed = JSON.parse(raw) - return { - tr: { ...DEFAULT_LYRICS_SETTINGS.tr, ...parsed.tr }, - main: { ...DEFAULT_LYRICS_SETTINGS.main, ...parsed.main }, - pr: { ...DEFAULT_LYRICS_SETTINGS.pr, ...parsed.pr }, - } + return normalizeLyricsSettings(JSON.parse(raw)) } } catch { /* ignore */ } - return { ...DEFAULT_LYRICS_SETTINGS } + return normalizeLyricsSettings() } const saveLyricsSettings = (settings) => { try { - localStorage.setItem(SETTINGS_STORAGE_KEY, JSON.stringify(settings)) + localStorage.setItem( + SETTINGS_STORAGE_KEY, + JSON.stringify(normalizeLyricsSettings(settings)), + ) } catch { /* ignore */ } @@ -97,7 +140,7 @@ const useStyles = makeStyles((theme) => ({ bottom: 100, transform: 'translateX(-50%)', zIndex: 1400, - width: 'min(900px, calc(100vw - 32px))', + width: 'min(1000px, calc(100vw - 32px))', minHeight: KARAOKE_MIN_HEIGHT_PX, background: 'rgba(6, 8, 12, 0.9)', borderRadius: 12, @@ -149,13 +192,39 @@ const useStyles = makeStyles((theme) => ({ gap: theme.spacing(1), minWidth: 0, }, - language: { - fontSize: 11, - letterSpacing: '0.08em', - opacity: 0.72, - textTransform: 'uppercase', + languageBadges: { + display: 'flex', + alignItems: 'center', + gap: theme.spacing(0.5), + flexWrap: 'wrap', + minWidth: 0, + }, + languageBadge: { + display: 'inline-flex', + alignItems: 'center', + gap: theme.spacing(0.35), + padding: theme.spacing(0.2, 0.7), + borderRadius: 999, + border: '1px solid rgba(148, 163, 184, 0.28)', + background: 'rgba(15, 23, 42, 0.42)', + color: 'rgba(226, 232, 240, 0.8)', + fontSize: 10, + letterSpacing: '0.04em', whiteSpace: 'nowrap', }, + languageBadgeActive: { + borderColor: 'rgba(148, 163, 184, 0.46)', + background: 'rgba(30, 41, 59, 0.56)', + color: 'rgba(248, 250, 252, 0.94)', + }, + languageBadgeLabel: { + fontWeight: 700, + textTransform: 'uppercase', + opacity: 0.78, + }, + languageBadgeValue: { + opacity: 0.9, + }, layerControls: { display: 'flex', alignItems: 'center', @@ -186,21 +255,31 @@ const useStyles = makeStyles((theme) => ({ closeButton: { color: 'rgba(255, 255, 255, 0.72)', }, + lineGroup: { + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + gap: theme.spacing(0.35), + }, inlineTr: { - margin: '0 0 2px 0', + margin: 0, textAlign: 'center', fontWeight: 400, - lineHeight: 1.2, + lineHeight: KARAOKE_AUX_LINE_HEIGHT, letterSpacing: '0.01em', transition: `opacity ${KARAOKE_ANIMATION_MS}ms ease-in-out, font-size ${KARAOKE_ANIMATION_MS}ms ease-in-out`, }, inlinePr: { - margin: '2px 0 0 0', + margin: 0, textAlign: 'center', fontWeight: 400, - lineHeight: 1.2, + lineHeight: KARAOKE_AUX_LINE_HEIGHT, letterSpacing: '0.01em', transition: `opacity ${KARAOKE_ANIMATION_MS}ms ease-in-out, font-size ${KARAOKE_ANIMATION_MS}ms ease-in-out`, + padding: theme.spacing(0.15, 0.9), + borderRadius: 999, + background: 'rgba(255, 255, 255, 0.08)', + border: '1px solid rgba(255, 255, 255, 0.12)', }, body: { padding: theme.spacing(0.5, 2, 1.4, 2), @@ -252,15 +331,29 @@ const useStyles = makeStyles((theme) => ({ border: '1px solid rgba(255, 255, 255, 0.12)', borderRadius: 10, padding: theme.spacing(1.5, 2), - width: 260, + 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: 'rgba(255, 255, 255, 0.78)', + }, settingsLabel: { fontSize: 10, fontWeight: 600, @@ -291,6 +384,21 @@ const useStyles = makeStyles((theme) => ({ minWidth: 22, textAlign: 'right', }, + settingsControlLabel: { + fontSize: 10, + letterSpacing: '0.06em', + textTransform: 'uppercase', + color: 'rgba(255, 255, 255, 0.45)', + minWidth: 72, + whiteSpace: 'nowrap', + }, + resetButton: { + color: 'rgba(255, 255, 255, 0.58)', + padding: 4, + '&:hover': { + color: 'rgba(255, 255, 255, 0.9)', + }, + }, colorDots: { display: 'flex', gap: 5, @@ -314,6 +422,9 @@ const useStyles = makeStyles((theme) => ({ 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() @@ -326,6 +437,34 @@ const shouldShowAuxLine = (mainLine, auxLine) => { ) } +const buildLanguageBadges = ({ + mainLyric, + translationLyric, + pronunciationLyric, + showTranslation, + showPronunciation, +}) => + [ + { + key: 'main', + label: 'Main', + lang: mainLyric?.lang, + active: true, + }, + { + key: 'pr', + label: 'PR', + lang: pronunciationLyric?.lang, + active: showPronunciation, + }, + { + key: 'tr', + label: 'TR', + lang: translationLyric?.lang, + active: showTranslation, + }, + ].filter((badge) => badge.lang) + const SettingsSection = ({ label, layer, settings, onChange, classes }) => { const s = settings[layer] return ( @@ -363,7 +502,37 @@ const SettingsSection = ({ label, layer, settings, onChange, classes }) => { ) } -const LyricsSettingsPopover = ({ settings, onChange }) => { +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) @@ -376,14 +545,19 @@ const LyricsSettingsPopover = ({ settings, onChange }) => { return ( <> - - - + + + + + + + { PaperProps={{ className: classes.settingsPanel }} style={{ zIndex: 1500 }} > +
+ Appearance + + + + + + + +
+ { classes={classes} /> { a.opacity === b.opacity && a.color === b.color && a.fontSize === b.fontSize && - a.fontWeight === b.fontWeight + a.fontWeight === b.fontWeight && + a.lineHeight === b.lineHeight ) } @@ -778,7 +974,6 @@ const KaraokeLyricsOverlay = ({ }) => { const classes = useStyles() const [playbackMs, setPlaybackMs] = useState(0) - const [overlayHeight, setOverlayHeight] = useState(KARAOKE_DEFAULT_HEIGHT_PX) const [maxHeightPx, setMaxHeightPx] = useState(getMaxHeightPx()) const [bodyViewportHeight, setBodyViewportHeight] = useState(0) const [isCompact, setIsCompact] = useState( @@ -787,8 +982,15 @@ const KaraokeLyricsOverlay = ({ const [lyricsSettings, setLyricsSettings] = useState(loadLyricsSettings) const handleSettingsChange = useCallback((next) => { - setLyricsSettings(next) - saveLyricsSettings(next) + const normalized = normalizeLyricsSettings(next) + setLyricsSettings(normalized) + saveLyricsSettings(normalized) + }, []) + + const handleResetAppearance = useCallback(() => { + const defaults = createDefaultLyricsSettings() + setLyricsSettings(defaults) + saveLyricsSettings(defaults) }, []) const bodyRef = useRef(null) @@ -803,15 +1005,17 @@ const KaraokeLyricsOverlay = ({ () => 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) - setOverlayHeight((previous) => - clamp(previous, KARAOKE_MIN_HEIGHT_PX, nextMaxHeight), - ) } onResize() @@ -853,9 +1057,14 @@ const KaraokeLyricsOverlay = ({ const onMove = (moveEvent) => { const delta = startY - moveEvent.clientY - setOverlayHeight( - clamp(startHeight + delta, KARAOKE_MIN_HEIGHT_PX, maxHeightPx), - ) + handleSettingsChange({ + ...lyricsSettings, + overlayHeight: clamp( + startHeight + delta, + KARAOKE_MIN_HEIGHT_PX, + maxHeightPx, + ), + }) } const onUp = () => { @@ -866,7 +1075,13 @@ const KaraokeLyricsOverlay = ({ window.addEventListener('mousemove', onMove) window.addEventListener('mouseup', onUp) }, - [isCompact, maxHeightPx, overlayHeight], + [ + handleSettingsChange, + isCompact, + lyricsSettings, + maxHeightPx, + overlayHeight, + ], ) useEffect(() => { @@ -967,13 +1182,29 @@ const KaraokeLyricsOverlay = ({ }, [audioInstance, visible]) const renderPlaybackMs = playbackMs + KARAOKE_RENDER_LEAD_MS - - const { lineIndex } = useMemo( - () => getActiveKaraokeState(mainLines, renderPlaybackMs), - [mainLines, renderPlaybackMs], + const hasTimedMainLines = useMemo( + () => hasUsableKaraokeTiming(mainLines), + [mainLines], ) - const activeIndex = lineIndex >= 0 ? lineIndex : 0 + 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, + }) const trByMainIndex = useMemo(() => { if (!showTranslation || translationLines.length === 0) return {} @@ -1008,12 +1239,14 @@ const KaraokeLyricsOverlay = ({ ? 260 : Math.max(220, overlayHeight - 170) const centerSpacerPx = Math.max( - KARAOKE_CENTER_SPACER_MIN_PX, - Math.floor(estimatedViewportHeight * KARAOKE_CENTER_SPACER_RATIO), + hasTimedMainLines ? KARAOKE_CENTER_SPACER_MIN_PX : 0, + hasTimedMainLines + ? Math.floor(estimatedViewportHeight * KARAOKE_CENTER_SPACER_RATIO) + : 0, ) useEffect(() => { - if (!visible) { + if (!visible || !hasTimedMainLines) { return } @@ -1050,6 +1283,7 @@ const KaraokeLyricsOverlay = ({ return () => window.cancelAnimationFrame(rafId) }, [ centerSpacerPx, + hasTimedMainLines, hasPronunciationLine, hasTranslationLine, lineIndex, @@ -1066,10 +1300,19 @@ const KaraokeLyricsOverlay = ({ } 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 [r, g, b] = parseColorRGB(getColorValue(lyricsSettings.main.colorKey)) const color = isActive ? `rgba(${r}, ${g}, ${b}, 0.98)` : delta < 0 @@ -1093,6 +1336,48 @@ const KaraokeLyricsOverlay = ({ opacity, color, fontSize, + lineHeight, + } + } + + const getAuxLineStyle = (idx, layerKey) => { + const [r, g, b] = parseColorRGB( + getColorValue(lyricsSettings[layerKey].colorKey), + ) + if (!hasTimedMainLines) { + return { + opacity: 0.94, + fontSize: lyricsSettings[layerKey].fontSize, + 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) + } + + return { + opacity, + fontSize: lyricsSettings[layerKey].fontSize, + color, + lineHeight: KARAOKE_AUX_LINE_HEIGHT, } } @@ -1109,36 +1394,61 @@ const KaraokeLyricsOverlay = ({ data-testid="karaoke-lyrics-overlay" style={overlayStyle} > -
+
- - {mainLyric?.lang || 'xxx'} - +
+ {languageBadges.map((badge) => ( +
+ + {badge.label} + + {badge.lang} +
+ ))} +
- - + + + + + + + + + +
@@ -1146,6 +1456,7 @@ const KaraokeLyricsOverlay = ({
-
+
{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 auxOpacity = - lineStyle.opacity != null ? lineStyle.opacity * 0.85 : 1 - const trStyle = { - opacity: auxOpacity, - fontSize: lyricsSettings.tr.fontSize, - color: getColorValue(lyricsSettings.tr.colorKey), - } - const prStyle = { - opacity: auxOpacity, - fontSize: lyricsSettings.pr.fontSize, - color: getColorValue(lyricsSettings.pr.colorKey), - } + const trStyle = getAuxLineStyle(idx, 'tr') + const prStyle = getAuxLineStyle(idx, 'pr') return (
{ if (audioInstance && line.start != null) { @@ -1190,33 +1511,35 @@ const KaraokeLyricsOverlay = ({ } }} > - {showTr && ( - - )} {showPr && ( + )} + {showTr && ( + )}
diff --git a/ui/src/audioplayer/KaraokeLyricsOverlay.test.jsx b/ui/src/audioplayer/KaraokeLyricsOverlay.test.jsx new file mode 100644 index 000000000..411116eae --- /dev/null +++ b/ui/src/audioplayer/KaraokeLyricsOverlay.test.jsx @@ -0,0 +1,344 @@ +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-toggle-translation')) + expect(await screen.findByText('Toggle translations')).toBeInTheDocument() + + fireEvent.mouseOver(screen.getByTestId('lyrics-toggle-pronunciation')) + expect(await screen.findByText('Toggle pronunciations')).toBeInTheDocument() + + fireEvent.mouseOver(screen.getByTestId('lyrics-settings-button')) + expect(await screen.findByText('Appearance')).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('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('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/Player.jsx b/ui/src/audioplayer/Player.jsx index b8b33b6d5..c6e73c916 100644 --- a/ui/src/audioplayer/Player.jsx +++ b/ui/src/audioplayer/Player.jsx @@ -40,6 +40,10 @@ import { selectLyricLayers, structuredLyricToLrc, } from './lyrics' +import { + resolveLyricsOverlayState, + togglePronunciationPreference, +} from './lyricsOverlayState' import KaraokeLyricsOverlay from './KaraokeLyricsOverlay' const emptyLyricLayers = { @@ -143,11 +147,12 @@ const Player = () => { const lyricCacheRef = useRef(new Map()) const lyricRequestIdRef = useRef(0) const playerRef = useRef(null) - const [karaokeVisible, setKaraokeVisible] = useState(false) + const [karaokeVisiblePreference, setKaraokeVisiblePreference] = + useState(false) const [selectedLyricLayers, setSelectedLyricLayers] = useState(emptyLyricLayers) - const [showTranslation, setShowTranslation] = useState(false) - const [showPronunciation, setShowPronunciation] = useState(false) + const [translationPreference, setTranslationPreference] = useState(false) + const [pronunciationPreference, setPronunciationPreference] = useState(null) const currentTrackId = playerState.current?.trackId const currentTrackIsRadio = playerState.current?.isRadio const selectedStructuredLyric = selectedLyricLayers.main @@ -158,6 +163,15 @@ const Player = () => { const hasPronunciationLyric = hasStructuredLyricContent( selectedLyricLayers.pronunciation, ) + const { karaokeVisible, showTranslation, showPronunciation } = + resolveLyricsOverlayState({ + karaokeVisiblePreference, + translationPreference, + pronunciationPreference, + hasKaraokeLyric, + hasTranslationLyric, + hasPronunciationLyric, + }) const applyLyricToRuntimePlayer = useCallback((trackId, lyric) => { if (!trackId) { @@ -255,9 +269,6 @@ const Player = () => { useEffect(() => { if (!currentTrackId || currentTrackIsRadio) { setSelectedLyricLayers(emptyLyricLayers) - setShowTranslation(false) - setShowPronunciation(false) - setKaraokeVisible(false) return } @@ -273,8 +284,6 @@ const Player = () => { } } setSelectedLyricLayers(layers) - setShowTranslation(false) - setShowPronunciation(hasStructuredLyricContent(layers.pronunciation)) }, [currentTrackId, currentTrackIsRadio]) useEffect(() => { @@ -297,10 +306,6 @@ const Player = () => { : normalizeLyricLayers({ main: cached?.structuredLyric }) setSelectedLyricLayers(cachedLayers) - setShowTranslation(false) - setShowPronunciation( - hasStructuredLyricContent(cachedLayers.pronunciation), - ) if (cachedLyric) { dispatch(updateQueueLyric(currentTrackId, cachedLyric)) applyLyricToRuntimePlayer(currentTrackId, cachedLyric) @@ -327,8 +332,6 @@ const Player = () => { layers, }) setSelectedLyricLayers(layers) - setShowTranslation(false) - setShowPronunciation(hasStructuredLyricContent(layers.pronunciation)) if (lyric !== '') { dispatch(updateQueueLyric(currentTrackId, lyric)) @@ -340,19 +343,11 @@ const Player = () => { return } setSelectedLyricLayers(emptyLyricLayers) - setShowTranslation(false) - setShowPronunciation(false) // Do not cache network/request failures as empty lyrics, so we can retry. lyricCacheRef.current.delete(currentTrackId) }) }, [dispatch, currentTrackId, currentTrackIsRadio, applyLyricToRuntimePlayer]) - useEffect(() => { - if (!hasKaraokeLyric && karaokeVisible) { - setKaraokeVisible(false) - } - }, [hasKaraokeLyric, karaokeVisible]) - const defaultOptions = useMemo( () => ({ theme: playerTheme, @@ -404,7 +399,9 @@ const Player = () => { setKaraokeVisible((visible) => !visible)} + onToggleLyrics={() => + setKaraokeVisiblePreference((visible) => !visible) + } lyricsActive={karaokeVisible} lyricsDisabled={!hasKaraokeLyric} /> @@ -616,17 +613,17 @@ const Player = () => { translationEnabled={hasTranslationLyric} pronunciationEnabled={hasPronunciationLyric} onToggleTranslation={() => - setShowTranslation((previous) => + setTranslationPreference((previous) => hasTranslationLyric ? !previous : false, ) } onTogglePronunciation={() => - setShowPronunciation((previous) => - hasPronunciationLyric ? !previous : false, + setPronunciationPreference((previous) => + togglePronunciationPreference(previous, hasPronunciationLyric), ) } audioInstance={audioInstance} - onClose={() => setKaraokeVisible(false)} + onClose={() => setKaraokeVisiblePreference(false)} /> diff --git a/ui/src/audioplayer/Player.lyricsState.test.jsx b/ui/src/audioplayer/Player.lyricsState.test.jsx new file mode 100644 index 000000000..c47abea76 --- /dev/null +++ b/ui/src/audioplayer/Player.lyricsState.test.jsx @@ -0,0 +1,77 @@ +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/lyrics.js b/ui/src/audioplayer/lyrics.js index 87b218d05..e9cd16d5a 100644 --- a/ui/src/audioplayer/lyrics.js +++ b/ui/src/audioplayer/lyrics.js @@ -12,6 +12,9 @@ const padTime = (value) => { } const toTime = (value) => { + if (value == null || value === '') { + return null + } const numeric = Number(value) return Number.isFinite(numeric) ? numeric : null } @@ -179,64 +182,6 @@ const lineTimeWindow = (lines, index) => { return { start, end } } -const buildSyntheticWordTokens = (line, token) => { - const text = typeof line?.value === 'string' ? line.value : '' - if (!text.trim()) { - return null - } - - const chunks = text.match(/\S+\s*/g) || [] - if (chunks.length < 2) { - return null - } - - const normalizedLine = text.replace(/\s+/g, ' ').trim().toLowerCase() - const normalizedTokenValue = (token?.value || '') - .replace(/\s+/g, ' ') - .trim() - .toLowerCase() - if (!normalizedTokenValue || !normalizedLine) { - return null - } - - const compressedLine = normalizedLine.replace(/\s+/g, '') - const compressedToken = normalizedTokenValue.replace(/\s+/g, '') - const tokenLooksLikeWholeLine = - compressedToken === compressedLine || - compressedToken.length >= Math.floor(compressedLine.length * 0.8) - if (!tokenLooksLikeWholeLine) { - return null - } - - const tokenStart = toTime(token?.start) - const tokenEnd = toTime(token?.end) - const lineStart = toTime(line?.start) - const lineEnd = toTime(line?.end) - - const baseStart = tokenStart ?? lineStart - const baseEnd = tokenEnd ?? lineEnd - if ( - baseStart == null || - baseEnd == null || - !Number.isFinite(baseStart) || - !Number.isFinite(baseEnd) || - baseEnd <= baseStart - ) { - return null - } - - const duration = baseEnd - baseStart - return chunks.map((chunk, idx) => ({ - start: baseStart + (duration * idx) / chunks.length, - end: baseStart + (duration * (idx + 1)) / chunks.length, - value: chunk, - role: typeof token?.role === 'string' ? token.role : '', - agentId: typeof token?.agentId === 'string' ? token.agentId : '', - agentName: typeof token?.agentName === 'string' ? token.agentName : '', - agentRole: typeof token?.agentRole === 'string' ? token.agentRole : '', - })) -} - export const hasCueTiming = (structuredLyric) => Boolean( structuredLyric && @@ -449,19 +394,6 @@ export const buildKaraokeLines = (structuredLyric) => { } return a.index - b.index }) - .map((line) => { - const nextLine = { ...line } - if (nextLine.tokens.length === 1) { - const syntheticTokens = buildSyntheticWordTokens( - nextLine, - nextLine.tokens[0], - ) - if (syntheticTokens) { - nextLine.tokens = syntheticTokens - } - } - return nextLine - }) for (let i = 0; i < normalized.length; i += 1) { if (normalized[i].end == null) { @@ -628,6 +560,17 @@ export const getActiveKaraokeState = (lines, currentTimeMs) => { 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) || @@ -692,3 +635,8 @@ export const resolveLayerLineForMain = (mainLines, layerLines, mainIndex) => { 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 index 3a5f83b2d..2fcf1df40 100644 --- a/ui/src/audioplayer/lyrics.test.js +++ b/ui/src/audioplayer/lyrics.test.js @@ -1,8 +1,11 @@ import { + buildHighlightedAuxLine, + buildHighlightedMainLine, buildKaraokeLines, findLayerLineIndexForMain, getActiveKaraokeState, getPreferredLyricLanguage, + hasUsableKaraokeTiming, hasStructuredLyricContent, pickStructuredLyric, resolveKaraokeTokenWindow, @@ -201,6 +204,110 @@ describe('lyrics helpers', () => { ) }) + 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: [] }, @@ -353,7 +460,7 @@ describe('lyrics helpers', () => { ]) }) - it('splits a single full-line token into synthetic word tokens', () => { + it('keeps a single full-line token unchanged instead of expanding it synthetically', () => { const lines = buildKaraokeLines({ lang: 'ko-Latn', synced: true, @@ -371,17 +478,13 @@ describe('lyrics helpers', () => { }) expect(lines).toHaveLength(1) - expect(lines[0].tokens).toHaveLength(2) - expect(lines[0].tokens[0].value).toBe('Da-la-lun, ') - expect(lines[0].tokens[1].value).toBe('dun') + expect(lines[0].tokens).toHaveLength(1) + expect(lines[0].tokens[0].value).toBe('Da-la-lun, dun') const firstWindow = resolveKaraokeTokenWindow(lines[0], 0) - const secondWindow = resolveKaraokeTokenWindow(lines[0], 1) expect(firstWindow.start).toBeCloseTo(1000) - expect(firstWindow.end).toBeCloseTo(1500) - expect(secondWindow.start).toBeCloseTo(1500) - expect(secondWindow.end).toBeCloseTo(2000) + expect(firstWindow.end).toBeCloseTo(2000) }) it('detects active line and token for karaoke timing', () => { @@ -509,4 +612,19 @@ describe('lyrics helpers', () => { }), ).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 new file mode 100644 index 000000000..e8ff0e0a8 --- /dev/null +++ b/ui/src/audioplayer/lyricsOverlayState.js @@ -0,0 +1,27 @@ +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 +}