mirror of
https://github.com/navidrome/navidrome.git
synced 2026-05-03 06:51:16 +00:00
Merge 59427e29ab4567b19c8278d4b8820c0287a033a8 into 84ab652ca751b094e1ae0e5b32d064b7be463881
This commit is contained in:
commit
324606b839
@ -1,5 +1,5 @@
|
|||||||
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
import React, { useCallback, useMemo } from 'react'
|
||||||
import { useDispatch, useSelector } from 'react-redux'
|
import { useSelector } from 'react-redux'
|
||||||
import { useMediaQuery } from '@material-ui/core'
|
import { useMediaQuery } from '@material-ui/core'
|
||||||
import { ThemeProvider } from '@material-ui/core/styles'
|
import { ThemeProvider } from '@material-ui/core/styles'
|
||||||
import {
|
import {
|
||||||
@ -16,32 +16,28 @@ import useCurrentTheme from '../themes/useCurrentTheme'
|
|||||||
import config from '../config'
|
import config from '../config'
|
||||||
import useStyle from './styles'
|
import useStyle from './styles'
|
||||||
import AudioTitle from './AudioTitle'
|
import AudioTitle from './AudioTitle'
|
||||||
import {
|
|
||||||
clearQueue,
|
|
||||||
currentPlaying,
|
|
||||||
setPlayMode,
|
|
||||||
setVolume,
|
|
||||||
syncQueue,
|
|
||||||
} from '../actions'
|
|
||||||
import PlayerToolbar from './PlayerToolbar'
|
import PlayerToolbar from './PlayerToolbar'
|
||||||
import { sendNotification } from '../utils'
|
import { sendNotification } from '../utils'
|
||||||
import subsonic from '../subsonic'
|
|
||||||
import locale from './locale'
|
import locale from './locale'
|
||||||
import { keyMap } from '../hotkeys'
|
import { keyMap } from '../hotkeys'
|
||||||
import keyHandlers from './keyHandlers'
|
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 Player = () => {
|
||||||
const theme = useCurrentTheme()
|
const theme = useCurrentTheme()
|
||||||
const translate = useTranslate()
|
const translate = useTranslate()
|
||||||
const playerTheme = theme.player?.theme || 'dark'
|
const playerTheme = theme.player?.theme || 'dark'
|
||||||
const dataProvider = useDataProvider()
|
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 isDesktop = useMediaQuery('(min-width:810px)')
|
||||||
const isMobilePlayer =
|
const isMobilePlayer =
|
||||||
/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(
|
/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(
|
||||||
@ -49,6 +45,39 @@ const Player = () => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
const { authenticated } = useAuthState()
|
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 visible = authenticated && playerState.queue.length > 0
|
||||||
const isRadio = playerState.current?.isRadio || false
|
const isRadio = playerState.current?.isRadio || false
|
||||||
const classes = useStyle({
|
const classes = useStyle({
|
||||||
@ -56,44 +85,6 @@ const Player = () => {
|
|||||||
visible,
|
visible,
|
||||||
enableCoverAnimation: config.enableCoverAnimation,
|
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(() => {
|
useEffect(() => {
|
||||||
const handleBeforeUnload = (e) => {
|
const handleBeforeUnload = (e) => {
|
||||||
@ -142,140 +133,84 @@ const Player = () => {
|
|||||||
locale: locale(translate),
|
locale: locale(translate),
|
||||||
sortableOptions: { delay: 200, delayOnTouchOnly: true },
|
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 options = useMemo(() => {
|
||||||
const current = playerState.current || {}
|
|
||||||
return {
|
return {
|
||||||
...defaultOptions,
|
...defaultOptions,
|
||||||
audioLists: playerState.queue.map((item) => item),
|
audioLists,
|
||||||
playIndex: playerState.playIndex,
|
playIndex: playerState.playIndex,
|
||||||
autoPlay: playerState.clear || playerState.playIndex === 0,
|
autoPlay: playerState.clear || playerState.playIndex === 0,
|
||||||
clearPriorAudioLists: playerState.clear,
|
clearPriorAudioLists: playerState.clear,
|
||||||
extendsContent: (
|
extendsContent: (
|
||||||
<PlayerToolbar id={current.trackId} isRadio={current.isRadio} />
|
<PlayerToolbar
|
||||||
|
id={currentTrack.trackId}
|
||||||
|
isRadio={currentTrack.isRadio}
|
||||||
|
/>
|
||||||
),
|
),
|
||||||
defaultVolume: isMobilePlayer ? 1 : playerState.volume,
|
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(
|
const onAudioListsChange = useCallback(
|
||||||
(_, audioLists, audioInfo) => dispatch(syncQueue(audioInfo, audioLists)),
|
(_, audioLists, audioInfo) => dispatchSyncQueue(audioInfo, audioLists),
|
||||||
[dispatch],
|
[dispatchSyncQueue],
|
||||||
)
|
|
||||||
|
|
||||||
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],
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const onAudioVolumeChange = useCallback(
|
const onAudioVolumeChange = useCallback(
|
||||||
// sqrt to compensate for the logarithmic volume
|
// sqrt to compensate for the logarithmic volume
|
||||||
(volume) => dispatch(setVolume(Math.sqrt(volume))),
|
(volume) => dispatchSetVolume(volume),
|
||||||
[dispatch],
|
[dispatchSetVolume],
|
||||||
)
|
)
|
||||||
|
|
||||||
const onAudioPlay = useCallback(
|
const handleAudioPlay = useCallback(
|
||||||
(info) => {
|
(info) => {
|
||||||
// Do this to start the context; on chrome-based browsers, the context
|
onAudioPlay(
|
||||||
// will start paused since it is created prior to user interaction
|
context,
|
||||||
if (context && context.state !== 'running') {
|
info,
|
||||||
context.resume()
|
(info) => dispatchCurrentPlaying(info),
|
||||||
}
|
showNotifications,
|
||||||
|
sendNotification,
|
||||||
dispatch(currentPlaying(info))
|
startTime,
|
||||||
if (startTime === null) {
|
setStartTime,
|
||||||
setStartTime(Date.now())
|
resetPreloading,
|
||||||
}
|
config,
|
||||||
if (info.duration) {
|
ReactGA,
|
||||||
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,
|
|
||||||
)
|
)
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[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(
|
const onAudioPause = useCallback(
|
||||||
(info) => dispatch(currentPlaying(info)),
|
(info) => dispatchCurrentPlaying(info),
|
||||||
[dispatch],
|
[dispatchCurrentPlaying],
|
||||||
)
|
|
||||||
|
|
||||||
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],
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const onCoverClick = useCallback((mode, audioLists, audioInfo) => {
|
const onCoverClick = useCallback((mode, audioLists, audioInfo) => {
|
||||||
@ -286,10 +221,10 @@ const Player = () => {
|
|||||||
|
|
||||||
const onBeforeDestroy = useCallback(() => {
|
const onBeforeDestroy = useCallback(() => {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
dispatch(clearQueue())
|
dispatchClearQueue()
|
||||||
reject()
|
reject()
|
||||||
})
|
})
|
||||||
}, [dispatch])
|
}, [dispatchClearQueue])
|
||||||
|
|
||||||
if (!visible) {
|
if (!visible) {
|
||||||
document.title = 'Navidrome'
|
document.title = 'Navidrome'
|
||||||
@ -300,30 +235,32 @@ const Player = () => {
|
|||||||
[audioInstance, playerState],
|
[audioInstance, playerState],
|
||||||
)
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isMobilePlayer && audioInstance) {
|
|
||||||
audioInstance.volume = 1
|
|
||||||
}
|
|
||||||
}, [isMobilePlayer, audioInstance])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ThemeProvider theme={createMuiTheme(theme)}>
|
<ThemeProvider theme={createMuiTheme(theme)}>
|
||||||
|
<div role="region" aria-label="Audio Player" aria-live="polite">
|
||||||
<ReactJkMusicPlayer
|
<ReactJkMusicPlayer
|
||||||
{...options}
|
{...options}
|
||||||
className={classes.player}
|
className={classes.player}
|
||||||
onAudioListsChange={onAudioListsChange}
|
onAudioListsChange={onAudioListsChange}
|
||||||
onAudioVolumeChange={onAudioVolumeChange}
|
onAudioVolumeChange={onAudioVolumeChange}
|
||||||
onAudioProgress={onAudioProgress}
|
onAudioProgress={onAudioProgress}
|
||||||
onAudioPlay={onAudioPlay}
|
onAudioPlay={handleAudioPlay}
|
||||||
onAudioPlayTrackChange={onAudioPlayTrackChange}
|
onAudioPlayTrackChange={onAudioPlayTrackChange}
|
||||||
onAudioPause={onAudioPause}
|
onAudioPause={onAudioPause}
|
||||||
onPlayModeChange={(mode) => dispatch(setPlayMode(mode))}
|
onPlayModeChange={dispatchSetPlayMode}
|
||||||
onAudioEnded={onAudioEnded}
|
onAudioEnded={onAudioEnded}
|
||||||
onCoverClick={onCoverClick}
|
onCoverClick={onCoverClick}
|
||||||
onBeforeDestroy={onBeforeDestroy}
|
onBeforeDestroy={onBeforeDestroy}
|
||||||
getAudioInstance={setAudioInstance}
|
getAudioInstance={setAudioInstance}
|
||||||
|
aria-label="Music Player"
|
||||||
/>
|
/>
|
||||||
<GlobalHotKeys handlers={handlers} keyMap={keyMap} allowChanges />
|
<GlobalHotKeys
|
||||||
|
handlers={handlers}
|
||||||
|
keyMap={keyMap}
|
||||||
|
allowChanges
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</ThemeProvider>
|
</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