mirror of
https://github.com/navidrome/navidrome.git
synced 2026-11-04 01:08:54 +01:00
feat(ui): add the responsive lyrics sidebar
- Integrate accessible desktop and mobile lyrics controls with responsive layout behavior. - Render localized lyric layers with stable wrapping, dimmed translations, and resilient portals. Signed-off-by: ranokay <github@ranokay.com>
This commit is contained in:
parent
b38362255e
commit
20ffa444b9
19 changed files with 1740 additions and 18 deletions
|
|
@ -23,6 +23,7 @@ import share from './share'
|
|||
import library from './library'
|
||||
import plugin from './plugin'
|
||||
import { Player } from './audioplayer'
|
||||
import { LyricsLayoutProvider } from './audioplayer/LyricsLayoutContext'
|
||||
import customRoutes from './routes'
|
||||
import {
|
||||
libraryReducer,
|
||||
|
|
@ -85,7 +86,9 @@ const adminStore = createAdminStore({
|
|||
|
||||
const App = () => (
|
||||
<Provider store={adminStore}>
|
||||
<Admin />
|
||||
<LyricsLayoutProvider>
|
||||
<Admin />
|
||||
</LyricsLayoutProvider>
|
||||
</Provider>
|
||||
)
|
||||
|
||||
|
|
|
|||
41
ui/src/audioplayer/LyricsLayoutContext.jsx
Normal file
41
ui/src/audioplayer/LyricsLayoutContext.jsx
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
/* eslint-disable react-refresh/only-export-components */
|
||||
import React, {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react'
|
||||
|
||||
const noop = () => {}
|
||||
|
||||
const fallbackContext = {
|
||||
desktopLyricsProps: null,
|
||||
setDesktopLyricsProps: noop,
|
||||
}
|
||||
|
||||
const LyricsLayoutContext = createContext(fallbackContext)
|
||||
|
||||
export const LyricsLayoutProvider = ({ children }) => {
|
||||
const [desktopLyricsProps, setDesktopLyricsPropsState] = useState(null)
|
||||
|
||||
const setDesktopLyricsProps = useCallback((nextProps) => {
|
||||
setDesktopLyricsPropsState(nextProps || null)
|
||||
}, [])
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
desktopLyricsProps,
|
||||
setDesktopLyricsProps,
|
||||
}),
|
||||
[desktopLyricsProps, setDesktopLyricsProps],
|
||||
)
|
||||
|
||||
return (
|
||||
<LyricsLayoutContext.Provider value={value}>
|
||||
{children}
|
||||
</LyricsLayoutContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export const useLyricsLayout = () => useContext(LyricsLayoutContext)
|
||||
415
ui/src/audioplayer/LyricsSidebar.jsx
Normal file
415
ui/src/audioplayer/LyricsSidebar.jsx
Normal file
|
|
@ -0,0 +1,415 @@
|
|||
import IconButton from '@material-ui/core/IconButton'
|
||||
import Tooltip from '@material-ui/core/Tooltip'
|
||||
import { alpha, makeStyles, useTheme } from '@material-ui/core/styles'
|
||||
import RecordVoiceOverIcon from '@material-ui/icons/RecordVoiceOver'
|
||||
import TranslateIcon from '@material-ui/icons/Translate'
|
||||
import clsx from 'clsx'
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import LyricsPanel from './LyricsPanel'
|
||||
import {
|
||||
LYRICS_SIDEBAR_MAX_WIDTH,
|
||||
LYRICS_SIDEBAR_MIN_WIDTH,
|
||||
LYRICS_SIDEBAR_TRANSITION_MS,
|
||||
LYRICS_SIDEBAR_WIDTH_STEP,
|
||||
clampSidebarWidth,
|
||||
loadSidebarWidth,
|
||||
notifySidebarWidthChange,
|
||||
saveSidebarWidth,
|
||||
} from './lyricsSidebarWidth'
|
||||
import useEnterExitTransition from './useEnterExitTransition'
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
sidebar: {
|
||||
position: 'fixed',
|
||||
top: 48,
|
||||
right: 0,
|
||||
bottom: 80,
|
||||
minHeight: 0,
|
||||
minWidth: LYRICS_SIDEBAR_MIN_WIDTH,
|
||||
maxWidth: LYRICS_SIDEBAR_MAX_WIDTH,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
color: theme.palette.text.primary,
|
||||
backgroundColor: theme.palette.background.default,
|
||||
backgroundImage: 'none',
|
||||
borderLeft: `1px solid ${alpha(theme.palette.divider, 0.1)}`,
|
||||
boxShadow: 'none',
|
||||
zIndex: theme.zIndex.appBar - 1,
|
||||
transition: `transform ${LYRICS_SIDEBAR_TRANSITION_MS}ms cubic-bezier(0.22, 1, 0.36, 1), opacity ${LYRICS_SIDEBAR_TRANSITION_MS}ms cubic-bezier(0.22, 1, 0.36, 1)`,
|
||||
willChange: 'transform, opacity',
|
||||
'@media (prefers-reduced-motion: reduce)': {
|
||||
transition: 'none',
|
||||
},
|
||||
'@media (hover: hover) and (pointer: fine)': {
|
||||
'&:hover $resizer::after': {
|
||||
background: alpha(theme.palette.primary.main, 0.32),
|
||||
},
|
||||
},
|
||||
},
|
||||
resizer: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
left: -6,
|
||||
width: 12,
|
||||
padding: 0,
|
||||
border: 0,
|
||||
background: 'transparent',
|
||||
cursor: 'ew-resize',
|
||||
zIndex: 3,
|
||||
'&::after': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
left: 5,
|
||||
width: 2,
|
||||
background: 'transparent',
|
||||
transition: 'background 160ms ease',
|
||||
},
|
||||
'&:focus::after': {
|
||||
background: alpha(theme.palette.primary.main, 0.48),
|
||||
},
|
||||
'@media (hover: hover) and (pointer: fine)': {
|
||||
'&:hover::after': {
|
||||
background: alpha(theme.palette.primary.main, 0.48),
|
||||
},
|
||||
},
|
||||
'&:focus': {
|
||||
outline: 'none',
|
||||
},
|
||||
},
|
||||
controls: {
|
||||
position: 'absolute',
|
||||
bottom: theme.spacing(1),
|
||||
right: theme.spacing(0.75),
|
||||
zIndex: 2,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: theme.spacing(0.25),
|
||||
padding: theme.spacing(0.25),
|
||||
borderRadius: theme.shape.borderRadius * 2,
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
controlButton: {
|
||||
padding: theme.spacing(0.75),
|
||||
color: alpha(theme.palette.text.primary, 0.58),
|
||||
backgroundColor: 'transparent',
|
||||
transition:
|
||||
'color 160ms ease, background-color 160ms ease, transform 160ms cubic-bezier(0.23, 1, 0.32, 1)',
|
||||
'@media (hover: hover) and (pointer: fine)': {
|
||||
'&:hover': {
|
||||
color: theme.palette.text.primary,
|
||||
backgroundColor: alpha(theme.palette.primary.main, 0.08),
|
||||
},
|
||||
'&$controlActive:hover': {
|
||||
color: theme.palette.primary.main,
|
||||
},
|
||||
},
|
||||
'&:focus-visible': {
|
||||
color: theme.palette.text.primary,
|
||||
backgroundColor: alpha(theme.palette.primary.main, 0.1),
|
||||
},
|
||||
'&:active:not(:disabled)': {
|
||||
transform: 'scale(0.97)',
|
||||
},
|
||||
'&$controlActive': {
|
||||
color: theme.palette.primary.main,
|
||||
},
|
||||
'&$controlActive:focus-visible': {
|
||||
color: theme.palette.primary.main,
|
||||
},
|
||||
'&:disabled': {
|
||||
color: alpha(theme.palette.text.primary, 0.28),
|
||||
},
|
||||
'@media (prefers-reduced-motion: reduce)': {
|
||||
transition: 'none',
|
||||
'&:active:not(:disabled)': {
|
||||
transform: 'none',
|
||||
},
|
||||
},
|
||||
},
|
||||
controlActive: {},
|
||||
panel: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
position: 'relative',
|
||||
},
|
||||
}))
|
||||
|
||||
const LayerButton = ({
|
||||
active,
|
||||
classes,
|
||||
disabled,
|
||||
label,
|
||||
onClick,
|
||||
testId,
|
||||
children,
|
||||
}) => {
|
||||
const theme = useTheme()
|
||||
const activeStyle =
|
||||
active && !disabled ? { color: theme.palette.primary.main } : undefined
|
||||
|
||||
return (
|
||||
<Tooltip title={label}>
|
||||
<span>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
aria-label={label}
|
||||
aria-pressed={active}
|
||||
data-testid={testId}
|
||||
style={activeStyle}
|
||||
className={clsx(classes.controlButton, {
|
||||
[classes.controlActive]: active && !disabled,
|
||||
})}
|
||||
>
|
||||
{children}
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
const LyricsSidebar = ({
|
||||
visible,
|
||||
mainLyric,
|
||||
translationLyric,
|
||||
pronunciationLyric,
|
||||
showTranslation,
|
||||
showPronunciation,
|
||||
translationEnabled,
|
||||
pronunciationEnabled,
|
||||
onToggleTranslation,
|
||||
onTogglePronunciation,
|
||||
audioInstance,
|
||||
loading = false,
|
||||
error = null,
|
||||
labels = {},
|
||||
}) => {
|
||||
const [width, setWidth] = useState(loadSidebarWidth)
|
||||
const sidebarRef = useRef(null)
|
||||
const resizerRef = useRef(null)
|
||||
const widthRef = useRef(width)
|
||||
const resizeCleanupRef = useRef(null)
|
||||
const { rendered, entered } = useEnterExitTransition(
|
||||
visible,
|
||||
LYRICS_SIDEBAR_TRANSITION_MS,
|
||||
)
|
||||
const [isResizing, setIsResizing] = useState(false)
|
||||
const classes = useStyles()
|
||||
|
||||
useEffect(() => {
|
||||
widthRef.current = width
|
||||
}, [width])
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
resizeCleanupRef.current?.()
|
||||
resizeCleanupRef.current = null
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
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,
|
||||
)
|
||||
widthRef.current = resolvedWidth
|
||||
if (sidebarRef.current) {
|
||||
sidebarRef.current.style.width = `${resolvedWidth}px`
|
||||
}
|
||||
resizerRef.current?.setAttribute('aria-valuenow', String(resolvedWidth))
|
||||
notifySidebarWidthChange(resolvedWidth)
|
||||
return resolvedWidth
|
||||
}, [])
|
||||
|
||||
const updateWidth = useCallback(
|
||||
(next, { persist = false } = {}) => {
|
||||
const resolvedWidth = applyWidth(next)
|
||||
setWidth(resolvedWidth)
|
||||
if (persist) saveSidebarWidth(resolvedWidth)
|
||||
},
|
||||
[applyWidth],
|
||||
)
|
||||
|
||||
const handleResizePointerDown = useCallback(
|
||||
(event) => {
|
||||
event.preventDefault()
|
||||
resizeCleanupRef.current?.()
|
||||
|
||||
const target = event.currentTarget
|
||||
const pointerId = event.pointerId
|
||||
const startX = event.clientX
|
||||
const startWidth = widthRef.current
|
||||
let latestWidth = startWidth
|
||||
let cleanedUp = false
|
||||
const canCapture =
|
||||
pointerId != null && typeof target.setPointerCapture === 'function'
|
||||
const listenerTarget = window
|
||||
setIsResizing(true)
|
||||
|
||||
const handlePointerMove = (moveEvent) => {
|
||||
latestWidth = applyWidth(startWidth + startX - moveEvent.clientX)
|
||||
}
|
||||
|
||||
const cleanupResize = ({ persist = false } = {}) => {
|
||||
if (cleanedUp) return
|
||||
cleanedUp = true
|
||||
listenerTarget.removeEventListener('pointermove', handlePointerMove)
|
||||
listenerTarget.removeEventListener('pointerup', handlePointerUp)
|
||||
listenerTarget.removeEventListener('pointercancel', handlePointerCancel)
|
||||
target.removeEventListener('lostpointercapture', handlePointerCancel)
|
||||
if (
|
||||
canCapture &&
|
||||
typeof target.releasePointerCapture === 'function' &&
|
||||
(!target.hasPointerCapture || target.hasPointerCapture(pointerId))
|
||||
) {
|
||||
try {
|
||||
target.releasePointerCapture(pointerId)
|
||||
} catch {
|
||||
// Ignore stale pointer capture state.
|
||||
}
|
||||
}
|
||||
setWidth(latestWidth)
|
||||
if (persist) saveSidebarWidth(latestWidth)
|
||||
setIsResizing(false)
|
||||
if (resizeCleanupRef.current === cleanupResize) {
|
||||
resizeCleanupRef.current = null
|
||||
}
|
||||
}
|
||||
|
||||
const handlePointerUp = () => cleanupResize({ persist: true })
|
||||
const handlePointerCancel = () => cleanupResize()
|
||||
|
||||
resizeCleanupRef.current = cleanupResize
|
||||
listenerTarget.addEventListener('pointermove', handlePointerMove)
|
||||
listenerTarget.addEventListener('pointerup', handlePointerUp)
|
||||
listenerTarget.addEventListener('pointercancel', handlePointerCancel)
|
||||
target.addEventListener('lostpointercapture', handlePointerCancel)
|
||||
if (canCapture) {
|
||||
try {
|
||||
target.setPointerCapture(pointerId)
|
||||
} catch {
|
||||
// Window-level listeners keep resize working when capture is unavailable.
|
||||
}
|
||||
}
|
||||
},
|
||||
[applyWidth],
|
||||
)
|
||||
|
||||
const handleResizeKeyDown = useCallback(
|
||||
(event) => {
|
||||
if (event.key === 'ArrowLeft') {
|
||||
event.preventDefault()
|
||||
updateWidth((current) => current + LYRICS_SIDEBAR_WIDTH_STEP, {
|
||||
persist: true,
|
||||
})
|
||||
} else if (event.key === 'ArrowRight') {
|
||||
event.preventDefault()
|
||||
updateWidth((current) => current - LYRICS_SIDEBAR_WIDTH_STEP, {
|
||||
persist: true,
|
||||
})
|
||||
} else if (event.key === 'Home') {
|
||||
event.preventDefault()
|
||||
updateWidth(LYRICS_SIDEBAR_MIN_WIDTH, { persist: true })
|
||||
} else if (event.key === 'End') {
|
||||
event.preventDefault()
|
||||
updateWidth(LYRICS_SIDEBAR_MAX_WIDTH, { persist: true })
|
||||
}
|
||||
},
|
||||
[updateWidth],
|
||||
)
|
||||
|
||||
if (!rendered) return null
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={classes.sidebar}
|
||||
data-testid="lyrics-sidebar"
|
||||
ref={sidebarRef}
|
||||
style={{
|
||||
width: widthRef.current,
|
||||
transform: entered ? 'translateX(0)' : 'translateX(100%)',
|
||||
opacity: entered ? 1 : 0,
|
||||
pointerEvents: entered ? 'auto' : 'none',
|
||||
}}
|
||||
aria-label={labels.title || 'Lyrics'}
|
||||
aria-hidden={!entered}
|
||||
data-resizing={isResizing ? 'true' : 'false'}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
ref={resizerRef}
|
||||
className={classes.resizer}
|
||||
data-testid="lyrics-sidebar-resizer"
|
||||
aria-label={labels.resize || 'Resize lyrics sidebar'}
|
||||
aria-orientation="vertical"
|
||||
aria-valuemin={LYRICS_SIDEBAR_MIN_WIDTH}
|
||||
aria-valuemax={LYRICS_SIDEBAR_MAX_WIDTH}
|
||||
aria-valuenow={width}
|
||||
role="separator"
|
||||
onPointerDown={handleResizePointerDown}
|
||||
onKeyDown={handleResizeKeyDown}
|
||||
/>
|
||||
<div className={classes.panel}>
|
||||
<div
|
||||
className={classes.controls}
|
||||
data-testid="lyrics-sidebar-floating-controls"
|
||||
>
|
||||
<LayerButton
|
||||
active={showPronunciation}
|
||||
classes={classes}
|
||||
disabled={!pronunciationEnabled}
|
||||
label={
|
||||
showPronunciation
|
||||
? labels.hidePronunciation || 'Hide pronunciation'
|
||||
: labels.showPronunciation || 'Show pronunciation'
|
||||
}
|
||||
onClick={onTogglePronunciation}
|
||||
testId="toggle-pronunciation-button"
|
||||
>
|
||||
<RecordVoiceOverIcon fontSize="small" />
|
||||
</LayerButton>
|
||||
<LayerButton
|
||||
active={showTranslation}
|
||||
classes={classes}
|
||||
disabled={!translationEnabled}
|
||||
label={
|
||||
showTranslation
|
||||
? labels.hideTranslation || 'Hide translation'
|
||||
: labels.showTranslation || 'Show translation'
|
||||
}
|
||||
onClick={onToggleTranslation}
|
||||
testId="toggle-translation-button"
|
||||
>
|
||||
<TranslateIcon fontSize="small" />
|
||||
</LayerButton>
|
||||
</div>
|
||||
<LyricsPanel
|
||||
visible={visible}
|
||||
mainLyric={mainLyric}
|
||||
translationLyric={translationLyric}
|
||||
pronunciationLyric={pronunciationLyric}
|
||||
showTranslation={showTranslation}
|
||||
showPronunciation={showPronunciation}
|
||||
audioInstance={audioInstance}
|
||||
loading={loading}
|
||||
error={error}
|
||||
labels={labels}
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
export default LyricsSidebar
|
||||
292
ui/src/audioplayer/LyricsSidebar.test.jsx
Normal file
292
ui/src/audioplayer/LyricsSidebar.test.jsx
Normal file
|
|
@ -0,0 +1,292 @@
|
|||
import React from 'react'
|
||||
import {
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from '@testing-library/react'
|
||||
import { ThemeProvider, createTheme } from '@material-ui/core/styles'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import LyricsSidebar from './LyricsSidebar'
|
||||
import {
|
||||
LYRICS_SIDEBAR_MAX_WIDTH,
|
||||
LYRICS_SIDEBAR_MIN_WIDTH,
|
||||
LYRICS_SIDEBAR_STORAGE_KEY,
|
||||
LYRICS_SIDEBAR_TRANSITION_MS,
|
||||
} from './lyricsSidebarWidth'
|
||||
|
||||
const theme = createTheme({
|
||||
palette: {
|
||||
primary: {
|
||||
main: '#35aa66',
|
||||
},
|
||||
background: {
|
||||
default: '#101820',
|
||||
paper: '#ffffff',
|
||||
},
|
||||
},
|
||||
})
|
||||
const lyric = {
|
||||
synced: true,
|
||||
line: [{ start: 0, end: 1000, value: 'Main line' }],
|
||||
}
|
||||
const originalPointerEvent = window.PointerEvent
|
||||
|
||||
const sidebarView = (props = {}) => (
|
||||
<ThemeProvider theme={theme}>
|
||||
<LyricsSidebar
|
||||
visible
|
||||
mainLyric={lyric}
|
||||
showTranslation
|
||||
showPronunciation
|
||||
translationEnabled
|
||||
pronunciationEnabled
|
||||
onToggleTranslation={vi.fn()}
|
||||
onTogglePronunciation={vi.fn()}
|
||||
{...props}
|
||||
/>
|
||||
</ThemeProvider>
|
||||
)
|
||||
|
||||
const renderSidebar = (props) => render(sidebarView(props))
|
||||
|
||||
describe('<LyricsSidebar />', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
cleanup()
|
||||
localStorage.clear()
|
||||
document.body.className = ''
|
||||
Object.defineProperty(window, 'PointerEvent', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: originalPointerEvent,
|
||||
})
|
||||
})
|
||||
|
||||
it('renders as an embedded sidebar without global layout side effects', () => {
|
||||
renderSidebar()
|
||||
|
||||
expect(document.body.className).toBe('')
|
||||
const sidebar = screen.getByTestId('lyrics-sidebar')
|
||||
expect(sidebar).toHaveStyle({
|
||||
transform: 'translateX(0)',
|
||||
opacity: '1',
|
||||
})
|
||||
expect(screen.getByTestId('lyrics-sidebar-resizer')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps the sidebar mounted while sliding closed', () => {
|
||||
vi.useFakeTimers()
|
||||
const { rerender } = renderSidebar()
|
||||
const resizer = screen.getByTestId('lyrics-sidebar-resizer')
|
||||
resizer.focus()
|
||||
|
||||
rerender(sidebarView({ visible: false }))
|
||||
|
||||
expect(screen.getByTestId('lyrics-sidebar')).toHaveStyle({
|
||||
transform: 'translateX(100%)',
|
||||
opacity: '0',
|
||||
})
|
||||
expect(document.body.className).toBe('')
|
||||
expect(document.activeElement).not.toBe(resizer)
|
||||
|
||||
vi.advanceTimersByTime(LYRICS_SIDEBAR_TRANSITION_MS)
|
||||
|
||||
expect(screen.queryByTestId('lyrics-sidebar')).toBeNull()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('clamps persisted and keyboard-resized widths', () => {
|
||||
localStorage.setItem(LYRICS_SIDEBAR_STORAGE_KEY, '999')
|
||||
renderSidebar()
|
||||
|
||||
const sidebar = screen.getByTestId('lyrics-sidebar')
|
||||
const resizer = screen.getByTestId('lyrics-sidebar-resizer')
|
||||
|
||||
expect(sidebar).toHaveStyle({ width: `${LYRICS_SIDEBAR_MAX_WIDTH}px` })
|
||||
expect(resizer).toHaveAttribute(
|
||||
'aria-valuenow',
|
||||
String(LYRICS_SIDEBAR_MAX_WIDTH),
|
||||
)
|
||||
|
||||
fireEvent.keyDown(resizer, { key: 'Home' })
|
||||
|
||||
expect(sidebar).toHaveStyle({ width: `${LYRICS_SIDEBAR_MIN_WIDTH}px` })
|
||||
expect(localStorage.getItem(LYRICS_SIDEBAR_STORAGE_KEY)).toBe(
|
||||
String(LYRICS_SIDEBAR_MIN_WIDTH),
|
||||
)
|
||||
|
||||
fireEvent.keyDown(resizer, { key: 'End' })
|
||||
|
||||
expect(sidebar).toHaveStyle({ width: `${LYRICS_SIDEBAR_MAX_WIDTH}px` })
|
||||
expect(localStorage.getItem(LYRICS_SIDEBAR_STORAGE_KEY)).toBe(
|
||||
String(LYRICS_SIDEBAR_MAX_WIDTH),
|
||||
)
|
||||
})
|
||||
|
||||
it('clamps pointer resizing from the left separator', async () => {
|
||||
Object.defineProperty(window, 'PointerEvent', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: MouseEvent,
|
||||
})
|
||||
renderSidebar()
|
||||
|
||||
const sidebar = screen.getByTestId('lyrics-sidebar')
|
||||
const resizer = screen.getByTestId('lyrics-sidebar-resizer')
|
||||
resizer.setPointerCapture = vi.fn(() => {
|
||||
throw new Error('pointer capture unavailable')
|
||||
})
|
||||
resizer.releasePointerCapture = vi.fn()
|
||||
|
||||
fireEvent.pointerDown(resizer, { clientX: 500 })
|
||||
window.dispatchEvent(new MouseEvent('pointermove', { clientX: -100 }))
|
||||
await waitFor(() =>
|
||||
expect(sidebar).toHaveStyle({ width: `${LYRICS_SIDEBAR_MAX_WIDTH}px` }),
|
||||
)
|
||||
expect(localStorage.getItem(LYRICS_SIDEBAR_STORAGE_KEY)).toBeNull()
|
||||
|
||||
window.dispatchEvent(new MouseEvent('pointermove', { clientX: 1000 }))
|
||||
await waitFor(() =>
|
||||
expect(sidebar).toHaveStyle({ width: `${LYRICS_SIDEBAR_MIN_WIDTH}px` }),
|
||||
)
|
||||
|
||||
window.dispatchEvent(new MouseEvent('pointerup'))
|
||||
expect(localStorage.getItem(LYRICS_SIDEBAR_STORAGE_KEY)).toBe(
|
||||
String(LYRICS_SIDEBAR_MIN_WIDTH),
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps the live pointer width through an unrelated rerender', async () => {
|
||||
Object.defineProperty(window, 'PointerEvent', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: MouseEvent,
|
||||
})
|
||||
const { rerender } = renderSidebar()
|
||||
const sidebar = screen.getByTestId('lyrics-sidebar')
|
||||
const resizer = screen.getByTestId('lyrics-sidebar-resizer')
|
||||
|
||||
fireEvent.pointerDown(resizer, { clientX: 500 })
|
||||
window.dispatchEvent(new MouseEvent('pointermove', { clientX: 450 }))
|
||||
await waitFor(() => expect(sidebar).toHaveStyle({ width: '410px' }))
|
||||
expect(resizer).toHaveAttribute('aria-valuenow', '410')
|
||||
|
||||
rerender(sidebarView({ labels: { title: 'Updated lyrics' } }))
|
||||
|
||||
expect(screen.getByTestId('lyrics-sidebar')).toHaveStyle({ width: '410px' })
|
||||
expect(screen.getByTestId('lyrics-sidebar-resizer')).toHaveAttribute(
|
||||
'aria-valuenow',
|
||||
'410',
|
||||
)
|
||||
window.dispatchEvent(new MouseEvent('pointercancel'))
|
||||
})
|
||||
|
||||
it('cleans pointer resizing on cancellation and unmount', async () => {
|
||||
Object.defineProperty(window, 'PointerEvent', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: MouseEvent,
|
||||
})
|
||||
const { unmount } = renderSidebar()
|
||||
|
||||
const sidebar = screen.getByTestId('lyrics-sidebar')
|
||||
const resizer = screen.getByTestId('lyrics-sidebar-resizer')
|
||||
|
||||
fireEvent.pointerDown(resizer, { clientX: 500 })
|
||||
window.dispatchEvent(new MouseEvent('pointermove', { clientX: -100 }))
|
||||
await waitFor(() =>
|
||||
expect(sidebar).toHaveStyle({ width: `${LYRICS_SIDEBAR_MAX_WIDTH}px` }),
|
||||
)
|
||||
|
||||
window.dispatchEvent(new MouseEvent('pointercancel'))
|
||||
await waitFor(() =>
|
||||
expect(sidebar).toHaveAttribute('data-resizing', 'false'),
|
||||
)
|
||||
|
||||
window.dispatchEvent(new MouseEvent('pointermove', { clientX: 1000 }))
|
||||
expect(sidebar).toHaveStyle({ width: `${LYRICS_SIDEBAR_MAX_WIDTH}px` })
|
||||
expect(localStorage.getItem(LYRICS_SIDEBAR_STORAGE_KEY)).toBeNull()
|
||||
|
||||
fireEvent.pointerDown(resizer, { clientX: 500 })
|
||||
window.dispatchEvent(new MouseEvent('pointermove', { clientX: 1000 }))
|
||||
await waitFor(() =>
|
||||
expect(sidebar).toHaveStyle({ width: `${LYRICS_SIDEBAR_MIN_WIDTH}px` }),
|
||||
)
|
||||
|
||||
unmount()
|
||||
window.dispatchEvent(new MouseEvent('pointerup'))
|
||||
|
||||
expect(localStorage.getItem(LYRICS_SIDEBAR_STORAGE_KEY)).toBeNull()
|
||||
})
|
||||
|
||||
it('uses icon toggle buttons with pressed and disabled states', () => {
|
||||
const onTogglePronunciation = vi.fn()
|
||||
const onToggleTranslation = vi.fn()
|
||||
renderSidebar({
|
||||
showPronunciation: true,
|
||||
showTranslation: false,
|
||||
pronunciationEnabled: true,
|
||||
translationEnabled: false,
|
||||
onTogglePronunciation,
|
||||
onToggleTranslation,
|
||||
labels: {
|
||||
title: 'Song words',
|
||||
resize: 'Resize song words',
|
||||
showTranslation: 'Reveal translation',
|
||||
hidePronunciation: 'Conceal pronunciation',
|
||||
},
|
||||
})
|
||||
|
||||
const pronunciation = screen.getByTestId('toggle-pronunciation-button')
|
||||
const translation = screen.getByTestId('toggle-translation-button')
|
||||
expect(screen.getByLabelText('Song words')).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('Resize song words')).toBeInTheDocument()
|
||||
expect(pronunciation).toHaveAttribute('aria-label', 'Conceal pronunciation')
|
||||
expect(pronunciation).toHaveAttribute('aria-pressed', 'true')
|
||||
expect(window.getComputedStyle(pronunciation).color).toBe(
|
||||
'rgb(53, 170, 102)',
|
||||
)
|
||||
fireEvent.click(pronunciation)
|
||||
expect(onTogglePronunciation).toHaveBeenCalledTimes(1)
|
||||
|
||||
expect(translation).toHaveAttribute('aria-label', 'Reveal translation')
|
||||
expect(translation).toHaveAttribute('aria-pressed', 'false')
|
||||
expect(translation).toBeDisabled()
|
||||
expect(window.getComputedStyle(translation).color).not.toBe(
|
||||
'rgb(53, 170, 102)',
|
||||
)
|
||||
fireEvent.click(translation)
|
||||
expect(onToggleTranslation).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('stays mounted without re-entering when an open sidebar receives no lyrics', () => {
|
||||
const { rerender } = renderSidebar()
|
||||
|
||||
const sidebar = screen.getByTestId('lyrics-sidebar')
|
||||
expect(sidebar).toHaveStyle({ transform: 'translateX(0)' })
|
||||
expect(screen.getByTestId('karaoke-lyrics-panel')).toBeInTheDocument()
|
||||
|
||||
rerender(
|
||||
sidebarView({
|
||||
mainLyric: null,
|
||||
showTranslation: false,
|
||||
showPronunciation: false,
|
||||
translationEnabled: false,
|
||||
pronunciationEnabled: false,
|
||||
}),
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('lyrics-sidebar')).toHaveStyle({
|
||||
transform: 'translateX(0)',
|
||||
})
|
||||
expect(screen.getByTestId('karaoke-lyrics-panel')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('lyrics-empty-state')).toHaveTextContent(
|
||||
'No lyrics available',
|
||||
)
|
||||
expect(screen.queryByTestId('lyrics-line-group')).toBeNull()
|
||||
expect(screen.getByTestId('toggle-pronunciation-button')).toBeDisabled()
|
||||
expect(screen.getByTestId('toggle-translation-button')).toBeDisabled()
|
||||
})
|
||||
})
|
||||
73
ui/src/audioplayer/MobileKaraokeLyricsPortal.jsx
Normal file
73
ui/src/audioplayer/MobileKaraokeLyricsPortal.jsx
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import React, { useEffect, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import useEnterExitTransition from './useEnterExitTransition'
|
||||
|
||||
export const MOBILE_KARAOKE_LYRICS_TRANSITION_MS = 260
|
||||
export const MOBILE_KARAOKE_LYRICS_HOST_SELECTOR =
|
||||
'.react-jinke-music-player-mobile-cover'
|
||||
export const MOBILE_KARAOKE_LYRICS_ACTIVE_CLASS = 'nd-mobile-lyrics-active'
|
||||
export const MOBILE_KARAOKE_LYRICS_ENTERED_CLASS = 'nd-mobile-lyrics-entered'
|
||||
export const MOBILE_KARAOKE_LYRICS_LAYER_CLASS = 'nd-mobile-lyrics-layer'
|
||||
|
||||
const resolveMobileLyricsHost = () => {
|
||||
if (typeof document === 'undefined') return null
|
||||
return document.querySelector(MOBILE_KARAOKE_LYRICS_HOST_SELECTOR)
|
||||
}
|
||||
|
||||
const MobileKaraokeLyricsPortal = ({ active, children }) => {
|
||||
const { rendered, entered } = useEnterExitTransition(
|
||||
active,
|
||||
MOBILE_KARAOKE_LYRICS_TRANSITION_MS,
|
||||
)
|
||||
const [host, setHost] = useState(() =>
|
||||
active ? resolveMobileLyricsHost() : null,
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof document === 'undefined') {
|
||||
setHost(null)
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (!rendered) {
|
||||
setHost(null)
|
||||
return undefined
|
||||
}
|
||||
|
||||
setHost(resolveMobileLyricsHost())
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
const nextHost = resolveMobileLyricsHost()
|
||||
setHost((current) => (current === nextHost ? current : nextHost))
|
||||
})
|
||||
observer.observe(document.body, { childList: true, subtree: true })
|
||||
|
||||
return () => observer.disconnect()
|
||||
}, [rendered])
|
||||
|
||||
useEffect(() => {
|
||||
if (!host) return undefined
|
||||
|
||||
host.classList.toggle(MOBILE_KARAOKE_LYRICS_ACTIVE_CLASS, rendered)
|
||||
host.classList.toggle(MOBILE_KARAOKE_LYRICS_ENTERED_CLASS, entered)
|
||||
return () => {
|
||||
host.classList.remove(MOBILE_KARAOKE_LYRICS_ACTIVE_CLASS)
|
||||
host.classList.remove(MOBILE_KARAOKE_LYRICS_ENTERED_CLASS)
|
||||
}
|
||||
}, [entered, host, rendered])
|
||||
|
||||
if (!rendered || !host) return null
|
||||
return createPortal(
|
||||
<div
|
||||
className={MOBILE_KARAOKE_LYRICS_LAYER_CLASS}
|
||||
data-entered={entered ? 'true' : 'false'}
|
||||
aria-hidden={!entered}
|
||||
style={{ pointerEvents: entered ? 'auto' : 'none' }}
|
||||
>
|
||||
{children}
|
||||
</div>,
|
||||
host,
|
||||
)
|
||||
}
|
||||
|
||||
export default MobileKaraokeLyricsPortal
|
||||
214
ui/src/audioplayer/MobileKaraokeLyricsPortal.test.jsx
Normal file
214
ui/src/audioplayer/MobileKaraokeLyricsPortal.test.jsx
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
import React from 'react'
|
||||
import {
|
||||
act,
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
within,
|
||||
} from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import MobileKaraokeLyricsPortal, {
|
||||
MOBILE_KARAOKE_LYRICS_ACTIVE_CLASS,
|
||||
MOBILE_KARAOKE_LYRICS_ENTERED_CLASS,
|
||||
MOBILE_KARAOKE_LYRICS_HOST_SELECTOR,
|
||||
MOBILE_KARAOKE_LYRICS_LAYER_CLASS,
|
||||
MOBILE_KARAOKE_LYRICS_TRANSITION_MS,
|
||||
} from './MobileKaraokeLyricsPortal'
|
||||
import usePlayerLyrics from './usePlayerLyrics'
|
||||
|
||||
const { useEnhancedLyricsMock } = vi.hoisted(() => ({
|
||||
useEnhancedLyricsMock: vi.fn(() => ({
|
||||
layers: {
|
||||
main: {
|
||||
synced: true,
|
||||
line: [{ start: 0, end: 1000, value: 'Persistent panel lyrics' }],
|
||||
},
|
||||
translation: null,
|
||||
pronunciation: null,
|
||||
},
|
||||
loading: false,
|
||||
error: null,
|
||||
retry: vi.fn(),
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock('./useEnhancedLyrics', () => ({
|
||||
default: useEnhancedLyricsMock,
|
||||
}))
|
||||
|
||||
const createHost = () => {
|
||||
const host = document.createElement('div')
|
||||
host.className = MOBILE_KARAOKE_LYRICS_HOST_SELECTOR.slice(1)
|
||||
document.body.appendChild(host)
|
||||
return host
|
||||
}
|
||||
|
||||
const portal = (text, active = true) => (
|
||||
<MobileKaraokeLyricsPortal active={active}>
|
||||
<span>{text}</span>
|
||||
</MobileKaraokeLyricsPortal>
|
||||
)
|
||||
|
||||
const MobileLyricsHarness = ({ trackId = 'track-1', isRadio = false }) => {
|
||||
const { toolbarLyricsProps, mobileLyricsSurface } = usePlayerLyrics({
|
||||
trackId,
|
||||
trackUpdatedAt: 'now',
|
||||
isRadio,
|
||||
audioInstance: null,
|
||||
isDesktop: false,
|
||||
translate: (key) => key,
|
||||
})
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={toolbarLyricsProps.onToggleLyrics}
|
||||
disabled={toolbarLyricsProps.lyricsDisabled}
|
||||
>
|
||||
Toggle lyrics
|
||||
</button>
|
||||
{mobileLyricsSurface}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
describe('<MobileKaraokeLyricsPortal />', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
useEnhancedLyricsMock.mockClear()
|
||||
cleanup()
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
it('mounts active lyrics into the mobile cover host and cleans up the class', () => {
|
||||
vi.useFakeTimers()
|
||||
const host = createHost()
|
||||
const { rerender } = render(portal('Inline lyrics'))
|
||||
|
||||
expect(within(host).getByText('Inline lyrics')).toBeInTheDocument()
|
||||
expect(host).toHaveClass(MOBILE_KARAOKE_LYRICS_ACTIVE_CLASS)
|
||||
expect(host).toHaveClass(MOBILE_KARAOKE_LYRICS_ENTERED_CLASS)
|
||||
expect(
|
||||
host.querySelector(`.${MOBILE_KARAOKE_LYRICS_LAYER_CLASS}`),
|
||||
).toHaveAttribute('data-entered', 'true')
|
||||
expect(
|
||||
host.querySelector(`.${MOBILE_KARAOKE_LYRICS_LAYER_CLASS}`),
|
||||
).toHaveStyle({ pointerEvents: 'auto' })
|
||||
|
||||
rerender(portal('Inline lyrics', false))
|
||||
|
||||
expect(within(host).getByText('Inline lyrics')).toBeInTheDocument()
|
||||
expect(host).toHaveClass(MOBILE_KARAOKE_LYRICS_ACTIVE_CLASS)
|
||||
expect(host).not.toHaveClass(MOBILE_KARAOKE_LYRICS_ENTERED_CLASS)
|
||||
expect(
|
||||
host.querySelector(`.${MOBILE_KARAOKE_LYRICS_LAYER_CLASS}`),
|
||||
).toHaveAttribute('data-entered', 'false')
|
||||
expect(
|
||||
host.querySelector(`.${MOBILE_KARAOKE_LYRICS_LAYER_CLASS}`),
|
||||
).toHaveStyle({ pointerEvents: 'none' })
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(MOBILE_KARAOKE_LYRICS_TRANSITION_MS)
|
||||
})
|
||||
|
||||
expect(screen.queryByText('Inline lyrics')).not.toBeInTheDocument()
|
||||
expect(host).not.toHaveClass(MOBILE_KARAOKE_LYRICS_ACTIVE_CLASS)
|
||||
expect(host).not.toHaveClass(MOBILE_KARAOKE_LYRICS_ENTERED_CLASS)
|
||||
})
|
||||
|
||||
it('attaches when the mobile cover host appears after activation', async () => {
|
||||
vi.useFakeTimers()
|
||||
|
||||
render(portal('Late lyrics'))
|
||||
|
||||
expect(screen.queryByText('Late lyrics')).not.toBeInTheDocument()
|
||||
|
||||
const host = createHost()
|
||||
|
||||
await waitFor(() =>
|
||||
expect(within(host).getByText('Late lyrics')).toBeInTheDocument(),
|
||||
)
|
||||
expect(host).toHaveClass(MOBILE_KARAOKE_LYRICS_ACTIVE_CLASS)
|
||||
})
|
||||
|
||||
it('moves active lyrics when the mobile cover host is replaced', async () => {
|
||||
const firstHost = createHost()
|
||||
|
||||
render(portal('Persistent lyrics'))
|
||||
expect(within(firstHost).getByText('Persistent lyrics')).toBeInTheDocument()
|
||||
|
||||
const secondHost = createHost()
|
||||
firstHost.replaceWith(secondHost)
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
within(secondHost).getByText('Persistent lyrics'),
|
||||
).toBeInTheDocument(),
|
||||
)
|
||||
expect(firstHost).not.toHaveClass(MOBILE_KARAOKE_LYRICS_ACTIVE_CLASS)
|
||||
expect(secondHost).toHaveClass(MOBILE_KARAOKE_LYRICS_ACTIVE_CLASS)
|
||||
})
|
||||
|
||||
it('keeps panel contents rendered for the complete mobile exit', async () => {
|
||||
vi.useFakeTimers()
|
||||
const host = createHost()
|
||||
const { rerender } = render(<MobileLyricsHarness />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Toggle lyrics' }))
|
||||
expect(
|
||||
within(host).getByText('Persistent panel lyrics'),
|
||||
).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Toggle lyrics' }))
|
||||
|
||||
expect(useEnhancedLyricsMock).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ requested: false }),
|
||||
)
|
||||
rerender(<MobileLyricsHarness trackId="track-2" />)
|
||||
const trackTwoRequests = useEnhancedLyricsMock.mock.calls
|
||||
.filter(([options]) => options.trackId === 'track-2')
|
||||
.map(([options]) => options.requested)
|
||||
expect(trackTwoRequests.length).toBeGreaterThan(0)
|
||||
expect(trackTwoRequests).not.toContain(true)
|
||||
expect(
|
||||
within(host).getByText('Persistent panel lyrics'),
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
host.querySelector(`.${MOBILE_KARAOKE_LYRICS_LAYER_CLASS}`),
|
||||
).toHaveStyle({ pointerEvents: 'none' })
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(MOBILE_KARAOKE_LYRICS_TRANSITION_MS)
|
||||
})
|
||||
|
||||
expect(
|
||||
screen.queryByText('Persistent panel lyrics'),
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps open lyrics closable after playback switches to radio', () => {
|
||||
vi.useFakeTimers()
|
||||
const host = createHost()
|
||||
const { rerender } = render(<MobileLyricsHarness />)
|
||||
const toggle = screen.getByRole('button', { name: 'Toggle lyrics' })
|
||||
|
||||
fireEvent.click(toggle)
|
||||
expect(
|
||||
within(host).getByText('Persistent panel lyrics'),
|
||||
).toBeInTheDocument()
|
||||
|
||||
rerender(<MobileLyricsHarness isRadio />)
|
||||
expect(toggle).toBeEnabled()
|
||||
fireEvent.click(toggle)
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(MOBILE_KARAOKE_LYRICS_TRANSITION_MS)
|
||||
})
|
||||
expect(
|
||||
screen.queryByText('Persistent panel lyrics'),
|
||||
).not.toBeInTheDocument()
|
||||
expect(toggle).toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
|
@ -34,6 +34,8 @@ import { keyMap } from '../hotkeys'
|
|||
import keyHandlers from './keyHandlers'
|
||||
import { calculateGain } from '../utils/calculateReplayGain'
|
||||
import { detectBrowserProfile, decisionService } from '../transcode'
|
||||
import usePlayerLyrics from './usePlayerLyrics'
|
||||
import { useLyricsLayout } from './LyricsLayoutContext'
|
||||
|
||||
const Player = () => {
|
||||
const theme = useCurrentTheme()
|
||||
|
|
@ -138,6 +140,32 @@ const Player = () => {
|
|||
const gainInfo = useSelector((state) => state.replayGain)
|
||||
const [context, setContext] = useState(null)
|
||||
const [gainNode, setGainNode] = useState(null)
|
||||
const { setDesktopLyricsProps } = useLyricsLayout()
|
||||
const {
|
||||
toolbarLyricsProps,
|
||||
desktopLyricsProps,
|
||||
mobileLyricsSurface,
|
||||
useInlineMobileLyrics,
|
||||
closeLyrics,
|
||||
} = usePlayerLyrics({
|
||||
trackId: playerState.current?.trackId || currentTrackId,
|
||||
trackUpdatedAt:
|
||||
playerState.current?.song?.updatedAt || playerState.current?.updatedAt,
|
||||
isRadio: playerState.current?.isRadio || false,
|
||||
audioInstance,
|
||||
isDesktop,
|
||||
translate,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDesktop) {
|
||||
setDesktopLyricsProps(null)
|
||||
return undefined
|
||||
}
|
||||
|
||||
setDesktopLyricsProps(desktopLyricsProps)
|
||||
return () => setDesktopLyricsProps(null)
|
||||
}, [desktopLyricsProps, isDesktop, setDesktopLyricsProps])
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
|
|
@ -213,7 +241,7 @@ const Player = () => {
|
|||
clearPriorAudioLists: false,
|
||||
showDestroy: true,
|
||||
showDownload: false,
|
||||
showLyric: true,
|
||||
showLyric: false,
|
||||
showReload: false,
|
||||
toggleMode: !isDesktop,
|
||||
glassBg: false,
|
||||
|
|
@ -251,12 +279,16 @@ const Player = () => {
|
|||
(playerState.clear || playerState.playIndex === 0),
|
||||
clearPriorAudioLists: playerState.clear,
|
||||
extendsContent: (
|
||||
<PlayerToolbar id={current.trackId} isRadio={current.isRadio} />
|
||||
<PlayerToolbar
|
||||
id={current.trackId}
|
||||
isRadio={current.isRadio}
|
||||
{...toolbarLyricsProps}
|
||||
/>
|
||||
),
|
||||
defaultVolume: isMobilePlayer ? 1 : playerState.volume,
|
||||
showMediaSession: !current.isRadio,
|
||||
}
|
||||
}, [playerState, defaultOptions, isMobilePlayer])
|
||||
}, [playerState, defaultOptions, isMobilePlayer, toolbarLyricsProps])
|
||||
|
||||
const onAudioListsChange = useCallback(
|
||||
(_, audioLists, audioInfo) => dispatch(syncQueue(audioInfo, audioLists)),
|
||||
|
|
@ -365,11 +397,15 @@ const Player = () => {
|
|||
[dispatch, dataProvider, currentTrackId],
|
||||
)
|
||||
|
||||
const onCoverClick = useCallback((mode, audioLists, audioInfo) => {
|
||||
if (mode === 'full' && audioInfo?.song?.albumId) {
|
||||
window.location.href = `#/album/${audioInfo.song.albumId}/show`
|
||||
}
|
||||
}, [])
|
||||
const onCoverClick = useCallback(
|
||||
(mode, audioLists, audioInfo) => {
|
||||
if (useInlineMobileLyrics) return
|
||||
if (mode === 'full' && audioInfo?.song?.albumId) {
|
||||
window.location.href = `#/album/${audioInfo.song.albumId}/show`
|
||||
}
|
||||
},
|
||||
[useInlineMobileLyrics],
|
||||
)
|
||||
|
||||
const onAudioError = useCallback(
|
||||
(error, currentPlayId, audioLists, audioInfo) => {
|
||||
|
|
@ -404,10 +440,11 @@ const Player = () => {
|
|||
}
|
||||
setHeartbeatTrackId(null)
|
||||
setCurrentTrackId(null)
|
||||
closeLyrics()
|
||||
dispatch(clearQueue())
|
||||
reject()
|
||||
})
|
||||
}, [dispatch, currentTrackId])
|
||||
}, [closeLyrics, dispatch, currentTrackId])
|
||||
|
||||
if (!visible) {
|
||||
document.title = 'Navidrome'
|
||||
|
|
@ -471,6 +508,7 @@ const Player = () => {
|
|||
onBeforeDestroy={onBeforeDestroy}
|
||||
getAudioInstance={setAudioInstance}
|
||||
/>
|
||||
{mobileLyricsSurface}
|
||||
<GlobalHotKeys handlers={handlers} keyMap={keyMap} allowChanges />
|
||||
</ThemeProvider>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import { useGetOne } from 'react-admin'
|
|||
import { GlobalHotKeys } from 'react-hotkeys'
|
||||
import IconButton from '@material-ui/core/IconButton'
|
||||
import { useMediaQuery } from '@material-ui/core'
|
||||
import Tooltip from '@material-ui/core/Tooltip'
|
||||
import { RiFileMusicLine } from 'react-icons/ri'
|
||||
import { RiSaveLine } from 'react-icons/ri'
|
||||
import { LoveButton, useToggleLove } from '../common'
|
||||
import { openSaveQueueDialog } from '../actions'
|
||||
|
|
@ -55,7 +57,16 @@ const useStyles = makeStyles((theme) => ({
|
|||
},
|
||||
}))
|
||||
|
||||
const PlayerToolbar = ({ id, isRadio }) => {
|
||||
const PlayerToolbar = ({
|
||||
id,
|
||||
isRadio,
|
||||
onToggleLyrics,
|
||||
lyricsActive = false,
|
||||
lyricsDisabled = false,
|
||||
lyricsLoading = false,
|
||||
lyricsLabel = 'Toggle lyrics',
|
||||
lyricsLoadingLabel = 'Loading lyrics',
|
||||
}) => {
|
||||
const dispatch = useDispatch()
|
||||
const { data, loading } = useGetOne('song', id, { enabled: !!id && !isRadio })
|
||||
const [toggleLove, toggling] = useToggleLove('song', data)
|
||||
|
|
@ -76,6 +87,7 @@ const PlayerToolbar = ({ id, isRadio }) => {
|
|||
|
||||
const buttonClass = isDesktop ? classes.button : classes.mobileButton
|
||||
const listItemClass = isDesktop ? classes.toolbar : classes.mobileListItem
|
||||
const lyricsUnavailable = lyricsDisabled || (lyricsLoading && !lyricsActive)
|
||||
|
||||
const saveQueueButton = (
|
||||
<IconButton
|
||||
|
|
@ -99,6 +111,29 @@ const PlayerToolbar = ({ id, isRadio }) => {
|
|||
/>
|
||||
)
|
||||
|
||||
const currentLyricsLabel = lyricsLoading ? lyricsLoadingLabel : lyricsLabel
|
||||
const lyricsButton = (
|
||||
<Tooltip title={currentLyricsLabel}>
|
||||
<span>
|
||||
<IconButton
|
||||
size={isDesktop ? 'small' : undefined}
|
||||
onClick={onToggleLyrics}
|
||||
disabled={!onToggleLyrics || lyricsUnavailable}
|
||||
data-testid="toggle-lyrics-button"
|
||||
className={buttonClass}
|
||||
color={lyricsActive ? 'primary' : 'default'}
|
||||
aria-label={currentLyricsLabel}
|
||||
aria-busy={lyricsLoading}
|
||||
aria-pressed={lyricsActive}
|
||||
>
|
||||
<RiFileMusicLine
|
||||
className={!isDesktop ? classes.mobileIcon : undefined}
|
||||
/>
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<GlobalHotKeys keyMap={keyMap} handlers={handlers} allowChanges />
|
||||
|
|
@ -106,11 +141,13 @@ const PlayerToolbar = ({ id, isRadio }) => {
|
|||
<li className={`${listItemClass} item`}>
|
||||
{saveQueueButton}
|
||||
{loveButton}
|
||||
{lyricsButton}
|
||||
</li>
|
||||
) : (
|
||||
<>
|
||||
<li className={`${listItemClass} item`}>{saveQueueButton}</li>
|
||||
<li className={`${listItemClass} item`}>{loveButton}</li>
|
||||
<li className={`${listItemClass} item`}>{lyricsButton}</li>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -61,16 +61,17 @@ describe('<PlayerToolbar />', () => {
|
|||
useMediaQuery.mockReturnValue(true) // isDesktop = true
|
||||
})
|
||||
|
||||
it('renders desktop toolbar with both buttons', () => {
|
||||
it('renders desktop toolbar with all buttons', () => {
|
||||
render(<PlayerToolbar id="song-1" />)
|
||||
|
||||
// Both buttons should be in a single list item
|
||||
// All buttons should be in a single list item
|
||||
const listItems = screen.getAllByRole('listitem')
|
||||
expect(listItems).toHaveLength(1)
|
||||
|
||||
// Verify both buttons are rendered
|
||||
// Verify all buttons are rendered
|
||||
expect(screen.getByTestId('save-queue-button')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('love-button')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('toggle-lyrics-button')).toBeInTheDocument()
|
||||
|
||||
// Verify desktop classes are applied
|
||||
expect(listItems[0].className).toContain('toolbar')
|
||||
|
|
@ -102,6 +103,29 @@ describe('<PlayerToolbar />', () => {
|
|||
type: 'OPEN_SAVE_QUEUE_DIALOG',
|
||||
})
|
||||
})
|
||||
|
||||
it('toggles lyrics from an accessible toolbar button', () => {
|
||||
const onToggleLyrics = vi.fn()
|
||||
render(
|
||||
<PlayerToolbar
|
||||
id="song-1"
|
||||
onToggleLyrics={onToggleLyrics}
|
||||
lyricsActive
|
||||
/>,
|
||||
)
|
||||
|
||||
const lyricsButton = screen.getByLabelText('Toggle lyrics')
|
||||
expect(lyricsButton).toHaveAttribute(
|
||||
'data-testid',
|
||||
'toggle-lyrics-button',
|
||||
)
|
||||
expect(lyricsButton).toHaveClass('MuiIconButton-colorPrimary')
|
||||
expect(lyricsButton).toHaveAttribute('aria-pressed', 'true')
|
||||
|
||||
fireEvent.click(lyricsButton)
|
||||
|
||||
expect(onToggleLyrics).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Mobile layout', () => {
|
||||
|
|
@ -114,15 +138,17 @@ describe('<PlayerToolbar />', () => {
|
|||
|
||||
// Each button should be in its own list item
|
||||
const listItems = screen.getAllByRole('listitem')
|
||||
expect(listItems).toHaveLength(2)
|
||||
expect(listItems).toHaveLength(3)
|
||||
|
||||
// Verify both buttons are rendered
|
||||
// Verify all buttons are rendered
|
||||
expect(screen.getByTestId('save-queue-button')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('love-button')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('toggle-lyrics-button')).toBeInTheDocument()
|
||||
|
||||
// Verify mobile classes are applied
|
||||
expect(listItems[0].className).toContain('mobileListItem')
|
||||
expect(listItems[1].className).toContain('mobileListItem')
|
||||
expect(listItems[2].className).toContain('mobileListItem')
|
||||
})
|
||||
|
||||
it('disables save queue button when isRadio is true', () => {
|
||||
|
|
@ -140,6 +166,26 @@ describe('<PlayerToolbar />', () => {
|
|||
const loveButton = screen.getByTestId('love-button')
|
||||
expect(loveButton).toBeDisabled()
|
||||
})
|
||||
|
||||
it('disables lyrics toggle when lyrics are unavailable', () => {
|
||||
render(
|
||||
<PlayerToolbar id="song-1" onToggleLyrics={vi.fn()} lyricsDisabled />,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('toggle-lyrics-button')).toBeDisabled()
|
||||
})
|
||||
|
||||
it('shows loading state on the lyrics toggle before lyrics are available', () => {
|
||||
render(
|
||||
<PlayerToolbar id="song-1" onToggleLyrics={vi.fn()} lyricsLoading />,
|
||||
)
|
||||
|
||||
const lyricsButton = screen.getByTestId('toggle-lyrics-button')
|
||||
expect(lyricsButton).toBeDisabled()
|
||||
expect(lyricsButton).toHaveAttribute('aria-label', 'Loading lyrics')
|
||||
expect(lyricsButton).toHaveAttribute('aria-busy', 'true')
|
||||
expect(lyricsButton).toHaveAttribute('aria-pressed', 'false')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Common behavior', () => {
|
||||
|
|
|
|||
28
ui/src/audioplayer/lyricsSidebarState.js
Normal file
28
ui/src/audioplayer/lyricsSidebarState.js
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
export const resolveLyricsSidebarState = ({
|
||||
lyricsVisiblePreference,
|
||||
translationPreference,
|
||||
pronunciationPreference,
|
||||
hasTranslationLyric,
|
||||
hasPronunciationLyric,
|
||||
}) => ({
|
||||
lyricsVisible: Boolean(lyricsVisiblePreference),
|
||||
showTranslation: Boolean(
|
||||
(translationPreference == null ? true : translationPreference) &&
|
||||
hasTranslationLyric,
|
||||
),
|
||||
showPronunciation: Boolean(
|
||||
(pronunciationPreference == null ? true : pronunciationPreference) &&
|
||||
hasPronunciationLyric,
|
||||
),
|
||||
})
|
||||
|
||||
export const toggleLayerPreference = (
|
||||
previousPreference,
|
||||
hasLayer,
|
||||
defaultEnabled = true,
|
||||
) => {
|
||||
if (!hasLayer) return false
|
||||
const currentPreference =
|
||||
previousPreference == null ? defaultEnabled : previousPreference
|
||||
return !currentPreference
|
||||
}
|
||||
41
ui/src/audioplayer/lyricsSidebarState.test.js
Normal file
41
ui/src/audioplayer/lyricsSidebarState.test.js
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
resolveLyricsSidebarState,
|
||||
toggleLayerPreference,
|
||||
} from './lyricsSidebarState'
|
||||
|
||||
describe('lyricsSidebarState', () => {
|
||||
it('defaults available layers on and keeps preferences local', () => {
|
||||
const resolve = (availability, preferences = {}) =>
|
||||
resolveLyricsSidebarState({
|
||||
lyricsVisiblePreference: true,
|
||||
translationPreference: null,
|
||||
pronunciationPreference: null,
|
||||
...availability,
|
||||
...preferences,
|
||||
})
|
||||
|
||||
expect(
|
||||
resolve({ hasTranslationLyric: true, hasPronunciationLyric: true }),
|
||||
).toEqual({
|
||||
lyricsVisible: true,
|
||||
showTranslation: true,
|
||||
showPronunciation: true,
|
||||
})
|
||||
expect(
|
||||
resolve(
|
||||
{ hasTranslationLyric: true, hasPronunciationLyric: false },
|
||||
{ translationPreference: false, pronunciationPreference: true },
|
||||
),
|
||||
).toEqual({
|
||||
lyricsVisible: true,
|
||||
showTranslation: false,
|
||||
showPronunciation: false,
|
||||
})
|
||||
|
||||
expect(toggleLayerPreference(null, true)).toBe(false)
|
||||
expect(toggleLayerPreference(null, true, false)).toBe(true)
|
||||
expect(toggleLayerPreference(false, true)).toBe(true)
|
||||
expect(toggleLayerPreference(true, false)).toBe(false)
|
||||
})
|
||||
})
|
||||
61
ui/src/audioplayer/lyricsSidebarWidth.js
Normal file
61
ui/src/audioplayer/lyricsSidebarWidth.js
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
export const LYRICS_SIDEBAR_STORAGE_KEY = 'nd.lyricsSidebar.width.v1'
|
||||
export const LYRICS_SIDEBAR_WIDTH_EVENT = 'nd:lyrics-sidebar-width'
|
||||
export const LYRICS_SIDEBAR_DEFAULT_WIDTH = 360
|
||||
export const LYRICS_SIDEBAR_MIN_WIDTH = 300
|
||||
export const LYRICS_SIDEBAR_MAX_WIDTH = 520
|
||||
export const LYRICS_SIDEBAR_WIDTH_STEP = 20
|
||||
export const LYRICS_SIDEBAR_TRANSITION_MS = 260
|
||||
|
||||
export const clampSidebarWidth = (value) => {
|
||||
const numeric = Number(value)
|
||||
if (!Number.isFinite(numeric)) return LYRICS_SIDEBAR_DEFAULT_WIDTH
|
||||
return Math.round(
|
||||
Math.min(
|
||||
LYRICS_SIDEBAR_MAX_WIDTH,
|
||||
Math.max(LYRICS_SIDEBAR_MIN_WIDTH, numeric),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
const hasLocalStorage = () => {
|
||||
try {
|
||||
return typeof window !== 'undefined' && Boolean(window.localStorage)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export const loadSidebarWidth = () => {
|
||||
if (!hasLocalStorage()) return LYRICS_SIDEBAR_DEFAULT_WIDTH
|
||||
try {
|
||||
const stored = window.localStorage.getItem(LYRICS_SIDEBAR_STORAGE_KEY)
|
||||
return stored == null
|
||||
? LYRICS_SIDEBAR_DEFAULT_WIDTH
|
||||
: clampSidebarWidth(stored)
|
||||
} catch {
|
||||
return LYRICS_SIDEBAR_DEFAULT_WIDTH
|
||||
}
|
||||
}
|
||||
|
||||
export const notifySidebarWidthChange = (width) => {
|
||||
if (typeof window === 'undefined') return
|
||||
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(LYRICS_SIDEBAR_WIDTH_EVENT, {
|
||||
detail: { width: clampSidebarWidth(width) },
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export const saveSidebarWidth = (width) => {
|
||||
const resolvedWidth = clampSidebarWidth(width)
|
||||
if (!hasLocalStorage()) return
|
||||
try {
|
||||
window.localStorage.setItem(
|
||||
LYRICS_SIDEBAR_STORAGE_KEY,
|
||||
String(resolvedWidth),
|
||||
)
|
||||
} catch {
|
||||
// Ignore storage failures.
|
||||
}
|
||||
}
|
||||
|
|
@ -32,7 +32,7 @@ const useStyle = makeStyles(
|
|||
display: 'none',
|
||||
},
|
||||
},
|
||||
'@media (prefers-reduced-motion)': {
|
||||
'@media (prefers-reduced-motion: reduce)': {
|
||||
'& .music-player-panel .panel-content div.img-rotate': {
|
||||
animation: 'none',
|
||||
},
|
||||
|
|
@ -62,11 +62,57 @@ const useStyle = makeStyles(
|
|||
// Fix cover display when image is not square
|
||||
aspectRatio: '1/1',
|
||||
display: 'flex',
|
||||
position: 'relative',
|
||||
},
|
||||
'& .react-jinke-music-player-mobile .react-jinke-music-player-mobile-cover.nd-mobile-lyrics-active':
|
||||
{
|
||||
width: '100%',
|
||||
maxWidth: '100%',
|
||||
height: '100%',
|
||||
aspectRatio: 'auto',
|
||||
alignItems: 'stretch',
|
||||
justifyContent: 'stretch',
|
||||
},
|
||||
'& .react-jinke-music-player-mobile .react-jinke-music-player-mobile-cover .nd-mobile-lyrics-layer':
|
||||
{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
opacity: 0,
|
||||
transform: 'scale(0.985)',
|
||||
transition:
|
||||
'opacity 260ms cubic-bezier(0.22, 1, 0.36, 1), transform 260ms cubic-bezier(0.22, 1, 0.36, 1)',
|
||||
'@media (prefers-reduced-motion: reduce)': {
|
||||
transition: 'opacity 120ms linear',
|
||||
transform: 'none',
|
||||
},
|
||||
},
|
||||
'& .react-jinke-music-player-mobile .react-jinke-music-player-mobile-cover .nd-mobile-lyrics-layer[data-entered="true"]':
|
||||
{
|
||||
opacity: 1,
|
||||
transform: 'scale(1)',
|
||||
'@media (prefers-reduced-motion: reduce)': {
|
||||
transform: 'none',
|
||||
},
|
||||
},
|
||||
'& .react-jinke-music-player-mobile .react-jinke-music-player-mobile-cover img.cover':
|
||||
{
|
||||
animationDuration: (props) => !props.enableCoverAnimation && '0s',
|
||||
objectFit: 'contain', // Fix cover display when image is not square
|
||||
transition:
|
||||
'opacity 260ms cubic-bezier(0.22, 1, 0.36, 1), transform 260ms cubic-bezier(0.22, 1, 0.36, 1)',
|
||||
'@media (prefers-reduced-motion: reduce)': {
|
||||
transition: 'opacity 120ms linear',
|
||||
transform: 'none',
|
||||
},
|
||||
},
|
||||
'& .react-jinke-music-player-mobile .react-jinke-music-player-mobile-cover.nd-mobile-lyrics-active img.cover':
|
||||
{
|
||||
opacity: 0,
|
||||
transform: 'scale(0.96)',
|
||||
pointerEvents: 'none',
|
||||
'@media (prefers-reduced-motion: reduce)': {
|
||||
transform: 'none',
|
||||
},
|
||||
},
|
||||
// Hide old singer display
|
||||
'& .react-jinke-music-player-mobile .react-jinke-music-player-mobile-singer':
|
||||
|
|
|
|||
22
ui/src/audioplayer/useEnterExitTransition.js
Normal file
22
ui/src/audioplayer/useEnterExitTransition.js
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
|
||||
const useEnterExitTransition = (active, transitionMs) => {
|
||||
const [rendered, setRendered] = useState(active)
|
||||
const [entered, setEntered] = useState(active)
|
||||
|
||||
useEffect(() => {
|
||||
if (active) {
|
||||
setRendered(true)
|
||||
const frameId = window.requestAnimationFrame(() => setEntered(true))
|
||||
return () => window.cancelAnimationFrame(frameId)
|
||||
}
|
||||
|
||||
setEntered(false)
|
||||
const timerId = window.setTimeout(() => setRendered(false), transitionMs)
|
||||
return () => window.clearTimeout(timerId)
|
||||
}, [active, transitionMs])
|
||||
|
||||
return { rendered, entered }
|
||||
}
|
||||
|
||||
export default useEnterExitTransition
|
||||
185
ui/src/audioplayer/usePlayerLyrics.jsx
Normal file
185
ui/src/audioplayer/usePlayerLyrics.jsx
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import LyricsPanel from './LyricsPanel'
|
||||
import MobileKaraokeLyricsPortal from './MobileKaraokeLyricsPortal'
|
||||
import { hasStructuredLyricContent } from './lyrics'
|
||||
import {
|
||||
resolveLyricsSidebarState,
|
||||
toggleLayerPreference,
|
||||
} from './lyricsSidebarState'
|
||||
import useEnhancedLyrics from './useEnhancedLyrics'
|
||||
|
||||
const usePlayerLyrics = ({
|
||||
trackId,
|
||||
trackUpdatedAt,
|
||||
isRadio,
|
||||
audioInstance,
|
||||
isDesktop,
|
||||
translate,
|
||||
}) => {
|
||||
const [lyricsVisiblePreference, setLyricsVisiblePreference] = useState(false)
|
||||
const [lyricsRequested, setLyricsRequested] = useState(false)
|
||||
const [translationPreference, setTranslationPreference] = useState(null)
|
||||
const [pronunciationPreference, setPronunciationPreference] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
setLyricsRequested(lyricsVisiblePreference)
|
||||
}, [trackId]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const {
|
||||
layers: lyricLayers,
|
||||
loading: lyricsLoading,
|
||||
error: lyricsError,
|
||||
retry: retryLyrics,
|
||||
} = useEnhancedLyrics({
|
||||
trackId,
|
||||
updatedAt: trackUpdatedAt,
|
||||
disabled: isRadio,
|
||||
requested: lyricsRequested || lyricsVisiblePreference,
|
||||
})
|
||||
|
||||
const hasTranslationLyric = hasStructuredLyricContent(lyricLayers.translation)
|
||||
const hasPronunciationLyric = hasStructuredLyricContent(
|
||||
lyricLayers.pronunciation,
|
||||
)
|
||||
const { lyricsVisible, showTranslation, showPronunciation } =
|
||||
resolveLyricsSidebarState({
|
||||
lyricsVisiblePreference,
|
||||
translationPreference,
|
||||
pronunciationPreference,
|
||||
hasTranslationLyric,
|
||||
hasPronunciationLyric,
|
||||
})
|
||||
const lyricsToggleDisabled = !lyricsVisiblePreference && (isRadio || !trackId)
|
||||
const useInlineMobileLyrics = lyricsVisible && !isDesktop
|
||||
|
||||
const labels = useMemo(
|
||||
() => ({
|
||||
title: translate('player.lyricsTitleText'),
|
||||
toggle: translate('player.toggleLyricText'),
|
||||
loading: translate('player.lyricsLoadingText'),
|
||||
unavailable: translate('player.lyricsUnavailableText'),
|
||||
empty: translate('player.emptyLyricText'),
|
||||
resize: translate('player.resizeLyricsSidebarText'),
|
||||
showTranslation: translate('player.showLyricsTranslationText'),
|
||||
hideTranslation: translate('player.hideLyricsTranslationText'),
|
||||
showPronunciation: translate('player.showLyricsPronunciationText'),
|
||||
hidePronunciation: translate('player.hideLyricsPronunciationText'),
|
||||
}),
|
||||
[translate],
|
||||
)
|
||||
|
||||
const toggleLyrics = useCallback(() => {
|
||||
const next = !lyricsVisiblePreference
|
||||
setLyricsVisiblePreference(next)
|
||||
setLyricsRequested(next)
|
||||
if (next) {
|
||||
if (lyricsError) retryLyrics()
|
||||
}
|
||||
}, [lyricsError, lyricsVisiblePreference, retryLyrics])
|
||||
|
||||
const closeLyrics = useCallback(() => {
|
||||
setLyricsVisiblePreference(false)
|
||||
setLyricsRequested(false)
|
||||
}, [])
|
||||
|
||||
const toggleTranslation = useCallback(() => {
|
||||
setTranslationPreference((current) =>
|
||||
toggleLayerPreference(current, hasTranslationLyric),
|
||||
)
|
||||
}, [hasTranslationLyric])
|
||||
|
||||
const togglePronunciation = useCallback(() => {
|
||||
setPronunciationPreference((current) =>
|
||||
toggleLayerPreference(current, hasPronunciationLyric, true),
|
||||
)
|
||||
}, [hasPronunciationLyric])
|
||||
|
||||
const toolbarLyricsProps = useMemo(
|
||||
() => ({
|
||||
onToggleLyrics: toggleLyrics,
|
||||
lyricsActive: lyricsVisible,
|
||||
lyricsDisabled: lyricsToggleDisabled,
|
||||
lyricsLoading,
|
||||
lyricsLabel: labels.toggle,
|
||||
lyricsLoadingLabel: labels.loading,
|
||||
}),
|
||||
[labels, lyricsLoading, lyricsToggleDisabled, lyricsVisible, toggleLyrics],
|
||||
)
|
||||
|
||||
const desktopLyricsProps = useMemo(
|
||||
() => ({
|
||||
visible: isDesktop && lyricsVisible,
|
||||
mainLyric: lyricLayers.main,
|
||||
translationLyric: lyricLayers.translation,
|
||||
pronunciationLyric: lyricLayers.pronunciation,
|
||||
showTranslation,
|
||||
showPronunciation,
|
||||
translationEnabled: hasTranslationLyric,
|
||||
pronunciationEnabled: hasPronunciationLyric,
|
||||
onToggleTranslation: toggleTranslation,
|
||||
onTogglePronunciation: togglePronunciation,
|
||||
audioInstance,
|
||||
loading: lyricsLoading,
|
||||
error: lyricsError,
|
||||
labels,
|
||||
}),
|
||||
[
|
||||
audioInstance,
|
||||
hasPronunciationLyric,
|
||||
hasTranslationLyric,
|
||||
isDesktop,
|
||||
labels,
|
||||
lyricLayers.main,
|
||||
lyricLayers.pronunciation,
|
||||
lyricLayers.translation,
|
||||
lyricsError,
|
||||
lyricsLoading,
|
||||
lyricsVisible,
|
||||
showPronunciation,
|
||||
showTranslation,
|
||||
togglePronunciation,
|
||||
toggleTranslation,
|
||||
],
|
||||
)
|
||||
|
||||
const mobileLyricsSurface = useMemo(
|
||||
() => (
|
||||
<MobileKaraokeLyricsPortal active={useInlineMobileLyrics}>
|
||||
<LyricsPanel
|
||||
mainLyric={lyricLayers.main}
|
||||
translationLyric={lyricLayers.translation}
|
||||
pronunciationLyric={lyricLayers.pronunciation}
|
||||
showTranslation={showTranslation}
|
||||
showPronunciation={showPronunciation}
|
||||
audioInstance={audioInstance}
|
||||
loading={lyricsLoading}
|
||||
error={lyricsError}
|
||||
labels={labels}
|
||||
inline
|
||||
/>
|
||||
</MobileKaraokeLyricsPortal>
|
||||
),
|
||||
[
|
||||
audioInstance,
|
||||
lyricLayers.main,
|
||||
lyricLayers.pronunciation,
|
||||
lyricLayers.translation,
|
||||
lyricsError,
|
||||
lyricsLoading,
|
||||
labels,
|
||||
showPronunciation,
|
||||
showTranslation,
|
||||
useInlineMobileLyrics,
|
||||
],
|
||||
)
|
||||
|
||||
return {
|
||||
toolbarLyricsProps,
|
||||
desktopLyricsProps,
|
||||
mobileLyricsSurface,
|
||||
useInlineMobileLyrics,
|
||||
closeLyrics,
|
||||
}
|
||||
}
|
||||
|
||||
export default usePlayerLyrics
|
||||
|
|
@ -652,6 +652,14 @@
|
|||
"reloadText": "Reload",
|
||||
"volumeText": "Volume",
|
||||
"toggleLyricText": "Toggle lyrics",
|
||||
"lyricsTitleText": "Lyrics",
|
||||
"lyricsLoadingText": "Loading lyrics",
|
||||
"lyricsUnavailableText": "Lyrics unavailable",
|
||||
"resizeLyricsSidebarText": "Resize lyrics sidebar",
|
||||
"showLyricsTranslationText": "Show translation",
|
||||
"hideLyricsTranslationText": "Hide translation",
|
||||
"showLyricsPronunciationText": "Show pronunciation",
|
||||
"hideLyricsPronunciationText": "Hide pronunciation",
|
||||
"toggleMiniModeText": "Minimize",
|
||||
"destroyText": "Destroy",
|
||||
"downloadText": "Download",
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import AppBar from './AppBar'
|
|||
import Notification from './Notification'
|
||||
import useCurrentTheme from '../themes/useCurrentTheme'
|
||||
import { useSearchRefocus } from '../common'
|
||||
import LyricsLayoutFrame from './LyricsLayoutFrame'
|
||||
|
||||
const useStyles = makeStyles({
|
||||
root: { paddingBottom: (props) => (props.addPadding ? '80px' : 0) },
|
||||
|
|
@ -33,7 +34,9 @@ const Layout = (props) => {
|
|||
appBar={AppBar}
|
||||
theme={theme}
|
||||
notification={Notification}
|
||||
/>
|
||||
>
|
||||
<LyricsLayoutFrame>{props.children}</LyricsLayoutFrame>
|
||||
</RALayout>
|
||||
</HotKeys>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
55
ui/src/layout/LyricsLayoutFrame.jsx
Normal file
55
ui/src/layout/LyricsLayoutFrame.jsx
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import React, { useEffect, useState } from 'react'
|
||||
import { makeStyles } from '@material-ui/core/styles'
|
||||
import LyricsSidebar from '../audioplayer/LyricsSidebar'
|
||||
import { useLyricsLayout } from '../audioplayer/LyricsLayoutContext'
|
||||
import {
|
||||
LYRICS_SIDEBAR_WIDTH_EVENT,
|
||||
clampSidebarWidth,
|
||||
loadSidebarWidth,
|
||||
} from '../audioplayer/lyricsSidebarWidth'
|
||||
|
||||
const useStyles = makeStyles(
|
||||
() => ({
|
||||
frame: {
|
||||
minWidth: 0,
|
||||
},
|
||||
}),
|
||||
{ name: 'NDLyricsLayoutFrame' },
|
||||
)
|
||||
|
||||
const LyricsLayoutFrame = ({ children }) => {
|
||||
const { desktopLyricsProps } = useLyricsLayout()
|
||||
const hasVisibleLyrics = Boolean(desktopLyricsProps?.visible)
|
||||
const [sidebarWidth, setSidebarWidth] = useState(loadSidebarWidth)
|
||||
const classes = useStyles()
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasVisibleLyrics) return undefined
|
||||
|
||||
const handleSidebarWidth = (event) => {
|
||||
const next = clampSidebarWidth(event.detail?.width)
|
||||
setSidebarWidth((current) => (current === next ? current : next))
|
||||
}
|
||||
|
||||
setSidebarWidth(loadSidebarWidth())
|
||||
window.addEventListener(LYRICS_SIDEBAR_WIDTH_EVENT, handleSidebarWidth)
|
||||
return () =>
|
||||
window.removeEventListener(LYRICS_SIDEBAR_WIDTH_EVENT, handleSidebarWidth)
|
||||
}, [hasVisibleLyrics])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classes.frame}
|
||||
data-testid="lyrics-layout-frame"
|
||||
data-lyrics-sidebar-visible={hasVisibleLyrics ? 'true' : 'false'}
|
||||
style={{
|
||||
marginRight: hasVisibleLyrics ? sidebarWidth : 0,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
{desktopLyricsProps && <LyricsSidebar {...desktopLyricsProps} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default LyricsLayoutFrame
|
||||
114
ui/src/layout/LyricsLayoutFrame.test.jsx
Normal file
114
ui/src/layout/LyricsLayoutFrame.test.jsx
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
import React, { useEffect } from 'react'
|
||||
import { act, cleanup, render, screen } from '@testing-library/react'
|
||||
import { ThemeProvider, createTheme } from '@material-ui/core/styles'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
LyricsLayoutProvider,
|
||||
useLyricsLayout,
|
||||
} from '../audioplayer/LyricsLayoutContext'
|
||||
import { LYRICS_SIDEBAR_TRANSITION_MS } from '../audioplayer/lyricsSidebarWidth'
|
||||
import LyricsLayoutFrame from './LyricsLayoutFrame'
|
||||
|
||||
const theme = createTheme({
|
||||
palette: {
|
||||
primary: { main: '#35aa66' },
|
||||
background: { default: '#101820' },
|
||||
},
|
||||
})
|
||||
|
||||
const lyric = {
|
||||
synced: true,
|
||||
line: [{ start: 0, end: 1000, value: 'Main line' }],
|
||||
}
|
||||
|
||||
const lyricsProps = {
|
||||
visible: true,
|
||||
mainLyric: lyric,
|
||||
showTranslation: false,
|
||||
showPronunciation: false,
|
||||
translationEnabled: false,
|
||||
pronunciationEnabled: false,
|
||||
onToggleTranslation: vi.fn(),
|
||||
onTogglePronunciation: vi.fn(),
|
||||
}
|
||||
|
||||
const PublishLyricsProps = ({ props }) => {
|
||||
const { setDesktopLyricsProps } = useLyricsLayout()
|
||||
|
||||
useEffect(() => {
|
||||
setDesktopLyricsProps(props)
|
||||
return () => setDesktopLyricsProps(null)
|
||||
}, [props, setDesktopLyricsProps])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const frame = (props) => (
|
||||
<ThemeProvider theme={theme}>
|
||||
<LyricsLayoutProvider>
|
||||
<PublishLyricsProps props={props} />
|
||||
<LyricsLayoutFrame>
|
||||
<div data-testid="route-content">Albums grid</div>
|
||||
</LyricsLayoutFrame>
|
||||
</LyricsLayoutProvider>
|
||||
</ThemeProvider>
|
||||
)
|
||||
|
||||
const renderFrame = (props) => {
|
||||
const view = render(frame(props))
|
||||
return {
|
||||
...view,
|
||||
rerenderFrame: (nextProps) => view.rerender(frame(nextProps)),
|
||||
}
|
||||
}
|
||||
|
||||
describe('<LyricsLayoutFrame />', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
cleanup()
|
||||
})
|
||||
|
||||
it('reserves a native right-hand lyrics pane beside route content', async () => {
|
||||
renderFrame(lyricsProps)
|
||||
|
||||
const sidebar = await screen.findByTestId('lyrics-sidebar')
|
||||
const frame = screen.getByTestId('lyrics-layout-frame')
|
||||
const content = screen.getByTestId('route-content')
|
||||
|
||||
expect(frame).toHaveAttribute('data-lyrics-sidebar-visible', 'true')
|
||||
expect(frame).toHaveStyle({ marginRight: '360px' })
|
||||
expect(frame).toContainElement(content)
|
||||
expect(frame).toContainElement(sidebar)
|
||||
expect(window.getComputedStyle(sidebar).position).toBe('fixed')
|
||||
})
|
||||
|
||||
it('releases the layout frame after the sidebar exit transition', async () => {
|
||||
vi.useFakeTimers()
|
||||
const { rerenderFrame } = renderFrame(lyricsProps)
|
||||
|
||||
await screen.findByTestId('lyrics-sidebar')
|
||||
|
||||
rerenderFrame({ ...lyricsProps, visible: false })
|
||||
|
||||
expect(screen.getByTestId('lyrics-layout-frame')).toHaveAttribute(
|
||||
'data-lyrics-sidebar-visible',
|
||||
'false',
|
||||
)
|
||||
expect(screen.getByTestId('lyrics-layout-frame')).toHaveStyle({
|
||||
marginRight: '0px',
|
||||
})
|
||||
expect(screen.getByTestId('lyrics-sidebar')).toHaveStyle({
|
||||
transform: 'translateX(100%)',
|
||||
})
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(LYRICS_SIDEBAR_TRANSITION_MS)
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('lyrics-layout-frame')).toHaveAttribute(
|
||||
'data-lyrics-sidebar-visible',
|
||||
'false',
|
||||
)
|
||||
expect(screen.queryByTestId('lyrics-sidebar')).toBeNull()
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue