Skip to content

Commit 683b4ee

Browse files
Fix Bluetooth & Spotify Improvements (PR #9662) (#9663)
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 83269c4 commit 683b4ee

File tree

11 files changed

+143
-95
lines changed

11 files changed

+143
-95
lines changed

app/client/connect/SignalQualityIndicator.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ const getStatusConfig = (
4444
}
4545
case 'warning':
4646
return {
47-
color: palette.warning.main,
47+
color: palette.warning.dark,
4848
label: 'Weak Signal',
4949
icon: SignalCellularAlt1BarIcon,
5050
isAnimated: true,

app/client/control/components/SpotifyControls.tsx

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,10 @@ const SpotifyControls = () => {
4343
const prevActiveIdRef = useRef<string | undefined>(undefined)
4444
const lastVolumeSyncTimeRef = useRef<number>(0)
4545
const hasPendingSendRef = useRef<boolean>(false)
46+
const [optimisticIsPlaying, setOptimisticIsPlaying] = useState<
47+
boolean | null
48+
>(null)
49+
const playbackGraceTimerRef = useRef<NodeJS.Timeout | null>(null)
4650

4751
const hrmDevice = useMemo(
4852
() =>
@@ -198,12 +202,38 @@ const SpotifyControls = () => {
198202
command === 'NEXT' ||
199203
command === 'PREVIOUS'
200204
) {
205+
// Optimistic UI update for Play/Pause
206+
if (command === 'PLAY') {
207+
setOptimisticIsPlaying(true)
208+
} else if (command === 'PAUSE') {
209+
setOptimisticIsPlaying(false)
210+
}
211+
212+
// Clear existing timer if any
213+
if (playbackGraceTimerRef.current) {
214+
clearTimeout(playbackGraceTimerRef.current)
215+
}
216+
217+
playbackGraceTimerRef.current = setTimeout(() => {
218+
setOptimisticIsPlaying(null)
219+
playbackGraceTimerRef.current = null
220+
}, 2500)
221+
201222
sendSpotifyCommand(command)
202223
}
203224
},
204225
[sendSpotifyCommand]
205226
)
206227

228+
// Cleanup timers on unmount
229+
useEffect(() => {
230+
return () => {
231+
if (playbackGraceTimerRef.current) {
232+
clearTimeout(playbackGraceTimerRef.current)
233+
}
234+
}
235+
}, [])
236+
207237
const handleVolumeChange = useCallback(
208238
(val: number) => {
209239
setIsSliding(true)
@@ -315,7 +345,11 @@ const SpotifyControls = () => {
315345
</Box>
316346

317347
<PlaybackControls
318-
isPlaying={spotifyData.playback.is_playing}
348+
isPlaying={
349+
optimisticIsPlaying !== null
350+
? optimisticIsPlaying
351+
: spotifyData.playback.is_playing
352+
}
319353
onCommand={handlePlaybackCommand}
320354
disabled={connectionStatus !== 'Connected'}
321355
/>

constants/bluetooth.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,12 @@ export const MISSED_PACKET_THRESHOLD_BUFFER_MS = 500
2121
/**
2222
* Minimum threshold for the watchdog to trigger a missed packet update.
2323
*/
24-
export const MIN_MISSED_PACKET_THRESHOLD_MS = 1500
24+
export const MIN_MISSED_PACKET_THRESHOLD_MS = 2000
25+
26+
/**
27+
* Total time with no data (in ms) before forcing a reconnect.
28+
*/
29+
export const DATA_LIVENESS_TIMEOUT_MS = 30000
2530

2631
/**
2732
* How many packets to keep in the rolling average history.

constants/spotify.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,5 +8,5 @@ export const HRM_WEB_PLAYER_NAME = 'HRM Web Player'
88
export const SPOTIFY_DEFAULT_TOKEN_EXPIRY_S = 3600
99

1010
// Centralized constants for Spotify integration
11-
export const VOLUME_SYNC_GRACE_PERIOD_MS = 600
11+
export const VOLUME_SYNC_GRACE_PERIOD_MS = 3000
1212
export const SPOTIFY_BRAND_COLOR = '#1DB954'

context/UserSettingsContext.tsx

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import React, { createContext, useContext, useEffect } from 'react'
33
import usePersistentStorage from '../hooks/usePersistentStorage'
44
import { MeasurementSystem, Gender } from '../types/core'
5+
import isEqual from 'lodash/isEqual'
56

67
// Directly define the preferences interface and defaults here
78
export interface UserPreferences {
@@ -54,7 +55,24 @@ export const migratePreferences = (stored: unknown): UserPreferences => {
5455
storedVal !== null &&
5556
(isTypeMatch || isNullableField)
5657
) {
57-
;(acc as Record<string, unknown>)[k] = storedVal
58+
let valueToStore = storedVal
59+
// Ensure numeric fields are actually numbers
60+
const numericFields: Array<keyof UserPreferences> = [
61+
'userAge',
62+
'userWeight',
63+
'userHeight',
64+
]
65+
if (
66+
numericFields.includes(k) &&
67+
typeof storedVal === 'string' &&
68+
storedVal !== ''
69+
) {
70+
const num = parseFloat(storedVal)
71+
if (!isNaN(num)) {
72+
valueToStore = num
73+
}
74+
}
75+
;(acc as Record<string, unknown>)[k] = valueToStore
5876
}
5977
return acc
6078
}, {} as Partial<UserPreferences>)
@@ -84,8 +102,7 @@ export const UserSettingsProvider: React.FC<{ children: React.ReactNode }> = ({
84102

85103
useEffect(() => {
86104
const migrated = migratePreferences(userPreferences)
87-
// Use JSON.stringify for comparison to avoid deep equal dependency
88-
if (JSON.stringify(migrated) !== JSON.stringify(userPreferences)) {
105+
if (!isEqual(migrated, userPreferences)) {
89106
setUserPreferences(migrated)
90107
}
91108
}, [userPreferences, setUserPreferences])

hooks/useBluetoothHRM.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
MISSED_PACKET_THRESHOLD_BUFFER_MS,
2222
MIN_MISSED_PACKET_THRESHOLD_MS,
2323
ROLLING_AVG_HISTORY_LENGTH,
24+
DATA_LIVENESS_TIMEOUT_MS,
2425
} from '@/constants/bluetooth'
2526

2627
const HR_SERVICE_UUID = 'heart_rate'
@@ -61,7 +62,7 @@ interface UseBluetoothHRMProps {
6162

6263
const useBluetoothHRM = (props: UseBluetoothHRMProps = {}) => {
6364
const {
64-
dataLivenessTimeoutMs = 10000,
65+
dataLivenessTimeoutMs = DATA_LIVENESS_TIMEOUT_MS,
6566
userName,
6667
userAge,
6768
onHeartRateUpdate,
@@ -221,6 +222,15 @@ const useBluetoothHRM = (props: UseBluetoothHRMProps = {}) => {
221222
const timeSinceLastData = Date.now() - lastDataTime.current
222223

223224
if (timeSinceLastData > dataLivenessTimeoutMs) {
225+
// Fix Race Condition: Don't force a disconnect/reconnect if we are already in a connection state transition
226+
if (isConnecting.current) {
227+
logger.warn(
228+
{ timeSinceLastData },
229+
'Watchdog: Connection is in progress. Skipping forced disconnect.'
230+
)
231+
return
232+
}
233+
224234
setStatus(BluetoothConnectionStatus.RECONNECTING)
225235
setCustomStatusMessage(BLUETOOTH_MESSAGES.unstableConnection)
226236
isTimeoutDisconnect.current = true

hooks/usePersistentStorage.ts

Lines changed: 54 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { useState, useEffect, useCallback, useRef } from 'react'
22
import Cookies from 'js-cookie'
3+
import isEqual from 'lodash/isEqual'
34

45
let isLocalStorageAvailable: boolean | null = null
56

@@ -37,73 +38,73 @@ function usePersistentStorage<T>(
3738
) {
3839
const { enableCookieFallback = false } = options
3940

40-
// Read value once during initialization
41-
const [storedValue, setStoredValue] = useState<T>(() => {
42-
if (typeof window === 'undefined') return initialValue
41+
// Initialize state with initialValue to avoid hydration mismatches.
42+
// The actual stored value will be loaded in a useEffect after mounting.
43+
const [storedValue, setStoredValue] = useState<T>(initialValue)
4344

44-
try {
45-
const isLocalAvailable = checkLocalStorage()
46-
// Use cookies only if localStorage is unavailable AND fallback is enabled
47-
const useCookie = !isLocalAvailable && enableCookieFallback
45+
// Use refs to avoid unnecessary re-renders or effect loops
46+
const initialValueRef = useRef(initialValue)
47+
const keyRef = useRef(key)
48+
const isHydrated = useRef(false)
4849

49-
let item: string | undefined | null = null
50+
// Handle hydration and key/initialValue changes
51+
useEffect(() => {
52+
const isLocalAvailable = checkLocalStorage()
53+
const useCookie = !isLocalAvailable && enableCookieFallback
5054

51-
if (isLocalAvailable) {
52-
item = window.localStorage.getItem(key)
53-
} else if (useCookie) {
54-
item = Cookies.get(key)
55-
}
55+
const loadFromStorage = () => {
56+
try {
57+
let item: string | undefined | null = null
58+
if (isLocalAvailable) {
59+
item = window.localStorage.getItem(key)
60+
} else if (useCookie) {
61+
item = Cookies.get(key)
62+
}
5663

57-
if (item) {
58-
const parsed = JSON.parse(item)
64+
if (item) {
65+
const parsed = JSON.parse(item)
66+
let valueToUse = parsed
5967

60-
if (isPlainObject(parsed) && isPlainObject(initialValue)) {
61-
const merged = { ...initialValue, ...parsed } as T
62-
// Sync merged value back to storage if using localStorage
63-
if (isLocalAvailable) {
64-
window.localStorage.setItem(key, JSON.stringify(merged))
68+
if (isPlainObject(parsed) && isPlainObject(initialValue)) {
69+
const merged = { ...initialValue, ...parsed } as T
70+
valueToUse = merged
71+
72+
if (isLocalAvailable) {
73+
window.localStorage.setItem(key, JSON.stringify(merged))
74+
}
6575
}
66-
return merged
76+
77+
setStoredValue((current) => {
78+
if (!isEqual(valueToUse, current)) {
79+
return valueToUse
80+
}
81+
return current
82+
})
6783
}
68-
return parsed
84+
} catch (error) {
85+
console.error(
86+
`Error reading or parsing persistent storage key “${key}”`,
87+
error
88+
)
6989
}
70-
} catch (error) {
71-
console.error(
72-
`Error reading or parsing persistent storage key “${key}”`,
73-
error
74-
)
7590
}
76-
return initialValue
77-
})
78-
79-
// Use refs to avoid unnecessary re-renders or effect loops
80-
const initialValueRef = useRef(initialValue)
81-
const keyRef = useRef(key)
82-
const isMounted = useRef(false)
8391

84-
// Handle key changes or external initialValue changes
85-
useEffect(() => {
86-
if (!isMounted.current) {
87-
isMounted.current = true
92+
if (!isHydrated.current) {
93+
isHydrated.current = true
94+
loadFromStorage()
8895
return
8996
}
9097

9198
// Only run if key changed OR initialValue changed significantly
9299
const keyChanged = keyRef.current !== key
93-
const initialValueChanged =
94-
JSON.stringify(initialValueRef.current) !== JSON.stringify(initialValue)
100+
const initialValueChanged = !isEqual(initialValueRef.current, initialValue)
95101

96-
if (!keyChanged && !initialValueChanged) return
102+
if (keyChanged || initialValueChanged) {
103+
keyRef.current = key
104+
initialValueRef.current = initialValue
97105

98-
keyRef.current = key
99-
initialValueRef.current = initialValue
100-
101-
if (typeof window === 'undefined') return
102-
103-
try {
104106
const isLocalAvailable = checkLocalStorage()
105107
const useCookie = !isLocalAvailable && enableCookieFallback
106-
107108
let item: string | undefined | null = null
108109

109110
if (isLocalAvailable) {
@@ -113,39 +114,12 @@ function usePersistentStorage<T>(
113114
}
114115

115116
if (item) {
116-
const parsed = JSON.parse(item)
117-
let valueToUse = parsed
118-
119-
if (isPlainObject(parsed) && isPlainObject(initialValue)) {
120-
const merged = { ...initialValue, ...parsed } as T
121-
valueToUse = merged
122-
123-
if (isLocalAvailable) {
124-
window.localStorage.setItem(key, JSON.stringify(merged))
125-
}
126-
}
127-
128-
// eslint-disable-next-line react-hooks/set-state-in-effect
129-
setStoredValue((current) => {
130-
if (JSON.stringify(valueToUse) !== JSON.stringify(current)) {
131-
return valueToUse
132-
}
133-
return current
134-
})
117+
loadFromStorage()
135118
} else {
136-
// If no item in storage, use initialValue
137-
setStoredValue((current) => {
138-
if (JSON.stringify(initialValue) !== JSON.stringify(current)) {
139-
return initialValue
140-
}
141-
return current
142-
})
119+
// Safe to call setStoredValue here because the effect body executes once when dependencies change
120+
// eslint-disable-next-line react-hooks/set-state-in-effect
121+
setStoredValue(initialValue)
143122
}
144-
} catch (error) {
145-
console.error(
146-
`Error reading or parsing persistent storage key “${key}”`,
147-
error
148-
)
149123
}
150124
}, [key, initialValue, enableCookieFallback])
151125

@@ -192,7 +166,7 @@ function usePersistentStorage<T>(
192166
try {
193167
const parsed = JSON.parse(e.newValue)
194168
setStoredValue((current) => {
195-
if (JSON.stringify(parsed) !== JSON.stringify(current)) {
169+
if (!isEqual(parsed, current)) {
196170
return parsed
197171
}
198172
return current

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,12 +74,14 @@
7474
"@mui/material": "^7.3.8",
7575
"@mui/material-nextjs": "^7.3.8",
7676
"@spotify/web-api-ts-sdk": "^1.2.0",
77+
"@types/lodash": "^4.17.24",
7778
"express": "^5.2.1",
7879
"express-rate-limit": "^8.2.1",
7980
"framer-motion": "^12.29.2",
8081
"he": "^1.2.0",
8182
"idb": "^8.0.3",
8283
"js-cookie": "^3.0.5",
84+
"lodash": "^4.17.23",
8385
"lodash.throttle": "^4.1.1",
8486
"next": "16.1.6",
8587
"next-auth": "^4.24.13",

0 commit comments

Comments
 (0)