Merge 59427e29ab4567b19c8278d4b8820c0287a033a8 into d9a215e1e3184ffd0ad85b2d4ddec250d661ab4b

This commit is contained in:
Xavier Araque 2026-02-28 12:26:34 -05:00 committed by GitHub
commit 59f795ee95
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 1348 additions and 188 deletions

View File

@ -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: (
<PlayerToolbar id={current.trackId} isRadio={current.isRadio} />
<PlayerToolbar
id={currentTrack.trackId}
isRadio={currentTrack.isRadio}
/>
),
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 (
<ThemeProvider theme={createMuiTheme(theme)}>
<ReactJkMusicPlayer
{...options}
className={classes.player}
onAudioListsChange={onAudioListsChange}
onAudioVolumeChange={onAudioVolumeChange}
onAudioProgress={onAudioProgress}
onAudioPlay={onAudioPlay}
onAudioPlayTrackChange={onAudioPlayTrackChange}
onAudioPause={onAudioPause}
onPlayModeChange={(mode) => dispatch(setPlayMode(mode))}
onAudioEnded={onAudioEnded}
onCoverClick={onCoverClick}
onBeforeDestroy={onBeforeDestroy}
getAudioInstance={setAudioInstance}
/>
<GlobalHotKeys handlers={handlers} keyMap={keyMap} allowChanges />
<div role="region" aria-label="Audio Player" aria-live="polite">
<ReactJkMusicPlayer
{...options}
className={classes.player}
onAudioListsChange={onAudioListsChange}
onAudioVolumeChange={onAudioVolumeChange}
onAudioProgress={onAudioProgress}
onAudioPlay={handleAudioPlay}
onAudioPlayTrackChange={onAudioPlayTrackChange}
onAudioPause={onAudioPause}
onPlayModeChange={dispatchSetPlayMode}
onAudioEnded={onAudioEnded}
onCoverClick={onCoverClick}
onBeforeDestroy={onBeforeDestroy}
getAudioInstance={setAudioInstance}
aria-label="Music Player"
/>
<GlobalHotKeys
handlers={handlers}
keyMap={keyMap}
allowChanges
aria-hidden="true"
/>
</div>
</ThemeProvider>
)
}

View File

@ -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 }) => (
<button data-testid="love-button" className={className} disabled={disabled}>
Love
</button>
),
useToggleLove: vi.fn(),
}))
vi.mock('../actions', () => ({
openSaveQueueDialog: vi.fn(),
}))
vi.mock('react-hotkeys', () => ({
GlobalHotKeys: () => <div data-testid="global-hotkeys" />,
}))
describe('<PlayerToolbar />', () => {
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(<PlayerToolbar id="song-1" />)
// 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(<PlayerToolbar id="song-1" isRadio={true} />)
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(<PlayerToolbar id="song-1" />)
const loveButton = screen.getByTestId('love-button')
expect(loveButton).toBeDisabled()
})
it('opens save queue dialog when save button is clicked', () => {
render(<PlayerToolbar id="song-1" />)
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(<PlayerToolbar id="song-1" />)
// 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(<PlayerToolbar id="song-1" isRadio={true} />)
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(<PlayerToolbar id="song-1" />)
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(<PlayerToolbar id="song-1" />)
expect(screen.getByTestId('global-hotkeys')).toBeInTheDocument()
// Cleanup and test mobile layout
cleanup()
useMediaQuery.mockReturnValue(false)
render(<PlayerToolbar id="song-1" />)
expect(screen.getByTestId('global-hotkeys')).toBeInTheDocument()
})
it('disables buttons when id is not provided', () => {
render(<PlayerToolbar />)
const loveButton = screen.getByTestId('love-button')
expect(loveButton).toBeDisabled()
})
})
})

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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