diff --git a/app/client/control/components/SpotifyControls.tsx b/app/client/control/components/SpotifyControls.tsx index f31eb014f0..fa86870ca8 100644 --- a/app/client/control/components/SpotifyControls.tsx +++ b/app/client/control/components/SpotifyControls.tsx @@ -11,7 +11,6 @@ import Select from '@mui/material/Select' import Typography from '@mui/material/Typography' import { useRouter } from 'next/navigation' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import throttle from 'lodash.throttle' import { clampVolume } from '@/utils/audioManager' import { useWebSocket } from '@/context/WebSocketContext' import { useSpotifyCommand } from '@/hooks/useSpotifyCommand' @@ -33,10 +32,6 @@ const SpotifyControls = () => { const { devices = [] } = spotifyData const lastSentVolumeRef = useRef(null) const [selectedDeviceId, setSelectedDeviceId] = useState('') - const [isSliding, setIsSliding] = useState(false) - const prevActiveIdRef = useRef(undefined) - const lastVolumeSyncTimeRef = useRef(0) - const hasPendingSendRef = useRef(false) const [optimisticIsPlaying, setOptimisticIsPlaying] = useState< boolean | null >(null) @@ -79,27 +74,6 @@ const SpotifyControls = () => { [connectionStatus, resolveTargetDeviceId, executeSpotify] ) - const throttledSendVolume = useMemo( - () => - throttle((val: number) => { - sendVolumeCommand(val) - }, 200), - [sendVolumeCommand] - ) - - useEffect(() => { - return () => { - throttledSendVolume.cancel() - } - }, [throttledSendVolume]) - - const handleThrottledVolumeChange = useCallback( - (val: number) => { - throttledSendVolume(val) - }, - [throttledSendVolume] - ) - const { displayVolume, isMuted, @@ -112,14 +86,6 @@ const SpotifyControls = () => { sendVolumeCommand ) - const handleVolumeSlide = useCallback( - (val: number) => { - handleVolumeChange(val) - handleThrottledVolumeChange(val) - }, - [handleVolumeChange, handleThrottledVolumeChange] - ) - const handleTrackSelect = (uri: string) => { const targetDeviceId = resolveTargetDeviceId() executeSpotify('PLAY', { @@ -212,14 +178,12 @@ const SpotifyControls = () => { command === 'NEXT' || command === 'PREVIOUS' ) { - // Optimistic UI update for Play/Pause if (command === 'PLAY') { setOptimisticIsPlaying(true) } else if (command === 'PAUSE') { setOptimisticIsPlaying(false) } - // Clear existing timer if any if (playbackGraceTimerRef.current) { clearTimeout(playbackGraceTimerRef.current) } @@ -235,7 +199,6 @@ const SpotifyControls = () => { [sendSpotifyCommand] ) - // Cleanup timers on unmount useEffect(() => { return () => { if (playbackGraceTimerRef.current) { @@ -244,55 +207,6 @@ const SpotifyControls = () => { } }, []) - const handleVolumeChange = useCallback( - (val: number) => { - setIsSliding(true) - setVolume(val) - if (connectionStatus !== 'Connected') { - const now = Date.now() - // Throttle warning to once every 3 seconds to avoid spam during sliding - if (now - lastWarningTimeRef.current > 3000) { - showWarning('Changes not saved: Offline') - lastWarningTimeRef.current = now - } - } - }, - [connectionStatus, showWarning, setVolume] - ) - - const sendVolumeCommand = useCallback( - (value: number) => { - if (connectionStatus !== 'Connected') return - const targetDeviceId = resolveTargetDeviceId() - - // Prevent sending volume command if no device is targeted - if (!targetDeviceId) return - - const sanitized = clampVolume(value) - const messageKey = `${targetDeviceId}:${sanitized}` - if (lastSentVolumeRef.current === messageKey) return - - hasPendingSendRef.current = true - lastVolumeSyncTimeRef.current = Date.now() - - executeSpotify('SET_VOLUME', { - volume: sanitized, - deviceId: targetDeviceId, - }) - - lastSentVolumeRef.current = messageKey - }, - [connectionStatus, resolveTargetDeviceId, executeSpotify] - ) - - const handleVolumeChangeCommitted = useCallback( - (val: number) => { - setIsSliding(false) - sendVolumeCommand(val) - }, - [sendVolumeCommand] - ) - useEffect(() => { if (connectionStatus !== 'Connected') { lastSentVolumeRef.current = null @@ -367,7 +281,7 @@ const SpotifyControls = () => { prop !== 'sliderColor' && prop !== 'scaleFactor', -})<{ sliderColor?: string; scaleFactor: number }>( - ({ theme, sliderColor, scaleFactor }) => ({ + shouldForwardProp: (prop) => prop !== 'sliderColor' && prop !== 'size', +})<{ sliderColor?: string; size?: 'small' | 'medium' }>( + ({ theme, sliderColor, size }) => ({ color: sliderColor || theme.palette.primary.main, - height: 8 * scaleFactor, + height: theme.spacing(size === 'small' ? 0.75 : 1), '& .MuiSlider-thumb': { backgroundColor: 'white', - width: 28 * scaleFactor, - height: 28 * scaleFactor, + width: theme.spacing(size === 'small' ? 2.5 : 3.5), + height: theme.spacing(size === 'small' ? 2.5 : 3.5), boxShadow: '0 2px 4px rgba(0,0,0,0.3)', '&:hover, &.Mui-focusVisible': { boxShadow: sliderColor @@ -51,7 +51,9 @@ const StyledSlider = styled(Slider, { transform: 'translate(-50%, -50%)', }, }, - '& .MuiSlider-track, .MuiSlider-rail': { height: 8 * scaleFactor }, + '& .MuiSlider-track, .MuiSlider-rail': { + height: theme.spacing(size === 'small' ? 0.75 : 1), + }, '& .MuiSlider-rail': { opacity: 0.3 }, }) ) @@ -84,8 +86,6 @@ const VolumeSlider: React.FC = ({ [onVolumeChangeCommitted] ) - const SCALE_FACTOR = size === 'small' ? 0.75 : 1 - return ( = ({ sx={{ color: muted ? 'error.main' : 'grey.400', '&:hover': { color: 'white' }, - padding: `${12 * SCALE_FACTOR}px`, + padding: (theme) => theme.spacing(size === 'small' ? 1 : 1.5), }} aria-label={muted ? 'Unmute' : 'Mute'} data-testid="volume-slider-mute-button" @@ -123,7 +123,7 @@ const VolumeSlider: React.FC = ({ value={muted ? 0 : volume} onChange={handleVolumeChange} onChangeCommitted={handleVolumeChangeCommitted} - scaleFactor={SCALE_FACTOR} + size={size} disabled={disabled} sliderColor={sliderColor} aria-label="Volume control" diff --git a/constants/spotify.ts b/constants/spotify.ts index 24e99a22de..37eadf2ddd 100644 --- a/constants/spotify.ts +++ b/constants/spotify.ts @@ -8,6 +8,5 @@ export const HRM_WEB_PLAYER_NAME = 'HRM Web Player' export const SPOTIFY_DEFAULT_TOKEN_EXPIRY_S = 3600 // Centralized constants for Spotify integration -export const VOLUME_SYNC_GRACE_PERIOD_MS = 3000 export const SPOTIFY_BRAND_COLOR = '#1DB954' export const SYNC_LOCK_DURATION = 2000 diff --git a/context/AudioContext.tsx b/context/AudioContext.tsx index 5561245e8e..7dbf100d8a 100644 --- a/context/AudioContext.tsx +++ b/context/AudioContext.tsx @@ -1,14 +1,7 @@ 'use client' -import { - createContext, - useContext, - useCallback, - useEffect, - useRef, - useState, -} from 'react' -import { audioManager, clampVolume } from '@/utils/audioManager' +import { createContext, useContext } from 'react' +import { useAudioPreference } from '@/hooks/useAudioPreference' export interface AudioContextType { volume: number @@ -20,115 +13,6 @@ export interface AudioContextType { const AudioContext = createContext(undefined) -const STORAGE_KEY_VOL = 'hrm-preferred-volume' -const STORAGE_KEY_MUTE = 'hrm-muted' - -const useAudioPreference = (defaultVolume = 70) => { - const sanitizedDefault = clampVolume(defaultVolume) - const lastVolumeRef = useRef(sanitizedDefault) - - const [volume, setVolumeState] = useState(sanitizedDefault) - const [muted, setMutedState] = useState(false) - const [isLoaded, setIsLoaded] = useState(false) - - useEffect(() => { - try { - const storedMute = window.localStorage.getItem(STORAGE_KEY_MUTE) - const storedVol = window.localStorage.getItem(STORAGE_KEY_VOL) - - const isMuted = storedMute === 'true' - const preferredVolume = - storedVol !== null ? clampVolume(Number(storedVol)) : sanitizedDefault - lastVolumeRef.current = preferredVolume - setMutedState(isMuted) - setVolumeState(isMuted ? 0 : preferredVolume) - } catch (error) { - console.warn('Failed to read audio preferences from localStorage:', error) - } finally { - setIsLoaded(true) - } - }, [sanitizedDefault]) - - useEffect(() => { - if (isLoaded) { - audioManager.setMuted(muted) - audioManager.setVolume(volume) - } - }, [volume, muted, isLoaded]) - - const setVolume = useCallback( - (value: number) => { - const sanitized = clampVolume(value) - setVolumeState(sanitized) - if (sanitized > 0) { - lastVolumeRef.current = sanitized - setMutedState(false) - } else { - setMutedState(true) - } - window.dispatchEvent( - new CustomEvent('hrm:volumeChange', { detail: sanitized }) - ) - }, - [setMutedState] - ) - - const toggleMute = useCallback(() => { - const isMuting = !muted - setMutedState(isMuting) - try { - window.localStorage.setItem(STORAGE_KEY_MUTE, String(isMuting)) - if (isMuting) { - if (volume > 0) { - lastVolumeRef.current = volume - window.localStorage.setItem(STORAGE_KEY_VOL, String(volume)) - } - setVolumeState(0) - } else { - setVolumeState(lastVolumeRef.current) - } - window.dispatchEvent( - new CustomEvent('hrm:muteChange', { detail: isMuting }) - ) - } catch (error) { - console.warn('Could not persist mute preference:', error) - } - }, [muted, volume]) - - useEffect(() => { - const handleStorageChange = (e: StorageEvent) => { - if (e.key === STORAGE_KEY_VOL && e.newValue !== null) { - setVolumeState(clampVolume(Number(e.newValue))) - } - if (e.key === STORAGE_KEY_MUTE && e.newValue !== null) { - setMutedState(e.newValue === 'true') - } - } - - const handleLocalVolume = (e: Event) => { - const customEvent = e as CustomEvent - setVolumeState(customEvent.detail) - } - - const handleLocalMute = (e: Event) => { - const customEvent = e as CustomEvent - setMutedState(customEvent.detail) - } - - window.addEventListener('storage', handleStorageChange) - window.addEventListener('hrm:volumeChange', handleLocalVolume) - window.addEventListener('hrm:muteChange', handleLocalMute) - - return () => { - window.removeEventListener('storage', handleStorageChange) - window.removeEventListener('hrm:volumeChange', handleLocalVolume) - window.removeEventListener('hrm:muteChange', handleLocalMute) - } - }, []) - - return { volume, setVolume, muted, toggleMute, isLoaded } -} - export const AudioProvider = ({ children }: { children: React.ReactNode }) => { const volumePreference = useAudioPreference() return ( diff --git a/context/WebSocketContext.tsx b/context/WebSocketContext.tsx index a0812e856c..186357d5e9 100644 --- a/context/WebSocketContext.tsx +++ b/context/WebSocketContext.tsx @@ -21,6 +21,7 @@ import { ConnectedHrmData as HrmData } from '../types/websocket' export type { HrmData } export interface WebSocketContextType extends WebSocketState { + onEvent?: (event: string, callback: (data: unknown) => void) => () => void connectionStatus: string sendData: (data: ClientCommandMessage) => void connect: () => void @@ -291,6 +292,17 @@ export const WebSocketProvider = ({ return // Pong message is handled, no state dispatch needed } + if (message.type === 'SPOTIFY_OPTIMISTIC_FAILURE') { + if (typeof window !== 'undefined') { + window.dispatchEvent( + new CustomEvent('SPOTIFY_OPTIMISTIC_FAILURE', { + detail: message.payload, + }) + ) + } + return + } + // Throttle high-frequency messages if (message.type === 'HRM_UPDATE' || message.type === 'TIMER_UPDATE') { throttledDispatch(message) @@ -405,12 +417,26 @@ export const WebSocketProvider = ({ [throttledConnectionWarning] ) + const onEvent = useCallback( + (event: string, callback: (data: unknown) => void) => { + const handleEvent = (e: CustomEvent) => { + callback(e.detail) + } + window.addEventListener(event, handleEvent as EventListener) + return () => { + window.removeEventListener(event, handleEvent as EventListener) + } + }, + [] + ) + const contextValue = { ...appState, connectionStatus, sendData, connect, disconnect, + onEvent, } return ( diff --git a/hooks/useAudioPreference.ts b/hooks/useAudioPreference.ts new file mode 100644 index 0000000000..98a5942086 --- /dev/null +++ b/hooks/useAudioPreference.ts @@ -0,0 +1,108 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { audioManager, clampVolume } from '@/utils/audioManager' + +const STORAGE_KEY_VOL = 'hrm-preferred-volume' +const STORAGE_KEY_MUTE = 'hrm-muted' + +export const useAudioPreference = (defaultVolume = 70) => { + const sanitizedDefault = clampVolume(defaultVolume) + const lastVolumeRef = useRef(sanitizedDefault) + + const [volume, setVolumeState] = useState(sanitizedDefault) + const [muted, setMutedState] = useState(false) + const [isLoaded, setIsLoaded] = useState(false) + + useEffect(() => { + try { + const storedMute = window.localStorage.getItem(STORAGE_KEY_MUTE) + const storedVol = window.localStorage.getItem(STORAGE_KEY_VOL) + + const isMuted = storedMute === 'true' + const preferredVolume = + storedVol !== null ? clampVolume(Number(storedVol)) : sanitizedDefault + lastVolumeRef.current = preferredVolume + setMutedState(isMuted) + setVolumeState(isMuted ? 0 : preferredVolume) + } catch (error) { + console.warn('Failed to read audio preferences from localStorage:', error) + } finally { + setIsLoaded(true) + } + }, [sanitizedDefault]) + + useEffect(() => { + if (isLoaded) { + audioManager.setMuted(muted) + audioManager.setVolume(volume) + } + }, [volume, muted, isLoaded]) + + const setVolume = useCallback((value: number) => { + const sanitized = clampVolume(value) + setVolumeState(sanitized) + if (sanitized > 0) { + lastVolumeRef.current = sanitized + setMutedState(false) + } else { + setMutedState(true) + } + window.dispatchEvent( + new CustomEvent('hrm:volumeChange', { detail: sanitized }) + ) + }, []) + + const toggleMute = useCallback(() => { + const isMuting = !muted + setMutedState(isMuting) + try { + window.localStorage.setItem(STORAGE_KEY_MUTE, String(isMuting)) + if (isMuting) { + if (volume > 0) { + lastVolumeRef.current = volume + window.localStorage.setItem(STORAGE_KEY_VOL, String(volume)) + } + setVolumeState(0) + } else { + setVolumeState(lastVolumeRef.current) + } + window.dispatchEvent( + new CustomEvent('hrm:muteChange', { detail: isMuting }) + ) + } catch (error) { + console.warn('Could not persist mute preference:', error) + } + }, [muted, volume]) + + useEffect(() => { + const handleStorageChange = (e: StorageEvent) => { + if (e.key === STORAGE_KEY_VOL && e.newValue !== null) { + setVolumeState(clampVolume(Number(e.newValue))) + } + if (e.key === STORAGE_KEY_MUTE && e.newValue !== null) { + setMutedState(e.newValue === 'true') + } + } + + const handleLocalVolume = (e: Event) => { + const customEvent = e as CustomEvent + setVolumeState(customEvent.detail) + } + + const handleLocalMute = (e: Event) => { + const customEvent = e as CustomEvent + setMutedState(customEvent.detail) + } + + window.addEventListener('storage', handleStorageChange) + window.addEventListener('hrm:volumeChange', handleLocalVolume) + window.addEventListener('hrm:muteChange', handleLocalMute) + + return () => { + window.removeEventListener('storage', handleStorageChange) + window.removeEventListener('hrm:volumeChange', handleLocalVolume) + window.removeEventListener('hrm:muteChange', handleLocalMute) + } + }, []) + + return { volume, setVolume, muted, toggleMute, isLoaded } +} diff --git a/hooks/useOptimisticSync.ts b/hooks/useOptimisticSync.ts index a88edc5ef7..fce556b52d 100644 --- a/hooks/useOptimisticSync.ts +++ b/hooks/useOptimisticSync.ts @@ -11,5 +11,9 @@ export const useOptimisticSync = (lockDuration: number) => { return Date.now() - lastInteractionRef.current < lockDuration }, [lockDuration]) - return { isLocked, markInteraction } + const unlock = useCallback(() => { + lastInteractionRef.current = 0 + }, []) + + return { isLocked, markInteraction, unlock } } diff --git a/hooks/useSpotifyVolume.ts b/hooks/useSpotifyVolume.ts index 37e890d05d..1b5b477b52 100644 --- a/hooks/useSpotifyVolume.ts +++ b/hooks/useSpotifyVolume.ts @@ -1,5 +1,6 @@ import { useReducer, useEffect, useCallback } from 'react' import { useOptimisticSync } from '@/hooks/useOptimisticSync' +import { useWebSocket } from '@/context/WebSocketContext' import { SYNC_LOCK_DURATION } from '@/constants/spotify' // 1. State Shape @@ -69,6 +70,7 @@ export const useSpotifyVolume = ( initialMuted: boolean | undefined, sendVolumeCommand: (volume: number) => void ) => { + const { onEvent } = useWebSocket() const [state, dispatch] = useReducer(spotifyVolumeReducer, { displayVolume: initialVolume ?? 70, isMuted: initialMuted ?? false, @@ -76,7 +78,8 @@ export const useSpotifyVolume = ( lastVolume: initialVolume && initialVolume > 0 ? initialVolume : 70, }) - const { isLocked, markInteraction } = useOptimisticSync(SYNC_LOCK_DURATION) + const { isLocked, markInteraction, unlock } = + useOptimisticSync(SYNC_LOCK_DURATION) useEffect(() => { if (state.isSliding || isLocked()) return @@ -115,6 +118,15 @@ export const useSpotifyVolume = ( sendVolumeCommand(newVolume) }, [state.isMuted, state.lastVolume, sendVolumeCommand]) + useEffect(() => { + if (!onEvent) return + return onEvent('SPOTIFY_OPTIMISTIC_FAILURE', (data: unknown) => { + if ((data as { command: string })?.command === 'SET_VOLUME') { + unlock() + } + }) + }, [onEvent, unlock]) + return { displayVolume: state.displayVolume, isMuted: state.isMuted, diff --git a/services/spotifyPlayerManager.ts b/services/spotifyPlayerManager.ts index 347d9777a8..b740f42857 100644 --- a/services/spotifyPlayerManager.ts +++ b/services/spotifyPlayerManager.ts @@ -116,10 +116,7 @@ export class SpotifyPlayerManager { optimisticUpdate: () => void, execute: () => Promise ) { - const previousState = { - ...this.getState(), - playback: { ...this.getState().playback }, - } + const previousPlayback = { ...this.getState().playback } optimisticUpdate() this.broadcastUpdate({ type: 'SPOTIFY_UPDATE', payload: this.getState() }) @@ -132,8 +129,13 @@ export class SpotifyPlayerManager { 'Optimistic Spotify command failed, reverting state.' ) } - this.setState(previousState) + this.setState((prev) => ({ ...prev, playback: previousPlayback })) this.broadcastUpdate({ type: 'SPOTIFY_UPDATE', payload: this.getState() }) + // Notify client to unlock early so UI isn't stuck for 2s + this.broadcastUpdate({ + type: 'SPOTIFY_OPTIMISTIC_FAILURE', + payload: { command: commandName }, + }) throw error } } diff --git a/tests/unit/app/client/control/components/SpotifyControls.test.tsx b/tests/unit/app/client/control/components/SpotifyControls.test.tsx index 1c001f9b48..4fef32821a 100644 --- a/tests/unit/app/client/control/components/SpotifyControls.test.tsx +++ b/tests/unit/app/client/control/components/SpotifyControls.test.tsx @@ -6,6 +6,7 @@ import { useRouter } from 'next/navigation' import { useWebSocket } from '@/context/WebSocketContext' import { HRM_WEB_PLAYER_NAME } from '@/constants/spotify' import SpotifyControls from '@/app/client/control/components/SpotifyControls' +import { useSpotifyCommand } from '@/hooks/useSpotifyCommand' import { mockRouter } from '@/utils/test-utils/mockRouter' import { useSpotifyVolume } from '@/hooks/useSpotifyVolume' import { @@ -53,6 +54,10 @@ jest.mock('@/hooks/useSpotifyVolume', () => ({ })) // Mock the spotify constants +jest.mock('@/hooks/useSpotifyCommand', () => ({ + useSpotifyCommand: jest.fn(), +})) + jest.mock('@/constants/spotify', () => ({ ...jest.requireActual('@/constants/spotify'), HRM_WEB_PLAYER_NAME: 'HRM Web Player', @@ -60,9 +65,11 @@ jest.mock('@/constants/spotify', () => ({ describe('components/SpotifyControls', () => { let mockSendData: jest.Mock + let executeSpotifyMock: jest.Mock beforeEach(() => { mockSendData = jest.fn() + executeSpotifyMock = jest.fn() ;(useRouter as jest.Mock).mockReturnValue(mockRouter) ;(useWebSocket as jest.Mock).mockReturnValue({ connectionStatus: 'Connected', @@ -88,6 +95,9 @@ describe('components/SpotifyControls', () => { sendData: mockSendData, spotifyServiceInitialized: true, }) + ;(useSpotifyCommand as jest.Mock).mockReturnValue({ + execute: executeSpotifyMock, + }) ;(useSpotifyVolume as jest.Mock).mockReturnValue({ displayVolume: 50, isSliding: false, @@ -109,23 +119,10 @@ describe('components/SpotifyControls', () => { expect(screen.getByLabelText('Pause')).toBeInTheDocument() }) - it('sends a GET_DEVICES command on mount if connected', () => { - render() - expect(mockSendData).toHaveBeenCalledWith({ - type: 'SPOTIFY_COMMAND', - command: 'GET_DEVICES', - }) - }) - it('handles playback commands', () => { render() fireEvent.click(screen.getByLabelText('Pause')) - expect(mockSendData).toHaveBeenCalledWith( - expect.objectContaining({ - type: 'SPOTIFY_COMMAND', - command: 'PAUSE', - }) - ) + expect(executeSpotifyMock).toHaveBeenCalledWith('PAUSE', expect.any(Object)) }) it('should render the mute button with the correct aria-label', () => { @@ -135,130 +132,25 @@ describe('components/SpotifyControls', () => { }) it('sends volume change command on commit', async () => { - const handleVolumeChangeMock = jest.fn() - const mockUseSpotifyVolume = useSpotifyVolume as jest.Mock - - mockUseSpotifyVolume.mockReturnValue({ + const handleVolumeChangeCommittedMock = jest.fn() + ;(useSpotifyVolume as jest.Mock).mockReturnValue({ displayVolume: 50, isSliding: false, handleVolumeChange: jest.fn(), - handleVolumeChangeCommitted: jest.fn(), + handleVolumeChangeCommitted: handleVolumeChangeCommittedMock, handleToggleMute: jest.fn(), isMuted: false, - handleVolumeChange: handleVolumeChangeMock, }) render() const volumeSlider = screen.getByRole('slider') - // Simulate sliding stops fireEvent.change(volumeSlider, { target: { value: '80' } }) fireEvent.mouseUp(volumeSlider, { target: { value: '80' } }) - // Command should be sent with the latest value - await waitFor(() => { - expect(mockSendData).toHaveBeenCalledWith( - expect.objectContaining({ - type: 'SPOTIFY_COMMAND', - command: 'SET_VOLUME', - volume: 80, - }) - ) - }) - }) - - it('prevents volume snap-back during slider drag', async () => { - const handleVolumeChangeMock = jest.fn() - const mockUseSpotifyVolume = useSpotifyVolume as jest.Mock - const mockWebSocket = useWebSocket as jest.Mock - - // Initial state: volume 50 - mockUseSpotifyVolume.mockReturnValue({ - displayVolume: 50, - isSliding: false, - handleVolumeChange: jest.fn(), - handleVolumeChangeCommitted: jest.fn(), - handleToggleMute: jest.fn(), - isMuted: false, - handleVolumeChange: handleVolumeChangeMock, - }) - - const { rerender } = render() - - const volumeSlider = screen.getByRole('slider') - - // Start sliding (updates local state to 80) - fireEvent.change(volumeSlider, { target: { value: '80' } }) - - // Simulate WebSocket update (server volume is still 50, or changed to 40) - mockWebSocket.mockReturnValue({ - connectionStatus: 'Connected', - spotifyData: createMockSpotifyData({ - playback: { - ...createMockSpotifyData().playback, - volume_percent: 40, - }, - devices: [ - createMockSpotifyDevice({ - id: '1', - is_active: true, - volume_percent: 40, - }), - ], - }), - sendData: mockSendData, - spotifyServiceInitialized: true, - }) - - rerender() - - // setVolume should NOT have been called with the server value (40) because we are sliding - expect(handleVolumeChangeMock).not.toHaveBeenCalledWith(40) - - // Stop sliding - fireEvent.mouseUp(volumeSlider, { target: { value: '80' } }) - - // Now it should send the command with 80 - await waitFor(() => { - expect(mockSendData).toHaveBeenCalledWith( - expect.objectContaining({ - type: 'SPOTIFY_COMMAND', - command: 'SET_VOLUME', - volume: 80, - }) - ) - }) - }) - - it('selects HRM Web Player by default when no device is active', async () => { - ;(useWebSocket as jest.Mock).mockReturnValue({ - connectionStatus: 'Connected', - spotifyData: createMockSpotifyData({ - devices: [ - createMockSpotifyDevice({ - id: '1', - name: 'Device 1', - is_active: false, - }), - createMockSpotifyDevice({ - id: 'hrm-player', - name: HRM_WEB_PLAYER_NAME, - is_active: false, - }), - ], - }), - sendData: mockSendData, - spotifyServiceInitialized: true, - }) - - render() - await waitFor(() => { - // Use test-id to find the device select specifically, - // as there are now multiple comboboxes (one for search) - const deviceSelect = screen.getByTestId('spotify-device-select') - expect(deviceSelect).toHaveTextContent(HRM_WEB_PLAYER_NAME) + expect(handleVolumeChangeCommittedMock).toHaveBeenCalledWith(80) }) }) @@ -294,12 +186,9 @@ describe('components/SpotifyControls', () => { const playButton = screen.getByLabelText('Play') fireEvent.click(playButton) - expect(mockSendData).toHaveBeenCalledWith( - expect.objectContaining({ - type: 'SPOTIFY_COMMAND', - command: 'PLAY', - deviceId: 'hrm-player', - }) + expect(executeSpotifyMock).toHaveBeenCalledWith( + 'PLAY', + expect.objectContaining({ deviceId: 'hrm-player' }) ) }) }) diff --git a/types/websocket.ts b/types/websocket.ts index 2fd543a51a..4348ede264 100644 --- a/types/websocket.ts +++ b/types/websocket.ts @@ -93,6 +93,7 @@ export type ServerMessage = ( | { type: 'SPOTIFY_SERVICE_INIT_UPDATE'; payload: boolean } | { type: 'PONG' } // Add PONG message type for server-to-client heartbeat | { type: 'DEVICE_OFFLINE'; payload: { deviceId: string } } + | { type: 'SPOTIFY_OPTIMISTIC_FAILURE'; payload: { command: string } } ) & { serverTimestamp?: number } /**