diff --git a/ui/src/audioplayer/LyricsPanel.jsx b/ui/src/audioplayer/LyricsPanel.jsx
index 41483f6a2..d8f04dd1d 100644
--- a/ui/src/audioplayer/LyricsPanel.jsx
+++ b/ui/src/audioplayer/LyricsPanel.jsx
@@ -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 (
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 ? (
', () => {
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('
', () => {
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('
', () => {
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('
', () => {
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('
', () => {
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('
', () => {
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('
', () => {
).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)
},
)
})
diff --git a/ui/src/audioplayer/LyricsSidebar.jsx b/ui/src/audioplayer/LyricsSidebar.jsx
index 04f42512e..cb4aa5867 100644
--- a/ui/src/audioplayer/LyricsSidebar.jsx
+++ b/ui/src/audioplayer/LyricsSidebar.jsx
@@ -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 : ''}
>
{
+ const [width, setWidth] = useState(initialWidth)
+ const handleWidthChange = useCallback(
+ (nextWidth, options) => {
+ setWidth(nextWidth)
+ onWidthChange?.(nextWidth, options)
+ },
+ [onWidthChange],
+ )
+
+ return (
+
+ )
+}
+
const sidebarView = (props = {}) => (
- ', () => {
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('', () => {
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('', () => {
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('', () => {
)
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('', () => {
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('', () => {
)
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('', () => {
)
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', () => {
diff --git a/ui/src/audioplayer/MobileKaraokeLyricsPortal.jsx b/ui/src/audioplayer/MobileKaraokeLyricsPortal.jsx
index f077262f3..34750f84e 100644
--- a/ui/src/audioplayer/MobileKaraokeLyricsPortal.jsx
+++ b/ui/src/audioplayer/MobileKaraokeLyricsPortal.jsx
@@ -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}
,
diff --git a/ui/src/audioplayer/MobileKaraokeLyricsPortal.test.jsx b/ui/src/audioplayer/MobileKaraokeLyricsPortal.test.jsx
index 7d5522d6a..2676db7f5 100644
--- a/ui/src/audioplayer/MobileKaraokeLyricsPortal.test.jsx
+++ b/ui/src/audioplayer/MobileKaraokeLyricsPortal.test.jsx
@@ -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) => (
-