diff --git a/ui/src/audioplayer/AudioTitle.jsx b/ui/src/audioplayer/AudioTitle.jsx
index df37edfbb..340b8d730 100644
--- a/ui/src/audioplayer/AudioTitle.jsx
+++ b/ui/src/audioplayer/AudioTitle.jsx
@@ -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(
diff --git a/ui/src/audioplayer/LyricsPanel.jsx b/ui/src/audioplayer/LyricsPanel.jsx
index fa47bf519..a998b4c5d 100644
--- a/ui/src/audioplayer/LyricsPanel.jsx
+++ b/ui/src/audioplayer/LyricsPanel.jsx
@@ -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"
diff --git a/ui/src/audioplayer/LyricsPanel.test.jsx b/ui/src/audioplayer/LyricsPanel.test.jsx
index 3a0533382..0eb18386a 100644
--- a/ui/src/audioplayer/LyricsPanel.test.jsx
+++ b/ui/src/audioplayer/LyricsPanel.test.jsx
@@ -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('', () => {
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('', () => {
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('', () => {
)
})
- 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)
+ },
+ )
})
diff --git a/ui/src/audioplayer/LyricsSidebar.jsx b/ui/src/audioplayer/LyricsSidebar.jsx
index 0ad0aa908..04f42512e 100644
--- a/ui/src/audioplayer/LyricsSidebar.jsx
+++ b/ui/src/audioplayer/LyricsSidebar.jsx
@@ -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'}
>
', () => {
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('', () => {
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('', () => {
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()
diff --git a/ui/src/audioplayer/MobileKaraokeLyricsPortal.jsx b/ui/src/audioplayer/MobileKaraokeLyricsPortal.jsx
index f853cd977..f077262f3 100644
--- a/ui/src/audioplayer/MobileKaraokeLyricsPortal.jsx
+++ b/ui/src/audioplayer/MobileKaraokeLyricsPortal.jsx
@@ -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(
{children}
diff --git a/ui/src/audioplayer/MobileKaraokeLyricsPortal.test.jsx b/ui/src/audioplayer/MobileKaraokeLyricsPortal.test.jsx
index 3bc0d24da..7d5522d6a 100644
--- a/ui/src/audioplayer/MobileKaraokeLyricsPortal.test.jsx
+++ b/ui/src/audioplayer/MobileKaraokeLyricsPortal.test.jsx
@@ -49,8 +49,8 @@ const createHost = () => {
return host
}
-const portal = (text, active = true) => (
-
+const portal = (text, active = true, returnFocusRef) => (
+
{text}
)
@@ -101,6 +101,9 @@ describe('', () => {
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('', () => {
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('', () => {
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(
+
+
+ ,
+ )
+ const layerControl = within(host).getByRole('button', {
+ name: 'Layer control',
+ })
+ layerControl.focus()
+
+ rerender(
+
+
+ ,
+ )
+
+ 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()
diff --git a/ui/src/audioplayer/Player.jsx b/ui/src/audioplayer/Player.jsx
index 1b21f9592..5aa9fdaf3 100644
--- a/ui/src/audioplayer/Player.jsx
+++ b/ui/src/audioplayer/Player.jsx
@@ -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: {
diff --git a/ui/src/audioplayer/PlayerToolbar.jsx b/ui/src/audioplayer/PlayerToolbar.jsx
index 494c5bf98..e146bc238 100644
--- a/ui/src/audioplayer/PlayerToolbar.jsx
+++ b/ui/src/audioplayer/PlayerToolbar.jsx
@@ -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 = ({
', () => {
it('toggles lyrics from an accessible toolbar button', () => {
const onToggleLyrics = vi.fn()
+ const lyricsToggleRef = React.createRef()
render(
,
)
@@ -121,6 +123,7 @@ describe('', () => {
)
expect(lyricsButton).toHaveClass('MuiIconButton-colorPrimary')
expect(lyricsButton).toHaveAttribute('aria-pressed', 'true')
+ expect(lyricsToggleRef.current).toBe(lyricsButton)
fireEvent.click(lyricsButton)
diff --git a/ui/src/audioplayer/lyricsKaraokeConstants.js b/ui/src/audioplayer/lyricsKaraokeConstants.js
index 629ccfa6b..bb3976641 100644
--- a/ui/src/audioplayer/lyricsKaraokeConstants.js
+++ b/ui/src/audioplayer/lyricsKaraokeConstants.js
@@ -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
diff --git a/ui/src/audioplayer/playerBreakpoints.js b/ui/src/audioplayer/playerBreakpoints.js
new file mode 100644
index 000000000..16dde7777
--- /dev/null
+++ b/ui/src/audioplayer/playerBreakpoints.js
@@ -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
diff --git a/ui/src/audioplayer/playerBreakpoints.test.js b/ui/src/audioplayer/playerBreakpoints.test.js
new file mode 100644
index 000000000..c603665ad
--- /dev/null
+++ b/ui/src/audioplayer/playerBreakpoints.test.js
@@ -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)
+ })
+})
diff --git a/ui/src/audioplayer/styles.js b/ui/src/audioplayer/styles.js
index bc5283b0f..84c5e1483 100644
--- a/ui/src/audioplayer/styles.js
+++ b/ui/src/audioplayer/styles.js
@@ -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',
},
diff --git a/ui/src/audioplayer/usePlayerLyrics.jsx b/ui/src/audioplayer/usePlayerLyrics.jsx
index 4cf6630dd..429d6ae1f 100644
--- a/ui/src/audioplayer/usePlayerLyrics.jsx
+++ b/ui/src/audioplayer/usePlayerLyrics.jsx
@@ -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(
() => (
-
+
{
+ 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
diff --git a/ui/src/layout/Menu.jsx b/ui/src/layout/Menu.jsx
index 45f40b26d..899c2e869 100644
--- a/ui/src/layout/Menu.jsx
+++ b/ui/src/layout/Menu.jsx
@@ -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,
diff --git a/ui/src/layout/layoutWidths.js b/ui/src/layout/layoutWidths.js
new file mode 100644
index 000000000..4587891f6
--- /dev/null
+++ b/ui/src/layout/layoutWidths.js
@@ -0,0 +1,2 @@
+export const APP_SIDEBAR_OPEN_WIDTH = 240
+export const APP_SIDEBAR_CLOSED_WIDTH = 55
diff --git a/ui/src/themes/nutball.css.js b/ui/src/themes/nutball.css.js
index aaf1c0e16..a1428461e 100644
--- a/ui/src/themes/nutball.css.js
+++ b/ui/src/themes/nutball.css.js
@@ -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;