fix(ui): harden responsive lyrics layout and accessibility

Signed-off-by: ranokay <github@ranokay.com>
This commit is contained in:
Yuuta 2026-07-29 09:22:19 +03:00 • committed by ranokay
commit fd80719a4d
No known key found for this signature in database
19 changed files with 339 additions and 111 deletions

View file

@ -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(

View file

@ -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"

View file

@ -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)
},
)
})

View file

@ -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

View file

@ -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()

View file

@ -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}

View file

@ -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()

View file

@ -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: {

View file

@ -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}

View file

@ -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)

View file

@ -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

View 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

View 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)
})
})

View file

@ -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',
},

View file

@ -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}

View 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

View file

@ -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,

View file

@ -0,0 +1,2 @@
export const APP_SIDEBAR_OPEN_WIDTH = 240
export const APP_SIDEBAR_CLOSED_WIDTH = 55

View file

@ -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;