From 7ff6744af0f91ecf30c1167123f4a921f213f390 Mon Sep 17 00:00:00 2001 From: Daniel Banariba Date: Wed, 19 Aug 2026 17:32:03 -0600 Subject: [PATCH] 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. --- ui/src/audioplayer/Player.jsx | 15 +++- ui/src/audioplayer/Player.test.jsx | 109 +++++++++++++++++++++++++++++ 2 files changed, 122 insertions(+), 2 deletions(-) create mode 100644 ui/src/audioplayer/Player.test.jsx diff --git a/ui/src/audioplayer/Player.jsx b/ui/src/audioplayer/Player.jsx index c3d795b26..ecab5877c 100644 --- a/ui/src/audioplayer/Player.jsx +++ b/ui/src/audioplayer/Player.jsx @@ -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(() => { diff --git a/ui/src/audioplayer/Player.test.jsx b/ui/src/audioplayer/Player.test.jsx new file mode 100644 index 000000000..63e7b6669 --- /dev/null +++ b/ui/src/audioplayer/Player.test.jsx @@ -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
+ }, +})) +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: () =>
})) +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('', () => { + 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() + 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() + }) + + expect(playerProps.current.mode).toBe('full') + }) + + it('keeps the collapsed mode while the viewport stays narrow', () => { + useMediaQuery.mockReturnValue(false) + const { rerender } = render() + + act(() => playerProps.current.onModeChange('mini')) + act(() => { + rerender() + }) + + expect(playerProps.current.mode).toBe('mini') + }) +})