pstream is dead; long live pstream taciturnaxolotl.github.io/pstream-ng/
at main 333 lines 10 kB view raw
1/* eslint-disable no-console */ 2import { useCallback, useEffect, useRef, useState } from "react"; 3 4// import { getRoomStatuses, getUserPlayerStatus } from "@/backend/player/status"; 5import { getRoomStatuses } from "@/backend/player/status"; 6import { useBackendUrl } from "@/hooks/auth/useBackendUrl"; 7import { useAuthStore } from "@/stores/auth"; 8import { usePlayerStore } from "@/stores/player/store"; 9import { useWatchPartyStore } from "@/stores/watchParty"; 10 11interface RoomUser { 12 userId: string; 13 isHost: boolean; 14 lastUpdate: number; 15 player: { 16 isPlaying: boolean; 17 isPaused: boolean; 18 time: number; 19 duration: number; 20 }; 21 content: { 22 title: string; 23 type: string; 24 tmdbId?: number; 25 seasonId?: number; 26 episodeId?: number; 27 seasonNumber?: number; 28 episodeNumber?: number; 29 }; 30} 31 32interface WatchPartySyncResult { 33 // All users in the room 34 roomUsers: RoomUser[]; 35 // The host user (if any) 36 hostUser: RoomUser | null; 37 // Whether our player is behind the host 38 isBehindHost: boolean; 39 // Whether our player is ahead of the host 40 isAheadOfHost: boolean; 41 // Seconds difference from host (positive means ahead, negative means behind) 42 timeDifferenceFromHost: number; 43 // Function to sync with host 44 syncWithHost: () => void; 45 // Whether we are currently syncing 46 isSyncing: boolean; 47 // Manually refresh room data 48 refreshRoomData: () => Promise<void>; 49 // Current user count in room 50 userCount: number; 51} 52 53/** 54 * Hook for syncing with other users in a watch party room 55 */ 56export function useWatchPartySync( 57 syncThresholdSeconds = 5, 58): WatchPartySyncResult { 59 const [roomUsers, setRoomUsers] = useState<RoomUser[]>([]); 60 const [isSyncing, setIsSyncing] = useState(false); 61 const [userCount, setUserCount] = useState(1); 62 63 // Refs for tracking state 64 const syncStateRef = useRef({ 65 lastUserCount: 1, 66 previousHostPlaying: null as boolean | null, 67 previousHostTime: null as number | null, 68 lastSyncTime: 0, 69 syncInProgress: false, 70 checkedUrlParams: false, 71 prevRoomUsers: [] as RoomUser[], 72 }); 73 74 // Get our auth and backend info 75 const account = useAuthStore((s) => s.account); 76 const backendUrl = useBackendUrl(); 77 78 // Get player store functions 79 const display = usePlayerStore((s) => s.display); 80 const currentTime = usePlayerStore((s) => s.progress.time); 81 const isPlaying = usePlayerStore((s) => s.mediaPlaying.isPlaying); 82 // Get watch party state 83 const { roomCode, isHost, enabled, enableAsGuest } = useWatchPartyStore(); 84 85 // Reset URL parameter checking when watch party is disabled 86 useEffect(() => { 87 if (!enabled) { 88 syncStateRef.current.checkedUrlParams = false; 89 } 90 }, [enabled]); 91 92 // Check URL parameters for watch party code 93 useEffect(() => { 94 if (syncStateRef.current.checkedUrlParams) return; 95 96 try { 97 const params = new URLSearchParams(window.location.search); 98 const watchPartyCode = params.get("watchparty"); 99 100 if (watchPartyCode && !enabled && watchPartyCode.length > 0) { 101 enableAsGuest(watchPartyCode); 102 } 103 104 syncStateRef.current.checkedUrlParams = true; 105 } catch (error) { 106 console.error("Failed to check URL parameters for watch party:", error); 107 } 108 }, [enabled, enableAsGuest]); 109 110 // Find the host user in the room 111 const hostUser = roomUsers.find((user) => user.isHost) || null; 112 113 // Calculate predicted host time by accounting for elapsed time since update 114 const getPredictedHostTime = useCallback(() => { 115 if (!hostUser) return 0; 116 117 const millisecondsSinceUpdate = Date.now() - hostUser.lastUpdate; 118 const secondsSinceUpdate = millisecondsSinceUpdate / 1000; 119 120 return hostUser.player.isPlaying && !hostUser.player.isPaused 121 ? hostUser.player.time + secondsSinceUpdate 122 : hostUser.player.time; 123 }, [hostUser]); 124 125 // Calculate time difference from host 126 const timeDifferenceFromHost = hostUser 127 ? currentTime - getPredictedHostTime() 128 : 0; 129 130 // Determine if we're ahead or behind the host 131 const isBehindHost = 132 hostUser && !isHost && timeDifferenceFromHost < -syncThresholdSeconds; 133 const isAheadOfHost = 134 hostUser && !isHost && timeDifferenceFromHost > syncThresholdSeconds; 135 136 // Function to sync with host 137 const syncWithHost = useCallback(() => { 138 if (!hostUser || isHost || !display || syncStateRef.current.syncInProgress) 139 return; 140 141 syncStateRef.current.syncInProgress = true; 142 setIsSyncing(true); 143 144 const predictedHostTime = getPredictedHostTime(); 145 display.setTime(predictedHostTime); 146 147 setTimeout(() => { 148 if (hostUser.player.isPlaying && !hostUser.player.isPaused) { 149 display.play(); 150 } else { 151 display.pause(); 152 } 153 154 setTimeout(() => { 155 setIsSyncing(false); 156 syncStateRef.current.syncInProgress = false; 157 }, 500); 158 159 syncStateRef.current.lastSyncTime = Date.now(); 160 }, 200); 161 }, [hostUser, isHost, display, getPredictedHostTime]); 162 163 // Combined effect for syncing time and play/pause state 164 useEffect(() => { 165 if (!hostUser || isHost || !display || syncStateRef.current.syncInProgress) 166 return; 167 168 const state = syncStateRef.current; 169 const hostIsPlaying = 170 hostUser.player.isPlaying && !hostUser.player.isPaused; 171 const predictedHostTime = getPredictedHostTime(); 172 const difference = currentTime - predictedHostTime; 173 174 // Handle time sync 175 const activeThreshold = isPlaying ? 2 : 5; 176 const needsTimeSync = Math.abs(difference) > activeThreshold; 177 178 // Handle play state sync 179 const needsPlayStateSync = 180 state.previousHostPlaying !== null && 181 state.previousHostPlaying !== hostIsPlaying; 182 183 // Handle time jumps 184 const needsJumpSync = 185 state.previousHostTime !== null && 186 Math.abs(hostUser.player.time - state.previousHostTime) > 5; 187 188 // Sync if needed 189 if ((needsTimeSync || needsPlayStateSync || needsJumpSync) && !isSyncing) { 190 state.syncInProgress = true; 191 setIsSyncing(true); 192 193 // Sync time 194 display.setTime(predictedHostTime); 195 196 // Then sync play state after a short delay 197 setTimeout(() => { 198 if (hostIsPlaying) { 199 display.play(); 200 } else { 201 display.pause(); 202 } 203 204 // Clear syncing flags 205 setTimeout(() => { 206 setIsSyncing(false); 207 state.syncInProgress = false; 208 }, 500); 209 }, 200); 210 } 211 212 // Update state refs 213 state.previousHostPlaying = hostIsPlaying; 214 state.previousHostTime = hostUser.player.time; 215 }, [ 216 hostUser, 217 isHost, 218 currentTime, 219 display, 220 isSyncing, 221 getPredictedHostTime, 222 isPlaying, 223 ]); 224 225 // Function to refresh room data 226 const refreshRoomData = useCallback(async () => { 227 if (!enabled || !roomCode || !backendUrl) return; 228 229 try { 230 const response = await getRoomStatuses(backendUrl, account, roomCode); 231 const users: RoomUser[] = []; 232 233 // Process each user's latest status 234 Object.entries(response.users).forEach( 235 ([userIdFromResponse, statuses]) => { 236 if (statuses.length > 0) { 237 // Get the latest status (sort by timestamp DESC) 238 const latestStatus = [...statuses].sort( 239 (a, b) => b.timestamp - a.timestamp, 240 )[0]; 241 242 users.push({ 243 userId: userIdFromResponse, 244 isHost: latestStatus.isHost, 245 lastUpdate: latestStatus.timestamp, 246 player: { 247 isPlaying: latestStatus.player.isPlaying, 248 isPaused: latestStatus.player.isPaused, 249 time: latestStatus.player.time, 250 duration: latestStatus.player.duration, 251 }, 252 content: { 253 title: latestStatus.content.title, 254 type: latestStatus.content.type, 255 tmdbId: latestStatus.content.tmdbId, 256 seasonId: latestStatus.content.seasonId, 257 episodeId: latestStatus.content.episodeId, 258 seasonNumber: latestStatus.content.seasonNumber, 259 episodeNumber: latestStatus.content.episodeNumber, 260 }, 261 }); 262 } 263 }, 264 ); 265 266 // Sort users with host first, then by lastUpdate 267 users.sort((a, b) => { 268 if (a.isHost && !b.isHost) return -1; 269 if (!a.isHost && b.isHost) return 1; 270 return b.lastUpdate - a.lastUpdate; 271 }); 272 273 // Update user count if changed 274 const newUserCount = users.length; 275 if (newUserCount !== syncStateRef.current.lastUserCount) { 276 setUserCount(newUserCount); 277 syncStateRef.current.lastUserCount = newUserCount; 278 } 279 280 // Update room users 281 syncStateRef.current.prevRoomUsers = users; 282 setRoomUsers(users); 283 } catch (error) { 284 console.error("Failed to refresh room data:", error); 285 } 286 }, [backendUrl, account, roomCode, enabled]); 287 288 // Periodically refresh room data 289 useEffect(() => { 290 // Store reference to current syncState for cleanup 291 const syncState = syncStateRef.current; 292 293 if (!enabled || !roomCode) { 294 setRoomUsers([]); 295 setUserCount(1); 296 297 // Reset all state 298 syncState.lastUserCount = 1; 299 syncState.prevRoomUsers = []; 300 syncState.previousHostPlaying = null; 301 syncState.previousHostTime = null; 302 return; 303 } 304 305 // Initial fetch 306 refreshRoomData(); 307 308 // Set up interval - refresh every 1 second for faster updates 309 const interval = setInterval(refreshRoomData, 1000); 310 311 return () => { 312 clearInterval(interval); 313 setRoomUsers([]); 314 setUserCount(1); 315 316 // Use captured reference from outer scope 317 syncState.previousHostPlaying = null; 318 syncState.previousHostTime = null; 319 }; 320 }, [enabled, roomCode, refreshRoomData]); 321 322 return { 323 roomUsers, 324 hostUser, 325 isBehindHost: !!isBehindHost, 326 isAheadOfHost: !!isAheadOfHost, 327 timeDifferenceFromHost, 328 syncWithHost, 329 isSyncing, 330 refreshRoomData, 331 userCount, 332 }; 333}