mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-01 07:21:17 +00:00
feat(lyrics): refine karaoke overlay timing and state
This commit is contained in:
parent
2ffb63477c
commit
73d94962e0
@ -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",
|
||||
},
|
||||
},
|
||||
|
||||
@ -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"))
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"))
|
||||
|
||||
@ -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>
|
||||
|
||||
344
ui/src/audioplayer/KaraokeLyricsOverlay.test.jsx
Normal file
344
ui/src/audioplayer/KaraokeLyricsOverlay.test.jsx
Normal 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)
|
||||
})
|
||||
})
|
||||
@ -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>
|
||||
|
||||
77
ui/src/audioplayer/Player.lyricsState.test.jsx
Normal file
77
ui/src/audioplayer/Player.lyricsState.test.jsx
Normal 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)
|
||||
})
|
||||
})
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
27
ui/src/audioplayer/lyricsOverlayState.js
Normal file
27
ui/src/audioplayer/lyricsOverlayState.js
Normal 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
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user