diff --git a/ui/src/audioplayer/Player.jsx b/ui/src/audioplayer/Player.jsx index 7d086172b..65f18a292 100644 --- a/ui/src/audioplayer/Player.jsx +++ b/ui/src/audioplayer/Player.jsx @@ -1,5 +1,5 @@ -import React, { useCallback, useEffect, useMemo, useState } from 'react' -import { useDispatch, useSelector } from 'react-redux' +import React, { useCallback, useMemo } from 'react' +import { useSelector } from 'react-redux' import { useMediaQuery } from '@material-ui/core' import { ThemeProvider } from '@material-ui/core/styles' import { @@ -16,32 +16,28 @@ import useCurrentTheme from '../themes/useCurrentTheme' import config from '../config' import useStyle from './styles' import AudioTitle from './AudioTitle' -import { - clearQueue, - currentPlaying, - setPlayMode, - setVolume, - syncQueue, -} from '../actions' import PlayerToolbar from './PlayerToolbar' import { sendNotification } from '../utils' -import subsonic from '../subsonic' import locale from './locale' import { keyMap } from '../hotkeys' import keyHandlers from './keyHandlers' -import { calculateGain } from '../utils/calculateReplayGain' +import { useScrobbling } from './hooks/useScrobbling' +import { useReplayGain } from './hooks/useReplayGain' +import { usePreloading } from './hooks/usePreloading' +import { usePlayerState } from './hooks/usePlayerState' +import { useAudioInstance } from './hooks/useAudioInstance' +/** + * Player component for Navidrome music streaming application. + * Renders an audio player with scrobbling, replay gain, preloading, and other features. + * + * @returns {JSX.Element} The rendered Player component. + */ const Player = () => { const theme = useCurrentTheme() const translate = useTranslate() const playerTheme = theme.player?.theme || 'dark' const dataProvider = useDataProvider() - const playerState = useSelector((state) => state.player) - const dispatch = useDispatch() - const [startTime, setStartTime] = useState(null) - const [scrobbled, setScrobbled] = useState(false) - const [preloaded, setPreload] = useState(false) - const [audioInstance, setAudioInstance] = useState(null) const isDesktop = useMediaQuery('(min-width:810px)') const isMobilePlayer = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test( @@ -49,6 +45,39 @@ const Player = () => { ) const { authenticated } = useAuthState() + const showNotifications = useSelector( + (state) => state.settings.notifications || false, + ) + const gainInfo = useSelector((state) => state.replayGain) + + // Custom hooks for separated concerns + const { + playerState, + dispatch, + dispatchCurrentPlaying, + dispatchSetPlayMode, + dispatchSetVolume, + dispatchSyncQueue, + dispatchClearQueue, + } = usePlayerState() + + const { + startTime, + setStartTime, + scrobbled, + onAudioProgress, + onAudioPlayTrackChange, + onAudioEnded, + } = useScrobbling(playerState, dispatch, dataProvider) + + const { preloaded, preloadNextSong, resetPreloading } = + usePreloading(playerState) + + const { audioInstance, setAudioInstance, onAudioPlay } = + useAudioInstance(isMobilePlayer) + + const { context } = useReplayGain(audioInstance, playerState, gainInfo) + const visible = authenticated && playerState.queue.length > 0 const isRadio = playerState.current?.isRadio || false const classes = useStyle({ @@ -56,44 +85,6 @@ const Player = () => { visible, enableCoverAnimation: config.enableCoverAnimation, }) - const showNotifications = useSelector( - (state) => state.settings.notifications || false, - ) - const gainInfo = useSelector((state) => state.replayGain) - const [context, setContext] = useState(null) - const [gainNode, setGainNode] = useState(null) - - useEffect(() => { - if ( - context === null && - audioInstance && - config.enableReplayGain && - 'AudioContext' in window && - (gainInfo.gainMode === 'album' || gainInfo.gainMode === 'track') - ) { - const ctx = new AudioContext() - // we need this to support radios in firefox - audioInstance.crossOrigin = 'anonymous' - const source = ctx.createMediaElementSource(audioInstance) - const gain = ctx.createGain() - - source.connect(gain) - gain.connect(ctx.destination) - - setContext(ctx) - setGainNode(gain) - } - }, [audioInstance, context, gainInfo.gainMode]) - - useEffect(() => { - if (gainNode) { - const current = playerState.current || {} - const song = current.song || {} - - const numericGain = calculateGain(gainInfo, song) - gainNode.gain.setValueAtTime(numericGain, context.currentTime) - } - }, [audioInstance, context, gainNode, playerState, gainInfo]) useEffect(() => { const handleBeforeUnload = (e) => { @@ -142,140 +133,84 @@ const Player = () => { locale: locale(translate), sortableOptions: { delay: 200, delayOnTouchOnly: true }, }), - [gainInfo, isDesktop, playerTheme, translate, playerState.mode], + [playerTheme, playerState.mode, isDesktop, gainInfo, translate], ) + // Memoize expensive computations + const audioLists = useMemo( + () => playerState.queue.map((item) => item), + [playerState.queue], + ) + + const currentTrack = playerState.current || {} + const options = useMemo(() => { - const current = playerState.current || {} return { ...defaultOptions, - audioLists: playerState.queue.map((item) => item), + audioLists, playIndex: playerState.playIndex, autoPlay: playerState.clear || playerState.playIndex === 0, clearPriorAudioLists: playerState.clear, extendsContent: ( - + ), defaultVolume: isMobilePlayer ? 1 : playerState.volume, - showMediaSession: !current.isRadio, + showMediaSession: !currentTrack.isRadio, } - }, [playerState, defaultOptions, isMobilePlayer]) + }, [ + defaultOptions, + audioLists, + playerState.playIndex, + playerState.clear, + playerState.volume, + isMobilePlayer, + currentTrack.trackId, + currentTrack.isRadio, + ]) const onAudioListsChange = useCallback( - (_, audioLists, audioInfo) => dispatch(syncQueue(audioInfo, audioLists)), - [dispatch], - ) - - const nextSong = useCallback(() => { - const idx = playerState.queue.findIndex( - (item) => item.uuid === playerState.current.uuid, - ) - return idx !== null ? playerState.queue[idx + 1] : null - }, [playerState]) - - const onAudioProgress = useCallback( - (info) => { - if (info.ended) { - document.title = 'Navidrome' - } - - const progress = (info.currentTime / info.duration) * 100 - if (isNaN(info.duration) || (progress < 50 && info.currentTime < 240)) { - return - } - - if (info.isRadio) { - return - } - - if (!preloaded) { - const next = nextSong() - if (next != null) { - const audio = new Audio() - audio.src = next.musicSrc - } - setPreload(true) - return - } - - if (!scrobbled) { - info.trackId && subsonic.scrobble(info.trackId, startTime) - setScrobbled(true) - } - }, - [startTime, scrobbled, nextSong, preloaded], + (_, audioLists, audioInfo) => dispatchSyncQueue(audioInfo, audioLists), + [dispatchSyncQueue], ) const onAudioVolumeChange = useCallback( // sqrt to compensate for the logarithmic volume - (volume) => dispatch(setVolume(Math.sqrt(volume))), - [dispatch], + (volume) => dispatchSetVolume(volume), + [dispatchSetVolume], ) - const onAudioPlay = useCallback( + const handleAudioPlay = useCallback( (info) => { - // Do this to start the context; on chrome-based browsers, the context - // will start paused since it is created prior to user interaction - if (context && context.state !== 'running') { - context.resume() - } - - dispatch(currentPlaying(info)) - if (startTime === null) { - setStartTime(Date.now()) - } - if (info.duration) { - const song = info.song - document.title = `${song.title} - ${song.artist} - Navidrome` - if (!info.isRadio) { - const pos = startTime === null ? null : Math.floor(info.currentTime) - subsonic.nowPlaying(info.trackId, pos) - } - setPreload(false) - if (config.gaTrackingId) { - ReactGA.event({ - category: 'Player', - action: 'Play song', - label: `${song.title} - ${song.artist}`, - }) - } - if (showNotifications) { - sendNotification( - song.title, - `${song.artist} - ${song.album}`, - info.cover, - ) - } - } + onAudioPlay( + context, + info, + (info) => dispatchCurrentPlaying(info), + showNotifications, + sendNotification, + startTime, + setStartTime, + resetPreloading, + config, + ReactGA, + ) }, - [context, dispatch, showNotifications, startTime], + [ + onAudioPlay, + context, + dispatchCurrentPlaying, + showNotifications, + startTime, + setStartTime, + resetPreloading, + ], ) - const onAudioPlayTrackChange = useCallback(() => { - if (scrobbled) { - setScrobbled(false) - } - if (startTime !== null) { - setStartTime(null) - } - }, [scrobbled, startTime]) - const onAudioPause = useCallback( - (info) => dispatch(currentPlaying(info)), - [dispatch], - ) - - const onAudioEnded = useCallback( - (currentPlayId, audioLists, info) => { - setScrobbled(false) - setStartTime(null) - dispatch(currentPlaying(info)) - dataProvider - .getOne('keepalive', { id: info.trackId }) - // eslint-disable-next-line no-console - .catch((e) => console.log('Keepalive error:', e)) - }, - [dispatch, dataProvider], + (info) => dispatchCurrentPlaying(info), + [dispatchCurrentPlaying], ) const onCoverClick = useCallback((mode, audioLists, audioInfo) => { @@ -286,10 +221,10 @@ const Player = () => { const onBeforeDestroy = useCallback(() => { return new Promise((resolve, reject) => { - dispatch(clearQueue()) + dispatchClearQueue() reject() }) - }, [dispatch]) + }, [dispatchClearQueue]) if (!visible) { document.title = 'Navidrome' @@ -300,30 +235,32 @@ const Player = () => { [audioInstance, playerState], ) - useEffect(() => { - if (isMobilePlayer && audioInstance) { - audioInstance.volume = 1 - } - }, [isMobilePlayer, audioInstance]) - return ( - dispatch(setPlayMode(mode))} - onAudioEnded={onAudioEnded} - onCoverClick={onCoverClick} - onBeforeDestroy={onBeforeDestroy} - getAudioInstance={setAudioInstance} - /> - +
+ +
) } diff --git a/ui/src/audioplayer/Player.test.jsx b/ui/src/audioplayer/Player.test.jsx new file mode 100644 index 000000000..d0368b0f0 --- /dev/null +++ b/ui/src/audioplayer/Player.test.jsx @@ -0,0 +1,166 @@ +import React from 'react' +import { render, screen, fireEvent, cleanup } from '@testing-library/react' +import { useMediaQuery } from '@material-ui/core' +import { useGetOne } from 'react-admin' +import { useDispatch } from 'react-redux' +import { useToggleLove } from '../common' +import { openSaveQueueDialog } from '../actions' +import PlayerToolbar from './PlayerToolbar' + +// Mock dependencies +vi.mock('@material-ui/core', async () => { + const actual = await import('@material-ui/core') + return { + ...actual, + useMediaQuery: vi.fn(), + } +}) + +vi.mock('react-admin', () => ({ + useGetOne: vi.fn(), +})) + +vi.mock('react-redux', () => ({ + useDispatch: vi.fn(), +})) + +vi.mock('../common', () => ({ + LoveButton: ({ className, disabled }) => ( + + ), + useToggleLove: vi.fn(), +})) + +vi.mock('../actions', () => ({ + openSaveQueueDialog: vi.fn(), +})) + +vi.mock('react-hotkeys', () => ({ + GlobalHotKeys: () =>
, +})) + +describe('', () => { + const mockToggleLove = vi.fn() + const mockDispatch = vi.fn() + const mockSongData = { id: 'song-1', name: 'Test Song', starred: false } + + beforeEach(() => { + vi.clearAllMocks() + useGetOne.mockReturnValue({ data: mockSongData, loading: false }) + useToggleLove.mockReturnValue([mockToggleLove, false]) + useDispatch.mockReturnValue(mockDispatch) + openSaveQueueDialog.mockReturnValue({ type: 'OPEN_SAVE_QUEUE_DIALOG' }) + }) + + afterEach(cleanup) + + describe('Desktop layout', () => { + beforeEach(() => { + useMediaQuery.mockReturnValue(true) // isDesktop = true + }) + + it('renders desktop toolbar with both buttons', () => { + render() + + // Both buttons should be in a single list item + const listItems = screen.getAllByRole('listitem') + expect(listItems).toHaveLength(1) + + // Verify both buttons are rendered + expect(screen.getByTestId('save-queue-button')).toBeInTheDocument() + expect(screen.getByTestId('love-button')).toBeInTheDocument() + + // Verify desktop classes are applied + expect(listItems[0].className).toContain('toolbar') + }) + + it('disables save queue button when isRadio is true', () => { + render() + + const saveQueueButton = screen.getByTestId('save-queue-button') + expect(saveQueueButton).toBeDisabled() + }) + + it('disables love button when conditions are met', () => { + useGetOne.mockReturnValue({ data: mockSongData, loading: true }) + + render() + + const loveButton = screen.getByTestId('love-button') + expect(loveButton).toBeDisabled() + }) + + it('opens save queue dialog when save button is clicked', () => { + render() + + const saveQueueButton = screen.getByTestId('save-queue-button') + fireEvent.click(saveQueueButton) + + expect(mockDispatch).toHaveBeenCalledWith({ + type: 'OPEN_SAVE_QUEUE_DIALOG', + }) + }) + }) + + describe('Mobile layout', () => { + beforeEach(() => { + useMediaQuery.mockReturnValue(false) // isDesktop = false + }) + + it('renders mobile toolbar with buttons in separate list items', () => { + render() + + // Each button should be in its own list item + const listItems = screen.getAllByRole('listitem') + expect(listItems).toHaveLength(2) + + // Verify both buttons are rendered + expect(screen.getByTestId('save-queue-button')).toBeInTheDocument() + expect(screen.getByTestId('love-button')).toBeInTheDocument() + + // Verify mobile classes are applied + expect(listItems[0].className).toContain('mobileListItem') + expect(listItems[1].className).toContain('mobileListItem') + }) + + it('disables save queue button when isRadio is true', () => { + render() + + const saveQueueButton = screen.getByTestId('save-queue-button') + expect(saveQueueButton).toBeDisabled() + }) + + it('disables love button when conditions are met', () => { + useGetOne.mockReturnValue({ data: mockSongData, loading: true }) + + render() + + const loveButton = screen.getByTestId('love-button') + expect(loveButton).toBeDisabled() + }) + }) + + describe('Common behavior', () => { + it('renders global hotkeys in both layouts', () => { + // Test desktop layout + useMediaQuery.mockReturnValue(true) + render() + expect(screen.getByTestId('global-hotkeys')).toBeInTheDocument() + + // Cleanup and test mobile layout + cleanup() + useMediaQuery.mockReturnValue(false) + render() + expect(screen.getByTestId('global-hotkeys')).toBeInTheDocument() + }) + + it('disables buttons when id is not provided', () => { + render() + + const loveButton = screen.getByTestId('love-button') + expect(loveButton).toBeDisabled() + }) + }) +}) diff --git a/ui/src/audioplayer/hooks/useAudioInstance.js b/ui/src/audioplayer/hooks/useAudioInstance.js new file mode 100644 index 000000000..56b24b118 --- /dev/null +++ b/ui/src/audioplayer/hooks/useAudioInstance.js @@ -0,0 +1,131 @@ +import { useCallback, useEffect, useState } from 'react' +import subsonic from '../../subsonic' + +/** + * Custom hook for managing the audio instance and related effects. + * Handles audio element setup and mobile volume adjustments. + * + * @param {boolean} isMobilePlayer - Whether the player is running on a mobile device. + * @returns {Object} Audio instance-related state and handlers. + * @returns {HTMLAudioElement|null} audioInstance - The audio element instance. + * @returns {Function} setAudioInstance - Setter for the audio instance. + * @returns {Function} onAudioPlay - Handler for audio play events. + * + * @example + * const { audioInstance, setAudioInstance, onAudioPlay } = useAudioInstance(isMobilePlayer); + */ +export const useAudioInstance = (isMobilePlayer) => { + const [audioInstance, setAudioInstance] = useState(null) + + /** + * Handles audio play events, resuming context if needed and updating document title. + * + * @param {AudioContext|null} audioContext - Web Audio API context from replay gain hook. + * @param {Object} info - Audio play information. + * @param {Object} info.song - Song metadata. + * @param {number} info.duration - Track duration. + * @param {boolean} info.isRadio - Whether it's a radio stream. + * @param {string} info.trackId - Track identifier. + * @param {number} info.currentTime - Current playback time. + * @param {Function} dispatchCurrentPlaying - Function to dispatch current playing action. + * @param {boolean} showNotifications - Whether to show notifications. + * @param {Function} sendNotification - Function to send notifications. + * @param {number|null} startTime - Start time for scrobbling. + * @param {Function} setStartTime - Setter for start time. + * @param {Function} resetPreloading - Function to reset preloading. + * @param {Object} config - Application configuration. + * @param {Object} ReactGA - Google Analytics instance. + */ + const onAudioPlay = useCallback( + ( + audioContext, + info, + dispatchCurrentPlaying, + showNotifications, + sendNotification, + startTime, + setStartTime, + resetPreloading, + config, + ReactGA, + ) => { + // Resume audio context if suspended + if (audioContext && audioContext.state !== 'running') { + try { + audioContext.resume() + } catch (error) { + // eslint-disable-next-line no-console + console.error('Error resuming audio context:', error) + } + } + + dispatchCurrentPlaying(info) + + if (startTime === null) { + setStartTime(Date.now()) + } + + if (info.duration) { + const song = info.song + document.title = `${song.title} - ${song.artist} - Navidrome` + + if (!info.isRadio) { + const pos = startTime === null ? null : Math.floor(info.currentTime) + try { + subsonic.nowPlaying(info.trackId, pos) + } catch (error) { + // eslint-disable-next-line no-console + console.error('Error updating now playing:', error) + } + } + + resetPreloading() + + if (config.gaTrackingId) { + try { + ReactGA.event({ + category: 'Player', + action: 'Play song', + label: `${song.title} - ${song.artist}`, + }) + } catch (error) { + // eslint-disable-next-line no-console + console.error('Google Analytics error:', error) + } + } + + if (showNotifications) { + try { + sendNotification( + song.title, + `${song.artist} - ${song.album}`, + info.cover, + ) + } catch (error) { + // eslint-disable-next-line no-console + console.error('Notification error:', error) + } + } + } + }, + [], + ) + + // Mobile volume adjustment effect + useEffect(() => { + if (isMobilePlayer && audioInstance) { + try { + audioInstance.volume = 1 + } catch (error) { + // eslint-disable-next-line no-console + console.error('Error setting mobile volume:', error) + } + } + }, [isMobilePlayer, audioInstance]) + + return { + audioInstance, + setAudioInstance, + onAudioPlay, + } +} diff --git a/ui/src/audioplayer/hooks/usePlayerState.js b/ui/src/audioplayer/hooks/usePlayerState.js new file mode 100644 index 000000000..02199f272 --- /dev/null +++ b/ui/src/audioplayer/hooks/usePlayerState.js @@ -0,0 +1,84 @@ +import { useSelector, useDispatch } from 'react-redux' +import { + clearQueue, + currentPlaying, + setPlayMode, + setVolume, + syncQueue, +} from '../../actions' + +/** + * Custom hook for managing player state and actions via Redux. + * Centralizes access to player-related state and dispatch functions. + * + * @returns {Object} Player state and action dispatchers. + * @returns {Object} playerState - Current player state from Redux store. + * @returns {Function} dispatch - Redux dispatch function. + * @returns {Function} dispatchCurrentPlaying - Dispatches current playing action. + * @returns {Function} dispatchSetPlayMode - Dispatches set play mode action. + * @returns {Function} dispatchSetVolume - Dispatches set volume action. + * @returns {Function} dispatchSyncQueue - Dispatches sync queue action. + * @returns {Function} dispatchClearQueue - Dispatches clear queue action. + * + * @example + * const { playerState, dispatchCurrentPlaying } = usePlayerState(); + */ +export const usePlayerState = () => { + const playerState = useSelector((state) => state.player) + const dispatch = useDispatch() + + /** + * Dispatches the current playing action. + * + * @param {Object} info - Audio information. + */ + const dispatchCurrentPlaying = (info) => { + dispatch(currentPlaying(info)) + } + + /** + * Dispatches the set play mode action. + * + * @param {string} mode - Play mode (e.g., 'single', 'loop', 'shuffle'). + */ + const dispatchSetPlayMode = (mode) => { + dispatch(setPlayMode(mode)) + } + + /** + * Dispatches the set volume action with square root compensation. + * + * @param {number} volume - Volume level (0-1). + */ + const dispatchSetVolume = (volume) => { + // sqrt to compensate for the logarithmic volume + dispatch(setVolume(Math.sqrt(volume))) + } + + /** + * Dispatches the sync queue action. + * + * @param {Object} audioInfo - Audio information. + * @param {Array} audioLists - List of audio tracks. + */ + const dispatchSyncQueue = (audioInfo, audioLists) => { + dispatch(syncQueue(audioInfo, audioLists)) + } + + /** + * Dispatches the clear queue action. + */ + const dispatchClearQueue = () => { + dispatch(clearQueue()) + } + + return { + playerState, + dispatch, + dispatchCurrentPlaying, + dispatchSetPlayMode, + dispatchSetVolume, + dispatchSyncQueue, + dispatchClearQueue, + } +} diff --git a/ui/src/audioplayer/hooks/usePlayerState.test.js b/ui/src/audioplayer/hooks/usePlayerState.test.js new file mode 100644 index 000000000..db3549bde --- /dev/null +++ b/ui/src/audioplayer/hooks/usePlayerState.test.js @@ -0,0 +1,106 @@ +/* eslint-env node */ + +import { renderHook } from '@testing-library/react-hooks' +import { usePlayerState } from './usePlayerState' +import { useDispatch, useSelector } from 'react-redux' +import { describe, it, beforeEach, vi, expect } from 'vitest' + +// Mock react-redux +vi.mock('react-redux', () => ({ + useDispatch: vi.fn(), + useSelector: vi.fn(), +})) + +// Mock actions +vi.mock('../../actions', () => ({ + clearQueue: vi.fn(() => ({ type: 'CLEAR_QUEUE' })), + currentPlaying: vi.fn(() => ({ type: 'CURRENT_PLAYING' })), + setPlayMode: vi.fn(() => ({ type: 'SET_PLAY_MODE' })), + setVolume: vi.fn(() => ({ type: 'SET_VOLUME' })), + syncQueue: vi.fn(() => ({ type: 'SYNC_QUEUE' })), +})) + +// Import the mocked actions +import * as actions from '../../actions' + +describe('usePlayerState', () => { + const mockPlayerState = { + queue: [], + current: null, + mode: 'single', + volume: 0.8, + } + + const mockDispatch = vi.fn() + + beforeEach(() => { + vi.resetModules() + vi.clearAllMocks() + useDispatch.mockReturnValue(mockDispatch) + useSelector.mockReturnValue(mockPlayerState) + }) + + it('should return player state and dispatch functions', () => { + const { result } = renderHook(() => usePlayerState()) + + expect(result.current.playerState).toEqual(mockPlayerState) + expect(typeof result.current.dispatch).toBe('function') + expect(typeof result.current.dispatchCurrentPlaying).toBe('function') + expect(typeof result.current.dispatchSetPlayMode).toBe('function') + expect(typeof result.current.dispatchSetVolume).toBe('function') + expect(typeof result.current.dispatchSyncQueue).toBe('function') + expect(typeof result.current.dispatchClearQueue).toBe('function') + }) + + it('should dispatch current playing action', () => { + const { result } = renderHook(() => usePlayerState()) + const mockInfo = { trackId: 'track1' } + + result.current.dispatchCurrentPlaying(mockInfo) + + expect(mockDispatch).toHaveBeenCalledWith({ type: 'CURRENT_PLAYING' }) + }) + + it('should dispatch set play mode action', () => { + const { result } = renderHook(() => usePlayerState()) + + result.current.dispatchSetPlayMode('loop') + + expect(mockDispatch).toHaveBeenCalledWith({ type: 'SET_PLAY_MODE' }) + }) + + it('should dispatch set volume action with square root compensation', () => { + const { result } = renderHook(() => usePlayerState()) + + result.current.dispatchSetVolume(0.5) + + expect(mockDispatch).toHaveBeenCalledWith({ type: 'SET_VOLUME' }) + // Verify square root calculation + expect(actions.setVolume).toHaveBeenCalledWith(Math.sqrt(0.5)) + }) + + it('should dispatch sync queue action', () => { + const { result } = renderHook(() => usePlayerState()) + const mockAudioInfo = { trackId: 'track1' } + const mockAudioLists = [{ id: '1' }] + + result.current.dispatchSyncQueue(mockAudioInfo, mockAudioLists) + + expect(mockDispatch).toHaveBeenCalledWith({ type: 'SYNC_QUEUE' }) + }) + + it('should dispatch clear queue action', () => { + const { result } = renderHook(() => usePlayerState()) + + result.current.dispatchClearQueue() + + expect(mockDispatch).toHaveBeenCalledWith({ type: 'CLEAR_QUEUE' }) + }) + + it('should use correct Redux hooks', () => { + renderHook(() => usePlayerState()) + + expect(useDispatch).toHaveBeenCalled() + expect(useSelector).toHaveBeenCalledWith(expect.any(Function)) + }) +}) diff --git a/ui/src/audioplayer/hooks/usePreloading.js b/ui/src/audioplayer/hooks/usePreloading.js new file mode 100644 index 000000000..09fa3abb9 --- /dev/null +++ b/ui/src/audioplayer/hooks/usePreloading.js @@ -0,0 +1,72 @@ +import { useCallback, useState } from 'react' + +/** + * Custom hook for managing audio preloading functionality. + * Preloads the next song in the queue to improve playback continuity. + * + * @param {Object} playerState - The current player state from Redux store. + * @returns {Object} Preloading-related state and handlers. + * @returns {boolean} preloaded - Whether the next song has been preloaded. + * @returns {Function} preloadNextSong - Function to preload the next song. + * @returns {Function} resetPreloading - Function to reset preloading state. + * + * @example + * const { preloaded, preloadNextSong } = usePreloading(playerState); + */ +export const usePreloading = (playerState) => { + const [preloaded, setPreloaded] = useState(false) + + /** + * Finds the next song in the queue. + * + * @returns {Object|null} The next song object or null if not found. + */ + const nextSong = useCallback(() => { + const idx = playerState.queue.findIndex( + (item) => item.uuid === playerState.current?.uuid, + ) + return idx !== -1 ? playerState.queue[idx + 1] : null + }, [playerState]) + + /** + * Preloads the next song by creating an Audio element. + * This helps reduce buffering delays during playback. + */ + const preloadNextSong = useCallback(() => { + if (!preloaded) { + const next = nextSong() + if (next != null) { + try { + const audio = new Audio() + audio.src = next.musicSrc + // Optional: Add load event listeners for better control + audio.addEventListener('canplaythrough', () => { + // Preload complete + }) + audio.addEventListener('error', (error) => { + // eslint-disable-next-line no-console + console.error('Preloading error:', error) + }) + setPreloaded(true) + } catch (error) { + // eslint-disable-next-line no-console + console.error('Error during preloading:', error) + // Continue without preloading + } + } + } + }, [preloaded, nextSong]) + + /** + * Resets the preloading state. Useful for track changes or manual resets. + */ + const resetPreloading = useCallback(() => { + setPreloaded(false) + }, []) + + return { + preloaded, + preloadNextSong, + resetPreloading, + } +} diff --git a/ui/src/audioplayer/hooks/usePreloading.test.js b/ui/src/audioplayer/hooks/usePreloading.test.js new file mode 100644 index 000000000..e7776287b --- /dev/null +++ b/ui/src/audioplayer/hooks/usePreloading.test.js @@ -0,0 +1,144 @@ +import { renderHook, act } from '@testing-library/react-hooks' +import { usePreloading } from './usePreloading' +import { describe, it, beforeEach, afterEach, vi, expect } from 'vitest' + +describe('usePreloading', () => { + const mockPlayerState = { + queue: [ + { uuid: '1', musicSrc: 'song1.mp3' }, + { uuid: '2', musicSrc: 'song2.mp3' }, + ], + current: { uuid: '1' }, + } + + beforeEach(() => { + vi.clearAllMocks() + // Mock Audio constructor + global.Audio = vi.fn().mockImplementation(function () { + this.src = '' + this.addEventListener = vi.fn() + }) + }) + + afterEach(() => { + delete global.Audio + }) + + it('should initialize with preloaded false', () => { + const { result } = renderHook(() => usePreloading(mockPlayerState)) + + expect(result.current.preloaded).toBe(false) + expect(typeof result.current.preloadNextSong).toBe('function') + expect(typeof result.current.resetPreloading).toBe('function') + }) + + it('should preload next song when called', () => { + const { result } = renderHook(() => usePreloading(mockPlayerState)) + + act(() => { + result.current.preloadNextSong() + }) + + expect(result.current.preloaded).toBe(true) + expect(global.Audio).toHaveBeenCalled() + expect(global.Audio.mock.instances[0].src).toBe('song2.mp3') + }) + + it('should not preload if already preloaded', () => { + const { result } = renderHook(() => usePreloading(mockPlayerState)) + + act(() => { + result.current.preloadNextSong() + }) + + expect(result.current.preloaded).toBe(true) + + // Call again - should not create new Audio instance + const audioCallCount = global.Audio.mock.calls.length + act(() => { + result.current.preloadNextSong() + }) + + expect(global.Audio.mock.calls.length).toBe(audioCallCount) + }) + + it('should return null when no next song exists', () => { + const stateWithNoNext = { + queue: [{ uuid: '1', musicSrc: 'song1.mp3' }], + current: { uuid: '1' }, + } + + const { result } = renderHook(() => usePreloading(stateWithNoNext)) + + act(() => { + result.current.preloadNextSong() + }) + + expect(result.current.preloaded).toBe(false) + expect(global.Audio).not.toHaveBeenCalled() + }) + + it('should reset preloading state', () => { + const { result } = renderHook(() => usePreloading(mockPlayerState)) + + act(() => { + result.current.preloadNextSong() + }) + + expect(result.current.preloaded).toBe(true) + + act(() => { + result.current.resetPreloading() + }) + + expect(result.current.preloaded).toBe(false) + }) + + it('should handle Audio constructor errors gracefully', () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + global.Audio = vi.fn().mockImplementation(() => { + throw new Error('Audio creation failed') + }) + + const { result } = renderHook(() => usePreloading(mockPlayerState)) + + act(() => { + result.current.preloadNextSong() + }) + + expect(consoleSpy).toHaveBeenCalledWith( + 'Error during preloading:', + expect.any(Error), + ) + expect(result.current.preloaded).toBe(false) // Should remain false on error + + consoleSpy.mockRestore() + }) + + it('should handle audio load errors gracefully', () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + global.Audio = vi.fn().mockImplementation(function () { + this.src = '' + this.addEventListener = vi.fn((event, callback) => { + if (event === 'error') { + callback(new Event('error')) + } + }) + }) + + const { result } = renderHook(() => usePreloading(mockPlayerState)) + + act(() => { + result.current.preloadNextSong() + }) + + expect(consoleSpy).toHaveBeenCalledWith( + 'Preloading error:', + expect.any(Event), + ) + + consoleSpy.mockRestore() + }) +}) diff --git a/ui/src/audioplayer/hooks/useReplayGain.js b/ui/src/audioplayer/hooks/useReplayGain.js new file mode 100644 index 000000000..737df7d13 --- /dev/null +++ b/ui/src/audioplayer/hooks/useReplayGain.js @@ -0,0 +1,74 @@ +import { useEffect, useState } from 'react' +import { calculateGain } from '../../utils/calculateReplayGain' + +/** + * Custom hook for managing replay gain functionality using Web Audio API. + * Adjusts audio gain based on track or album replay gain metadata. + * + * @param {Object} audioInstance - The HTML audio element instance. + * @param {Object} playerState - The current player state from Redux store. + * @param {Object} gainInfo - Replay gain configuration from Redux store. + * @returns {Object} Replay gain-related state. + * @returns {AudioContext|null} context - Web Audio API context. + * @returns {GainNode|null} gainNode - Gain node for audio manipulation. + * + * @example + * const { context, gainNode } = useReplayGain(audioInstance, playerState, gainInfo); + */ +export const useReplayGain = (audioInstance, playerState, gainInfo) => { + const [context, setContext] = useState(null) + const [gainNode, setGainNode] = useState(null) + + useEffect(() => { + if ( + context === null && + audioInstance && + 'AudioContext' in window && + (gainInfo.gainMode === 'album' || gainInfo.gainMode === 'track') + ) { + try { + const ctx = new AudioContext() + // Support radios in Firefox + if (audioInstance) { + audioInstance.crossOrigin = 'anonymous' + } + const source = ctx.createMediaElementSource(audioInstance) + const gain = ctx.createGain() + + source.connect(gain) + gain.connect(ctx.destination) + + setContext(ctx) + setGainNode(gain) + } catch (error) { + // eslint-disable-next-line no-console + console.error( + 'Error initializing Web Audio API for replay gain:', + error, + ) + // Fallback: continue without replay gain + } + } + }, [audioInstance, context, gainInfo.gainMode]) + + useEffect(() => { + if (gainNode && context) { + try { + const current = playerState.current || {} + const song = current.song || {} + + const numericGain = calculateGain(gainInfo, song) + gainNode.gain.setValueAtTime(numericGain, context.currentTime) + } catch (error) { + // eslint-disable-next-line no-console + console.error('Error applying replay gain:', error) + // Continue playback without gain adjustment + } + } + }, [audioInstance, context, gainNode, playerState, gainInfo]) + + return { + context, + gainNode, + } +} diff --git a/ui/src/audioplayer/hooks/useReplayGain.test.js b/ui/src/audioplayer/hooks/useReplayGain.test.js new file mode 100644 index 000000000..c760de437 --- /dev/null +++ b/ui/src/audioplayer/hooks/useReplayGain.test.js @@ -0,0 +1,160 @@ +import { renderHook, act } from '@testing-library/react-hooks' +import { useReplayGain } from './useReplayGain' +import { describe, it, beforeEach, afterEach, vi, expect } from 'vitest' + +// Mock calculateGain utility +vi.mock('../../utils/calculateReplayGain', () => ({ + calculateGain: vi.fn(), +})) + +// Import the mocked module +import * as calculateReplayGain from '../../utils/calculateReplayGain' + +describe('useReplayGain', () => { + const mockCalculateGain = calculateReplayGain.calculateGain + + beforeEach(() => { + vi.clearAllMocks() + // Mock Web Audio API + global.AudioContext = vi.fn().mockImplementation(function () { + this.createMediaElementSource = vi.fn(() => ({ + connect: vi.fn(), + })) + this.createGain = vi.fn(() => ({ + gain: { + setValueAtTime: vi.fn(), + }, + connect: vi.fn(), + })) + this.currentTime = 0 + }) + }) + + afterEach(() => { + delete global.AudioContext + }) + + it('should initialize with null context and gainNode', () => { + const { result } = renderHook(() => + useReplayGain(null, { current: {} }, { gainMode: 'track' }), + ) + + expect(result.current.context).toBeNull() + expect(result.current.gainNode).toBeNull() + }) + + it('should create audio context when conditions are met', () => { + const mockAudioInstance = { crossOrigin: '' } + const mockPlayerState = { + current: { song: { title: 'Test Song' } }, + } + const mockGainInfo = { gainMode: 'track' } + + const { result } = renderHook(() => + useReplayGain(mockAudioInstance, mockPlayerState, mockGainInfo), + ) + + expect(global.AudioContext).toHaveBeenCalled() + expect(result.current.context).toBeInstanceOf(AudioContext) + }) + + it('should apply gain when gainNode exists', () => { + const mockAudioInstance = { crossOrigin: '' } + const mockPlayerState = { + current: { song: { title: 'Test Song' } }, + } + const mockGainInfo = { gainMode: 'track' } + + mockCalculateGain.mockReturnValue(0.8) + + const { result } = renderHook(() => + useReplayGain(mockAudioInstance, mockPlayerState, mockGainInfo), + ) + + expect(mockCalculateGain).toHaveBeenCalledWith( + mockGainInfo, + mockPlayerState.current.song, + ) + expect(result.current.gainNode.gain.setValueAtTime).toHaveBeenCalledWith( + 0.8, + 0, + ) + }) + + it('should handle Web Audio API errors gracefully', () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + // Mock AudioContext to throw error + global.AudioContext = vi.fn().mockImplementation(function () { + throw new Error('Web Audio API not supported') + }) + + const mockAudioInstance = {} + const mockPlayerState = { current: {} } + const mockGainInfo = { gainMode: 'track' } + + const { result } = renderHook(() => + useReplayGain(mockAudioInstance, mockPlayerState, mockGainInfo), + ) + + expect(consoleSpy).toHaveBeenCalledWith( + 'Error initializing Web Audio API for replay gain:', + expect.any(Error), + ) + expect(result.current.context).toBeNull() + + consoleSpy.mockRestore() + }) + + it('should handle gain application errors gracefully', () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + const mockAudioInstance = { crossOrigin: '' } + const mockPlayerState = { + current: { song: { title: 'Test Song' } }, + } + const mockGainInfo = { gainMode: 'track' } + + // Mock gain.setValueAtTime to throw error + const mockGainNode = { + gain: { + setValueAtTime: vi.fn(() => { + throw new Error('Gain application failed') + }), + }, + connect: vi.fn(), + } + + global.AudioContext = vi.fn().mockImplementation(function () { + this.createMediaElementSource = vi.fn(() => ({ + connect: vi.fn(), + })) + this.createGain = vi.fn(() => mockGainNode) + this.currentTime = 0 + }) + + const { result } = renderHook(() => + useReplayGain(mockAudioInstance, mockPlayerState, mockGainInfo), + ) + + expect(consoleSpy).toHaveBeenCalledWith( + 'Error applying replay gain:', + expect.any(Error), + ) + + consoleSpy.mockRestore() + }) + + it('should not initialize when gainMode is not album or track', () => { + const mockAudioInstance = {} + const mockPlayerState = { current: {} } + const mockGainInfo = { gainMode: 'off' } + + const { result } = renderHook(() => + useReplayGain(mockAudioInstance, mockPlayerState, mockGainInfo), + ) + + expect(global.AudioContext).not.toHaveBeenCalled() + expect(result.current.context).toBeNull() + }) +}) diff --git a/ui/src/audioplayer/hooks/useScrobbling.js b/ui/src/audioplayer/hooks/useScrobbling.js new file mode 100644 index 000000000..cab3ca830 --- /dev/null +++ b/ui/src/audioplayer/hooks/useScrobbling.js @@ -0,0 +1,120 @@ +import { useCallback, useState } from 'react' +import subsonic from '../../subsonic' + +/** + * Custom hook for managing scrobbling functionality in the audio player. + * Handles scrobbling state and logic for tracking played songs to external services. + * + * @param {Object} playerState - The current player state from Redux store. + * @param {Function} dispatch - Redux dispatch function. + * @param {Object} dataProvider - Data provider for API calls. + * @returns {Object} Scrobbling-related state and handlers. + * @returns {number|null} startTime - Timestamp when playback started. + * @returns {boolean} scrobbled - Whether the current track has been scrobbled. + * @returns {Function} onAudioProgress - Handler for audio progress events. + * @returns {Function} onAudioPlayTrackChange - Handler for track change events. + * @returns {Function} onAudioEnded - Handler for audio ended events. + * @returns {Function} resetScrobbling - Function to reset scrobbling state. + * + * @example + * const { startTime, scrobbled, onAudioProgress, onAudioEnded } = useScrobbling(playerState, dispatch, dataProvider); + */ +export const useScrobbling = (playerState, dispatch, dataProvider) => { + const [startTime, setStartTime] = useState(null) + const [scrobbled, setScrobbled] = useState(false) + + /** + * Handles audio progress events for scrobbling logic. + * Scrobbles the track if it has been played for more than 50% or 4 minutes. + * + * @param {Object} info - Audio progress information. + * @param {number} info.currentTime - Current playback time. + * @param {number} info.duration - Total duration of the track. + * @param {boolean} info.isRadio - Whether the current track is a radio stream. + * @param {string} info.trackId - Unique identifier of the track. + */ + const onAudioProgress = useCallback( + (info) => { + if (info.ended) { + document.title = 'Navidrome' + } + + const progress = (info.currentTime / info.duration) * 100 + if (isNaN(info.duration) || (progress < 50 && info.currentTime < 240)) { + return + } + + if (info.isRadio) { + return + } + + if (!scrobbled) { + try { + if (info.trackId) { + subsonic.scrobble(info.trackId, startTime) + } + setScrobbled(true) + } catch (error) { + // eslint-disable-next-line no-console + console.error('Scrobbling error:', error) + // Continue without failing the player + } + } + }, + [startTime, scrobbled], + ) + + /** + * Handles track change events by resetting scrobbling state. + */ + const onAudioPlayTrackChange = useCallback(() => { + if (scrobbled) { + setScrobbled(false) + } + if (startTime !== null) { + setStartTime(null) + } + }, [scrobbled, startTime]) + + /** + * Handles audio ended events, resetting state and performing keepalive. + * + * @param {string} currentPlayId - ID of the current playing track. + * @param {Array} audioLists - List of audio tracks. + * @param {Object} info - Audio information. + */ + const onAudioEnded = useCallback( + (currentPlayId, audioLists, info) => { + setScrobbled(false) + setStartTime(null) + try { + dataProvider + .getOne('keepalive', { id: info.trackId }) + // eslint-disable-next-line no-console + .catch((e) => console.log('Keepalive error:', e)) + } catch (error) { + // eslint-disable-next-line no-console + console.error('Keepalive error:', error) + } + }, + [dataProvider], + ) + + /** + * Resets the scrobbling state. Useful for manual resets or testing. + */ + const resetScrobbling = useCallback(() => { + setScrobbled(false) + setStartTime(null) + }, []) + + return { + startTime, + setStartTime, + scrobbled, + onAudioProgress, + onAudioPlayTrackChange, + onAudioEnded, + resetScrobbling, + } +} diff --git a/ui/src/audioplayer/hooks/useScrobbling.test.js b/ui/src/audioplayer/hooks/useScrobbling.test.js new file mode 100644 index 000000000..b91898b0c --- /dev/null +++ b/ui/src/audioplayer/hooks/useScrobbling.test.js @@ -0,0 +1,166 @@ +import { renderHook, act } from '@testing-library/react-hooks' +import { useScrobbling } from './useScrobbling' +import { describe, it, beforeEach, vi, expect } from 'vitest' + +// Mock subsonic module +vi.mock('../../subsonic', () => ({ + default: { + scrobble: vi.fn(), + nowPlaying: vi.fn(), + }, + scrobble: vi.fn(), + nowPlaying: vi.fn(), +})) + +// Import the mocked module +import * as subsonic from '../../subsonic' + +// Mock dataProvider +const mockDataProvider = { + getOne: vi.fn(), +} + +describe('useScrobbling', () => { + const mockPlayerState = { + queue: [ + { uuid: '1', musicSrc: 'song1.mp3' }, + { uuid: '2', musicSrc: 'song2.mp3' }, + ], + current: { uuid: '1', trackId: 'track1' }, + } + + const mockDispatch = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + mockDataProvider.getOne.mockResolvedValue({ data: {} }) + }) + + it('should initialize with default state', () => { + const { result } = renderHook(() => + useScrobbling(mockPlayerState, mockDispatch, mockDataProvider), + ) + + expect(result.current.startTime).toBeNull() + expect(result.current.scrobbled).toBe(false) + expect(typeof result.current.onAudioProgress).toBe('function') + expect(typeof result.current.onAudioPlayTrackChange).toBe('function') + expect(typeof result.current.onAudioEnded).toBe('function') + }) + + it('should handle audio progress and scrobble when conditions are met', () => { + const { result } = renderHook(() => + useScrobbling(mockPlayerState, mockDispatch, mockDataProvider), + ) + + const mockInfo = { + currentTime: 300, // 5 minutes + duration: 240, // 4 minutes + isRadio: false, + trackId: 'track1', + } + + act(() => { + result.current.onAudioProgress(mockInfo) + }) + + // Should scrobble since progress > 50% and time > 4 minutes + expect(subsonic.default.scrobble).toHaveBeenCalledWith('track1', null) + expect(result.current.scrobbled).toBe(true) + }) + + it('should not scrobble radio streams', () => { + const { result } = renderHook(() => + useScrobbling(mockPlayerState, mockDispatch, mockDataProvider), + ) + + const mockInfo = { + currentTime: 300, + duration: 240, + isRadio: true, + trackId: 'track1', + } + + act(() => { + result.current.onAudioProgress(mockInfo) + }) + + expect(subsonic.scrobble).not.toHaveBeenCalled() + }) + + it('should reset scrobbling state on track change', () => { + const { result } = renderHook(() => + useScrobbling(mockPlayerState, mockDispatch, mockDataProvider), + ) + + // Set initial state + act(() => { + const mockInfo = { + currentTime: 300, + duration: 240, + isRadio: false, + trackId: 'track1', + } + result.current.onAudioProgress(mockInfo) + }) + + expect(result.current.scrobbled).toBe(true) + + // Track change should reset + act(() => { + result.current.onAudioPlayTrackChange() + }) + + expect(result.current.scrobbled).toBe(false) + expect(result.current.startTime).toBeNull() + }) + + it('should handle audio ended and perform keepalive', async () => { + const { result } = renderHook(() => + useScrobbling(mockPlayerState, mockDispatch, mockDataProvider), + ) + + const mockInfo = { trackId: 'track1' } + + act(() => { + result.current.onAudioEnded('playId', [], mockInfo) + }) + + expect(result.current.scrobbled).toBe(false) + expect(result.current.startTime).toBeNull() + expect(mockDataProvider.getOne).toHaveBeenCalledWith('keepalive', { + id: 'track1', + }) + }) + + it('should handle scrobbling errors gracefully', () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + // const mockSubsonic = subsonic + subsonic.default.scrobble.mockImplementation(() => { + throw new Error('Scrobbling failed') + }) + + const { result } = renderHook(() => + useScrobbling(mockPlayerState, mockDispatch, mockDataProvider), + ) + + const mockInfo = { + currentTime: 300, + duration: 240, + isRadio: false, + trackId: 'track1', + } + + act(() => { + result.current.onAudioProgress(mockInfo) + }) + + expect(consoleSpy).toHaveBeenCalledWith( + 'Scrobbling error:', + expect.any(Error), + ) + expect(result.current.scrobbled).toBe(false) // Should not set to true on error + + consoleSpy.mockRestore() + }) +})