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'} > + , + ) + 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;