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:
ranokay 2026-07-17 09:21:56 +03:00
commit 20ffa444b9
No known key found for this signature in database
19 changed files with 1740 additions and 18 deletions

View file

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

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

View 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

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

View 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

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

View file

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

View file

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

View file

@ -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', () => {

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

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

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

View file

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

View 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

View 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

View file

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

View file

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

View 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

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