Skip to content

Commit 55f65cc

Browse files
fix(spotify): Volume control failures on /client/control - duplicate player and snap-back (#9282)
Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com> Co-authored-by: arii <342438+arii@users.noreply.github.com>
1 parent c8b3a68 commit 55f65cc

6 files changed

Lines changed: 202 additions & 114 deletions

File tree

app/client/control/components/SpotifyControls.tsx

Lines changed: 55 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@ import useVolumePreference, { clampVolume } from '@/hooks/useVolumePreference'
1616
import { useAppSnackbar } from '@/hooks/useAppSnackbar'
1717
import { useWebSocket } from '@/context/WebSocketContext'
1818
import { useSpotifyCommand } from '@/hooks/useSpotifyCommand'
19-
import useSpotifyWebPlayback from '@/hooks/useSpotifyWebPlayback'
2019
import { SpotifyCommand } from '@/types/websocket'
2120
import {
2221
HRM_WEB_PLAYER_NAME,
@@ -31,19 +30,16 @@ const SpotifyControls = () => {
3130
const { spotifyData, connectionStatus, sendData, spotifyServiceInitialized } =
3231
useWebSocket()
3332
const { execute: executeSpotify } = useSpotifyCommand()
34-
const { player, isReady } = useSpotifyWebPlayback()
3533
const { devices = [] } = spotifyData // Default to empty array if undefined
3634
const { volume, setVolume, muted, toggleMute } = useVolumePreference()
3735
const { showWarning } = useAppSnackbar()
3836
const lastSentVolumeRef = useRef<string | null>(null)
3937
const lastWarningTimeRef = useRef<number>(0)
4038
const [selectedDeviceId, setSelectedDeviceId] = useState<string>('')
41-
const [isSyncingVolume, setIsSyncingVolume] = useState(false)
39+
const [isSliding, setIsSliding] = useState(false)
4240
const prevActiveIdRef = useRef<string | undefined>(undefined)
4341
const lastVolumeSyncTimeRef = useRef<number>(0)
44-
const volumeLockTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
45-
null
46-
)
42+
const hasPendingSendRef = useRef<boolean>(false)
4743

4844
const hrmDevice = useMemo(
4945
() =>
@@ -105,16 +101,33 @@ const SpotifyControls = () => {
105101
// We rely on the server as the source of truth for volume, but use a grace period
106102
// to prevent local sliders from "jumping" while the user is actively adjusting them.
107103
const playbackVolume = spotifyData.playback.volume_percent
104+
105+
if (isSliding) return
106+
107+
const timeSinceLastVolumeSend = Date.now() - lastVolumeSyncTimeRef.current
108+
109+
// Only sync if we haven't sent a volume command recently.
110+
// The server broadcasts a SPOTIFY_UPDATE immediately after a SET_VOLUME command,
111+
// confirming the new state to all clients.
112+
const shouldRespectGracePeriod =
113+
hasPendingSendRef.current &&
114+
timeSinceLastVolumeSend < VOLUME_SYNC_GRACE_PERIOD_MS
115+
116+
if (shouldRespectGracePeriod) {
117+
return
118+
}
119+
120+
// Clear pending flag after grace period
121+
if (
122+
hasPendingSendRef.current &&
123+
timeSinceLastVolumeSend >= VOLUME_SYNC_GRACE_PERIOD_MS
124+
) {
125+
hasPendingSendRef.current = false
126+
}
127+
108128
if (activeDevice && typeof playbackVolume === 'number') {
109-
const timeSinceLastVolumeSend = Date.now() - lastVolumeSyncTimeRef.current
110-
111-
// Only sync if we haven't sent a volume command recently.
112-
// The server broadcasts a SPOTIFY_UPDATE immediately after a SET_VOLUME command,
113-
// confirming the new state to all clients.
114-
if (timeSinceLastVolumeSend > VOLUME_SYNC_GRACE_PERIOD_MS) {
115-
if (playbackVolume !== volume) {
116-
setVolume(playbackVolume)
117-
}
129+
if (playbackVolume !== volume) {
130+
setVolume(playbackVolume)
118131
}
119132
}
120133

@@ -190,6 +203,7 @@ const SpotifyControls = () => {
190203

191204
const handleVolumeChange = useCallback(
192205
(val: number) => {
206+
setIsSliding(true)
193207
setVolume(val)
194208
if (connectionStatus !== 'Connected') {
195209
const now = Date.now()
@@ -205,7 +219,7 @@ const SpotifyControls = () => {
205219

206220
const sendVolumeCommand = useCallback(
207221
(value: number) => {
208-
if (connectionStatus !== 'Connected' || isSyncingVolume) return
222+
if (connectionStatus !== 'Connected') return
209223
const targetDeviceId = resolveTargetDeviceId()
210224

211225
// Prevent sending volume command if no device is targeted
@@ -215,58 +229,33 @@ const SpotifyControls = () => {
215229
const messageKey = `${targetDeviceId}:${sanitized}`
216230
if (lastSentVolumeRef.current === messageKey) return
217231

218-
setIsSyncingVolume(true)
232+
hasPendingSendRef.current = true
233+
lastVolumeSyncTimeRef.current = Date.now()
234+
219235
executeSpotify('SET_VOLUME', {
220236
volume: sanitized,
221237
deviceId: targetDeviceId,
222238
})
223239

224240
lastSentVolumeRef.current = messageKey
225-
lastVolumeSyncTimeRef.current = Date.now()
226-
227-
// Release lock after a short delay to allow state to settle
228-
if (volumeLockTimeoutRef.current) {
229-
clearTimeout(volumeLockTimeoutRef.current)
230-
}
231-
volumeLockTimeoutRef.current = setTimeout(() => {
232-
setIsSyncingVolume(false)
233-
volumeLockTimeoutRef.current = null
234-
}, 500)
235241
},
236-
[connectionStatus, resolveTargetDeviceId, executeSpotify, isSyncingVolume]
242+
[connectionStatus, resolveTargetDeviceId, executeSpotify]
237243
)
238244

239-
const debounceTimeoutRef = useRef<number | null>(null)
245+
const handleVolumeChangeCommitted = useCallback(
246+
(val: number) => {
247+
setIsSliding(false)
248+
sendVolumeCommand(val)
249+
},
250+
[sendVolumeCommand]
251+
)
240252

241253
useEffect(() => {
242254
if (connectionStatus !== 'Connected') {
243255
lastSentVolumeRef.current = null
244256
}
245257
}, [connectionStatus])
246258

247-
useEffect(() => {
248-
// Clear any existing timer
249-
if (debounceTimeoutRef.current) {
250-
window.clearTimeout(debounceTimeoutRef.current)
251-
}
252-
253-
// Set a new timer to send the volume command after 300ms
254-
debounceTimeoutRef.current = window.setTimeout(() => {
255-
sendVolumeCommand(volume)
256-
}, 300)
257-
258-
// Cleanup function to clear the timeout if the component unmounts
259-
// or if the volume changes again before the timeout has passed
260-
return () => {
261-
if (debounceTimeoutRef.current) {
262-
window.clearTimeout(debounceTimeoutRef.current)
263-
}
264-
if (volumeLockTimeoutRef.current) {
265-
clearTimeout(volumeLockTimeoutRef.current)
266-
}
267-
}
268-
}, [volume, sendVolumeCommand])
269-
270259
return (
271260
<ControlCard
272261
data-testid="spotify-controls"
@@ -306,26 +295,20 @@ const SpotifyControls = () => {
306295
justifyContent: 'center',
307296
}}
308297
>
309-
{player && !isReady ? (
310-
<Typography variant="body2" sx={{ color: 'orange' }}>
311-
Registering HRM Web Player...
298+
<>
299+
<Typography
300+
variant="subtitle1"
301+
sx={{ fontWeight: 'medium', lineHeight: 1.2 }}
302+
>
303+
{spotifyData.playback.track.name}
312304
</Typography>
313-
) : (
314-
<>
315-
<Typography
316-
variant="subtitle1"
317-
sx={{ fontWeight: 'medium', lineHeight: 1.2 }}
318-
>
319-
{spotifyData.playback.track.name}
320-
</Typography>
321-
<Typography
322-
variant="body2"
323-
sx={{ color: 'grey.400', lineHeight: 1.2 }}
324-
>
325-
{spotifyData.playback.track.artist}
326-
</Typography>
327-
</>
328-
)}
305+
<Typography
306+
variant="body2"
307+
sx={{ color: 'grey.400', lineHeight: 1.2 }}
308+
>
309+
{spotifyData.playback.track.artist}
310+
</Typography>
311+
</>
329312
</Box>
330313

331314
<PlaybackControls
@@ -338,6 +321,7 @@ const SpotifyControls = () => {
338321
volume={volume}
339322
muted={muted}
340323
onVolumeChange={handleVolumeChange}
324+
onVolumeChangeCommitted={handleVolumeChangeCommitted}
341325
onToggleMute={toggleMute}
342326
showValue={true}
343327
/>

services/tabataTimer.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -303,6 +303,29 @@ class TabataTimer {
303303
this.timerInterval = null
304304
}
305305
}
306+
307+
/**
308+
* Resets the timer to its default state.
309+
* This is primarily used for testing to ensure a clean state between tests.
310+
*/
311+
public reset(): void {
312+
this.stop()
313+
this.mode = 'TABATA'
314+
this.workDuration = DEFAULT_WORK_DURATION
315+
this.restDuration = DEFAULT_REST_DURATION
316+
this.soundToPlay = undefined
317+
this.soundEventId = 0
318+
this.lastCountdownSecond = -1
319+
320+
// Explicitly reset timeRemaining to default for TABATA mode
321+
this.timeRemaining = DEFAULT_WORK_DURATION
322+
this.pausedTimeRemaining = DEFAULT_WORK_DURATION
323+
324+
this.broadcastUpdate({
325+
type: 'TIMER_UPDATE',
326+
payload: this.getState(),
327+
})
328+
}
306329
}
307330

308331
export default TabataTimer

0 commit comments

Comments
 (0)