File size: 3,591 Bytes
00a912e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
import { useEffect, useRef } from 'react'
import { EVENTS, NTP } from '@music-together/shared'
import { useSocketContext } from '@/providers/socket-context'
import { recordPing, processPong, resetClockSync, isCalibrated, getMedianRTT } from '@/lib/clockSync'
import { useSettingsStore } from '@/stores/settingsStore'

/**
 * Runs the NTP clock-sync loop for the lifetime of the socket connection.
 *
 * Phase 1 (calibration): rapid pings every `NTP.INITIAL_INTERVAL_MS` until
 *   `MAX_INITIAL_SAMPLES` are collected.
 * Phase 2 (steady state): pings at the user-configured sync packet interval
 *   to track drift.
 */
export function useClockSync(): void {
  const { socket } = useSocketContext()
  const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
  const switchedRef = useRef(false)

  useEffect(() => {
    const burstTimers: ReturnType<typeof setTimeout>[] = []

    const sendPing = () => {
      const id = recordPing()
      const rtt = getMedianRTT()
      socket.emit(EVENTS.NTP_PING, { clientPingId: id, lastRttMs: rtt > 0 ? rtt : undefined })
    }

    const startSteadyHeartbeat = () => {
      if (intervalRef.current !== null) clearInterval(intervalRef.current)
      const intervalMs = useSettingsStore.getState().syncPacketIntervalSeconds * 1000
      intervalRef.current = setInterval(sendPing, intervalMs)
    }

    const startFastCalibration = () => {
      if (intervalRef.current !== null) clearInterval(intervalRef.current)
      resetClockSync()
      switchedRef.current = false
      sendPing()
      intervalRef.current = setInterval(sendPing, NTP.INITIAL_INTERVAL_MS)
    }

    const onPong = (data: { clientPingId: number; serverTime: number }) => {
      processPong(data.clientPingId, data.serverTime)
      if (!switchedRef.current && isCalibrated() && intervalRef.current !== null) {
        switchedRef.current = true
        startSteadyHeartbeat()
      }
    }

    const unsubscribeSettings = useSettingsStore.subscribe((state, previousState) => {
      if (state.syncPacketIntervalSeconds === previousState.syncPacketIntervalSeconds) return
      if (switchedRef.current && intervalRef.current !== null) startSteadyHeartbeat()
    })

    const onDisconnect = () => {
      if (intervalRef.current !== null) clearInterval(intervalRef.current)
      intervalRef.current = null
      resetClockSync()
    }

    const onVisibilityChange = () => {
      if (document.visibilityState !== 'visible' || !socket.connected) return
      // Timers and performance clocks can behave differently across sleep and
      // mobile backgrounding. Refresh several samples without discarding the
      // last usable anchor, so an arriving playback action can still schedule.
      for (let i = 0; i < 5; i++) {
        burstTimers.push(setTimeout(sendPing, i * NTP.INITIAL_INTERVAL_MS))
      }
    }

    socket.on(EVENTS.NTP_PONG, onPong)
    socket.on('connect', startFastCalibration)
    socket.on('disconnect', onDisconnect)
    document.addEventListener('visibilitychange', onVisibilityChange)
    if (socket.connected) startFastCalibration()

    return () => {
      socket.off(EVENTS.NTP_PONG, onPong)
      socket.off('connect', startFastCalibration)
      socket.off('disconnect', onDisconnect)
      document.removeEventListener('visibilitychange', onVisibilityChange)
      unsubscribeSettings()
      for (const timer of burstTimers) clearTimeout(timer)
      if (intervalRef.current !== null) {
        clearInterval(intervalRef.current)
        intervalRef.current = null
      }
      resetClockSync()
    }
  }, [socket])
}