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