diff --git a/app/client/control/components/SpotifyControls.tsx b/app/client/control/components/SpotifyControls.tsx index 8e1501b751..ff387aa2d3 100644 --- a/app/client/control/components/SpotifyControls.tsx +++ b/app/client/control/components/SpotifyControls.tsx @@ -12,15 +12,13 @@ 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 useVolumePreference, { clampVolume } from '@/hooks/useVolumePreference' -import { useAppSnackbar } from '@/hooks/useAppSnackbar' +import { clampVolume } from '@/utils/audioManager' +import { useSpotifyVolume } from '@/hooks/useSpotifyVolume' + import { useWebSocket } from '@/context/WebSocketContext' import { useSpotifyCommand } from '@/hooks/useSpotifyCommand' import { SpotifyCommand } from '@/types/websocket' -import { - HRM_WEB_PLAYER_NAME, - VOLUME_SYNC_GRACE_PERIOD_MS, -} from '@/constants/spotify' +import { HRM_WEB_PLAYER_NAME } from '@/constants/spotify' import PlaybackControls from '@/components/shared/PlaybackControls' import SpotifySearchInput from '@/components/SpotifySearchInput' import VolumeSlider from '@/components/shared/VolumeSlider' @@ -34,15 +32,11 @@ const SpotifyControls = () => { useWebSocket() const { execute: executeSpotify } = useSpotifyCommand() const { devices = [] } = spotifyData // Default to empty array if undefined - const { volume, setVolume, muted, toggleMute } = useVolumePreference() - const { showWarning } = useAppSnackbar() + const lastSentVolumeRef = useRef(null) - const lastWarningTimeRef = useRef(0) + 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) @@ -83,63 +77,15 @@ const SpotifyControls = () => { } }, [connectionStatus, sendData, spotifyServiceInitialized]) - // 4. Sync selected device and volume with active device + // Auto-select HRM Web Player if no active device is available useEffect(() => { const activeDevice = devices.find((d) => d.is_active) const activeId = activeDevice?.id - - // Helper: determine if device should be updated to activeId - const shouldUpdateToActive = () => { - // Initial sync or active device changed externally - if (!prevActiveIdRef.current || activeId !== prevActiveIdRef.current) { - return Boolean(activeId) - } - // Selected device no longer exists or no device selected - const selectedStillExists = devices.some((d) => d.id === selectedDeviceId) - return (!selectedDeviceId || !selectedStillExists) && Boolean(activeId) - } - - if (shouldUpdateToActive()) { - setSelectedDeviceId(activeId!) - } - prevActiveIdRef.current = activeId - - // Sync Volume (if not dragging and not within grace period after send) - // We rely on the server as the source of truth for volume, but use a grace period - // to prevent local sliders from "jumping" while the user is actively adjusting them. - const playbackVolume = spotifyData.playback.volume_percent - - if (isSliding) return - - const timeSinceLastVolumeSend = Date.now() - lastVolumeSyncTimeRef.current - - // Only sync if we haven't sent a volume command recently. - // The server broadcasts a SPOTIFY_UPDATE immediately after a SET_VOLUME command, - // confirming the new state to all clients. - const shouldRespectGracePeriod = - hasPendingSendRef.current && - timeSinceLastVolumeSend < VOLUME_SYNC_GRACE_PERIOD_MS - - if (shouldRespectGracePeriod) { - return + if (activeId && selectedDeviceId !== activeId) { + setSelectedDeviceId(activeId) } - - // Clear pending flag after grace period - if ( - hasPendingSendRef.current && - timeSinceLastVolumeSend >= VOLUME_SYNC_GRACE_PERIOD_MS - ) { - hasPendingSendRef.current = false - } - - if (activeDevice && typeof playbackVolume === 'number') { - if (playbackVolume !== volume) { - setVolume(playbackVolume) - } - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [devices]) // Rely on devices update to trigger sync + }, [devices.map((d) => `${d.id}:${d.is_active}`).join(','), selectedDeviceId]) // Auto-select HRM Web Player if no active device is available useEffect(() => { @@ -234,37 +180,17 @@ 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, @@ -275,13 +201,13 @@ const SpotifyControls = () => { [connectionStatus, resolveTargetDeviceId, executeSpotify] ) - const handleVolumeChangeCommitted = useCallback( - (val: number) => { - setIsSliding(false) - sendVolumeCommand(val) - }, - [sendVolumeCommand] - ) + const { + displayVolume, + isMuted, + handleVolumeChange, + handleVolumeChangeCommitted, + handleToggleMute, + } = useSpotifyVolume(spotifyData.playback.volume_percent, sendVolumeCommand) useEffect(() => { if (connectionStatus !== 'Connected') { @@ -355,11 +281,11 @@ const SpotifyControls = () => { /> { - switch (action.type) { - case 'SYNC_WITH_WEBSOCKET': { - if (state.isSliding) return state - const { volume, isMuted } = action.payload - const newVolume = volume ?? state.displayVolume - return { - ...state, - displayVolume: newVolume, - isMuted: isMuted ?? state.isMuted, - lastVolume: newVolume > 0 ? newVolume : state.lastVolume, - } - } - case 'SET_SLIDING': - return { ...state, isSliding: action.payload } - case 'SET_VOLUME': - return { - ...state, - isSliding: true, - displayVolume: action.payload, - isMuted: action.payload === 0, - lastVolume: action.payload > 0 ? action.payload : state.lastVolume, - } - case 'TOGGLE_MUTE': { - const newMutedState = !state.isMuted - if (newMutedState) { - // Muting: set volume to 0 - return { - ...state, - isMuted: true, - displayVolume: 0, - } - } else { - // Unmuting: restore to last known volume - return { - ...state, - isMuted: false, - displayVolume: state.lastVolume > 0 ? state.lastVolume : 50, // fallback - } - } - } - case 'SELECT_DEVICE': - return { - ...state, - selectedDeviceId: action.payload, - deviceMenuAnchor: null, - } - case 'OPEN_DEVICE_MENU': - return { ...state, deviceMenuAnchor: action.payload } - case 'CLOSE_DEVICE_MENU': - return { ...state, deviceMenuAnchor: null } - default: - return state - } -} +import { useSpotifyVolume } from '@/hooks/useSpotifyVolume' const SpotifyDisplay = () => { const { isLoggedIn } = useSpotifyAuth() const { spotifyData, connectionStatus } = useWebSocket() const { execute: executeSpotify } = useSpotifyCommand() - - // 4. Integrate useReducer - const [state, dispatch] = useReducer(spotifyDisplayReducer, { - displayVolume: spotifyData.playback.volume_percent ?? 70, - isMuted: spotifyData.playback.isMuted ?? false, - isSliding: false, - lastVolume: - spotifyData.playback.volume_percent && - spotifyData.playback.volume_percent > 0 - ? spotifyData.playback.volume_percent - : 70, - selectedDeviceId: '', - deviceMenuAnchor: null, - }) - const { displayVolume, isMuted, selectedDeviceId, deviceMenuAnchor } = state + const [selectedDeviceId, setSelectedDeviceId] = useState('') + const [deviceMenuAnchor, setDeviceMenuAnchor] = useState( + null + ) const hasActiveDevice = !!selectedDeviceId || spotifyData.devices?.some((device) => device.is_active) - // Track the last time volume command was sent to prevent sync race conditions - const lastVolumeSendTimeRef = useRef(0) - const hasPendingSendRef = useRef(false) - const handleLogout = async () => { await signOut({ redirect: false }) window.location.reload() @@ -143,45 +45,6 @@ const SpotifyDisplay = () => { // Enable remote Spotify control from controllers useDashboardRegistration(player) - // Synchronize with WebSocket data whenever it changes. - // We rely on the server as the source of truth for volume, but use a grace period - // to prevent local sliders from "jumping" while the user is actively adjusting them. - useEffect(() => { - const timeSinceLastSend = Date.now() - lastVolumeSendTimeRef.current - - // Only apply grace period if a send is pending and within the window. - // The server broadcasts a SPOTIFY_UPDATE immediately after a SET_VOLUME command, - // confirming the new state to all clients. - const shouldRespectGracePeriod = - hasPendingSendRef.current && - timeSinceLastSend < VOLUME_SYNC_GRACE_PERIOD_MS - - if (state.isSliding || shouldRespectGracePeriod) { - return - } - - // Once grace period has elapsed, clear the pending send flag - if ( - hasPendingSendRef.current && - timeSinceLastSend >= VOLUME_SYNC_GRACE_PERIOD_MS - ) { - hasPendingSendRef.current = false - } - - dispatch({ - type: 'SYNC_WITH_WEBSOCKET', - payload: { - volume: spotifyData.playback.volume_percent, - isMuted: spotifyData.playback.isMuted, - }, - }) - }, [ - spotifyData.playback.volume_percent, - spotifyData.playback.isMuted, - state.isSliding, - ]) - - // Centralized command sender for volume changes const sendVolumeCommand = useCallback( (volume: number) => { if (connectionStatus !== 'Connected') return @@ -189,15 +52,10 @@ const SpotifyDisplay = () => { selectedDeviceId || spotifyData.devices?.find((device) => device.is_active)?.id - // Refinement: Only attempt to send the command if a target device is identified. - // The VolumeSlider is already disabled in the UI if !hasActiveDevice. if (!targetDeviceId) return const sanitized = clampVolume(volume) - lastVolumeSendTimeRef.current = Date.now() - hasPendingSendRef.current = true - executeSpotify('SET_VOLUME', { volume: sanitized, deviceId: targetDeviceId, @@ -206,50 +64,34 @@ const SpotifyDisplay = () => { [connectionStatus, selectedDeviceId, executeSpotify, spotifyData.devices] ) - // Handler for immediate UI update while sliding - const handleVolumeChange = (newVolume: number) => { - dispatch({ type: 'SET_VOLUME', payload: newVolume }) // Update UI immediately - } - - // Handler for sending the final volume value after sliding stops - const handleVolumeChangeCommitted = (newVolume: number) => { - sendVolumeCommand(newVolume) - dispatch({ type: 'SET_SLIDING', payload: false }) // Reset sliding state - } - - // Handler for the VolumeSlider's mute button - const handleToggleMute = useCallback(() => { - // Calculate the next state to determine the command payload - const newMutedState = !isMuted - const newVolume = newMutedState - ? 0 - : state.lastVolume > 0 - ? state.lastVolume - : 50 - - dispatch({ type: 'TOGGLE_MUTE' }) // Update UI - sendVolumeCommand(newVolume) // Send command with the new volume - }, [isMuted, state.lastVolume, sendVolumeCommand]) + const { + displayVolume, + isMuted, + handleVolumeChange, + handleVolumeChangeCommitted, + handleToggleMute, + } = useSpotifyVolume(spotifyData.playback.volume_percent, sendVolumeCommand) // Effect to auto-select the active device useEffect(() => { const devices = spotifyData.devices || [] if (devices.length === 0) { if (selectedDeviceId !== '') { - dispatch({ type: 'SELECT_DEVICE', payload: '' }) + // eslint-disable-next-line react-hooks/set-state-in-effect + setSelectedDeviceId('') } return } const activeDevice = devices.find((device) => device.is_active) if (!selectedDeviceId && activeDevice) { - dispatch({ type: 'SELECT_DEVICE', payload: activeDevice.id }) + setSelectedDeviceId(activeDevice.id) return } if ( selectedDeviceId && !devices.some((device) => device.id === selectedDeviceId) ) { - dispatch({ type: 'SELECT_DEVICE', payload: activeDevice?.id ?? '' }) + setSelectedDeviceId(activeDevice?.id ?? '') } }, [spotifyData.devices, selectedDeviceId]) @@ -262,7 +104,8 @@ const SpotifyDisplay = () => { } const handleDeviceSelect = (deviceId: string) => { - dispatch({ type: 'SELECT_DEVICE', payload: deviceId }) + setSelectedDeviceId(deviceId) + setDeviceMenuAnchor(null) executeSpotify('TRANSFER_PLAYBACK', { deviceId }) } @@ -451,10 +294,8 @@ const SpotifyDisplay = () => { availableDevices={spotifyData.devices || []} deviceMenuAnchor={deviceMenuAnchor} onDeviceSelect={handleDeviceSelect} - onMenuOpen={(e) => - dispatch({ type: 'OPEN_DEVICE_MENU', payload: e.currentTarget }) - } - onMenuClose={() => dispatch({ type: 'CLOSE_DEVICE_MENU' })} + onMenuOpen={(e) => setDeviceMenuAnchor(e.currentTarget)} + onMenuClose={() => setDeviceMenuAnchor(null)} />