fix(ui): harden responsive lyrics interactions

Signed-off-by: ranokay <github@ranokay.com>
This commit is contained in:
ranokay 2026-07-29 14:16:32 +03:00
commit d562462a3b
No known key found for this signature in database
13 changed files with 838 additions and 180 deletions

View file

@ -25,7 +25,6 @@ import {
KARAOKE_LINE_MOTION_EASING,
KARAOKE_MANUAL_SCROLL_PAUSE_MS,
KARAOKE_SCROLLBAR_VISIBLE_MS,
KARAOKE_TRANSLATION_IDLE_OPACITY,
} from './lyricsKaraokeConstants'
import {
animateScrollTop,
@ -112,7 +111,6 @@ const useStyles = makeStyles((theme) => ({
'--lyrics-translation-current-color':
'var(--lyrics-translation-idle-color, currentColor)',
'--lyrics-layer-opacity': KARAOKE_IDLE_LAYER_OPACITY,
'--lyrics-translation-opacity': KARAOKE_TRANSLATION_IDLE_OPACITY,
transform: 'translateY(0)',
transition: `background-color 150ms ${KARAOKE_LINE_MOTION_EASING}`,
'&[role="button"]:focus-visible': {
@ -136,7 +134,6 @@ const useStyles = makeStyles((theme) => ({
'--lyrics-translation-current-color':
'var(--lyrics-translation-active-color, var(--lyrics-translation-idle-color, currentColor))',
'--lyrics-layer-opacity': 1,
'--lyrics-translation-opacity': 1,
},
'@media (prefers-reduced-motion: reduce)': {
transition: 'none',
@ -182,17 +179,17 @@ const useStyles = makeStyles((theme) => ({
},
auxLine: {
display: 'block',
opacity: 1,
fontWeight: 600,
fontSize: 15,
lineHeight: KARAOKE_AUX_LINE_HEIGHT,
overflowWrap: 'anywhere',
whiteSpace: 'pre-wrap',
letterSpacing: 0,
opacity: 'var(--lyrics-translation-opacity)',
color: 'var(--lyrics-translation-current-color, currentColor)',
WebkitTextFillColor:
'var(--lyrics-translation-current-color, currentColor)',
transition: KARAOKE_LAYER_OPACITY_TRANSITION,
transition: 'none',
'@media (prefers-reduced-motion: reduce)': {
transition: 'none',
transform: 'none',
@ -412,6 +409,7 @@ const usePrefersReducedMotion = () => {
const LyricsPanel = ({
visible = true,
obscured = false,
mainLyric,
translationLyric,
pronunciationLyric,
@ -436,7 +434,9 @@ const LyricsPanel = ({
const [hasTopFade, setHasTopFade] = useState(false)
const [scrollEndPadding, setScrollEndPadding] = useState(0)
const [scrollStartPadding, setScrollStartPadding] = useState(0)
const [focusedLineIndex, setFocusedLineIndex] = useState(null)
const prefersReducedMotion = usePrefersReducedMotion()
const timelineVisible = visible && !obscured
const activeLineAnchorRatio = inline
? KARAOKE_INLINE_ACTIVE_LINE_ANCHOR_RATIO
: KARAOKE_DESKTOP_ACTIVE_LINE_ANCHOR_RATIO
@ -466,10 +466,26 @@ const LyricsPanel = ({
} = useLyricsTimeline({
lines: mainLines,
audioInstance,
visible: visible && hasTimedMainLines,
visible: timelineVisible && hasTimedMainLines,
reducedMotion: prefersReducedMotion,
})
const activeIndexSet = useMemo(() => new Set(activeIndexes), [activeIndexes])
const seekableLineIndexes = useMemo(() => {
if (!audioInstance) return []
return mainLines.reduce((indexes, line, index) => {
if (line.renderable !== false && line.start != null) indexes.push(index)
return indexes
}, [])
}, [audioInstance, mainLines])
const tabbableLineIndex = seekableLineIndexes.includes(focusedLineIndex)
? focusedLineIndex
: seekableLineIndexes.includes(activeIndex)
? activeIndex
: seekableLineIndexes[0]
useEffect(() => {
setFocusedLineIndex(null)
}, [mainLyric])
const trByMainIndex = useMemo(() => {
if (!showTranslation || translationLines.length === 0) return {}
@ -630,7 +646,7 @@ const LyricsPanel = ({
}, [clearManualScrollTimer, mainLyric, visible])
useEffect(() => {
if (!visible || !hasTimedMainLines || scrollTargetIndex < 0) {
if (!timelineVisible || !hasTimedMainLines || scrollTargetIndex < 0) {
cancelScrollAnimation(scrollAnimationRef)
return undefined
}
@ -661,7 +677,7 @@ const LyricsPanel = ({
layoutVersion,
prefersReducedMotion,
scrollTargetIndex,
visible,
timelineVisible,
])
if (!visible) return null
@ -697,6 +713,38 @@ const LyricsPanel = ({
resumeAutoScroll()
}
const focusSeekableLine = (index) => {
setFocusedLineIndex(index)
getLineNode(index)?.focus()
}
const handleLineKeyDown = (event, index, line) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
seekToLine(line)
return
}
const currentPosition = seekableLineIndexes.indexOf(index)
if (currentPosition < 0) return
let nextPosition = null
if (event.key === 'ArrowDown' || event.key === 'ArrowRight') {
nextPosition = Math.min(
currentPosition + 1,
seekableLineIndexes.length - 1,
)
} else if (event.key === 'ArrowUp' || event.key === 'ArrowLeft') {
nextPosition = Math.max(currentPosition - 1, 0)
} else if (event.key === 'Home') {
nextPosition = 0
} else if (event.key === 'End') {
nextPosition = seekableLineIndexes.length - 1
}
if (nextPosition == null) return
event.preventDefault()
focusSeekableLine(seekableLineIndexes[nextPosition])
}
return (
<div
className={clsx(classes.root, { [classes.inlineRoot]: inline })}
@ -780,8 +828,22 @@ const LyricsPanel = ({
data-testid="lyrics-line-group"
style={buildLineGroupStyle(canSeekLine, layerStyles)}
role={canSeekLine ? 'button' : undefined}
tabIndex={canSeekLine ? 0 : undefined}
tabIndex={
canSeekLine ? (idx === tabbableLineIndex ? 0 : -1) : undefined
}
onClick={() => seekToLine(line)}
onFocus={
canSeekLine ? () => setFocusedLineIndex(idx) : undefined
}
onBlur={
canSeekLine
? (event) => {
if (!bodyRef.current?.contains(event.relatedTarget)) {
setFocusedLineIndex(null)
}
}
: undefined
}
onMouseDown={
canSeekLine
? (event) => {
@ -789,15 +851,11 @@ const LyricsPanel = ({
}
: undefined
}
onKeyDown={(event) => {
if (
canSeekLine &&
(event.key === 'Enter' || event.key === ' ')
) {
event.preventDefault()
seekToLine(line)
}
}}
onKeyDown={
canSeekLine
? (event) => handleLineKeyDown(event, idx, line)
: undefined
}
>
{lineLanes.length > 1 ? (
<div

View file

@ -19,7 +19,6 @@ import {
KARAOKE_HIGHLIGHT_LEAD_MS,
KARAOKE_LINE_LIFT_PX,
KARAOKE_MANUAL_SCROLL_PAUSE_MS,
KARAOKE_TRANSLATION_IDLE_OPACITY,
TOKEN_FUTURE_ALPHA,
} from './lyricsKaraokeConstants'
@ -356,7 +355,7 @@ describe('<LyricsPanel />', () => {
expect(pronunciation.style.color).toBe('')
})
it('dims inactive translations without moving them', () => {
it('keeps translations subordinate and stable between line states', () => {
const propsAt = (currentTime) => ({
mainLyric,
pronunciationLyric: {
@ -388,17 +387,17 @@ describe('<LyricsPanel />', () => {
expect(window.getComputedStyle(mainRow).transition).toContain(
`opacity ${KARAOKE_LINE_OPACITY_MS}ms ${KARAOKE_EASING}`,
)
expect(window.getComputedStyle(translationRow).transition).toContain(
`opacity ${KARAOKE_LINE_OPACITY_MS}ms ${KARAOKE_EASING}`,
)
expect(window.getComputedStyle(translationRow).transition).toBe('none')
pronunciation.forEach((token) =>
expect(token).toHaveAttribute('data-timed', 'false'),
)
expect(window.getComputedStyle(translationRow).opacity).toBe('1')
expect(
window
.getComputedStyle(lineGroup)
.getPropertyValue('--lyrics-translation-opacity'),
).toBe('1')
lineGroup.style.getPropertyValue('--lyrics-translation-idle-color'),
).toBe(theme.palette.text.secondary)
expect(
lineGroup.style.getPropertyValue('--lyrics-translation-active-color'),
).toBe(theme.palette.text.secondary)
expect(window.getComputedStyle(translationRow).transform).toBe('')
expect(window.getComputedStyle(translationRow).marginTop).toBe(
`${theme.spacing(0.6)}px`,
@ -406,12 +405,8 @@ describe('<LyricsPanel />', () => {
rerenderPanel(propsAt(1.1))
const inactiveOpacity = Number(
window
.getComputedStyle(lineGroup)
.getPropertyValue('--lyrics-translation-opacity'),
)
expect(inactiveOpacity).toBe(KARAOKE_TRANSLATION_IDLE_OPACITY)
expect(window.getComputedStyle(translationRow).opacity).toBe('1')
expect(window.getComputedStyle(translationRow).transition).toBe('none')
expect(window.getComputedStyle(translationRow).transform).toBe('')
})
@ -766,6 +761,97 @@ describe('<LyricsPanel />', () => {
expect(audioInstance.currentTime).toBe(2.3)
})
it('uses roving focus for seekable lines without seeking on navigation', () => {
const audioInstance = createAudio({ currentTime: 1.5 })
renderPanel({
mainLyric: {
synced: true,
line: [
{ start: 0, end: 900, value: 'First seekable line' },
{ value: 'Untimed line' },
{ start: 1000, end: 1900, value: 'Active seekable line' },
{ start: 2000, end: 2900, value: 'Last seekable line' },
],
},
audioInstance,
})
const first = screen
.getByText('First seekable line')
.closest('[data-testid="lyrics-line-group"]')
const untimed = screen
.getByText('Untimed line')
.closest('[data-testid="lyrics-line-group"]')
const active = screen
.getByText('Active seekable line')
.closest('[data-testid="lyrics-line-group"]')
const last = screen
.getByText('Last seekable line')
.closest('[data-testid="lyrics-line-group"]')
expect(first).toHaveAttribute('tabindex', '-1')
expect(untimed).not.toHaveAttribute('tabindex')
expect(active).toHaveAttribute('tabindex', '0')
expect(last).toHaveAttribute('tabindex', '-1')
active.focus()
fireEvent.keyDown(active, { key: 'ArrowDown' })
expect(document.activeElement).toBe(last)
expect(last).toHaveAttribute('tabindex', '0')
expect(audioInstance.currentTime).toBe(1.5)
fireEvent.keyDown(last, { key: 'ArrowRight' })
expect(document.activeElement).toBe(last)
fireEvent.keyDown(last, { key: 'ArrowUp' })
expect(document.activeElement).toBe(active)
fireEvent.keyDown(active, { key: 'ArrowLeft' })
expect(document.activeElement).toBe(first)
expect(audioInstance.currentTime).toBe(1.5)
fireEvent.keyDown(first, { key: 'End' })
expect(document.activeElement).toBe(last)
fireEvent.keyDown(last, { key: 'Home' })
expect(document.activeElement).toBe(first)
expect(audioInstance.currentTime).toBe(1.5)
fireEvent.keyDown(first, { key: ' ' })
expect(audioInstance.currentTime).toBe(0)
fireEvent.keyDown(last, { key: 'Enter' })
expect(audioInstance.currentTime).toBe(2)
})
it('keeps lyrics mounted but pauses timeline frames while obscured', () => {
const audioInstance = createAudio({ currentTime: 0.25 })
audioInstance.paused = false
window.requestAnimationFrame.mockImplementation(() => 7)
window.requestAnimationFrame.mockClear()
window.cancelAnimationFrame.mockClear()
const { rerenderPanel } = renderPanel({
mainLyric: tokenizedMainLyric,
audioInstance,
obscured: true,
})
expect(screen.getByTestId('karaoke-lyrics-panel')).toBeInTheDocument()
expect(window.requestAnimationFrame).not.toHaveBeenCalled()
rerenderPanel({
mainLyric: tokenizedMainLyric,
audioInstance,
obscured: false,
})
expect(window.requestAnimationFrame).toHaveBeenCalled()
rerenderPanel({
mainLyric: tokenizedMainLyric,
audioInstance,
obscured: true,
})
expect(screen.getByTestId('karaoke-lyrics-panel')).toBeInTheDocument()
expect(window.cancelAnimationFrame).toHaveBeenCalledWith(7)
})
it('pauses auto-scroll only for genuine manual scroll intent', async () => {
vi.useFakeTimers()
const requestAnimationFrameSpy = vi
@ -834,21 +920,23 @@ describe('<LyricsPanel />', () => {
const accessibleTheme = createTheme({ palette: { type } })
const background = accessibleTheme.palette.background.default
renderPanel(
{
mainLyric: tokenizedMainLyric,
translationLyric: {
synced: true,
line: [{ start: 0, end: 1000, value: 'Readable translation' }],
},
showTranslation: true,
audioInstance: { currentTime: 0.25, paused: true },
const propsAt = (currentTime) => ({
mainLyric: tokenizedMainLyric,
translationLyric: {
synced: true,
line: [{ start: 0, end: 1000, value: 'Readable translation' }],
},
accessibleTheme,
)
showTranslation: true,
audioInstance: { currentTime, paused: true },
})
const { rerenderPanel } = renderPanel(propsAt(0.25), accessibleTheme)
const group = screen.getByTestId('lyrics-line-group')
expect(screen.getByText('Readable translation')).toBeInTheDocument()
const translation = screen
.getByText('Readable translation')
.closest('[data-tokenized]')
expect(translation).toBeInTheDocument()
expect(window.getComputedStyle(translation).opacity).toBe('1')
const token = screen.getAllByTestId('lyrics-token')[0]
const gradientNode = [token, ...token.querySelectorAll('*')].find(
(node) => node.style.backgroundImage.includes('linear-gradient'),
@ -865,11 +953,6 @@ describe('<LyricsPanel />', () => {
expect(
group.style.getPropertyValue('--lyrics-translation-active-color'),
).toBe(accessibleTheme.palette.text.secondary)
expect(
window
.getComputedStyle(group)
.getPropertyValue('--lyrics-translation-opacity'),
).toBe('1')
expect(parseCssColor(futureColor).alpha).toBe(TOKEN_FUTURE_ALPHA)
expect(contrastRatio(futureColor, background)).toBeGreaterThanOrEqual(4.5)
expect(
@ -880,6 +963,16 @@ describe('<LyricsPanel />', () => {
).toBeLessThan(
contrastRatio(accessibleTheme.palette.text.primary, background),
)
rerenderPanel(propsAt(1.25))
expect(group).toHaveAttribute('data-active', 'false')
expect(window.getComputedStyle(translation).opacity).toBe('1')
expect(
group.style.getPropertyValue('--lyrics-translation-idle-color'),
).toBe(accessibleTheme.palette.text.secondary)
expect(
group.style.getPropertyValue('--lyrics-translation-active-color'),
).toBe(accessibleTheme.palette.text.secondary)
},
)
})

View file

@ -1,16 +1,15 @@
import { alpha, makeStyles } from '@material-ui/core/styles'
import React, { useCallback, useEffect, useRef, useState } from 'react'
import React, { useCallback, useEffect, useRef } from 'react'
import LyricsLayerControls from './LyricsLayerControls'
import LyricsPanel from './LyricsPanel'
import {
LYRICS_SIDEBAR_DEFAULT_WIDTH,
LYRICS_SIDEBAR_MAX_WIDTH,
LYRICS_SIDEBAR_MIN_WIDTH,
LYRICS_SIDEBAR_TRANSITION_MS,
LYRICS_SIDEBAR_WIDTH_STEP,
clampSidebarMaxWidth,
clampSidebarWidth,
loadSidebarWidth,
notifySidebarWidthChange,
saveSidebarWidth,
} from './lyricsSidebarWidth'
import useEnterExitTransition from './useEnterExitTransition'
import useRestoreFocusOnExit from './useRestoreFocusOnExit'
@ -83,8 +82,14 @@ const useStyles = makeStyles((theme) => ({
},
}))
const noop = () => {}
const LyricsSidebar = ({
visible,
obscuredByQueue = false,
width = LYRICS_SIDEBAR_DEFAULT_WIDTH,
maxWidth = LYRICS_SIDEBAR_MAX_WIDTH,
onWidthChange = noop,
mainLyric,
translationLyric,
pronunciationLyric,
@ -100,27 +105,28 @@ const LyricsSidebar = ({
labels = {},
returnFocusRef,
}) => {
const [width, setWidth] = useState(loadSidebarWidth)
const resolvedMaxWidth = clampSidebarMaxWidth(maxWidth)
const resolvedWidth = clampSidebarWidth(width, resolvedMaxWidth)
const sidebarRef = useRef(null)
const resizerRef = useRef(null)
const widthRef = useRef(width)
const widthRef = useRef(resolvedWidth)
const resizeCleanupRef = useRef(null)
const { rendered, entered } = useEnterExitTransition(
visible,
LYRICS_SIDEBAR_TRANSITION_MS,
)
const [isResizing, setIsResizing] = useState(false)
const interactive = entered && !obscuredByQueue
const classes = useStyles()
useRestoreFocusOnExit({
surfaceRef: sidebarRef,
entered,
entered: interactive,
returnFocusRef,
})
useEffect(() => {
widthRef.current = width
}, [width])
widthRef.current = resolvedWidth
}, [resolvedWidth])
useEffect(
() => () => {
@ -130,26 +136,21 @@ const LyricsSidebar = ({
[],
)
const applyWidth = useCallback((next) => {
const resolvedWidth = clampSidebarWidth(
typeof next === 'function' ? next(widthRef.current) : next,
)
widthRef.current = resolvedWidth
if (sidebarRef.current) {
sidebarRef.current.style.width = `${resolvedWidth}px`
}
resizerRef.current?.setAttribute('aria-valuenow', String(resolvedWidth))
notifySidebarWidthChange(resolvedWidth)
return resolvedWidth
}, [])
const updateWidth = useCallback(
const applyWidth = useCallback(
(next, { persist = false } = {}) => {
const resolvedWidth = applyWidth(next)
setWidth(resolvedWidth)
if (persist) saveSidebarWidth(resolvedWidth)
const nextWidth = clampSidebarWidth(
typeof next === 'function' ? next(widthRef.current) : next,
resolvedMaxWidth,
)
widthRef.current = nextWidth
if (sidebarRef.current) {
sidebarRef.current.style.width = `${nextWidth}px`
}
resizerRef.current?.setAttribute('aria-valuenow', String(nextWidth))
onWidthChange(nextWidth, { persist })
return nextWidth
},
[applyWidth],
[onWidthChange, resolvedMaxWidth],
)
const handleResizePointerDown = useCallback(
@ -166,7 +167,6 @@ const LyricsSidebar = ({
const canCapture =
pointerId != null && typeof target.setPointerCapture === 'function'
const listenerTarget = window
setIsResizing(true)
const handlePointerMove = (moveEvent) => {
latestWidth = applyWidth(startWidth + startX - moveEvent.clientX)
@ -190,9 +190,7 @@ const LyricsSidebar = ({
// Ignore stale pointer capture state.
}
}
setWidth(latestWidth)
if (persist) saveSidebarWidth(latestWidth)
setIsResizing(false)
if (persist) onWidthChange(latestWidth, { persist: true })
if (resizeCleanupRef.current === cleanupResize) {
resizeCleanupRef.current = null
}
@ -214,30 +212,30 @@ const LyricsSidebar = ({
}
}
},
[applyWidth],
[applyWidth, onWidthChange],
)
const handleResizeKeyDown = useCallback(
(event) => {
if (event.key === 'ArrowLeft') {
event.preventDefault()
updateWidth((current) => current + LYRICS_SIDEBAR_WIDTH_STEP, {
applyWidth((current) => current + LYRICS_SIDEBAR_WIDTH_STEP, {
persist: true,
})
} else if (event.key === 'ArrowRight') {
event.preventDefault()
updateWidth((current) => current - LYRICS_SIDEBAR_WIDTH_STEP, {
applyWidth((current) => current - LYRICS_SIDEBAR_WIDTH_STEP, {
persist: true,
})
} else if (event.key === 'Home') {
event.preventDefault()
updateWidth(LYRICS_SIDEBAR_MIN_WIDTH, { persist: true })
applyWidth(LYRICS_SIDEBAR_MIN_WIDTH, { persist: true })
} else if (event.key === 'End') {
event.preventDefault()
updateWidth(LYRICS_SIDEBAR_MAX_WIDTH, { persist: true })
applyWidth(resolvedMaxWidth, { persist: true })
}
},
[updateWidth],
[applyWidth, resolvedMaxWidth],
)
if (!rendered) return null
@ -248,15 +246,14 @@ const LyricsSidebar = ({
data-testid="lyrics-sidebar"
ref={sidebarRef}
style={{
width: widthRef.current,
width: resolvedWidth,
transform: entered ? 'translateX(0)' : 'translateX(100%)',
opacity: entered ? 1 : 0,
pointerEvents: entered ? 'auto' : 'none',
pointerEvents: interactive ? 'auto' : 'none',
}}
aria-label={labels.title || 'Lyrics'}
aria-hidden={!entered}
inert={entered ? undefined : ''}
data-resizing={isResizing ? 'true' : 'false'}
aria-hidden={!interactive}
inert={interactive ? undefined : ''}
>
<button
type="button"
@ -266,8 +263,8 @@ const LyricsSidebar = ({
aria-label={labels.resize || 'Resize lyrics sidebar'}
aria-orientation="vertical"
aria-valuemin={LYRICS_SIDEBAR_MIN_WIDTH}
aria-valuemax={LYRICS_SIDEBAR_MAX_WIDTH}
aria-valuenow={width}
aria-valuemax={resolvedMaxWidth}
aria-valuenow={resolvedWidth}
role="separator"
onPointerDown={handleResizePointerDown}
onKeyDown={handleResizeKeyDown}
@ -285,6 +282,7 @@ const LyricsSidebar = ({
/>
<LyricsPanel
visible={visible}
obscured={obscuredByQueue}
mainLyric={mainLyric}
translationLyric={translationLyric}
pronunciationLyric={pronunciationLyric}

View file

@ -1,4 +1,4 @@
import React from 'react'
import React, { useCallback, useState } from 'react'
import {
cleanup,
fireEvent,
@ -10,9 +10,9 @@ import { ThemeProvider, createTheme } from '@material-ui/core/styles'
import { afterEach, describe, expect, it, vi } from 'vitest'
import LyricsSidebar from './LyricsSidebar'
import {
LYRICS_SIDEBAR_DEFAULT_WIDTH,
LYRICS_SIDEBAR_MAX_WIDTH,
LYRICS_SIDEBAR_MIN_WIDTH,
LYRICS_SIDEBAR_STORAGE_KEY,
LYRICS_SIDEBAR_TRANSITION_MS,
} from './lyricsSidebarWidth'
@ -33,9 +33,34 @@ const lyric = {
}
const originalPointerEvent = window.PointerEvent
const ControlledLyricsSidebar = ({
initialWidth = LYRICS_SIDEBAR_DEFAULT_WIDTH,
maxWidth = LYRICS_SIDEBAR_MAX_WIDTH,
onWidthChange,
...props
}) => {
const [width, setWidth] = useState(initialWidth)
const handleWidthChange = useCallback(
(nextWidth, options) => {
setWidth(nextWidth)
onWidthChange?.(nextWidth, options)
},
[onWidthChange],
)
return (
<LyricsSidebar
width={width}
maxWidth={maxWidth}
onWidthChange={handleWidthChange}
{...props}
/>
)
}
const sidebarView = (props = {}) => (
<ThemeProvider theme={theme}>
<LyricsSidebar
<ControlledLyricsSidebar
visible
mainLyric={lyric}
showTranslation
@ -122,32 +147,66 @@ describe('<LyricsSidebar />', () => {
expect(document.activeElement).toBe(returnTarget)
})
it('clamps persisted and keyboard-resized widths', () => {
localStorage.setItem(LYRICS_SIDEBAR_STORAGE_KEY, '999')
renderSidebar()
it('keeps lyrics mounted but noninteractive while the queue obscures them', () => {
const returnTarget = document.createElement('button')
returnTarget.dataset.lyricsReturnTarget = 'true'
document.body.appendChild(returnTarget)
const returnFocusRef = { current: returnTarget }
const { rerender } = renderSidebar({ returnFocusRef })
const sidebar = screen.getByTestId('lyrics-sidebar')
const panel = screen.getByTestId('karaoke-lyrics-panel')
screen.getByTestId('lyrics-sidebar-resizer').focus()
rerender(
sidebarView({
obscuredByQueue: true,
returnFocusRef,
}),
)
expect(screen.getByTestId('lyrics-sidebar')).toBe(sidebar)
expect(screen.getByTestId('karaoke-lyrics-panel')).toBe(panel)
expect(sidebar).toHaveStyle({
transform: 'translateX(0)',
opacity: '1',
pointerEvents: 'none',
})
expect(sidebar).toHaveAttribute('aria-hidden', 'true')
expect(sidebar).toHaveAttribute('inert')
expect(document.activeElement).toBe(returnTarget)
rerender(sidebarView({ returnFocusRef }))
expect(screen.getByTestId('lyrics-sidebar')).toBe(sidebar)
expect(screen.getByTestId('karaoke-lyrics-panel')).toBe(panel)
expect(sidebar).toHaveStyle({ pointerEvents: 'auto' })
expect(sidebar).toHaveAttribute('aria-hidden', 'false')
expect(sidebar).not.toHaveAttribute('inert')
})
it('clamps controlled keyboard resizing to the available width', () => {
const onWidthChange = vi.fn()
renderSidebar({ initialWidth: 999, maxWidth: 435, onWidthChange })
const sidebar = screen.getByTestId('lyrics-sidebar')
const resizer = screen.getByTestId('lyrics-sidebar-resizer')
expect(sidebar).toHaveStyle({ width: `${LYRICS_SIDEBAR_MAX_WIDTH}px` })
expect(resizer).toHaveAttribute(
'aria-valuenow',
String(LYRICS_SIDEBAR_MAX_WIDTH),
)
expect(sidebar).toHaveStyle({ width: '435px' })
expect(resizer).toHaveAttribute('aria-valuemax', '435')
expect(resizer).toHaveAttribute('aria-valuenow', '435')
fireEvent.keyDown(resizer, { key: 'Home' })
expect(sidebar).toHaveStyle({ width: `${LYRICS_SIDEBAR_MIN_WIDTH}px` })
expect(localStorage.getItem(LYRICS_SIDEBAR_STORAGE_KEY)).toBe(
String(LYRICS_SIDEBAR_MIN_WIDTH),
)
expect(onWidthChange).toHaveBeenLastCalledWith(LYRICS_SIDEBAR_MIN_WIDTH, {
persist: true,
})
fireEvent.keyDown(resizer, { key: 'End' })
expect(sidebar).toHaveStyle({ width: `${LYRICS_SIDEBAR_MAX_WIDTH}px` })
expect(localStorage.getItem(LYRICS_SIDEBAR_STORAGE_KEY)).toBe(
String(LYRICS_SIDEBAR_MAX_WIDTH),
)
expect(sidebar).toHaveStyle({ width: '435px' })
expect(resizer).toHaveAttribute('aria-valuenow', '435')
expect(onWidthChange).toHaveBeenLastCalledWith(435, { persist: true })
})
it('clamps pointer resizing from the left separator', async () => {
@ -156,7 +215,8 @@ describe('<LyricsSidebar />', () => {
writable: true,
value: MouseEvent,
})
renderSidebar()
const onWidthChange = vi.fn()
renderSidebar({ maxWidth: 435, onWidthChange })
const sidebar = screen.getByTestId('lyrics-sidebar')
const resizer = screen.getByTestId('lyrics-sidebar-resizer')
@ -167,10 +227,10 @@ describe('<LyricsSidebar />', () => {
fireEvent.pointerDown(resizer, { clientX: 500 })
window.dispatchEvent(new MouseEvent('pointermove', { clientX: -100 }))
await waitFor(() =>
expect(sidebar).toHaveStyle({ width: `${LYRICS_SIDEBAR_MAX_WIDTH}px` }),
)
expect(localStorage.getItem(LYRICS_SIDEBAR_STORAGE_KEY)).toBeNull()
await waitFor(() => expect(sidebar).toHaveStyle({ width: '435px' }))
expect(resizer).toHaveAttribute('aria-valuemax', '435')
expect(resizer).toHaveAttribute('aria-valuenow', '435')
expect(onWidthChange).toHaveBeenLastCalledWith(435, { persist: false })
window.dispatchEvent(new MouseEvent('pointermove', { clientX: 1000 }))
await waitFor(() =>
@ -178,9 +238,9 @@ describe('<LyricsSidebar />', () => {
)
window.dispatchEvent(new MouseEvent('pointerup'))
expect(localStorage.getItem(LYRICS_SIDEBAR_STORAGE_KEY)).toBe(
String(LYRICS_SIDEBAR_MIN_WIDTH),
)
expect(onWidthChange).toHaveBeenLastCalledWith(LYRICS_SIDEBAR_MIN_WIDTH, {
persist: true,
})
})
it('keeps the live pointer width through an unrelated rerender', async () => {
@ -214,7 +274,8 @@ describe('<LyricsSidebar />', () => {
writable: true,
value: MouseEvent,
})
const { unmount } = renderSidebar()
const onWidthChange = vi.fn()
const { unmount } = renderSidebar({ onWidthChange })
const sidebar = screen.getByTestId('lyrics-sidebar')
const resizer = screen.getByTestId('lyrics-sidebar-resizer')
@ -226,13 +287,14 @@ describe('<LyricsSidebar />', () => {
)
window.dispatchEvent(new MouseEvent('pointercancel'))
await waitFor(() =>
expect(sidebar).toHaveAttribute('data-resizing', 'false'),
)
const callsAfterCancel = onWidthChange.mock.calls.length
window.dispatchEvent(new MouseEvent('pointermove', { clientX: 1000 }))
expect(sidebar).toHaveStyle({ width: `${LYRICS_SIDEBAR_MAX_WIDTH}px` })
expect(localStorage.getItem(LYRICS_SIDEBAR_STORAGE_KEY)).toBeNull()
expect(onWidthChange).toHaveBeenCalledTimes(callsAfterCancel)
expect(onWidthChange).not.toHaveBeenCalledWith(expect.anything(), {
persist: true,
})
fireEvent.pointerDown(resizer, { clientX: 500 })
window.dispatchEvent(new MouseEvent('pointermove', { clientX: 1000 }))
@ -241,9 +303,13 @@ describe('<LyricsSidebar />', () => {
)
unmount()
const callsBeforePointerUp = onWidthChange.mock.calls.length
window.dispatchEvent(new MouseEvent('pointerup'))
expect(localStorage.getItem(LYRICS_SIDEBAR_STORAGE_KEY)).toBeNull()
expect(onWidthChange).toHaveBeenCalledTimes(callsBeforePointerUp)
expect(onWidthChange).not.toHaveBeenCalledWith(expect.anything(), {
persist: true,
})
})
it('uses icon toggle buttons with pressed and disabled states', () => {

View file

@ -1,9 +1,9 @@
import React, { useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { MOBILE_KARAOKE_LYRICS_TRANSITION_MS } from './lyricsKaraokeConstants'
import useEnterExitTransition from './useEnterExitTransition'
import useRestoreFocusOnExit from './useRestoreFocusOnExit'
export const MOBILE_KARAOKE_LYRICS_TRANSITION_MS = 260
export const MOBILE_KARAOKE_LYRICS_HOST_SELECTOR =
'.react-jinke-music-player-mobile-cover'
export const MOBILE_KARAOKE_LYRICS_ACTIVE_CLASS = 'nd-mobile-lyrics-active'
@ -15,7 +15,12 @@ const resolveMobileLyricsHost = () => {
return document.querySelector(MOBILE_KARAOKE_LYRICS_HOST_SELECTOR)
}
const MobileKaraokeLyricsPortal = ({ active, children, returnFocusRef }) => {
const MobileKaraokeLyricsPortal = ({
active,
children,
obscured = false,
returnFocusRef,
}) => {
const { rendered, entered } = useEnterExitTransition(
active,
MOBILE_KARAOKE_LYRICS_TRANSITION_MS,
@ -24,10 +29,11 @@ const MobileKaraokeLyricsPortal = ({ active, children, returnFocusRef }) => {
active ? resolveMobileLyricsHost() : null,
)
const layerRef = useRef(null)
const interactive = entered && !obscured
useRestoreFocusOnExit({
surfaceRef: layerRef,
entered,
entered: interactive,
returnFocusRef,
surfaceKey: host,
})
@ -71,9 +77,9 @@ const MobileKaraokeLyricsPortal = ({ active, children, returnFocusRef }) => {
className={MOBILE_KARAOKE_LYRICS_LAYER_CLASS}
ref={layerRef}
data-entered={entered ? 'true' : 'false'}
aria-hidden={!entered}
inert={entered ? undefined : ''}
style={{ pointerEvents: entered ? 'auto' : 'none' }}
aria-hidden={!interactive}
inert={interactive ? undefined : ''}
style={{ pointerEvents: interactive ? 'auto' : 'none' }}
>
{children}
</div>,

View file

@ -14,8 +14,8 @@ import MobileKaraokeLyricsPortal, {
MOBILE_KARAOKE_LYRICS_ENTERED_CLASS,
MOBILE_KARAOKE_LYRICS_HOST_SELECTOR,
MOBILE_KARAOKE_LYRICS_LAYER_CLASS,
MOBILE_KARAOKE_LYRICS_TRANSITION_MS,
} from './MobileKaraokeLyricsPortal'
import { MOBILE_KARAOKE_LYRICS_TRANSITION_MS } from './lyricsKaraokeConstants'
import usePlayerLyrics from './usePlayerLyrics'
const { defaultLyricsResponse, useEnhancedLyricsMock } = vi.hoisted(() => {
@ -49,19 +49,28 @@ const createHost = () => {
return host
}
const portal = (text, active = true, returnFocusRef) => (
<MobileKaraokeLyricsPortal active={active} returnFocusRef={returnFocusRef}>
const portal = (text, active = true, returnFocusRef, obscured = false) => (
<MobileKaraokeLyricsPortal
active={active}
obscured={obscured}
returnFocusRef={returnFocusRef}
>
<span>{text}</span>
</MobileKaraokeLyricsPortal>
)
const MobileLyricsHarness = ({ trackId = 'track-1', isRadio = false }) => {
const MobileLyricsHarness = ({
trackId = 'track-1',
isRadio = false,
obscuredByQueue = false,
}) => {
const { toolbarLyricsProps, mobileLyricsSurface } = usePlayerLyrics({
trackId,
trackUpdatedAt: 'now',
isRadio,
audioInstance: null,
isDesktop: false,
obscuredByQueue,
translate: (key) => key,
})
return (
@ -157,6 +166,38 @@ describe('<MobileKaraokeLyricsPortal />', () => {
expect(document.activeElement).toBe(returnTarget)
})
it('keeps open lyrics mounted and inert while the queue obscures them', async () => {
const host = createHost()
const { rerender } = render(<MobileLyricsHarness />)
fireEvent.click(screen.getByRole('button', { name: 'Toggle lyrics' }))
const layer = host.querySelector(`.${MOBILE_KARAOKE_LYRICS_LAYER_CLASS}`)
const panel = within(host).getByTestId('karaoke-lyrics-panel')
await waitFor(() => expect(layer).toHaveAttribute('aria-hidden', 'false'))
rerender(<MobileLyricsHarness obscuredByQueue />)
expect(host).toHaveClass(MOBILE_KARAOKE_LYRICS_ACTIVE_CLASS)
expect(host).toHaveClass(MOBILE_KARAOKE_LYRICS_ENTERED_CLASS)
expect(host.querySelector(`.${MOBILE_KARAOKE_LYRICS_LAYER_CLASS}`)).toBe(
layer,
)
expect(within(host).getByTestId('karaoke-lyrics-panel')).toBe(panel)
expect(layer).toHaveAttribute('aria-hidden', 'true')
expect(layer).toHaveAttribute('inert')
expect(layer).toHaveStyle({ pointerEvents: 'none' })
expect(useEnhancedLyricsMock).toHaveBeenLastCalledWith(
expect.objectContaining({ requested: true }),
)
rerender(<MobileLyricsHarness />)
expect(within(host).getByTestId('karaoke-lyrics-panel')).toBe(panel)
await waitFor(() => expect(layer).toHaveAttribute('aria-hidden', 'false'))
expect(layer).not.toHaveAttribute('inert')
expect(layer).toHaveStyle({ pointerEvents: 'auto' })
})
it('attaches when the mobile cover host appears after activation', async () => {
vi.useFakeTimers()
@ -248,7 +289,6 @@ describe('<MobileKaraokeLyricsPortal />', () => {
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%',

View file

@ -54,6 +54,7 @@ const Player = () => {
const currentTrackIdRef = useRef(null)
const stoppedRef = useRef(false)
const [audioInstance, setAudioInstance] = useState(null)
const [audioListsPanelVisible, setAudioListsPanelVisible] = useState(false)
const isDesktop = useMediaQuery(PLAYER_DESKTOP_MEDIA_QUERY)
const isMobilePlayer =
/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(
@ -158,6 +159,7 @@ const Player = () => {
isRadio: playerState.current?.isRadio || false,
audioInstance,
isDesktop,
obscuredByQueue: audioListsPanelVisible,
translate,
})
@ -501,6 +503,7 @@ const Player = () => {
{...options}
className={classes.player}
onAudioListsChange={onAudioListsChange}
onAudioListsPanelChange={setAudioListsPanelVisible}
onAudioVolumeChange={onAudioVolumeChange}
onAudioProgress={onAudioProgress}
onAudioPlay={onAudioPlay}

View file

@ -19,9 +19,9 @@ 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_IDLE_OPACITY = 0.62
export const KARAOKE_IDLE_LAYER_OPACITY = 0.49
export const KARAOKE_LINE_RELEASE_MS = KARAOKE_LINE_OPACITY_MS
export const MOBILE_KARAOKE_LYRICS_TRANSITION_MS = 260
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

@ -1,14 +1,14 @@
export const LYRICS_SIDEBAR_STORAGE_KEY = 'nd.lyricsSidebar.width.v1'
export const LYRICS_SIDEBAR_WIDTH_EVENT = 'nd:lyrics-sidebar-width'
export const LYRICS_SIDEBAR_DEFAULT_WIDTH = 360
export const LYRICS_SIDEBAR_MIN_WIDTH = 300
export const LYRICS_SIDEBAR_MAX_WIDTH = 520
export const LYRICS_ROUTE_MIN_WIDTH = 320
export const LYRICS_SIDEBAR_WIDTH_STEP = 20
export const LYRICS_SIDEBAR_TRANSITION_MS = 260
export const clampSidebarWidth = (value) => {
export const clampSidebarMaxWidth = (value) => {
const numeric = Number(value)
if (!Number.isFinite(numeric)) return LYRICS_SIDEBAR_DEFAULT_WIDTH
if (!Number.isFinite(numeric)) return LYRICS_SIDEBAR_MAX_WIDTH
return Math.round(
Math.min(
LYRICS_SIDEBAR_MAX_WIDTH,
@ -17,6 +17,28 @@ export const clampSidebarWidth = (value) => {
)
}
export const clampSidebarWidth = (
value,
maxWidth = LYRICS_SIDEBAR_MAX_WIDTH,
) => {
const resolvedMaxWidth = clampSidebarMaxWidth(maxWidth)
const numeric = Number(value)
if (!Number.isFinite(numeric)) {
return Math.min(LYRICS_SIDEBAR_DEFAULT_WIDTH, resolvedMaxWidth)
}
return Math.round(
Math.min(resolvedMaxWidth, Math.max(LYRICS_SIDEBAR_MIN_WIDTH, numeric)),
)
}
export const getSidebarMaxWidth = (contentWidth) => {
const numeric = Number(contentWidth)
if (!Number.isFinite(numeric) || numeric <= 0) {
return LYRICS_SIDEBAR_MAX_WIDTH
}
return clampSidebarMaxWidth(numeric - LYRICS_ROUTE_MIN_WIDTH)
}
const hasLocalStorage = () => {
try {
return typeof window !== 'undefined' && Boolean(window.localStorage)
@ -37,16 +59,6 @@ export const loadSidebarWidth = () => {
}
}
export const notifySidebarWidthChange = (width) => {
if (typeof window === 'undefined') return
window.dispatchEvent(
new CustomEvent(LYRICS_SIDEBAR_WIDTH_EVENT, {
detail: { width: clampSidebarWidth(width) },
}),
)
}
export const saveSidebarWidth = (width) => {
const resolvedWidth = clampSidebarWidth(width)
if (!hasLocalStorage()) return

View file

@ -1,8 +1,8 @@
import { makeStyles } from '@material-ui/core/styles'
import { MOBILE_KARAOKE_LYRICS_TRANSITION_MS } from './lyricsKaraokeConstants'
import { PLAYER_MOBILE_MEDIA_QUERY } from './playerBreakpoints'
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 MOBILE_LYRICS_TRANSITION = `opacity ${MOBILE_KARAOKE_LYRICS_TRANSITION_MS}ms cubic-bezier(0.22, 1, 0.36, 1), transform ${MOBILE_KARAOKE_LYRICS_TRANSITION_MS}ms cubic-bezier(0.22, 1, 0.36, 1)`
const useStyle = makeStyles(
(theme) => ({
@ -48,6 +48,9 @@ const useStyle = makeStyles(
'& .play-mode-title': {
pointerEvents: 'none',
},
'& .audio-lists-panel': {
zIndex: theme.zIndex.appBar,
},
'& .music-player-panel .panel-content div.img-rotate': {
// Customize desktop player when cover animation is disabled
animationDuration: (props) => !props.enableCoverAnimation && '0s',

View file

@ -15,6 +15,7 @@ const usePlayerLyrics = ({
isRadio,
audioInstance,
isDesktop,
obscuredByQueue = false,
translate,
}) => {
const [lyricsVisiblePreference, setLyricsVisiblePreference] = useState(false)
@ -125,6 +126,7 @@ const usePlayerLyrics = ({
loading: lyricsLoading,
error: lyricsError,
labels,
obscuredByQueue,
returnFocusRef: lyricsToggleRef,
}),
[
@ -139,6 +141,7 @@ const usePlayerLyrics = ({
lyricsError,
lyricsLoading,
lyricsVisible,
obscuredByQueue,
showPronunciation,
showTranslation,
togglePronunciation,
@ -150,9 +153,12 @@ const usePlayerLyrics = ({
() => (
<MobileKaraokeLyricsPortal
active={useInlineMobileLyrics}
obscured={obscuredByQueue}
returnFocusRef={lyricsToggleRef}
>
<LyricsPanel
visible
obscured={obscuredByQueue}
mainLyric={lyricLayers.main}
translationLyric={lyricLayers.translation}
pronunciationLyric={lyricLayers.pronunciation}
@ -187,6 +193,7 @@ const usePlayerLyrics = ({
lyricsError,
lyricsLoading,
labels,
obscuredByQueue,
showPronunciation,
showTranslation,
togglePronunciation,

View file

@ -1,13 +1,21 @@
import React, { useEffect, useState } from 'react'
import React, { useCallback, useEffect, useRef, useState } from 'react'
import { makeStyles } from '@material-ui/core/styles'
import { setSidebarVisibility } from 'react-admin'
import { useDispatch, useSelector } from 'react-redux'
import LyricsSidebar from '../audioplayer/LyricsSidebar'
import { useLyricsLayout } from '../audioplayer/LyricsLayoutContext'
import {
LYRICS_SIDEBAR_WIDTH_EVENT,
LYRICS_ROUTE_MIN_WIDTH,
LYRICS_SIDEBAR_MIN_WIDTH,
clampSidebarWidth,
getSidebarMaxWidth,
loadSidebarWidth,
saveSidebarWidth,
} from '../audioplayer/lyricsSidebarWidth'
const MIN_CONTENT_WITH_LYRICS =
LYRICS_ROUTE_MIN_WIDTH + LYRICS_SIDEBAR_MIN_WIDTH
const useStyles = makeStyles(
() => ({
frame: {
@ -20,25 +28,126 @@ const useStyles = makeStyles(
const LyricsLayoutFrame = ({ children }) => {
const { desktopLyricsProps } = useLyricsLayout()
const hasVisibleLyrics = Boolean(desktopLyricsProps?.visible)
const [sidebarWidth, setSidebarWidth] = useState(loadSidebarWidth)
const [preferredSidebarWidth, setPreferredSidebarWidth] =
useState(loadSidebarWidth)
const [contentWidth, setContentWidth] = useState(null)
const frameRef = useRef(null)
const autoCollapsedRef = useRef(false)
const manualSidebarOverrideRef = useRef(false)
const pendingSidebarStateRef = useRef(null)
const previousSidebarOpenRef = useRef(null)
const dispatch = useDispatch()
const sidebarOpen = useSelector(
(state) => state.admin?.ui?.sidebarOpen ?? false,
)
const classes = useStyles()
const sidebarMaxWidth = getSidebarMaxWidth(contentWidth)
const sidebarWidth = clampSidebarWidth(preferredSidebarWidth, sidebarMaxWidth)
useEffect(() => {
if (!hasVisibleLyrics) return undefined
const parent = frameRef.current?.parentElement
if (!parent) return undefined
const handleSidebarWidth = (event) => {
const next = clampSidebarWidth(event.detail?.width)
setSidebarWidth((current) => (current === next ? current : next))
const updateContentWidth = (nextWidth) => {
const numeric = Number(nextWidth)
if (!Number.isFinite(numeric) || numeric <= 0) return
const rounded = Math.round(numeric)
setContentWidth((current) => (current === rounded ? current : rounded))
}
setSidebarWidth(loadSidebarWidth())
window.addEventListener(LYRICS_SIDEBAR_WIDTH_EVENT, handleSidebarWidth)
return () =>
window.removeEventListener(LYRICS_SIDEBAR_WIDTH_EVENT, handleSidebarWidth)
const measureParent = () => {
updateContentWidth(parent.clientWidth)
}
measureParent()
const ResizeObserverConstructor = window.ResizeObserver
if (!ResizeObserverConstructor) {
window.addEventListener('resize', measureParent)
return () => window.removeEventListener('resize', measureParent)
}
const resizeObserver = new ResizeObserverConstructor((entries) => {
const entry = entries.find((candidate) => candidate.target === parent)
const borderBoxSize = Array.isArray(entry?.borderBoxSize)
? entry.borderBoxSize[0]
: entry?.borderBoxSize
updateContentWidth(
borderBoxSize?.inlineSize ||
entry?.target?.clientWidth ||
entry?.contentRect?.width,
)
})
resizeObserver.observe(parent)
return () => resizeObserver.disconnect()
}, [])
useEffect(() => {
if (hasVisibleLyrics) {
setPreferredSidebarWidth(loadSidebarWidth())
}
}, [hasVisibleLyrics])
useEffect(() => {
const previousSidebarOpen = previousSidebarOpenRef.current
previousSidebarOpenRef.current = sidebarOpen
if (previousSidebarOpen == null || previousSidebarOpen === sidebarOpen) {
return
}
if (pendingSidebarStateRef.current === sidebarOpen) {
pendingSidebarStateRef.current = null
return
}
if (hasVisibleLyrics) {
manualSidebarOverrideRef.current = true
autoCollapsedRef.current = false
}
}, [hasVisibleLyrics, sidebarOpen])
useEffect(() => {
if (!hasVisibleLyrics) {
if (
autoCollapsedRef.current &&
!manualSidebarOverrideRef.current &&
!sidebarOpen
) {
autoCollapsedRef.current = false
pendingSidebarStateRef.current = true
dispatch(setSidebarVisibility(true))
}
manualSidebarOverrideRef.current = false
return
}
if (
manualSidebarOverrideRef.current ||
!Number.isFinite(contentWidth) ||
contentWidth <= 0
) {
return
}
if (sidebarOpen && contentWidth < MIN_CONTENT_WITH_LYRICS) {
autoCollapsedRef.current = true
pendingSidebarStateRef.current = false
dispatch(setSidebarVisibility(false))
return
}
}, [contentWidth, dispatch, hasVisibleLyrics, sidebarOpen])
const handleSidebarWidthChange = useCallback(
(nextWidth, { persist = false } = {}) => {
const resolvedWidth = clampSidebarWidth(nextWidth, sidebarMaxWidth)
setPreferredSidebarWidth(resolvedWidth)
if (persist) saveSidebarWidth(resolvedWidth)
},
[sidebarMaxWidth],
)
return (
<div
ref={frameRef}
className={classes.frame}
data-testid="lyrics-layout-frame"
data-lyrics-sidebar-visible={hasVisibleLyrics ? 'true' : 'false'}
@ -47,7 +156,14 @@ const LyricsLayoutFrame = ({ children }) => {
}}
>
{children}
{desktopLyricsProps && <LyricsSidebar {...desktopLyricsProps} />}
{desktopLyricsProps && (
<LyricsSidebar
{...desktopLyricsProps}
width={sidebarWidth}
maxWidth={sidebarMaxWidth}
onWidthChange={handleSidebarWidthChange}
/>
)}
</div>
)
}

View file

@ -1,12 +1,25 @@
import React, { useEffect } from 'react'
import { act, cleanup, render, screen } from '@testing-library/react'
import {
act,
cleanup,
fireEvent,
render,
screen,
waitFor,
} from '@testing-library/react'
import { ThemeProvider, createTheme } from '@material-ui/core/styles'
import { setSidebarVisibility } from 'react-admin'
import { Provider } from 'react-redux'
import { createStore } from 'redux'
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
LyricsLayoutProvider,
useLyricsLayout,
} from '../audioplayer/LyricsLayoutContext'
import { LYRICS_SIDEBAR_TRANSITION_MS } from '../audioplayer/lyricsSidebarWidth'
import {
LYRICS_SIDEBAR_STORAGE_KEY,
LYRICS_SIDEBAR_TRANSITION_MS,
} from '../audioplayer/lyricsSidebarWidth'
import LyricsLayoutFrame from './LyricsLayoutFrame'
const theme = createTheme({
@ -32,6 +45,61 @@ const lyricsProps = {
onTogglePronunciation: vi.fn(),
}
const originalResizeObserver = window.ResizeObserver
const resizeObservers = []
class TestResizeObserver {
constructor(callback) {
this.callback = callback
this.target = null
this.disconnected = false
resizeObservers.push(this)
}
observe(target) {
this.target = target
}
disconnect() {
this.disconnected = true
}
}
const notifyContentWidth = (width) => {
act(() => {
resizeObservers.forEach((observer) => {
if (observer.disconnected || !observer.target) return
observer.callback([
{
target: observer.target,
contentRect: { width },
borderBoxSize: [{ inlineSize: width }],
},
])
})
})
}
const sidebarVisibilityActionType = setSidebarVisibility(true).type
const createLayoutStore = (sidebarOpen = false) =>
createStore(
(state, action) => {
if (action.type !== sidebarVisibilityActionType) return state
return {
...state,
admin: {
...state.admin,
ui: {
...state.admin.ui,
sidebarOpen: action.payload,
},
},
}
},
{ admin: { ui: { sidebarOpen } } },
)
const PublishLyricsProps = ({ props }) => {
const { setDesktopLyricsProps } = useLyricsLayout()
@ -43,22 +111,27 @@ const PublishLyricsProps = ({ props }) => {
return null
}
const frame = (props) => (
<ThemeProvider theme={theme}>
<LyricsLayoutProvider>
<PublishLyricsProps props={props} />
<LyricsLayoutFrame>
<div data-testid="route-content">Albums grid</div>
</LyricsLayoutFrame>
</LyricsLayoutProvider>
</ThemeProvider>
const frame = (props, store) => (
<Provider store={store}>
<ThemeProvider theme={theme}>
<LyricsLayoutProvider>
<PublishLyricsProps props={props} />
<LyricsLayoutFrame>
<div data-testid="route-content">Albums grid</div>
</LyricsLayoutFrame>
</LyricsLayoutProvider>
</ThemeProvider>
</Provider>
)
const renderFrame = (props) => {
const view = render(frame(props))
const renderFrame = (props, { sidebarOpen = false, store } = {}) => {
window.ResizeObserver = TestResizeObserver
const layoutStore = store || createLayoutStore(sidebarOpen)
const view = render(frame(props, layoutStore))
return {
...view,
rerenderFrame: (nextProps) => view.rerender(frame(nextProps)),
store: layoutStore,
rerenderFrame: (nextProps) => view.rerender(frame(nextProps, layoutStore)),
}
}
@ -66,6 +139,9 @@ describe('<LyricsLayoutFrame />', () => {
afterEach(() => {
vi.useRealTimers()
cleanup()
localStorage.clear()
resizeObservers.splice(0)
window.ResizeObserver = originalResizeObserver
})
it('reserves a native right-hand lyrics pane beside route content', async () => {
@ -82,6 +158,186 @@ describe('<LyricsLayoutFrame />', () => {
expect(window.getComputedStyle(sidebar).position).toBe('fixed')
})
it('caps a persisted width against the measured route reserve without overwriting it', async () => {
localStorage.setItem(LYRICS_SIDEBAR_STORAGE_KEY, '520')
renderFrame(lyricsProps)
const sidebar = await screen.findByTestId('lyrics-sidebar')
const resizer = screen.getByTestId('lyrics-sidebar-resizer')
notifyContentWidth(755)
expect(screen.getByTestId('lyrics-layout-frame')).toHaveStyle({
marginRight: '435px',
})
expect(sidebar).toHaveStyle({ width: '435px' })
expect(resizer).toHaveAttribute('aria-valuemax', '435')
expect(resizer).toHaveAttribute('aria-valuenow', '435')
expect(localStorage.getItem(LYRICS_SIDEBAR_STORAGE_KEY)).toBe('520')
notifyContentWidth(840)
expect(screen.getByTestId('lyrics-layout-frame')).toHaveStyle({
marginRight: '520px',
})
expect(sidebar).toHaveStyle({ width: '520px' })
expect(resizer).toHaveAttribute('aria-valuemax', '520')
expect(resizer).toHaveAttribute('aria-valuenow', '520')
expect(localStorage.getItem(LYRICS_SIDEBAR_STORAGE_KEY)).toBe('520')
})
it('keeps the layout width reserved while the queue obscures lyrics', async () => {
localStorage.setItem(LYRICS_SIDEBAR_STORAGE_KEY, '520')
renderFrame({ ...lyricsProps, obscuredByQueue: true })
const sidebar = await screen.findByTestId('lyrics-sidebar')
notifyContentWidth(755)
expect(screen.getByTestId('lyrics-layout-frame')).toHaveStyle({
marginRight: '435px',
})
expect(sidebar).toHaveStyle({
width: '435px',
transform: 'translateX(0)',
pointerEvents: 'none',
})
expect(sidebar).toHaveAttribute('aria-hidden', 'true')
expect(sidebar).toHaveAttribute('inert')
expect(screen.getByTestId('karaoke-lyrics-panel')).toBeInTheDocument()
})
it('collapses an open app menu only when the minimum pane cannot fit', async () => {
localStorage.setItem(LYRICS_SIDEBAR_STORAGE_KEY, '520')
const { rerenderFrame, store } = renderFrame(lyricsProps, {
sidebarOpen: true,
})
await screen.findByTestId('lyrics-sidebar')
notifyContentWidth(570)
await waitFor(() =>
expect(store.getState().admin.ui.sidebarOpen).toBe(false),
)
expect(screen.getByTestId('lyrics-layout-frame')).toHaveStyle({
marginRight: '300px',
})
expect(screen.getByTestId('lyrics-sidebar-resizer')).toHaveAttribute(
'aria-valuemax',
'300',
)
notifyContentWidth(755)
expect(screen.getByTestId('lyrics-layout-frame')).toHaveStyle({
marginRight: '435px',
})
expect(store.getState().admin.ui.sidebarOpen).toBe(false)
rerenderFrame({ ...lyricsProps, visible: false })
await waitFor(() =>
expect(store.getState().admin.ui.sidebarOpen).toBe(true),
)
})
it('keeps the app menu open at the 960px and 1280px layout caps', async () => {
localStorage.setItem(LYRICS_SIDEBAR_STORAGE_KEY, '520')
const { store } = renderFrame(lyricsProps, { sidebarOpen: true })
await screen.findByTestId('lyrics-sidebar')
notifyContentWidth(720)
expect(store.getState().admin.ui.sidebarOpen).toBe(true)
expect(screen.getByTestId('lyrics-layout-frame')).toHaveStyle({
marginRight: '400px',
})
expect(screen.getByTestId('lyrics-sidebar-resizer')).toHaveAttribute(
'aria-valuemax',
'400',
)
notifyContentWidth(1040)
expect(screen.getByTestId('lyrics-layout-frame')).toHaveStyle({
marginRight: '520px',
})
expect(screen.getByTestId('lyrics-sidebar-resizer')).toHaveAttribute(
'aria-valuemax',
'520',
)
})
it('keeps an automatic collapse until lyrics close when space returns', async () => {
const { rerenderFrame, store } = renderFrame(lyricsProps, {
sidebarOpen: true,
})
await screen.findByTestId('lyrics-sidebar')
notifyContentWidth(570)
await waitFor(() =>
expect(store.getState().admin.ui.sidebarOpen).toBe(false),
)
notifyContentWidth(1040)
expect(store.getState().admin.ui.sidebarOpen).toBe(false)
rerenderFrame({ ...lyricsProps, visible: false })
await waitFor(() =>
expect(store.getState().admin.ui.sidebarOpen).toBe(true),
)
})
it('does not restore the menu after a manual override', async () => {
const { rerenderFrame, store } = renderFrame(lyricsProps, {
sidebarOpen: true,
})
await screen.findByTestId('lyrics-sidebar')
notifyContentWidth(570)
await waitFor(() =>
expect(store.getState().admin.ui.sidebarOpen).toBe(false),
)
act(() => {
store.dispatch(setSidebarVisibility(true))
})
await waitFor(() =>
expect(store.getState().admin.ui.sidebarOpen).toBe(true),
)
act(() => {
store.dispatch(setSidebarVisibility(false))
})
await waitFor(() =>
expect(store.getState().admin.ui.sidebarOpen).toBe(false),
)
rerenderFrame({ ...lyricsProps, visible: false })
expect(store.getState().admin.ui.sidebarOpen).toBe(false)
})
it('persists deliberate keyboard resizing at the measured cap', async () => {
localStorage.setItem(LYRICS_SIDEBAR_STORAGE_KEY, '520')
renderFrame(lyricsProps)
await screen.findByTestId('lyrics-sidebar')
notifyContentWidth(755)
const resizer = screen.getByTestId('lyrics-sidebar-resizer')
fireEvent.keyDown(resizer, { key: 'Home' })
expect(screen.getByTestId('lyrics-layout-frame')).toHaveStyle({
marginRight: '300px',
})
expect(localStorage.getItem(LYRICS_SIDEBAR_STORAGE_KEY)).toBe('300')
fireEvent.keyDown(resizer, { key: 'End' })
expect(screen.getByTestId('lyrics-layout-frame')).toHaveStyle({
marginRight: '435px',
})
expect(resizer).toHaveAttribute('aria-valuenow', '435')
expect(localStorage.getItem(LYRICS_SIDEBAR_STORAGE_KEY)).toBe('435')
})
it('releases the layout frame after the sidebar exit transition', async () => {
vi.useFakeTimers()
const { rerenderFrame } = renderFrame(lyricsProps)