fix(ui): improve lyrics mobile UX and code quality

- Center lyrics start position on mobile via dynamic paddingTop
- Fix oval panel shape on mobile by forcing borderRadius: 0
- Remove touch highlight from PR/TR toggle buttons
- Separate line-by-line animation timing (260ms) from syllable (500ms)
- Deduplicate sameIndexes, finiteTime, and transition string constants
- Move KARAOKE_IDLE_LAYER_OPACITY to shared constants file
- Remove redundant useTheme hook and inlineLine class
- Extract DEFAULT_RGB constant in LyricsLineRows
- Use nullish coalescing in lyricsSidebarState
- Fix pointer-events kebab-case inconsistency in styles
- Link KARAOKE_CHARACTER_LIFT_PX to KARAOKE_LINE_LIFT_PX

Signed-off-by: ranokay <github@ranokay.com>
This commit is contained in:
ranokay 2026-07-26 11:14:39 +03:00
commit 5ef4bbefcf
No known key found for this signature in database
14 changed files with 336 additions and 199 deletions

View file

@ -0,0 +1,158 @@
import IconButton from '@material-ui/core/IconButton'
import Tooltip from '@material-ui/core/Tooltip'
import { alpha, makeStyles } from '@material-ui/core/styles'
import RecordVoiceOverIcon from '@material-ui/icons/RecordVoiceOver'
import TranslateIcon from '@material-ui/icons/Translate'
import clsx from 'clsx'
import React from 'react'
const useStyles = makeStyles(
(theme) => ({
controls: {
position: 'absolute',
bottom: theme.spacing(1),
zIndex: 2,
display: 'flex',
alignItems: 'center',
gap: theme.spacing(0.25),
padding: theme.spacing(0.25),
borderRadius: theme.shape.borderRadius * 2,
backgroundColor: 'transparent',
},
sidebarPlacement: {
right: theme.spacing(0.75),
},
mobilePlacement: {
left: '50%',
transform: 'translateX(-50%)',
},
controlButton: {
padding: theme.spacing(0.75),
color: alpha(theme.palette.text.primary, 0.58),
backgroundColor: 'transparent',
WebkitTapHighlightColor: 'transparent',
transition:
'color 160ms ease, background-color 160ms ease, transform 160ms cubic-bezier(0.23, 1, 0.32, 1)',
'@media (hover: hover) and (pointer: fine)': {
'&:hover': {
color: theme.palette.text.primary,
backgroundColor: alpha(theme.palette.primary.main, 0.08),
},
'&$controlActive:hover': {
color: theme.palette.primary.main,
},
},
'&:focus-visible': {
color: theme.palette.text.primary,
backgroundColor: alpha(theme.palette.primary.main, 0.1),
},
'&:active:not(:disabled)': {
transform: 'scale(0.97)',
},
'&$controlActive': {
color: theme.palette.primary.main,
},
'&$controlActive:focus-visible': {
color: theme.palette.primary.main,
},
'&:disabled': {
color: alpha(theme.palette.text.primary, 0.28),
},
'@media (prefers-reduced-motion: reduce)': {
transition: 'none',
'&:active:not(:disabled)': {
transform: 'none',
},
},
},
controlActive: {},
}),
{ name: 'NDLyricsLayerControls' },
)
const LayerButton = ({
active,
classes,
disabled,
label,
onClick,
testId,
children,
}) => {
return (
<Tooltip title={label}>
<span>
<IconButton
size="small"
onClick={onClick}
disabled={disabled}
aria-label={label}
aria-pressed={active}
data-testid={testId}
className={clsx(classes.controlButton, {
[classes.controlActive]: active && !disabled,
})}
>
{children}
</IconButton>
</span>
</Tooltip>
)
}
const LyricsLayerControls = ({
placement = 'sidebar',
showTranslation,
showPronunciation,
translationEnabled,
pronunciationEnabled,
onToggleTranslation,
onTogglePronunciation,
labels = {},
testId = 'lyrics-layer-controls',
}) => {
const classes = useStyles()
return (
<div
className={clsx(classes.controls, {
[classes.mobilePlacement]: placement === 'mobile',
[classes.sidebarPlacement]: placement !== 'mobile',
})}
data-testid={testId}
data-placement={placement}
onClick={(event) => event.stopPropagation()}
>
<LayerButton
active={showPronunciation}
classes={classes}
disabled={!pronunciationEnabled}
label={
showPronunciation
? labels.hidePronunciation || 'Hide pronunciation'
: labels.showPronunciation || 'Show pronunciation'
}
onClick={onTogglePronunciation}
testId="toggle-pronunciation-button"
>
<RecordVoiceOverIcon fontSize="small" />
</LayerButton>
<LayerButton
active={showTranslation}
classes={classes}
disabled={!translationEnabled}
label={
showTranslation
? labels.hideTranslation || 'Hide translation'
: labels.showTranslation || 'Show translation'
}
onClick={onToggleTranslation}
testId="toggle-translation-button"
>
<TranslateIcon fontSize="small" />
</LayerButton>
</div>
)
}
export default LyricsLayerControls

View file

@ -24,6 +24,7 @@ import { resolveKaraokeTokenWindows } from './lyricsTimeline'
const EMPHASIS_TONE = 0.7
const EMPHASIS_PAINT_OVERHANG = '0.18em'
const DEFAULT_RGB = [255, 255, 255]
const emphasisPaintOverhangStyle = {
marginInlineEnd: `-${EMPHASIS_PAINT_OVERHANG}`,
@ -225,7 +226,7 @@ export const KaraokeLineRow = memo(
[line, nextLineStart],
)
const tokenRGB = useMemo(
() => (style?.color ? parseColorRGB(style.color) : [255, 255, 255]),
() => (style?.color ? parseColorRGB(style.color) : DEFAULT_RGB),
[style?.color],
)
const hasTimedTokens = windows.some(
@ -444,7 +445,7 @@ export const KaraokeStackedLineRow = memo(
() =>
pronunciationStyle?.color
? parseColorRGB(pronunciationStyle.color)
: [255, 255, 255],
: DEFAULT_RGB,
[pronunciationStyle?.color],
)
const hasTimedTokens = mainWindows.some(

View file

@ -15,10 +15,11 @@ import {
} from './lyrics'
import { KaraokeLineRow, KaraokeStackedLineRow } from './LyricsLineRows'
import {
KARAOKE_ANIMATION_MS,
KARAOKE_LINE_OPACITY_MS,
KARAOKE_AUX_LINE_HEIGHT,
KARAOKE_DESKTOP_ACTIVE_LINE_ANCHOR_RATIO,
KARAOKE_EASING,
KARAOKE_IDLE_LAYER_OPACITY,
KARAOKE_INLINE_ACTIVE_LINE_ANCHOR_RATIO,
KARAOKE_LINE_ENTER_MS,
KARAOKE_LINE_LIFT_PX,
@ -33,10 +34,10 @@ import {
getAnchoredScrollTop,
getScrollEndPadding,
} from './lyricsScroll'
import { finiteTime } from './lyricsTimeline'
import useLyricsTimeline from './useLyricsTimeline'
const KARAOKE_LAYER_OPACITY_TRANSITION = `opacity ${KARAOKE_ANIMATION_MS}ms ${KARAOKE_EASING}`
const KARAOKE_IDLE_LAYER_OPACITY = 0.49
const KARAOKE_LAYER_OPACITY_TRANSITION = `opacity ${KARAOKE_LINE_OPACITY_MS}ms ${KARAOKE_EASING}`
const KARAOKE_TRANSLATION_IDLE_OPACITY =
KARAOKE_IDLE_LAYER_OPACITY * KARAOKE_TRANSLATION_OPACITY
@ -103,6 +104,7 @@ const useStyles = makeStyles((theme) => ({
alignItems: 'stretch',
gap: theme.spacing(3),
},
inlineLines: {},
lineGroup: {
width: '100%',
borderRadius: theme.shape.borderRadius,
@ -187,9 +189,6 @@ const useStyles = makeStyles((theme) => ({
transition: 'none',
},
},
inlineLine: {
fontSize: 24,
},
auxLine: {
display: 'block',
marginTop: theme.spacing(0.8),
@ -307,18 +306,12 @@ const shouldShowAuxLine = (mainLine, auxLine) =>
normalizeLineText(auxLine.value) !== normalizeLineText(mainLine?.value),
)
const finiteLineTime = (value) => {
if (value == null || value === '') return null
const number = Number(value)
return Number.isFinite(number) ? number : null
}
const getLayerMatchWindow = (lines, index) => {
const line = lines[index]
if (!line) return { start: null, end: null }
return {
start: finiteLineTime(line.start),
end: finiteLineTime(line.end) ?? finiteLineTime(lines[index + 1]?.start),
start: finiteTime(line.start),
end: finiteTime(line.end) ?? finiteTime(lines[index + 1]?.start),
}
}
@ -454,6 +447,7 @@ const LyricsPanel = ({
const [layoutVersion, setLayoutVersion] = useState(0)
const [hasTopFade, setHasTopFade] = useState(false)
const [scrollEndPadding, setScrollEndPadding] = useState(0)
const [scrollStartPadding, setScrollStartPadding] = useState(0)
const prefersReducedMotion = usePrefersReducedMotion()
const activeLineAnchorRatio = inline
? KARAOKE_INLINE_ACTIVE_LINE_ANCHOR_RATIO
@ -617,13 +611,24 @@ const LyricsPanel = ({
const body = bodyRef.current
if (!visible || !body) {
setScrollEndPadding(0)
setScrollStartPadding(0)
return
}
const nextPadding = getScrollEndPadding(body, activeLineAnchorRatio)
const nextEndPadding = getScrollEndPadding(body, activeLineAnchorRatio)
setScrollEndPadding((current) =>
current === nextPadding ? current : nextPadding,
current === nextEndPadding ? current : nextEndPadding,
)
}, [activeLineAnchorRatio, layoutVersion, mainLines.length, visible])
if (inline) {
const nextStartPadding = Math.round(
body.clientHeight * activeLineAnchorRatio,
)
setScrollStartPadding((current) =>
current === nextStartPadding ? current : nextStartPadding,
)
} else {
setScrollStartPadding(0)
}
}, [activeLineAnchorRatio, inline, layoutVersion, mainLines.length, visible])
useLayoutEffect(() => {
const body = bodyRef.current
@ -726,11 +731,16 @@ const LyricsPanel = ({
onTouchMove={markManualScrollIntent}
>
<div
className={classes.lines}
className={clsx(classes.lines, {
[classes.inlineLines]: inline,
})}
data-scroll-end-padding={scrollEndPadding}
style={
scrollEndPadding > 0
? { paddingBottom: scrollEndPadding }
scrollEndPadding > 0 || scrollStartPadding > 0
? {
paddingTop: scrollStartPadding || undefined,
paddingBottom: scrollEndPadding || undefined,
}
: undefined
}
>
@ -807,7 +817,6 @@ const LyricsPanel = ({
>
{lineLanes.map((lane, laneIdx) => {
const laneClassName = clsx(classes.line, {
[classes.inlineLine]: inline,
[classes.secondaryVoiceLane]: laneIdx > 0,
})
const rowKey = lane.key || `lane-${laneIdx}`
@ -855,9 +864,7 @@ const LyricsPanel = ({
pronunciationLine={prLine}
pronunciationStyle={layerStyles.pronunciation}
nextLineStart={mainNextLineStart}
className={clsx(classes.line, {
[classes.inlineLine]: inline,
})}
className={classes.line}
style={layerStyles.main}
tokenClassName={classes.token}
waveCharacterClassName={classes.waveCharacter}
@ -871,9 +878,7 @@ const LyricsPanel = ({
lineIndex={idx}
line={line}
nextLineStart={mainNextLineStart}
className={clsx(classes.line, {
[classes.inlineLine]: inline,
})}
className={classes.line}
style={layerStyles.main}
tokenClassName={classes.token}
waveCharacterClassName={classes.waveCharacter}

View file

@ -12,8 +12,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import LyricsPanel from './LyricsPanel'
import { KaraokeLineRow } from './LyricsLineRows'
import {
KARAOKE_ANIMATION_MS,
KARAOKE_LINE_OPACITY_MS,
KARAOKE_CHARACTER_LIFT_PX,
KARAOKE_CHARACTER_RISE_MS,
KARAOKE_HIGHLIGHT_LEAD_MS,
KARAOKE_IDLE_LAYER_OPACITY,
KARAOKE_LINE_LIFT_PX,
KARAOKE_MANUAL_SCROLL_PAUSE_MS,
KARAOKE_TRANSLATION_OPACITY,
@ -318,7 +321,7 @@ describe('<LyricsPanel />', () => {
expect(row).toHaveAttribute('data-tokenized', 'false')
expect(row.style.opacity).toBe('')
expect(window.getComputedStyle(row).transition).toContain(
`opacity ${KARAOKE_ANIMATION_MS}ms`,
`opacity ${KARAOKE_LINE_OPACITY_MS}ms`,
)
})
pronunciation.forEach((token) =>
@ -372,6 +375,8 @@ describe('<LyricsPanel />', () => {
})
it('keeps every word-timed layer on the same rise and release lifecycle', () => {
const settledFirstCharacterTime =
(KARAOKE_CHARACTER_RISE_MS - KARAOKE_HIGHLIGHT_LEAD_MS) / 1000
const propsAt = (currentTime) => ({
mainLyric: tokenizedMainLyric,
pronunciationLyric: tokenizedPronunciationLyric,
@ -383,7 +388,7 @@ describe('<LyricsPanel />', () => {
showTranslation: true,
audioInstance: { currentTime, paused: true },
})
const { rerenderPanel } = renderPanel(propsAt(0.15))
const { rerenderPanel } = renderPanel(propsAt(settledFirstCharacterTime))
const group = screen.getByTestId('lyrics-line-group')
const translation = screen
@ -790,16 +795,19 @@ describe('<LyricsPanel />', () => {
.getComputedStyle(group)
.getPropertyValue('--lyrics-layer-opacity')
.trim()
expect(groups.map(layerOpacity)).toEqual(['1', '0.49'])
expect(groups.map(layerOpacity)).toEqual([
'1',
String(KARAOKE_IDLE_LAYER_OPACITY),
])
expect(window.getComputedStyle(activeTranslation).opacity).toBe(
String(KARAOKE_TRANSLATION_OPACITY),
)
expect(Number(window.getComputedStyle(futureTranslation).opacity)).toBe(
0.49 * KARAOKE_TRANSLATION_OPACITY,
KARAOKE_IDLE_LAYER_OPACITY * KARAOKE_TRANSLATION_OPACITY,
)
;[activeTranslation, futureTranslation].forEach((translation) =>
expect(window.getComputedStyle(translation).transition).toContain(
`opacity ${KARAOKE_ANIMATION_MS}ms`,
`opacity ${KARAOKE_LINE_OPACITY_MS}ms`,
),
)
})

View file

@ -1,10 +1,6 @@
import IconButton from '@material-ui/core/IconButton'
import Tooltip from '@material-ui/core/Tooltip'
import { alpha, makeStyles, useTheme } from '@material-ui/core/styles'
import RecordVoiceOverIcon from '@material-ui/icons/RecordVoiceOver'
import TranslateIcon from '@material-ui/icons/Translate'
import clsx from 'clsx'
import { alpha, makeStyles } from '@material-ui/core/styles'
import React, { useCallback, useEffect, useRef, useState } from 'react'
import LyricsLayerControls from './LyricsLayerControls'
import LyricsPanel from './LyricsPanel'
import {
LYRICS_SIDEBAR_MAX_WIDTH,
@ -79,57 +75,6 @@ const useStyles = makeStyles((theme) => ({
outline: 'none',
},
},
controls: {
position: 'absolute',
bottom: theme.spacing(1),
right: theme.spacing(0.75),
zIndex: 2,
display: 'flex',
alignItems: 'center',
gap: theme.spacing(0.25),
padding: theme.spacing(0.25),
borderRadius: theme.shape.borderRadius * 2,
backgroundColor: 'transparent',
},
controlButton: {
padding: theme.spacing(0.75),
color: alpha(theme.palette.text.primary, 0.58),
backgroundColor: 'transparent',
transition:
'color 160ms ease, background-color 160ms ease, transform 160ms cubic-bezier(0.23, 1, 0.32, 1)',
'@media (hover: hover) and (pointer: fine)': {
'&:hover': {
color: theme.palette.text.primary,
backgroundColor: alpha(theme.palette.primary.main, 0.08),
},
'&$controlActive:hover': {
color: theme.palette.primary.main,
},
},
'&:focus-visible': {
color: theme.palette.text.primary,
backgroundColor: alpha(theme.palette.primary.main, 0.1),
},
'&:active:not(:disabled)': {
transform: 'scale(0.97)',
},
'&$controlActive': {
color: theme.palette.primary.main,
},
'&$controlActive:focus-visible': {
color: theme.palette.primary.main,
},
'&:disabled': {
color: alpha(theme.palette.text.primary, 0.28),
},
'@media (prefers-reduced-motion: reduce)': {
transition: 'none',
'&:active:not(:disabled)': {
transform: 'none',
},
},
},
controlActive: {},
panel: {
flex: 1,
minHeight: 0,
@ -137,41 +82,6 @@ const useStyles = makeStyles((theme) => ({
},
}))
const LayerButton = ({
active,
classes,
disabled,
label,
onClick,
testId,
children,
}) => {
const theme = useTheme()
const activeStyle =
active && !disabled ? { color: theme.palette.primary.main } : undefined
return (
<Tooltip title={label}>
<span>
<IconButton
size="small"
onClick={onClick}
disabled={disabled}
aria-label={label}
aria-pressed={active}
data-testid={testId}
style={activeStyle}
className={clsx(classes.controlButton, {
[classes.controlActive]: active && !disabled,
})}
>
{children}
</IconButton>
</span>
</Tooltip>
)
}
const LyricsSidebar = ({
visible,
mainLyric,
@ -362,39 +272,16 @@ const LyricsSidebar = ({
onKeyDown={handleResizeKeyDown}
/>
<div className={classes.panel}>
<div
className={classes.controls}
data-testid="lyrics-sidebar-floating-controls"
>
<LayerButton
active={showPronunciation}
classes={classes}
disabled={!pronunciationEnabled}
label={
showPronunciation
? labels.hidePronunciation || 'Hide pronunciation'
: labels.showPronunciation || 'Show pronunciation'
}
onClick={onTogglePronunciation}
testId="toggle-pronunciation-button"
>
<RecordVoiceOverIcon fontSize="small" />
</LayerButton>
<LayerButton
active={showTranslation}
classes={classes}
disabled={!translationEnabled}
label={
showTranslation
? labels.hideTranslation || 'Hide translation'
: labels.showTranslation || 'Show translation'
}
onClick={onToggleTranslation}
testId="toggle-translation-button"
>
<TranslateIcon fontSize="small" />
</LayerButton>
</div>
<LyricsLayerControls
showPronunciation={showPronunciation}
showTranslation={showTranslation}
pronunciationEnabled={pronunciationEnabled}
translationEnabled={translationEnabled}
onTogglePronunciation={onTogglePronunciation}
onToggleTranslation={onToggleTranslation}
labels={labels}
testId="lyrics-sidebar-floating-controls"
/>
<LyricsPanel
visible={visible}
mainLyric={mainLyric}

View file

@ -245,18 +245,14 @@ describe('<LyricsSidebar />', () => {
expect(screen.getByLabelText('Resize song words')).toBeInTheDocument()
expect(pronunciation).toHaveAttribute('aria-label', 'Conceal pronunciation')
expect(pronunciation).toHaveAttribute('aria-pressed', 'true')
expect(window.getComputedStyle(pronunciation).color).toBe(
'rgb(53, 170, 102)',
)
expect(pronunciation.className).toContain('controlActive')
fireEvent.click(pronunciation)
expect(onTogglePronunciation).toHaveBeenCalledTimes(1)
expect(translation).toHaveAttribute('aria-label', 'Reveal translation')
expect(translation).toHaveAttribute('aria-pressed', 'false')
expect(translation).toBeDisabled()
expect(window.getComputedStyle(translation).color).not.toBe(
'rgb(53, 170, 102)',
)
expect(translation.className).not.toContain('controlActive')
fireEvent.click(translation)
expect(onToggleTranslation).not.toHaveBeenCalled()
})

View file

@ -18,8 +18,8 @@ import MobileKaraokeLyricsPortal, {
} from './MobileKaraokeLyricsPortal'
import usePlayerLyrics from './usePlayerLyrics'
const { useEnhancedLyricsMock } = vi.hoisted(() => ({
useEnhancedLyricsMock: vi.fn(() => ({
const { defaultLyricsResponse, useEnhancedLyricsMock } = vi.hoisted(() => {
const defaultLyricsResponse = {
layers: {
main: {
synced: true,
@ -31,8 +31,12 @@ const { useEnhancedLyricsMock } = vi.hoisted(() => ({
loading: false,
error: null,
retry: vi.fn(),
})),
}))
}
return {
defaultLyricsResponse,
useEnhancedLyricsMock: vi.fn(() => defaultLyricsResponse),
}
})
vi.mock('./useEnhancedLyrics', () => ({
default: useEnhancedLyricsMock,
@ -77,7 +81,8 @@ const MobileLyricsHarness = ({ trackId = 'track-1', isRadio = false }) => {
describe('<MobileKaraokeLyricsPortal />', () => {
afterEach(() => {
vi.useRealTimers()
useEnhancedLyricsMock.mockClear()
useEnhancedLyricsMock.mockReset()
useEnhancedLyricsMock.mockImplementation(() => defaultLyricsResponse)
cleanup()
document.body.innerHTML = ''
})
@ -188,6 +193,60 @@ describe('<MobileKaraokeLyricsPortal />', () => {
).not.toBeInTheDocument()
})
it('centers working pronunciation and translation toggles below mobile lyrics', () => {
useEnhancedLyricsMock.mockImplementation(() => ({
...defaultLyricsResponse,
layers: {
...defaultLyricsResponse.layers,
pronunciation: {
synced: true,
line: [{ start: 0, end: 1000, value: 'Spoken lyrics' }],
},
translation: {
synced: true,
line: [{ start: 0, end: 1000, value: 'Translated lyrics' }],
},
},
}))
const host = createHost()
render(<MobileLyricsHarness />)
fireEvent.click(screen.getByRole('button', { name: 'Toggle lyrics' }))
const controls = within(host).getByTestId('lyrics-mobile-layer-controls')
expect(controls).toHaveAttribute('data-placement', 'mobile')
expect(controls).toHaveStyle({
bottom: '8px',
left: '50%',
transform: 'translateX(-50%)',
})
const pronunciation = within(host).getByTestId(
'toggle-pronunciation-button',
)
const translation = within(host).getByTestId('toggle-translation-button')
expect(pronunciation).toHaveAttribute(
'aria-label',
'player.hideLyricsPronunciationText',
)
expect(pronunciation).toHaveAttribute('aria-pressed', 'true')
expect(translation).toHaveAttribute(
'aria-label',
'player.hideLyricsTranslationText',
)
expect(translation).toHaveAttribute('aria-pressed', 'true')
fireEvent.click(pronunciation)
fireEvent.click(translation)
expect(pronunciation).toHaveAttribute('aria-pressed', 'false')
expect(translation).toHaveAttribute('aria-pressed', 'false')
expect(within(host).queryByText('Spoken lyrics')).not.toBeInTheDocument()
expect(
within(host).queryByText('Translated lyrics'),
).not.toBeInTheDocument()
})
it('keeps open lyrics closable after playback switches to radio', () => {
vi.useFakeTimers()
const host = createHost()

View file

@ -1,22 +1,24 @@
export const KARAOKE_CLOCK_DRIFT_RESET_MS = 140
export const KARAOKE_HIGHLIGHT_LEAD_MS = 120
export const KARAOKE_ANIMATION_MS = 500
export const KARAOKE_LINE_OPACITY_MS = 200
export const KARAOKE_SCROLLBAR_VISIBLE_MS = 1400
export const KARAOKE_MANUAL_SCROLL_PAUSE_MS = 2200
export const KARAOKE_SCROLL_ANIMATION_MS = 300
export const KARAOKE_LINE_ENTER_MS = KARAOKE_ANIMATION_MS
export const KARAOKE_LINE_ENTER_MS = KARAOKE_LINE_OPACITY_MS
export const KARAOKE_LINE_LIFT_PX = 1.5
const KARAOKE_EASE_IN_OUT_X1 = 0.77
const KARAOKE_EASE_IN_OUT_Y1 = 0
const KARAOKE_EASE_IN_OUT_X2 = 0.175
const KARAOKE_EASE_IN_OUT_Y2 = 1
export const KARAOKE_LINE_MOTION_EASING = `cubic-bezier(${KARAOKE_EASE_IN_OUT_X1}, ${KARAOKE_EASE_IN_OUT_Y1}, ${KARAOKE_EASE_IN_OUT_X2}, ${KARAOKE_EASE_IN_OUT_Y2})`
export const KARAOKE_CHARACTER_LIFT_PX = 1.5
export const KARAOKE_CHARACTER_LIFT_PX = KARAOKE_LINE_LIFT_PX
export const KARAOKE_CHARACTER_RISE_MS = KARAOKE_ANIMATION_MS
export const KARAOKE_CHARACTER_WAVE_SPAN_RATIO = 0.36
export const KARAOKE_CHARACTER_WAVE_SPAN_MAX_MS = 140
export const KARAOKE_TRANSLATION_OPACITY = 0.62
export const KARAOKE_LINE_RELEASE_MS = KARAOKE_ANIMATION_MS
export const KARAOKE_IDLE_LAYER_OPACITY = 0.49
export const KARAOKE_LINE_RELEASE_MS = KARAOKE_LINE_OPACITY_MS
export const KARAOKE_SCROLL_PRE_ROLL_MS = 320
export const KARAOKE_DESKTOP_ACTIVE_LINE_ANCHOR_RATIO = 0.1
export const KARAOKE_INLINE_ACTIVE_LINE_ANCHOR_RATIO = 0.42

View file

@ -7,12 +7,10 @@ export const resolveLyricsSidebarState = ({
}) => ({
lyricsVisible: Boolean(lyricsVisiblePreference),
showTranslation: Boolean(
(translationPreference == null ? true : translationPreference) &&
hasTranslationLyric,
(translationPreference ?? true) && hasTranslationLyric,
),
showPronunciation: Boolean(
(pronunciationPreference == null ? true : pronunciationPreference) &&
hasPronunciationLyric,
(pronunciationPreference ?? true) && hasPronunciationLyric,
),
})
@ -22,7 +20,5 @@ export const toggleLayerPreference = (
defaultEnabled = true,
) => {
if (!hasLayer) return false
const currentPreference =
previousPreference == null ? defaultEnabled : previousPreference
return !currentPreference
return !(previousPreference ?? defaultEnabled)
}

View file

@ -1,6 +1,6 @@
import { KARAOKE_SCROLL_PRE_ROLL_MS } from './lyricsKaraokeConstants'
const finiteTime = (value) => {
export const finiteTime = (value) => {
if (value == null || value === '') return null
const number = Number(value)
return Number.isFinite(number) ? number : null
@ -17,7 +17,7 @@ const upperBound = (items, time, getTime) => {
return low
}
const sameIndexes = (left, right) =>
export const sameIndexes = (left, right) =>
left.length === right.length &&
left.every((value, index) => value === right[index])

View file

@ -1,5 +1,8 @@
import { makeStyles } from '@material-ui/core/styles'
const MOBILE_LYRICS_TRANSITION =
'opacity 260ms cubic-bezier(0.22, 1, 0.36, 1), transform 260ms cubic-bezier(0.22, 1, 0.36, 1)'
const useStyle = makeStyles(
(theme) => ({
audioTitle: {
@ -42,7 +45,7 @@ const useStyle = makeStyles(
flexDirection: 'column',
},
'& .play-mode-title': {
'pointer-events': 'none',
pointerEvents: 'none',
},
'& .music-player-panel .panel-content div.img-rotate': {
// Customize desktop player when cover animation is disabled
@ -70,6 +73,7 @@ const useStyle = makeStyles(
maxWidth: '100%',
height: '100%',
aspectRatio: 'auto',
borderRadius: 0,
alignItems: 'stretch',
justifyContent: 'stretch',
},
@ -79,8 +83,7 @@ const useStyle = makeStyles(
inset: 0,
opacity: 0,
transform: 'scale(0.985)',
transition:
'opacity 260ms cubic-bezier(0.22, 1, 0.36, 1), transform 260ms cubic-bezier(0.22, 1, 0.36, 1)',
transition: MOBILE_LYRICS_TRANSITION,
'@media (prefers-reduced-motion: reduce)': {
transition: 'opacity 120ms linear',
transform: 'none',
@ -98,8 +101,7 @@ const useStyle = makeStyles(
{
animationDuration: (props) => !props.enableCoverAnimation && '0s',
objectFit: 'contain', // Fix cover display when image is not square
transition:
'opacity 260ms cubic-bezier(0.22, 1, 0.36, 1), transform 260ms cubic-bezier(0.22, 1, 0.36, 1)',
transition: MOBILE_LYRICS_TRANSITION,
'@media (prefers-reduced-motion: reduce)': {
transition: 'opacity 120ms linear',
transform: 'none',

View file

@ -20,6 +20,7 @@ import {
buildLyricsTimeline,
getTimelineScrollTarget,
LyricTimelineCursor,
sameIndexes,
tokenProgressAt,
} from './lyricsTimeline'
@ -33,10 +34,6 @@ const mediaDurationMs = (audio) => {
return Number.isFinite(seconds) && seconds > 0 ? seconds * 1000 : null
}
const sameIndexes = (left, right) =>
left.length === right.length &&
left.every((value, index) => value === right[index])
const setProgress = (record, value) => {
const next = Math.max(0, Math.min(1, value))
if (record.progress != null && Math.abs(record.progress - next) < 0.001)

View file

@ -6,7 +6,9 @@ import {
KARAOKE_CHARACTER_RISE_MS,
KARAOKE_CHARACTER_WAVE_SPAN_MAX_MS,
KARAOKE_CHARACTER_WAVE_SPAN_RATIO,
KARAOKE_CLOCK_DRIFT_RESET_MS,
KARAOKE_HIGHLIGHT_LEAD_MS,
KARAOKE_LINE_RELEASE_MS,
} from './lyricsKaraokeConstants'
import useLyricsTimeline from './useLyricsTimeline'
@ -233,7 +235,8 @@ describe('useLyricsTimeline', () => {
'first',
)
syncNow(result, 1259)
const releaseEnd = 1000 + KARAOKE_LINE_RELEASE_MS
syncNow(result, releaseEnd - 1)
const gradient = tokenNode.style.backgroundImage
const releaseAlpha = Number(
tokenNode.style.getPropertyValue('--lyrics-token-active-alpha'),
@ -242,7 +245,7 @@ describe('useLyricsTimeline', () => {
expect(tokenNode.style.color).toBe('transparent')
expect(tokenNode.style.opacity).toBe('1')
syncNow(result, 1260)
syncNow(result, releaseEnd)
const pastAlpha = Number(
tokenNode.style.getPropertyValue('--lyrics-token-active-alpha'),
@ -286,7 +289,7 @@ describe('useLyricsTimeline', () => {
expect(character.style.transform).toBe(''),
)
syncNow(result, tokenStart + KARAOKE_CHARACTER_RISE_MS / 2)
syncNow(result, tokenStart + KARAOKE_CHARACTER_WAVE_SPAN_MAX_MS / 2)
expect(characters[0].style.transform).not.toBe(fullCharacterLift)
expect(characters[0].style.transform).not.toBe('')
expect(characters[4].style.transform).toBe('')
@ -376,9 +379,9 @@ describe('useLyricsTimeline', () => {
/^translate3d\(0, -\d+\.\d{4}px, 0\)$/,
)
expect(character.style.transform).not.toBe(fullCharacterLift)
syncNow(result, tokenStart + 240)
syncNow(result, tokenStart + KARAOKE_CHARACTER_RISE_MS - 20)
expect(character.style.transform).not.toBe(fullCharacterLift)
syncNow(result, tokenStart + 260)
syncNow(result, tokenStart + KARAOKE_CHARACTER_RISE_MS)
expect(character.style.transform).toBe(fullCharacterLift)
expect(
Number(tokenNode.style.getPropertyValue('--lyrics-progress')),
@ -485,7 +488,7 @@ describe('useLyricsTimeline', () => {
const character = tokenNode.querySelector('[data-lyrics-character="true"]')
expect(character.style.transform).toBe('')
now = KARAOKE_CHARACTER_RISE_MS / 4
now = KARAOKE_CLOCK_DRIFT_RESET_MS / 4
act(() => frameCallback())
expect(character.style.transform).toMatch(
/^translate3d\(0, -\d+\.\d{4}px, 0\)$/,
@ -525,7 +528,14 @@ describe('useLyricsTimeline', () => {
act(() => {
window.document.dispatchEvent(new Event('visibilitychange'))
})
audio.currentTime = 0.8
const characterMotionEnd =
500 +
Math.min(
500 * KARAOKE_CHARACTER_WAVE_SPAN_RATIO,
KARAOKE_CHARACTER_WAVE_SPAN_MAX_MS,
) +
KARAOKE_CHARACTER_RISE_MS
audio.currentTime = (characterMotionEnd - KARAOKE_HIGHLIGHT_LEAD_MS) / 1000
visibility = 'visible'
act(() => {
window.document.dispatchEvent(new Event('visibilitychange'))

View file

@ -1,4 +1,5 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import LyricsLayerControls from './LyricsLayerControls'
import LyricsPanel from './LyricsPanel'
import MobileKaraokeLyricsPortal from './MobileKaraokeLyricsPortal'
import { hasStructuredLyricContent } from './lyrics'
@ -157,10 +158,23 @@ const usePlayerLyrics = ({
labels={labels}
inline
/>
<LyricsLayerControls
placement="mobile"
showTranslation={showTranslation}
showPronunciation={showPronunciation}
translationEnabled={hasTranslationLyric}
pronunciationEnabled={hasPronunciationLyric}
onToggleTranslation={toggleTranslation}
onTogglePronunciation={togglePronunciation}
labels={labels}
testId="lyrics-mobile-layer-controls"
/>
</MobileKaraokeLyricsPortal>
),
[
audioInstance,
hasPronunciationLyric,
hasTranslationLyric,
lyricLayers.main,
lyricLayers.pronunciation,
lyricLayers.translation,
@ -169,6 +183,8 @@ const usePlayerLyrics = ({
labels,
showPronunciation,
showTranslation,
togglePronunciation,
toggleTranslation,
useInlineMobileLyrics,
],
)