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
+}