feat(lyrics): refine karaoke overlay timing and state

This commit is contained in:
ranokay 2026-03-27 12:41:27 +02:00
parent 2ffb63477c
commit 73d94962e0
No known key found for this signature in database
11 changed files with 1070 additions and 235 deletions

View File

@ -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",
},
},

View File

@ -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"))

View File

@ -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

View File

@ -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"))

View File

@ -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 }) => (
<div className={classes.settingsSection}>
<div className={classes.settingsLabel}>Spacing</div>
<div className={classes.settingsRow}>
<div className={classes.settingsControlLabel}>Line height</div>
<Slider
className={classes.settingsSlider}
min={KARAOKE_MIN_LINE_HEIGHT}
max={KARAOKE_MAX_LINE_HEIGHT}
step={KARAOKE_LINE_HEIGHT_STEP}
value={settings.lineHeight}
aria-label="Line height"
data-testid="lyrics-line-height-slider"
onChange={(_, val) =>
onChange({
...settings,
lineHeight: clampLineHeight(Array.isArray(val) ? val[0] : val),
})
}
/>
<span
className={classes.settingsSliderValue}
data-testid="lyrics-line-height-value"
>
{formatLineHeight(settings.lineHeight)}
</span>
</div>
</div>
)
const LyricsSettingsPopover = ({ settings, onChange, onReset }) => {
const classes = useStyles()
const [anchorEl, setAnchorEl] = useState(null)
@ -376,14 +545,19 @@ const LyricsSettingsPopover = ({ settings, onChange }) => {
return (
<>
<IconButton
className={classes.settingsButton}
size="small"
onClick={handleToggle}
aria-label="Lyrics settings"
>
<TuneIcon style={{ fontSize: 18 }} />
</IconButton>
<Tooltip title="Appearance">
<span>
<IconButton
className={classes.settingsButton}
size="small"
onClick={handleToggle}
aria-label="Lyrics settings"
data-testid="lyrics-settings-button"
>
<TuneIcon style={{ fontSize: 18 }} />
</IconButton>
</span>
</Tooltip>
<Popover
open={Boolean(anchorEl)}
anchorEl={anchorEl}
@ -393,6 +567,27 @@ const LyricsSettingsPopover = ({ settings, onChange }) => {
PaperProps={{ className: classes.settingsPanel }}
style={{ zIndex: 1500 }}
>
<div className={classes.settingsHeader}>
<Typography className={classes.settingsTitle}>Appearance</Typography>
<Tooltip title="Reset appearance">
<span>
<IconButton
className={classes.resetButton}
size="small"
onClick={onReset}
aria-label="Reset appearance"
data-testid="lyrics-reset-appearance"
>
<RestoreIcon style={{ fontSize: 18 }} />
</IconButton>
</span>
</Tooltip>
</div>
<LineHeightSetting
settings={settings}
onChange={onChange}
classes={classes}
/>
<SettingsSection
label="Translation"
layer="tr"
@ -401,7 +596,7 @@ const LyricsSettingsPopover = ({ settings, onChange }) => {
classes={classes}
/>
<SettingsSection
label="Default"
label="Main"
layer="main"
settings={settings}
onChange={onChange}
@ -595,7 +790,8 @@ const areLineStylesEqual = (prevStyle, nextStyle) => {
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}
>
<div className={classes.resizeHandle} onMouseDown={onResizeStart} />
<div
className={classes.resizeHandle}
onMouseDown={onResizeStart}
data-testid="lyrics-resize-handle"
/>
<div className={classes.header}>
<div className={classes.headerLeft}>
<Typography className={classes.language}>
{mainLyric?.lang || 'xxx'}
</Typography>
<div className={classes.languageBadges}>
{languageBadges.map((badge) => (
<div
key={badge.key}
className={clsx(classes.languageBadge, {
[classes.languageBadgeActive]: badge.active,
})}
data-testid={`lyrics-language-badge-${badge.key}`}
>
<span className={classes.languageBadgeLabel}>
{badge.label}
</span>
<span className={classes.languageBadgeValue}>{badge.lang}</span>
</div>
))}
</div>
<div className={classes.layerControls}>
<Button
size="small"
onClick={onToggleTranslation}
disabled={!translationEnabled}
className={clsx(classes.layerToggle, {
[classes.layerToggleActive]: showTranslation,
})}
data-testid="lyrics-toggle-translation"
>
TR
</Button>
<Button
size="small"
onClick={onTogglePronunciation}
disabled={!pronunciationEnabled}
className={clsx(classes.layerToggle, {
[classes.layerToggleActive]: showPronunciation,
})}
data-testid="lyrics-toggle-pronunciation"
>
PR
</Button>
<Tooltip title="Toggle translations">
<span>
<Button
size="small"
onClick={onToggleTranslation}
disabled={!translationEnabled}
className={clsx(classes.layerToggle, {
[classes.layerToggleActive]: showTranslation,
})}
data-testid="lyrics-toggle-translation"
>
TR
</Button>
</span>
</Tooltip>
<Tooltip title="Toggle pronunciations">
<span>
<Button
size="small"
onClick={onTogglePronunciation}
disabled={!pronunciationEnabled}
className={clsx(classes.layerToggle, {
[classes.layerToggleActive]: showPronunciation,
})}
data-testid="lyrics-toggle-pronunciation"
>
PR
</Button>
</span>
</Tooltip>
</div>
</div>
@ -1146,6 +1456,7 @@ const KaraokeLyricsOverlay = ({
<LyricsSettingsPopover
settings={lyricsSettings}
onChange={handleSettingsChange}
onReset={handleResetAppearance}
/>
<IconButton
className={classes.closeButton}
@ -1159,30 +1470,40 @@ const KaraokeLyricsOverlay = ({
</div>
<div className={classes.body} ref={bodyRef}>
<div className={classes.lines}>
<div className={classes.lines} style={{ gap: lineGap }}>
<div aria-hidden style={{ height: centerSpacerPx }} />
{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 (
<div
key={`line-${line.index}-${line.start ?? idx}`}
ref={idx === activeIndex ? activeLineRef : null}
ref={
idx === activeIndex && hasTimedMainLines
? activeLineRef
: null
}
className={classes.lineGroup}
style={{ cursor: line.start != null ? 'pointer' : undefined }}
onClick={() => {
if (audioInstance && line.start != null) {
@ -1190,33 +1511,35 @@ const KaraokeLyricsOverlay = ({
}
}}
>
{showTr && (
<KaraokeLineRow
line={trLine}
nextLineStart={null}
renderPlaybackMs={renderPlaybackMs}
className={classes.inlineTr}
style={trStyle}
tokenClassName={classes.token}
highlightTokens={false}
/>
)}
<KaraokeLineRow
line={line}
nextLineStart={mainLines[idx + 1]?.start ?? null}
line={highlightedMainLine}
nextLineStart={mainNextLineStart}
renderPlaybackMs={renderPlaybackMs}
className={classes.line}
style={lineStyle}
tokenClassName={classes.token}
highlightTokens={hasTimedMainLines}
/>
{showPr && (
<KaraokeLineRow
line={prLine}
line={highlightedPrLine}
nextLineStart={null}
renderPlaybackMs={renderPlaybackMs}
className={classes.inlinePr}
style={prStyle}
tokenClassName={classes.token}
highlightTokens={hasTimedMainLines}
/>
)}
{showTr && (
<KaraokeLineRow
line={highlightedTrLine}
nextLineStart={null}
renderPlaybackMs={renderPlaybackMs}
className={classes.inlineTr}
style={trStyle}
tokenClassName={classes.token}
highlightTokens={hasTimedMainLines}
/>
)}
</div>

View File

@ -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(
<KaraokeLyricsOverlay
visible={true}
mainLyric={buildLyric('main', 'ja', 'こんにちは')}
translationLyric={buildLyric('translation', 'en', 'Hello')}
pronunciationLyric={buildLyric('pronunciation', 'ja-Latn', 'konnichiwa')}
showTranslation={false}
showPronunciation={true}
translationEnabled={true}
pronunciationEnabled={true}
onToggleTranslation={() => {}}
onTogglePronunciation={() => {}}
audioInstance={audioInstance}
onClose={() => {}}
{...props}
/>,
)
describe('<KaraokeLyricsOverlay /> 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)
})
})

View File

@ -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 = () => {
<PlayerToolbar
id={current.trackId}
isRadio={current.isRadio}
onToggleLyrics={() => 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)}
/>
<GlobalHotKeys handlers={handlers} keyMap={keyMap} allowChanges />
</ThemeProvider>

View File

@ -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)
})
})

View File

@ -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

View File

@ -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)
})
})

View File

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