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