mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-12 20:37:12 +02:00
fix(ui): harden responsive lyrics layout and accessibility
Signed-off-by: ranokay <github@ranokay.com>
This commit is contained in:
parent
79b920cc03
commit
e06fcbbaeb
19 changed files with 339 additions and 111 deletions
|
|
@ -7,11 +7,12 @@ import { decisionService } from '../transcode'
|
|||
import useStyle from './styles'
|
||||
import { useDrag } from 'react-dnd'
|
||||
import { DraggableTypes } from '../consts'
|
||||
import { PLAYER_DESKTOP_MEDIA_QUERY } from './playerBreakpoints'
|
||||
|
||||
const AudioTitle = React.memo(({ audioInfo, gainInfo, isMobile }) => {
|
||||
const classes = useStyle()
|
||||
const className = classes.audioTitle
|
||||
const isDesktop = useMediaQuery('(min-width:810px)')
|
||||
const isDesktop = useMediaQuery(PLAYER_DESKTOP_MEDIA_QUERY)
|
||||
|
||||
const song = audioInfo.song
|
||||
const [, dragSongRef] = useDrag(
|
||||
|
|
|
|||
|
|
@ -26,7 +26,6 @@ import {
|
|||
KARAOKE_LINE_MOTION_EASING,
|
||||
KARAOKE_MANUAL_SCROLL_PAUSE_MS,
|
||||
KARAOKE_SCROLLBAR_VISIBLE_MS,
|
||||
KARAOKE_TRANSLATION_OPACITY,
|
||||
} from './lyricsKaraokeConstants'
|
||||
import {
|
||||
animateScrollTop,
|
||||
|
|
@ -38,8 +37,7 @@ import { finiteTime } from './lyricsTimeline'
|
|||
import useLyricsTimeline from './useLyricsTimeline'
|
||||
|
||||
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
|
||||
const KARAOKE_AUX_LINE_TRANSITION = `color ${KARAOKE_LINE_OPACITY_MS}ms ${KARAOKE_EASING}, -webkit-text-fill-color ${KARAOKE_LINE_OPACITY_MS}ms ${KARAOKE_EASING}`
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
root: {
|
||||
|
|
@ -135,7 +133,7 @@ const useStyles = makeStyles((theme) => ({
|
|||
},
|
||||
'&[data-raised="true"][data-line-motion="character"] $translationLine': {
|
||||
transform: `translateY(-${KARAOKE_LINE_LIFT_PX}px)`,
|
||||
transition: `${KARAOKE_LAYER_OPACITY_TRANSITION}, transform ${KARAOKE_LINE_ENTER_MS}ms ${KARAOKE_LINE_MOTION_EASING}`,
|
||||
transition: `${KARAOKE_AUX_LINE_TRANSITION}, transform ${KARAOKE_LINE_ENTER_MS}ms ${KARAOKE_LINE_MOTION_EASING}`,
|
||||
},
|
||||
'&[data-active="true"]': {
|
||||
'--lyrics-main-current-color':
|
||||
|
|
@ -198,18 +196,16 @@ const useStyles = makeStyles((theme) => ({
|
|||
overflowWrap: 'anywhere',
|
||||
whiteSpace: 'pre-wrap',
|
||||
letterSpacing: 0,
|
||||
opacity: KARAOKE_TRANSLATION_IDLE_OPACITY,
|
||||
color: 'var(--lyrics-translation-active-color, currentColor)',
|
||||
WebkitTextFillColor: 'var(--lyrics-translation-active-color, currentColor)',
|
||||
transition: KARAOKE_LAYER_OPACITY_TRANSITION,
|
||||
opacity: 1,
|
||||
color: 'var(--lyrics-translation-current-color, currentColor)',
|
||||
WebkitTextFillColor:
|
||||
'var(--lyrics-translation-current-color, currentColor)',
|
||||
transition: KARAOKE_AUX_LINE_TRANSITION,
|
||||
'@media (prefers-reduced-motion: reduce)': {
|
||||
transition: 'none',
|
||||
transform: 'none',
|
||||
},
|
||||
},
|
||||
activeAuxLine: {
|
||||
opacity: KARAOKE_TRANSLATION_OPACITY,
|
||||
},
|
||||
stackedToken: {
|
||||
display: 'inline-flex',
|
||||
flexDirection: 'column',
|
||||
|
|
@ -512,7 +508,7 @@ const LyricsPanel = ({
|
|||
return {
|
||||
opacity: 1,
|
||||
color: sourceColor,
|
||||
'--lyrics-active-color': sourceColor,
|
||||
'--lyrics-active-color': colors.main,
|
||||
}
|
||||
}
|
||||
if (!hasTimedMainLines) {
|
||||
|
|
@ -892,9 +888,7 @@ const LyricsPanel = ({
|
|||
lineIndex={idx}
|
||||
line={buildSynchronizedTranslationLine(line, trLine)}
|
||||
nextLineStart={null}
|
||||
className={clsx(classes.auxLine, classes.translationLine, {
|
||||
[classes.activeAuxLine]: isStaticLine || isActiveLine,
|
||||
})}
|
||||
className={clsx(classes.auxLine, classes.translationLine)}
|
||||
style={layerStyles.translation}
|
||||
tokenClassName={classes.token}
|
||||
rowKey="translation"
|
||||
|
|
|
|||
|
|
@ -16,10 +16,9 @@ import {
|
|||
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,
|
||||
TOKEN_FUTURE_ALPHA,
|
||||
} from './lyricsKaraokeConstants'
|
||||
|
||||
const theme = createTheme({
|
||||
|
|
@ -44,6 +43,62 @@ const renderPanel = (props, selectedTheme = theme) => {
|
|||
}
|
||||
}
|
||||
|
||||
const parseCssColor = (value) => {
|
||||
const hex = String(value).match(/^#([0-9a-f]{6})$/i)
|
||||
if (hex) {
|
||||
return {
|
||||
channels: [
|
||||
parseInt(hex[1].slice(0, 2), 16),
|
||||
parseInt(hex[1].slice(2, 4), 16),
|
||||
parseInt(hex[1].slice(4, 6), 16),
|
||||
],
|
||||
alpha: 1,
|
||||
}
|
||||
}
|
||||
|
||||
const rgb = String(value).match(
|
||||
/^rgba?\(\s*(\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\s*\)$/i,
|
||||
)
|
||||
if (!rgb) throw new Error(`Unsupported CSS color: ${value}`)
|
||||
return {
|
||||
channels: rgb.slice(1, 4).map(Number),
|
||||
alpha: rgb[4] == null ? 1 : Number(rgb[4]),
|
||||
}
|
||||
}
|
||||
|
||||
const compositeColor = (foreground, background) => {
|
||||
const fg = parseCssColor(foreground)
|
||||
const bg = parseCssColor(background)
|
||||
return fg.channels.map(
|
||||
(channel, index) =>
|
||||
channel * fg.alpha + bg.channels[index] * (1 - fg.alpha),
|
||||
)
|
||||
}
|
||||
|
||||
const relativeLuminance = (channels) =>
|
||||
channels
|
||||
.map((channel) => channel / 255)
|
||||
.map((channel) =>
|
||||
channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4,
|
||||
)
|
||||
.reduce(
|
||||
(total, channel, index) =>
|
||||
total + channel * [0.2126, 0.7152, 0.0722][index],
|
||||
0,
|
||||
)
|
||||
|
||||
const contrastRatio = (foreground, background) => {
|
||||
const foregroundLuminance = relativeLuminance(
|
||||
compositeColor(foreground, background),
|
||||
)
|
||||
const backgroundLuminance = relativeLuminance(
|
||||
parseCssColor(background).channels,
|
||||
)
|
||||
const lighter = Math.max(foregroundLuminance, backgroundLuminance)
|
||||
const darker = Math.min(foregroundLuminance, backgroundLuminance)
|
||||
return (lighter + 0.05) / (darker + 0.05)
|
||||
}
|
||||
|
||||
const mainLyric = {
|
||||
synced: true,
|
||||
line: [{ start: 0, end: 1000, value: 'Main line' }],
|
||||
|
|
@ -292,7 +347,7 @@ describe('<LyricsPanel />', () => {
|
|||
expect(pronunciation.style.color).toBe('')
|
||||
})
|
||||
|
||||
it('uses one shared opacity animation for every static line layer', () => {
|
||||
it('uses one shared state transition for every static line layer', () => {
|
||||
renderPanel({
|
||||
mainLyric,
|
||||
pronunciationLyric: {
|
||||
|
|
@ -320,16 +375,17 @@ describe('<LyricsPanel />', () => {
|
|||
expect(row).toHaveAttribute('data-layer-animation', 'shared-opacity')
|
||||
expect(row).toHaveAttribute('data-tokenized', 'false')
|
||||
expect(row.style.opacity).toBe('')
|
||||
expect(window.getComputedStyle(row).transition).toContain(
|
||||
`opacity ${KARAOKE_LINE_OPACITY_MS}ms`,
|
||||
)
|
||||
})
|
||||
expect(window.getComputedStyle(mainRow).transition).toContain(
|
||||
`opacity ${KARAOKE_LINE_OPACITY_MS}ms`,
|
||||
)
|
||||
expect(window.getComputedStyle(translationRow).transition).toContain(
|
||||
`color ${KARAOKE_LINE_OPACITY_MS}ms`,
|
||||
)
|
||||
pronunciation.forEach((token) =>
|
||||
expect(token).toHaveAttribute('data-timed', 'false'),
|
||||
)
|
||||
expect(window.getComputedStyle(translationRow).opacity).toBe(
|
||||
String(KARAOKE_TRANSLATION_OPACITY),
|
||||
)
|
||||
expect(window.getComputedStyle(translationRow).opacity).toBe('1')
|
||||
})
|
||||
|
||||
it('raises a line once and keeps it elevated after release', () => {
|
||||
|
|
@ -747,68 +803,51 @@ describe('<LyricsPanel />', () => {
|
|||
)
|
||||
})
|
||||
|
||||
it('dims translations relative to their semantic theme color', () => {
|
||||
const sameTextTheme = createTheme({
|
||||
palette: {
|
||||
primary: { main: '#ffffff' },
|
||||
text: { primary: '#ffffff', secondary: '#ffffff' },
|
||||
},
|
||||
})
|
||||
renderPanel(
|
||||
{
|
||||
mainLyric: {
|
||||
synced: true,
|
||||
line: [
|
||||
{ start: 0, end: 1000, value: 'Active' },
|
||||
{ start: 1000, end: 2000, value: 'Future' },
|
||||
],
|
||||
},
|
||||
translationLyric: {
|
||||
synced: true,
|
||||
line: [
|
||||
{ start: 0, end: 1000, value: 'Activa' },
|
||||
{ start: 1000, end: 2000, value: 'Futura' },
|
||||
],
|
||||
},
|
||||
showTranslation: true,
|
||||
audioInstance: { currentTime: 0.5, paused: true },
|
||||
},
|
||||
sameTextTheme,
|
||||
)
|
||||
it.each(['light', 'dark'])(
|
||||
'keeps future tokens and translations readable in the %s theme',
|
||||
(type) => {
|
||||
const accessibleTheme = createTheme({ palette: { type } })
|
||||
const background = accessibleTheme.palette.background.default
|
||||
|
||||
const groups = screen.getAllByTestId('lyrics-line-group')
|
||||
const activeTranslation = screen
|
||||
.getByText('Activa')
|
||||
.closest('[data-tokenized]')
|
||||
const futureTranslation = screen
|
||||
.getByText('Futura')
|
||||
.closest('[data-tokenized]')
|
||||
groups.forEach((group) => {
|
||||
renderPanel(
|
||||
{
|
||||
mainLyric: tokenizedMainLyric,
|
||||
translationLyric: {
|
||||
synced: true,
|
||||
line: [{ start: 0, end: 1000, value: 'Readable translation' }],
|
||||
},
|
||||
showTranslation: true,
|
||||
audioInstance: { currentTime: 0.25, paused: true },
|
||||
},
|
||||
accessibleTheme,
|
||||
)
|
||||
|
||||
const group = screen.getByTestId('lyrics-line-group')
|
||||
const translation = screen
|
||||
.getByText('Readable translation')
|
||||
.closest('[data-tokenized]')
|
||||
const token = screen.getAllByTestId('lyrics-token')[0]
|
||||
const gradientNode = [token, ...token.querySelectorAll('*')].find(
|
||||
(node) => node.style.backgroundImage.includes('linear-gradient'),
|
||||
)
|
||||
const gradientColors = Array.from(
|
||||
gradientNode.style.backgroundImage.matchAll(/rgba?\([^)]+\)/g),
|
||||
(match) => match[0],
|
||||
)
|
||||
const futureColor = gradientColors.at(-1)
|
||||
|
||||
expect(
|
||||
group.style.getPropertyValue('--lyrics-translation-idle-color'),
|
||||
).toBe(accessibleTheme.palette.text.secondary)
|
||||
expect(
|
||||
group.style.getPropertyValue('--lyrics-translation-active-color'),
|
||||
).toBe('#ffffff')
|
||||
})
|
||||
expect(groups[0]).toHaveAttribute('data-active', 'true')
|
||||
expect(groups[1]).toHaveAttribute('data-active', 'false')
|
||||
const layerOpacity = (group) =>
|
||||
window
|
||||
.getComputedStyle(group)
|
||||
.getPropertyValue('--lyrics-layer-opacity')
|
||||
.trim()
|
||||
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(
|
||||
KARAOKE_IDLE_LAYER_OPACITY * KARAOKE_TRANSLATION_OPACITY,
|
||||
)
|
||||
;[activeTranslation, futureTranslation].forEach((translation) =>
|
||||
expect(window.getComputedStyle(translation).transition).toContain(
|
||||
`opacity ${KARAOKE_LINE_OPACITY_MS}ms`,
|
||||
),
|
||||
)
|
||||
})
|
||||
).toBe(accessibleTheme.palette.text.primary)
|
||||
expect(window.getComputedStyle(translation).opacity).toBe('1')
|
||||
expect(parseCssColor(futureColor).alpha).toBe(TOKEN_FUTURE_ALPHA)
|
||||
expect(contrastRatio(futureColor, background)).toBeGreaterThanOrEqual(4.5)
|
||||
expect(
|
||||
contrastRatio(accessibleTheme.palette.text.secondary, background),
|
||||
).toBeGreaterThanOrEqual(4.5)
|
||||
},
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import {
|
|||
saveSidebarWidth,
|
||||
} from './lyricsSidebarWidth'
|
||||
import useEnterExitTransition from './useEnterExitTransition'
|
||||
import useRestoreFocusOnExit from './useRestoreFocusOnExit'
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
sidebar: {
|
||||
|
|
@ -97,6 +98,7 @@ const LyricsSidebar = ({
|
|||
loading = false,
|
||||
error = null,
|
||||
labels = {},
|
||||
returnFocusRef,
|
||||
}) => {
|
||||
const [width, setWidth] = useState(loadSidebarWidth)
|
||||
const sidebarRef = useRef(null)
|
||||
|
|
@ -110,6 +112,12 @@ const LyricsSidebar = ({
|
|||
const [isResizing, setIsResizing] = useState(false)
|
||||
const classes = useStyles()
|
||||
|
||||
useRestoreFocusOnExit({
|
||||
surfaceRef: sidebarRef,
|
||||
entered,
|
||||
returnFocusRef,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
widthRef.current = width
|
||||
}, [width])
|
||||
|
|
@ -122,14 +130,6 @@ const LyricsSidebar = ({
|
|||
[],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (entered || typeof document === 'undefined') return
|
||||
const activeElement = document.activeElement
|
||||
if (activeElement && sidebarRef.current?.contains(activeElement)) {
|
||||
activeElement.blur()
|
||||
}
|
||||
}, [entered])
|
||||
|
||||
const applyWidth = useCallback((next) => {
|
||||
const resolvedWidth = clampSidebarWidth(
|
||||
typeof next === 'function' ? next(widthRef.current) : next,
|
||||
|
|
@ -255,6 +255,7 @@ const LyricsSidebar = ({
|
|||
}}
|
||||
aria-label={labels.title || 'Lyrics'}
|
||||
aria-hidden={!entered}
|
||||
inert={entered ? undefined : ''}
|
||||
data-resizing={isResizing ? 'true' : 'false'}
|
||||
>
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -57,6 +57,9 @@ describe('<LyricsSidebar />', () => {
|
|||
cleanup()
|
||||
localStorage.clear()
|
||||
document.body.className = ''
|
||||
document
|
||||
.querySelectorAll('[data-lyrics-return-target]')
|
||||
.forEach((node) => node.remove())
|
||||
Object.defineProperty(window, 'PointerEvent', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
|
|
@ -78,18 +81,26 @@ describe('<LyricsSidebar />', () => {
|
|||
|
||||
it('keeps the sidebar mounted while sliding closed', () => {
|
||||
vi.useFakeTimers()
|
||||
const { rerender } = renderSidebar()
|
||||
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 resizer = screen.getByTestId('lyrics-sidebar-resizer')
|
||||
expect(sidebar).not.toHaveAttribute('inert')
|
||||
resizer.focus()
|
||||
|
||||
rerender(sidebarView({ visible: false }))
|
||||
rerender(sidebarView({ visible: false, returnFocusRef }))
|
||||
|
||||
expect(screen.getByTestId('lyrics-sidebar')).toHaveStyle({
|
||||
expect(sidebar).toHaveStyle({
|
||||
transform: 'translateX(100%)',
|
||||
opacity: '0',
|
||||
})
|
||||
expect(sidebar).toHaveAttribute('aria-hidden', 'true')
|
||||
expect(sidebar).toHaveAttribute('inert')
|
||||
expect(document.body.className).toBe('')
|
||||
expect(document.activeElement).not.toBe(resizer)
|
||||
expect(document.activeElement).toBe(returnTarget)
|
||||
|
||||
vi.advanceTimersByTime(LYRICS_SIDEBAR_TRANSITION_MS)
|
||||
|
||||
|
|
@ -97,6 +108,20 @@ describe('<LyricsSidebar />', () => {
|
|||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('restores focus if a breakpoint unmounts the open sidebar', () => {
|
||||
const returnTarget = document.createElement('button')
|
||||
returnTarget.dataset.lyricsReturnTarget = 'true'
|
||||
document.body.appendChild(returnTarget)
|
||||
const returnFocusRef = { current: returnTarget }
|
||||
const { unmount } = renderSidebar({ returnFocusRef })
|
||||
const resizer = screen.getByTestId('lyrics-sidebar-resizer')
|
||||
resizer.focus()
|
||||
|
||||
unmount()
|
||||
|
||||
expect(document.activeElement).toBe(returnTarget)
|
||||
})
|
||||
|
||||
it('clamps persisted and keyboard-resized widths', () => {
|
||||
localStorage.setItem(LYRICS_SIDEBAR_STORAGE_KEY, '999')
|
||||
renderSidebar()
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import React, { useEffect, useState } from 'react'
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import useEnterExitTransition from './useEnterExitTransition'
|
||||
import useRestoreFocusOnExit from './useRestoreFocusOnExit'
|
||||
|
||||
export const MOBILE_KARAOKE_LYRICS_TRANSITION_MS = 260
|
||||
export const MOBILE_KARAOKE_LYRICS_HOST_SELECTOR =
|
||||
|
|
@ -14,7 +15,7 @@ const resolveMobileLyricsHost = () => {
|
|||
return document.querySelector(MOBILE_KARAOKE_LYRICS_HOST_SELECTOR)
|
||||
}
|
||||
|
||||
const MobileKaraokeLyricsPortal = ({ active, children }) => {
|
||||
const MobileKaraokeLyricsPortal = ({ active, children, returnFocusRef }) => {
|
||||
const { rendered, entered } = useEnterExitTransition(
|
||||
active,
|
||||
MOBILE_KARAOKE_LYRICS_TRANSITION_MS,
|
||||
|
|
@ -22,6 +23,14 @@ const MobileKaraokeLyricsPortal = ({ active, children }) => {
|
|||
const [host, setHost] = useState(() =>
|
||||
active ? resolveMobileLyricsHost() : null,
|
||||
)
|
||||
const layerRef = useRef(null)
|
||||
|
||||
useRestoreFocusOnExit({
|
||||
surfaceRef: layerRef,
|
||||
entered,
|
||||
returnFocusRef,
|
||||
surfaceKey: host,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof document === 'undefined') {
|
||||
|
|
@ -60,8 +69,10 @@ const MobileKaraokeLyricsPortal = ({ active, children }) => {
|
|||
return createPortal(
|
||||
<div
|
||||
className={MOBILE_KARAOKE_LYRICS_LAYER_CLASS}
|
||||
ref={layerRef}
|
||||
data-entered={entered ? 'true' : 'false'}
|
||||
aria-hidden={!entered}
|
||||
inert={entered ? undefined : ''}
|
||||
style={{ pointerEvents: entered ? 'auto' : 'none' }}
|
||||
>
|
||||
{children}
|
||||
|
|
|
|||
|
|
@ -49,8 +49,8 @@ const createHost = () => {
|
|||
return host
|
||||
}
|
||||
|
||||
const portal = (text, active = true) => (
|
||||
<MobileKaraokeLyricsPortal active={active}>
|
||||
const portal = (text, active = true, returnFocusRef) => (
|
||||
<MobileKaraokeLyricsPortal active={active} returnFocusRef={returnFocusRef}>
|
||||
<span>{text}</span>
|
||||
</MobileKaraokeLyricsPortal>
|
||||
)
|
||||
|
|
@ -101,6 +101,9 @@ describe('<MobileKaraokeLyricsPortal />', () => {
|
|||
expect(
|
||||
host.querySelector(`.${MOBILE_KARAOKE_LYRICS_LAYER_CLASS}`),
|
||||
).toHaveStyle({ pointerEvents: 'auto' })
|
||||
expect(
|
||||
host.querySelector(`.${MOBILE_KARAOKE_LYRICS_LAYER_CLASS}`),
|
||||
).not.toHaveAttribute('inert')
|
||||
|
||||
rerender(portal('Inline lyrics', false))
|
||||
|
||||
|
|
@ -113,6 +116,9 @@ describe('<MobileKaraokeLyricsPortal />', () => {
|
|||
expect(
|
||||
host.querySelector(`.${MOBILE_KARAOKE_LYRICS_LAYER_CLASS}`),
|
||||
).toHaveStyle({ pointerEvents: 'none' })
|
||||
expect(
|
||||
host.querySelector(`.${MOBILE_KARAOKE_LYRICS_LAYER_CLASS}`),
|
||||
).toHaveAttribute('inert')
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(MOBILE_KARAOKE_LYRICS_TRANSITION_MS)
|
||||
|
|
@ -123,6 +129,34 @@ describe('<MobileKaraokeLyricsPortal />', () => {
|
|||
expect(host).not.toHaveClass(MOBILE_KARAOKE_LYRICS_ENTERED_CLASS)
|
||||
})
|
||||
|
||||
it('makes the exiting layer inert and returns focus to the lyrics toggle', () => {
|
||||
vi.useFakeTimers()
|
||||
const host = createHost()
|
||||
const returnTarget = document.createElement('button')
|
||||
document.body.appendChild(returnTarget)
|
||||
const returnFocusRef = { current: returnTarget }
|
||||
const { rerender } = render(
|
||||
<MobileKaraokeLyricsPortal active returnFocusRef={returnFocusRef}>
|
||||
<button type="button">Layer control</button>
|
||||
</MobileKaraokeLyricsPortal>,
|
||||
)
|
||||
const layerControl = within(host).getByRole('button', {
|
||||
name: 'Layer control',
|
||||
})
|
||||
layerControl.focus()
|
||||
|
||||
rerender(
|
||||
<MobileKaraokeLyricsPortal active={false} returnFocusRef={returnFocusRef}>
|
||||
<button type="button">Layer control</button>
|
||||
</MobileKaraokeLyricsPortal>,
|
||||
)
|
||||
|
||||
const layer = host.querySelector(`.${MOBILE_KARAOKE_LYRICS_LAYER_CLASS}`)
|
||||
expect(layer).toHaveAttribute('aria-hidden', 'true')
|
||||
expect(layer).toHaveAttribute('inert')
|
||||
expect(document.activeElement).toBe(returnTarget)
|
||||
})
|
||||
|
||||
it('attaches when the mobile cover host appears after activation', async () => {
|
||||
vi.useFakeTimers()
|
||||
|
||||
|
|
|
|||
|
|
@ -36,6 +36,10 @@ import { calculateGain } from '../utils/calculateReplayGain'
|
|||
import { detectBrowserProfile, decisionService } from '../transcode'
|
||||
import usePlayerLyrics from './usePlayerLyrics'
|
||||
import { useLyricsLayout } from './LyricsLayoutContext'
|
||||
import {
|
||||
PLAYER_DESKTOP_MEDIA_QUERY,
|
||||
PLAYER_MOBILE_MATCH_MEDIA_QUERY,
|
||||
} from './playerBreakpoints'
|
||||
|
||||
const Player = () => {
|
||||
const theme = useCurrentTheme()
|
||||
|
|
@ -50,7 +54,7 @@ const Player = () => {
|
|||
const currentTrackIdRef = useRef(null)
|
||||
const stoppedRef = useRef(false)
|
||||
const [audioInstance, setAudioInstance] = useState(null)
|
||||
const isDesktop = useMediaQuery('(min-width:810px)')
|
||||
const isDesktop = useMediaQuery(PLAYER_DESKTOP_MEDIA_QUERY)
|
||||
const isMobilePlayer =
|
||||
/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(
|
||||
navigator.userAgent,
|
||||
|
|
@ -247,6 +251,7 @@ const Player = () => {
|
|||
glassBg: false,
|
||||
showThemeSwitch: false,
|
||||
showMediaSession: true,
|
||||
mobileMediaQuery: PLAYER_MOBILE_MATCH_MEDIA_QUERY,
|
||||
restartCurrentOnPrev: true,
|
||||
quietUpdate: true,
|
||||
defaultPosition: {
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import { LoveButton, useToggleLove } from '../common'
|
|||
import { openSaveQueueDialog } from '../actions'
|
||||
import { keyMap } from '../hotkeys'
|
||||
import { makeStyles } from '@material-ui/core/styles'
|
||||
import { PLAYER_DESKTOP_MEDIA_QUERY } from './playerBreakpoints'
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
toolbar: {
|
||||
|
|
@ -66,11 +67,12 @@ const PlayerToolbar = ({
|
|||
lyricsLoading = false,
|
||||
lyricsLabel = 'Toggle lyrics',
|
||||
lyricsLoadingLabel = 'Loading lyrics',
|
||||
lyricsToggleRef,
|
||||
}) => {
|
||||
const dispatch = useDispatch()
|
||||
const { data, loading } = useGetOne('song', id, { enabled: !!id && !isRadio })
|
||||
const [toggleLove, toggling] = useToggleLove('song', data)
|
||||
const isDesktop = useMediaQuery('(min-width:810px)')
|
||||
const isDesktop = useMediaQuery(PLAYER_DESKTOP_MEDIA_QUERY)
|
||||
const classes = useStyles()
|
||||
|
||||
const handlers = {
|
||||
|
|
@ -116,6 +118,7 @@ const PlayerToolbar = ({
|
|||
<Tooltip title={currentLyricsLabel}>
|
||||
<span>
|
||||
<IconButton
|
||||
ref={lyricsToggleRef}
|
||||
size={isDesktop ? 'small' : undefined}
|
||||
onClick={onToggleLyrics}
|
||||
disabled={!onToggleLyrics || lyricsUnavailable}
|
||||
|
|
|
|||
|
|
@ -106,11 +106,13 @@ describe('<PlayerToolbar />', () => {
|
|||
|
||||
it('toggles lyrics from an accessible toolbar button', () => {
|
||||
const onToggleLyrics = vi.fn()
|
||||
const lyricsToggleRef = React.createRef()
|
||||
render(
|
||||
<PlayerToolbar
|
||||
id="song-1"
|
||||
onToggleLyrics={onToggleLyrics}
|
||||
lyricsActive
|
||||
lyricsToggleRef={lyricsToggleRef}
|
||||
/>,
|
||||
)
|
||||
|
||||
|
|
@ -121,6 +123,7 @@ describe('<PlayerToolbar />', () => {
|
|||
)
|
||||
expect(lyricsButton).toHaveClass('MuiIconButton-colorPrimary')
|
||||
expect(lyricsButton).toHaveAttribute('aria-pressed', 'true')
|
||||
expect(lyricsToggleRef.current).toBe(lyricsButton)
|
||||
|
||||
fireEvent.click(lyricsButton)
|
||||
|
||||
|
|
|
|||
|
|
@ -16,7 +16,6 @@ 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_IDLE_LAYER_OPACITY = 0.49
|
||||
export const KARAOKE_LINE_RELEASE_MS = KARAOKE_LINE_OPACITY_MS
|
||||
export const KARAOKE_SCROLL_PRE_ROLL_MS = 320
|
||||
|
|
@ -25,7 +24,7 @@ export const KARAOKE_INLINE_ACTIVE_LINE_ANCHOR_RATIO = 0.42
|
|||
export const KARAOKE_SCROLL_SETTLE_PX = 2
|
||||
export const KARAOKE_AUX_LINE_HEIGHT = 1.18
|
||||
export const KARAOKE_EASING = 'cubic-bezier(0.22, 1, 0.36, 1)'
|
||||
export const TOKEN_FUTURE_ALPHA = 0.34
|
||||
export const TOKEN_FUTURE_ALPHA = 0.56
|
||||
export const TOKEN_ACTIVE_ALPHA = 1
|
||||
export const TOKEN_WIPE_SOFT_SPREAD_PCT = 34
|
||||
export const TOKEN_WIPE_EDGE_PCT = 12
|
||||
|
|
|
|||
17
ui/src/audioplayer/playerBreakpoints.js
Normal file
17
ui/src/audioplayer/playerBreakpoints.js
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { APP_SIDEBAR_OPEN_WIDTH } from '../layout/layoutWidths'
|
||||
import { LYRICS_SIDEBAR_MAX_WIDTH } from './lyricsSidebarWidth'
|
||||
|
||||
export const LYRICS_SIDEBAR_MIN_CONTENT_WIDTH = LYRICS_SIDEBAR_MAX_WIDTH
|
||||
export const PLAYER_DESKTOP_MIN_WIDTH =
|
||||
APP_SIDEBAR_OPEN_WIDTH +
|
||||
LYRICS_SIDEBAR_MAX_WIDTH +
|
||||
LYRICS_SIDEBAR_MIN_CONTENT_WIDTH
|
||||
export const PLAYER_MEDIA_QUERY_STEP = 0.05
|
||||
export const PLAYER_MOBILE_MAX_WIDTH =
|
||||
PLAYER_DESKTOP_MIN_WIDTH - PLAYER_MEDIA_QUERY_STEP
|
||||
export const LYRICS_SIDEBAR_DESKTOP_MIN_WIDTH = PLAYER_DESKTOP_MIN_WIDTH
|
||||
|
||||
export const PLAYER_DESKTOP_MEDIA_QUERY = `(min-width:${PLAYER_DESKTOP_MIN_WIDTH}px)`
|
||||
export const PLAYER_MOBILE_MATCH_MEDIA_QUERY = `(max-width:${PLAYER_MOBILE_MAX_WIDTH}px)`
|
||||
export const PLAYER_MOBILE_MEDIA_QUERY = `@media screen and ${PLAYER_MOBILE_MATCH_MEDIA_QUERY}`
|
||||
export const LYRICS_SIDEBAR_DESKTOP_MEDIA_QUERY = PLAYER_DESKTOP_MEDIA_QUERY
|
||||
43
ui/src/audioplayer/playerBreakpoints.test.js
Normal file
43
ui/src/audioplayer/playerBreakpoints.test.js
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { APP_SIDEBAR_OPEN_WIDTH } from '../layout/layoutWidths'
|
||||
import {
|
||||
LYRICS_SIDEBAR_DESKTOP_MEDIA_QUERY,
|
||||
LYRICS_SIDEBAR_DESKTOP_MIN_WIDTH,
|
||||
LYRICS_SIDEBAR_MIN_CONTENT_WIDTH,
|
||||
PLAYER_DESKTOP_MEDIA_QUERY,
|
||||
PLAYER_DESKTOP_MIN_WIDTH,
|
||||
PLAYER_MOBILE_MATCH_MEDIA_QUERY,
|
||||
PLAYER_MOBILE_MAX_WIDTH,
|
||||
PLAYER_MOBILE_MEDIA_QUERY,
|
||||
PLAYER_MEDIA_QUERY_STEP,
|
||||
} from './playerBreakpoints'
|
||||
import { LYRICS_SIDEBAR_MAX_WIDTH } from './lyricsSidebarWidth'
|
||||
|
||||
describe('player breakpoints', () => {
|
||||
it('keeps compact and desktop player ranges from overlapping', () => {
|
||||
expect(PLAYER_DESKTOP_MIN_WIDTH - PLAYER_MOBILE_MAX_WIDTH).toBeCloseTo(
|
||||
PLAYER_MEDIA_QUERY_STEP,
|
||||
)
|
||||
expect(PLAYER_DESKTOP_MEDIA_QUERY).toBe(
|
||||
`(min-width:${PLAYER_DESKTOP_MIN_WIDTH}px)`,
|
||||
)
|
||||
expect(PLAYER_MOBILE_MEDIA_QUERY).toBe(
|
||||
`@media screen and (max-width:${PLAYER_MOBILE_MAX_WIDTH}px)`,
|
||||
)
|
||||
expect(PLAYER_MOBILE_MATCH_MEDIA_QUERY).toBe(
|
||||
`(max-width:${PLAYER_MOBILE_MAX_WIDTH}px)`,
|
||||
)
|
||||
})
|
||||
|
||||
it('only opens the desktop lyrics sidebar when primary content still fits', () => {
|
||||
expect(LYRICS_SIDEBAR_DESKTOP_MEDIA_QUERY).toBe(
|
||||
`(min-width:${LYRICS_SIDEBAR_DESKTOP_MIN_WIDTH}px)`,
|
||||
)
|
||||
expect(
|
||||
LYRICS_SIDEBAR_DESKTOP_MIN_WIDTH -
|
||||
APP_SIDEBAR_OPEN_WIDTH -
|
||||
LYRICS_SIDEBAR_MAX_WIDTH,
|
||||
).toBeGreaterThanOrEqual(LYRICS_SIDEBAR_MIN_CONTENT_WIDTH)
|
||||
expect(LYRICS_SIDEBAR_DESKTOP_MIN_WIDTH).toBe(PLAYER_DESKTOP_MIN_WIDTH)
|
||||
})
|
||||
})
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import { makeStyles } from '@material-ui/core/styles'
|
||||
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)'
|
||||
|
|
@ -30,7 +31,7 @@ const useStyle = makeStyles(
|
|||
},
|
||||
player: {
|
||||
display: (props) => (props.visible ? 'block' : 'none'),
|
||||
'@media screen and (max-width:810px)': {
|
||||
[PLAYER_MOBILE_MEDIA_QUERY]: {
|
||||
'& .sound-operation': {
|
||||
display: 'none',
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import LyricsLayerControls from './LyricsLayerControls'
|
||||
import LyricsPanel from './LyricsPanel'
|
||||
import MobileKaraokeLyricsPortal from './MobileKaraokeLyricsPortal'
|
||||
|
|
@ -21,6 +21,7 @@ const usePlayerLyrics = ({
|
|||
const [lyricsRequested, setLyricsRequested] = useState(false)
|
||||
const [translationPreference, setTranslationPreference] = useState(null)
|
||||
const [pronunciationPreference, setPronunciationPreference] = useState(null)
|
||||
const lyricsToggleRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
setLyricsRequested(lyricsVisiblePreference)
|
||||
|
|
@ -103,6 +104,7 @@ const usePlayerLyrics = ({
|
|||
lyricsLoading,
|
||||
lyricsLabel: labels.toggle,
|
||||
lyricsLoadingLabel: labels.loading,
|
||||
lyricsToggleRef,
|
||||
}),
|
||||
[labels, lyricsLoading, lyricsToggleDisabled, lyricsVisible, toggleLyrics],
|
||||
)
|
||||
|
|
@ -123,6 +125,7 @@ const usePlayerLyrics = ({
|
|||
loading: lyricsLoading,
|
||||
error: lyricsError,
|
||||
labels,
|
||||
returnFocusRef: lyricsToggleRef,
|
||||
}),
|
||||
[
|
||||
audioInstance,
|
||||
|
|
@ -145,7 +148,10 @@ const usePlayerLyrics = ({
|
|||
|
||||
const mobileLyricsSurface = useMemo(
|
||||
() => (
|
||||
<MobileKaraokeLyricsPortal active={useInlineMobileLyrics}>
|
||||
<MobileKaraokeLyricsPortal
|
||||
active={useInlineMobileLyrics}
|
||||
returnFocusRef={lyricsToggleRef}
|
||||
>
|
||||
<LyricsPanel
|
||||
mainLyric={lyricLayers.main}
|
||||
translationLyric={lyricLayers.translation}
|
||||
|
|
|
|||
38
ui/src/audioplayer/useRestoreFocusOnExit.js
Normal file
38
ui/src/audioplayer/useRestoreFocusOnExit.js
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { useLayoutEffect } from 'react'
|
||||
|
||||
const restoreFocusFromSurface = (surface, returnFocusRef) => {
|
||||
if (!surface || typeof document === 'undefined') return
|
||||
|
||||
const activeElement = document.activeElement
|
||||
if (!activeElement || !surface.contains(activeElement)) return
|
||||
|
||||
const returnTarget = returnFocusRef?.current
|
||||
const canReturnFocus =
|
||||
returnTarget?.isConnected &&
|
||||
!returnTarget.disabled &&
|
||||
returnTarget.getAttribute?.('aria-disabled') !== 'true' &&
|
||||
typeof returnTarget.focus === 'function'
|
||||
|
||||
if (canReturnFocus) {
|
||||
returnTarget.focus()
|
||||
return
|
||||
}
|
||||
|
||||
activeElement.blur?.()
|
||||
}
|
||||
|
||||
const useRestoreFocusOnExit = ({
|
||||
surfaceRef,
|
||||
entered,
|
||||
returnFocusRef,
|
||||
surfaceKey,
|
||||
}) => {
|
||||
useLayoutEffect(() => {
|
||||
const surface = surfaceRef.current
|
||||
if (!entered) restoreFocusFromSurface(surface, returnFocusRef)
|
||||
|
||||
return () => restoreFocusFromSurface(surface, returnFocusRef)
|
||||
}, [entered, returnFocusRef, surfaceKey, surfaceRef])
|
||||
}
|
||||
|
||||
export default useRestoreFocusOnExit
|
||||
|
|
@ -11,6 +11,10 @@ import albumLists from '../album/albumLists'
|
|||
import PlaylistsSubMenu from './PlaylistsSubMenu'
|
||||
import LibrarySelector from '../common/LibrarySelector'
|
||||
import config from '../config'
|
||||
import {
|
||||
APP_SIDEBAR_CLOSED_WIDTH,
|
||||
APP_SIDEBAR_OPEN_WIDTH,
|
||||
} from './layoutWidths'
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
root: {
|
||||
|
|
@ -23,10 +27,10 @@ const useStyles = makeStyles((theme) => ({
|
|||
paddingBottom: (props) => (props.addPadding ? '80px' : '20px'),
|
||||
},
|
||||
open: {
|
||||
width: 240,
|
||||
width: APP_SIDEBAR_OPEN_WIDTH,
|
||||
},
|
||||
closed: {
|
||||
width: 55,
|
||||
width: APP_SIDEBAR_CLOSED_WIDTH,
|
||||
},
|
||||
active: {
|
||||
color: theme.palette.text.primary,
|
||||
|
|
|
|||
2
ui/src/layout/layoutWidths.js
Normal file
2
ui/src/layout/layoutWidths.js
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export const APP_SIDEBAR_OPEN_WIDTH = 240
|
||||
export const APP_SIDEBAR_CLOSED_WIDTH = 55
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
import { PLAYER_MOBILE_MAX_WIDTH } from '../audioplayer/playerBreakpoints'
|
||||
|
||||
const stylesheet = `
|
||||
html {
|
||||
scrollbar-width: none;
|
||||
|
|
@ -136,7 +138,7 @@ button[data-testid="save-queue-button"] {
|
|||
stroke: #fff;
|
||||
height: .8em;
|
||||
}
|
||||
@media screen and (max-width: 810px) {
|
||||
@media screen and (max-width: ${PLAYER_MOBILE_MAX_WIDTH}px) {
|
||||
.react-jinke-music-player-main .music-player-panel .panel-content .player-content .play-sounds .sounds-icon {
|
||||
margin-left: 5px;
|
||||
margin-right: 0;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue