Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 1 addition & 87 deletions app/client/control/components/SpotifyControls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -33,10 +32,6 @@ const SpotifyControls = () => {
const { devices = [] } = spotifyData
const lastSentVolumeRef = useRef<string | null>(null)
const [selectedDeviceId, setSelectedDeviceId] = useState<string>('')
const [isSliding, setIsSliding] = useState(false)
const prevActiveIdRef = useRef<string | undefined>(undefined)
const lastVolumeSyncTimeRef = useRef<number>(0)
const hasPendingSendRef = useRef<boolean>(false)
const [optimisticIsPlaying, setOptimisticIsPlaying] = useState<
boolean | null
>(null)
Expand Down Expand Up @@ -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,
Expand All @@ -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', {
Expand Down Expand Up @@ -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)
}
Expand All @@ -235,7 +199,6 @@ const SpotifyControls = () => {
[sendSpotifyCommand]
)

// Cleanup timers on unmount
useEffect(() => {
return () => {
if (playbackGraceTimerRef.current) {
Expand All @@ -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
Expand Down Expand Up @@ -367,7 +281,7 @@ const SpotifyControls = () => {
<VolumeSlider
volume={displayVolume}
muted={isMuted}
onVolumeChange={handleVolumeSlide}
onVolumeChange={handleVolumeChange}
onVolumeChangeCommitted={handleVolumeChangeCommitted}
onToggleMute={handleToggleMute}
showValue
Expand Down
22 changes: 11 additions & 11 deletions components/shared/VolumeSlider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,15 +26,15 @@ interface VolumeSliderProps {
}

const StyledSlider = styled(Slider, {
shouldForwardProp: (prop) => 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
Expand All @@ -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 },
})
)
Expand Down Expand Up @@ -84,8 +86,6 @@ const VolumeSlider: React.FC<VolumeSliderProps> = ({
[onVolumeChangeCommitted]
)

const SCALE_FACTOR = size === 'small' ? 0.75 : 1

return (
<Stack
direction="row"
Expand All @@ -107,7 +107,7 @@ const VolumeSlider: React.FC<VolumeSliderProps> = ({
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"
Expand All @@ -123,7 +123,7 @@ const VolumeSlider: React.FC<VolumeSliderProps> = ({
value={muted ? 0 : volume}
onChange={handleVolumeChange}
onChangeCommitted={handleVolumeChangeCommitted}
scaleFactor={SCALE_FACTOR}
size={size}
disabled={disabled}
sliderColor={sliderColor}
aria-label="Volume control"
Expand Down
1 change: 0 additions & 1 deletion constants/spotify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
120 changes: 2 additions & 118 deletions context/AudioContext.tsx
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -20,115 +13,6 @@ export interface AudioContextType {

const AudioContext = createContext<AudioContextType | undefined>(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 (
Expand Down
Loading
Loading