fix(ui): restore the player when the window grows into desktop

`toggleMode` is disabled on desktop, and `onOpenPanel` is gated behind it,
so a player collapsed into the mini circle while the window was narrow
could never be reopened once the window grew past the 810px breakpoint.
The circle stayed on screen, labelled "Open", with its click handler inert.

The player already supports `mode` as a controlled prop paired with
`onModeChange`, but `mode` was hardcoded to `full`. Since `updateMode`
only reacts when the value actually changes, that hardcoded prop never
triggered anything.

Track the mode in state, feed it back through `onModeChange`, and reset it
to `full` when the viewport crosses into desktop. The footer player is
restored on resize, `toggleMode` keeps its current behaviour, and the
narrow-window experience is unchanged.
This commit is contained in:
Daniel Banariba 2026-08-19 17:32:03 -06:00
commit 7ff6744af0
2 changed files with 122 additions and 2 deletions

View file

@ -49,6 +49,7 @@ const Player = () => {
const stoppedRef = useRef(false)
const [audioInstance, setAudioInstance] = useState(null)
const isDesktop = useMediaQuery('(min-width:810px)')
const [playerMode, setPlayerMode] = useState('full')
const isMobilePlayer =
/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(
navigator.userAgent,
@ -56,6 +57,15 @@ const Player = () => {
const { authenticated } = useAuthState()
// On desktop the player is the footer panel and `toggleMode` is disabled, so
// a player left collapsed while the window was narrow can never be reopened.
// Driving `mode` back to full on the transition restores the footer layout.
useEffect(() => {
if (isDesktop) {
setPlayerMode('full')
}
}, [isDesktop])
// Keep a ref to playerState so the mount effect can read the latest value
// without re-triggering on every queue/position change
const playerStateRef = useRef(playerState)
@ -207,7 +217,8 @@ const Player = () => {
theme: playerTheme,
bounds: 'body',
playMode: playerState.mode,
mode: 'full',
mode: playerMode,
onModeChange: setPlayerMode,
loadAudioErrorPlayNext: false,
autoPlayInitLoadPlayList: true,
clearPriorAudioLists: false,
@ -236,7 +247,7 @@ const Player = () => {
locale: locale(translate),
sortableOptions: { delay: 200, delayOnTouchOnly: true },
}),
[gainInfo, isDesktop, playerTheme, translate, playerState.mode],
[gainInfo, isDesktop, playerMode, playerTheme, translate, playerState.mode],
)
const options = useMemo(() => {

View file

@ -0,0 +1,109 @@
import React from 'react'
import { render, act, cleanup } from '@testing-library/react'
import { useMediaQuery } from '@material-ui/core'
import { useAuthState } from 'react-admin'
import { useSelector } from 'react-redux'
import { Player } from './Player'
// Captures the props the music player is rendered with, and exposes the
// onModeChange callback so a test can simulate the user collapsing the player.
const playerProps = { current: null }
vi.mock('navidrome-music-player', () => ({
default: (props) => {
playerProps.current = props
return <div data-testid="music-player" />
},
}))
vi.mock('navidrome-music-player/assets/index.css', () => ({}))
vi.mock('@material-ui/core', async () => {
const actual = await import('@material-ui/core')
return { ...actual, useMediaQuery: vi.fn() }
})
vi.mock('react-admin', async () => {
const actual = await import('react-admin')
return {
...actual,
useAuthState: vi.fn(),
useDataProvider: () => ({ getOne: vi.fn().mockResolvedValue({}) }),
useTranslate: () => (key) => key,
}
})
vi.mock('react-redux', () => ({
useDispatch: () => vi.fn(),
useSelector: vi.fn(),
}))
vi.mock('../transcode', () => ({
detectBrowserProfile: () => ({}),
decisionService: {
setProfile: vi.fn(),
resolveStreamUrl: vi.fn().mockResolvedValue(''),
prefetchDecisions: vi.fn(),
invalidateAll: vi.fn(),
},
}))
vi.mock('./PlayerToolbar', () => ({ default: () => <div /> }))
vi.mock('../subsonic', () => ({ default: { reportPlayback: vi.fn() } }))
const state = {
player: {
queue: [{ trackId: 't1', uuid: 'u1', isRadio: false }],
playIndex: 0,
volume: 1,
mode: 'order',
current: { trackId: 't1' },
clear: false,
savedPlayIndex: 0,
},
settings: { notifications: false },
replayGain: { gainMode: 'none' },
theme: 'dark',
}
describe('<Player />', () => {
beforeEach(() => {
playerProps.current = null
useAuthState.mockReturnValue({ authenticated: true })
useSelector.mockImplementation((selector) => selector(state))
})
afterEach(cleanup)
// Regression test for #5917: a player collapsed while the window was narrow
// could never be reopened once the window grew past the desktop breakpoint,
// because toggleMode is disabled on desktop.
it('returns to full mode when the viewport grows into desktop', () => {
useMediaQuery.mockReturnValue(false)
const { rerender } = render(<Player />)
expect(playerProps.current.mode).toBe('full')
// The user collapses the player into the mini circle.
act(() => playerProps.current.onModeChange('mini'))
expect(playerProps.current.mode).toBe('mini')
// The window is widened past the desktop breakpoint.
useMediaQuery.mockReturnValue(true)
act(() => {
rerender(<Player />)
})
expect(playerProps.current.mode).toBe('full')
})
it('keeps the collapsed mode while the viewport stays narrow', () => {
useMediaQuery.mockReturnValue(false)
const { rerender } = render(<Player />)
act(() => playerProps.current.onModeChange('mini'))
act(() => {
rerender(<Player />)
})
expect(playerProps.current.mode).toBe('mini')
})
})