| import { getServerTime, isCalibrated, getMedianRTT } from '@/lib/clockSync' |
| import { |
| DRIFT_SEEK_THRESHOLD_MS, |
| DRIFT_DEAD_ZONE_MS, |
| DRIFT_TEMPO_KP, |
| MAX_TEMPO_ADJUSTMENT, |
| DRIFT_SMOOTH_ALPHA, |
| MAX_NETWORK_DELAY_S, |
| DRIFT_SEEK_RTT_MARGIN_MS, |
| HARD_SEEK_CONFIRM_COUNT, |
| } from '@/lib/constants' |
| import { useSocketContext } from '@/providers/socket-context' |
| import { usePlayerStore } from '@/stores/playerStore' |
| import { useRoomStore } from '@/stores/roomStore' |
| import { useSettingsStore } from '@/stores/settingsStore' |
| import type { ScheduledPlayState } from '@music-together/shared' |
| import { EVENTS } from '@music-together/shared' |
| import type { Howl } from 'howler' |
| import { useEffect, useRef, type RefObject } from 'react' |
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| function scheduleDelay(serverTimeToExecute: number): number { |
| if (!isCalibrated()) return 0 |
| return Math.max(0, serverTimeToExecute - getServerTime()) |
| } |
|
|
| function clamp(value: number, limit: number): number { |
| return Math.max(-limit, Math.min(limit, value)) |
| } |
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export function usePlayerSync( |
| howlRef: RefObject<Howl | null>, |
| soundIdRef: RefObject<number | undefined>, |
| schedulePlayback: (targetTime: number, executeAt: number) => void, |
| cancelScheduledPlayback: () => void, |
| pausePlayback: (position: number) => void, |
| setPlaybackTempo: (tempo: number) => void, |
| ) { |
| const { socket } = useSocketContext() |
| const setCurrentTime = usePlayerStore((s) => s.setCurrentTime) |
| const syncPacketIntervalSeconds = useSettingsStore((s) => s.syncPacketIntervalSeconds) |
|
|
| |
| const scheduledTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null) |
| |
| |
| const actionIdRef = useRef(0) |
| |
| const smoothedDriftRef = useRef(0) |
| |
| |
| const emaColdStartRef = useRef(true) |
| |
| const hardSeekCountRef = useRef(0) |
|
|
| const clearScheduled = () => { |
| if (scheduledTimerRef.current) { |
| clearTimeout(scheduledTimerRef.current) |
| scheduledTimerRef.current = null |
| } |
| } |
|
|
| |
| |
| |
| useEffect(() => { |
| |
| const onSeek = (data: { playState: ScheduledPlayState }) => { |
| clearScheduled() |
| const id = ++actionIdRef.current |
| const delay = scheduleDelay(data.playState.serverTimeToExecute) |
|
|
| scheduledTimerRef.current = setTimeout(() => { |
| if (actionIdRef.current !== id) return |
| if (howlRef.current) { |
| howlRef.current.seek(data.playState.currentTime) |
| } |
| setCurrentTime(data.playState.currentTime) |
| setPlaybackTempo(1) |
| smoothedDriftRef.current = 0 |
| emaColdStartRef.current = true |
| |
| useRoomStore.getState().updateRoom({ |
| playState: { |
| isPlaying: data.playState.isPlaying, |
| currentTime: data.playState.currentTime, |
| serverTimestamp: data.playState.serverTimestamp, |
| }, |
| }) |
| }, delay) |
| } |
|
|
| |
| const onPause = (data: { playState: ScheduledPlayState }) => { |
| clearScheduled() |
| cancelScheduledPlayback() |
| const id = ++actionIdRef.current |
| const delay = scheduleDelay(data.playState.serverTimeToExecute) |
|
|
| scheduledTimerRef.current = setTimeout(() => { |
| if (actionIdRef.current !== id) return |
| if (howlRef.current) { |
| pausePlayback(data.playState.currentTime) |
| setCurrentTime(data.playState.currentTime) |
| } |
| |
| smoothedDriftRef.current = 0 |
| emaColdStartRef.current = true |
| usePlayerStore.getState().setSyncDrift(0) |
| setPlaybackTempo(1) |
| |
| useRoomStore.getState().updateRoom({ |
| playState: { |
| isPlaying: data.playState.isPlaying, |
| currentTime: data.playState.currentTime, |
| serverTimestamp: data.playState.serverTimestamp, |
| }, |
| }) |
| }, delay) |
| } |
|
|
| |
| const onResume = (data: { playState: ScheduledPlayState }) => { |
| clearScheduled() |
| const id = ++actionIdRef.current |
| const delay = scheduleDelay(data.playState.serverTimeToExecute) |
|
|
| if (howlRef.current) { |
| schedulePlayback(data.playState.currentTime, data.playState.serverTimeToExecute) |
| } |
|
|
| scheduledTimerRef.current = setTimeout(() => { |
| if (actionIdRef.current !== id) return |
| if (!howlRef.current) return |
| smoothedDriftRef.current = 0 |
| emaColdStartRef.current = true |
| setPlaybackTempo(1) |
| |
| useRoomStore.getState().updateRoom({ |
| playState: { |
| isPlaying: data.playState.isPlaying, |
| currentTime: data.playState.currentTime, |
| serverTimestamp: data.playState.serverTimestamp, |
| }, |
| }) |
| }, delay) |
| } |
|
|
| |
| |
| |
| const onPlay = () => { |
| clearScheduled() |
| ++actionIdRef.current |
| hardSeekCountRef.current = 0 |
| smoothedDriftRef.current = 0 |
| emaColdStartRef.current = true |
| setPlaybackTempo(1) |
| } |
|
|
| |
| const onSyncResponse = (data: { currentTime: number; isPlaying: boolean; serverTimestamp: number }) => { |
| if (!howlRef.current) return |
| if (!howlRef.current.playing()) return |
| if (!isCalibrated()) return |
|
|
| |
| const networkDelaySec = Math.max( |
| 0, |
| Math.min(MAX_NETWORK_DELAY_S, (getServerTime() - data.serverTimestamp) / 1000), |
| ) |
| const expectedTime = data.currentTime + (data.isPlaying ? networkDelaySec : 0) |
|
|
| const currentSeek = howlRef.current.seek() as number |
| const rawDrift = currentSeek - expectedTime |
|
|
| |
| |
| |
| if (emaColdStartRef.current) { |
| smoothedDriftRef.current = rawDrift |
| emaColdStartRef.current = false |
| } else { |
| smoothedDriftRef.current = DRIFT_SMOOTH_ALPHA * rawDrift + (1 - DRIFT_SMOOTH_ALPHA) * smoothedDriftRef.current |
| } |
| const sd = smoothedDriftRef.current |
| const absDrift = Math.abs(sd) |
|
|
| |
| usePlayerStore.getState().setSyncDrift(sd) |
|
|
| |
| |
| |
| |
| const rttBasedThreshold = (getMedianRTT() + DRIFT_SEEK_RTT_MARGIN_MS) / 1000 |
| const hardSeekThreshold = Math.max(DRIFT_SEEK_THRESHOLD_MS / 1000, rttBasedThreshold) |
| const { playbackTempoSyncEnabled, playbackHardSeekSyncEnabled } = useSettingsStore.getState() |
| const hardSeekEnabled = playbackTempoSyncEnabled || playbackHardSeekSyncEnabled |
|
|
| if (absDrift > hardSeekThreshold) { |
| if (!hardSeekEnabled) { |
| hardSeekCountRef.current = 0 |
| setPlaybackTempo(1) |
| return |
| } |
| |
| |
| hardSeekCountRef.current++ |
| if (hardSeekCountRef.current < HARD_SEEK_CONFIRM_COUNT) return |
| |
| hardSeekCountRef.current = 0 |
| const howl = howlRef.current |
| const soundId = soundIdRef.current |
| const restoreVolume = usePlayerStore.getState().volume |
| setPlaybackTempo(1) |
|
|
| |
| |
| |
| |
| |
| if (soundId === undefined) howl.seek(expectedTime) |
| else howl.seek(expectedTime, soundId) |
| if (soundId === undefined) howl.volume(restoreVolume) |
| else howl.volume(restoreVolume, soundId) |
| setCurrentTime(expectedTime) |
| smoothedDriftRef.current = 0 |
| emaColdStartRef.current = true |
| } else if (absDrift > DRIFT_DEAD_ZONE_MS / 1000) { |
| hardSeekCountRef.current = 0 |
| if (playbackTempoSyncEnabled) { |
| const adjustment = clamp(sd * DRIFT_TEMPO_KP, MAX_TEMPO_ADJUSTMENT) |
| setPlaybackTempo(1 - adjustment) |
| } else { |
| setPlaybackTempo(1) |
| } |
| } else { |
| hardSeekCountRef.current = 0 |
| setPlaybackTempo(1) |
| } |
| } |
|
|
| socket.on(EVENTS.PLAYER_SEEK, onSeek) |
| socket.on(EVENTS.PLAYER_PAUSE, onPause) |
| socket.on(EVENTS.PLAYER_RESUME, onResume) |
| socket.on(EVENTS.PLAYER_PLAY, onPlay) |
| socket.on(EVENTS.PLAYER_SYNC_RESPONSE, onSyncResponse) |
|
|
| return () => { |
| clearScheduled() |
| socket.off(EVENTS.PLAYER_SEEK, onSeek) |
| socket.off(EVENTS.PLAYER_PAUSE, onPause) |
| socket.off(EVENTS.PLAYER_RESUME, onResume) |
| socket.off(EVENTS.PLAYER_PLAY, onPlay) |
| socket.off(EVENTS.PLAYER_SYNC_RESPONSE, onSyncResponse) |
| } |
| }, [ |
| socket, |
| howlRef, |
| soundIdRef, |
| setCurrentTime, |
| schedulePlayback, |
| cancelScheduledPlayback, |
| pausePlayback, |
| setPlaybackTempo, |
| ]) |
|
|
| |
| |
| |
| useEffect(() => { |
| const requestSyncIfPlaying = () => { |
| if (!socket.connected || !howlRef.current?.playing()) return |
| socket.emit(EVENTS.PLAYER_SYNC_REQUEST) |
| } |
|
|
| let visibilityFollowUp: ReturnType<typeof setTimeout> | null = null |
| const onVisibilityChange = () => { |
| if (document.visibilityState !== 'visible') return |
| requestSyncIfPlaying() |
| |
| |
| visibilityFollowUp = setTimeout(requestSyncIfPlaying, 250) |
| } |
| const interval = setInterval(requestSyncIfPlaying, syncPacketIntervalSeconds * 1000) |
| document.addEventListener('visibilitychange', onVisibilityChange) |
| return () => { |
| clearInterval(interval) |
| if (visibilityFollowUp) clearTimeout(visibilityFollowUp) |
| document.removeEventListener('visibilitychange', onVisibilityChange) |
| } |
| }, [socket, howlRef, syncPacketIntervalSeconds]) |
| } |
|
|