mirror of
https://github.com/navidrome/navidrome.git
synced 2026-04-03 06:41:01 +00:00
Merge 59427e29ab4567b19c8278d4b8820c0287a033a8 into d9a215e1e3184ffd0ad85b2d4ddec250d661ab4b
This commit is contained in:
commit
59f795ee95
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
166
ui/src/audioplayer/Player.test.jsx
Normal file
166
ui/src/audioplayer/Player.test.jsx
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
131
ui/src/audioplayer/hooks/useAudioInstance.js
Normal file
131
ui/src/audioplayer/hooks/useAudioInstance.js
Normal 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,
|
||||
}
|
||||
}
|
||||
84
ui/src/audioplayer/hooks/usePlayerState.js
Normal file
84
ui/src/audioplayer/hooks/usePlayerState.js
Normal 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,
|
||||
}
|
||||
}
|
||||
106
ui/src/audioplayer/hooks/usePlayerState.test.js
Normal file
106
ui/src/audioplayer/hooks/usePlayerState.test.js
Normal 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))
|
||||
})
|
||||
})
|
||||
72
ui/src/audioplayer/hooks/usePreloading.js
Normal file
72
ui/src/audioplayer/hooks/usePreloading.js
Normal 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,
|
||||
}
|
||||
}
|
||||
144
ui/src/audioplayer/hooks/usePreloading.test.js
Normal file
144
ui/src/audioplayer/hooks/usePreloading.test.js
Normal 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()
|
||||
})
|
||||
})
|
||||
74
ui/src/audioplayer/hooks/useReplayGain.js
Normal file
74
ui/src/audioplayer/hooks/useReplayGain.js
Normal 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,
|
||||
}
|
||||
}
|
||||
160
ui/src/audioplayer/hooks/useReplayGain.test.js
Normal file
160
ui/src/audioplayer/hooks/useReplayGain.test.js
Normal 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()
|
||||
})
|
||||
})
|
||||
120
ui/src/audioplayer/hooks/useScrobbling.js
Normal file
120
ui/src/audioplayer/hooks/useScrobbling.js
Normal 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,
|
||||
}
|
||||
}
|
||||
166
ui/src/audioplayer/hooks/useScrobbling.test.js
Normal file
166
ui/src/audioplayer/hooks/useScrobbling.test.js
Normal 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()
|
||||
})
|
||||
})
|
||||
Loading…
x
Reference in New Issue
Block a user