pstream is dead; long live pstream
taciturnaxolotl.github.io/pstream-ng/
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}