import { t } from "i18next"; import { useEffect, useRef } from "react"; import { getRoomStatuses, sendPlayerStatus } from "@/backend/player/status"; import { usePlayerStatusPolling } from "@/components/player/hooks/usePlayerStatusPolling"; import { useBackendUrl } from "@/hooks/auth/useBackendUrl"; import { useAuthStore } from "@/stores/auth"; import { usePlayerStore } from "@/stores/player/store"; import { useWatchPartyStore } from "@/stores/watchParty"; // Event for content validation status const VALIDATION_EVENT = "watchparty:validation"; export const emitValidationStatus = (success: boolean) => { window.dispatchEvent( new CustomEvent(VALIDATION_EVENT, { detail: { success } }), ); }; /** * Component that sends player status to the backend when watch party is enabled */ export function WebhookReporter() { const { statusHistory, latestStatus } = usePlayerStatusPolling(5); // Keep last 5 status points const lastReportTime = useRef(0); const lastReportedStateRef = useRef(""); const contentValidatedRef = useRef(false); // Auth data const account = useAuthStore((s) => s.account); const userId = account?.userId || "guest"; const backendUrl = useBackendUrl(); // Player metadata const meta = usePlayerStore((s) => s.meta); // Watch party state const { enabled: watchPartyEnabled, roomCode, isHost, disable, } = useWatchPartyStore(); // Reset validation state when watch party is disabled useEffect(() => { if (!watchPartyEnabled) { contentValidatedRef.current = false; } }, [watchPartyEnabled]); // Validate content matches when joining a room useEffect(() => { const validateContent = async () => { if ( !watchPartyEnabled || !roomCode || !meta?.tmdbId || isHost || contentValidatedRef.current ) return; try { const roomData = await getRoomStatuses(backendUrl, account, roomCode); const users = Object.values(roomData.users).flat(); const hostUser = users.find((user) => user.isHost); if (hostUser && hostUser.content.tmdbId) { const hostTmdbId = hostUser.content.tmdbId; const currentTmdbId = parseInt(meta.tmdbId, 10); // Check base content ID (movie or show) if (hostTmdbId !== currentTmdbId) { console.error("Content mismatch - disconnecting from watch party", { hostContent: hostTmdbId, currentContent: currentTmdbId, }); disable(); emitValidationStatus(false); // eslint-disable-next-line no-alert alert(t("watchParty.contentMismatch")); return; } // Check season and episode IDs for TV shows if (meta.type === "show" && hostUser.content.type === "TV Show") { const hostSeasonId = hostUser.content.seasonId; const hostEpisodeId = hostUser.content.episodeId; const currentSeasonId = meta.season?.tmdbId ? parseInt(meta.season.tmdbId, 10) : undefined; const currentEpisodeId = meta.episode?.tmdbId ? parseInt(meta.episode.tmdbId, 10) : undefined; // Validate episode match (if host has this info) if ( (hostSeasonId && currentSeasonId && hostSeasonId !== currentSeasonId) || (hostEpisodeId && currentEpisodeId && hostEpisodeId !== currentEpisodeId) ) { console.error( "Episode mismatch - disconnecting from watch party", { host: { seasonId: hostSeasonId, episodeId: hostEpisodeId }, current: { seasonId: currentSeasonId, episodeId: currentEpisodeId, }, }, ); disable(); emitValidationStatus(false); // eslint-disable-next-line no-alert alert(t("watchParty.episodeMismatch")); return; } } } contentValidatedRef.current = true; emitValidationStatus(true); } catch (error) { console.error("Failed to validate watch party content:", error); disable(); emitValidationStatus(false); } }; validateContent(); }, [ watchPartyEnabled, roomCode, meta?.tmdbId, meta?.season?.tmdbId, meta?.episode?.tmdbId, meta?.type, isHost, backendUrl, account, disable, ]); useEffect(() => { // Skip if watch party is not enabled if ( !watchPartyEnabled || !latestStatus || !latestStatus.hasPlayedOnce || !roomCode || (!isHost && !contentValidatedRef.current) // Don't send updates until content is validated for non-hosts ) return; const now = Date.now(); // Create a state fingerprint to detect meaningful changes // Use more precise time tracking (round to nearest second) to detect smaller changes const stateFingerprint = JSON.stringify({ isPlaying: latestStatus.isPlaying, isPaused: latestStatus.isPaused, isLoading: latestStatus.isLoading, time: Math.floor(latestStatus.time), // Track seconds directly // volume: Math.round(latestStatus.volume * 100), playbackRate: latestStatus.playbackRate, }); // Check if state has changed meaningfully OR // it's been at least 2 seconds since last report const hasStateChanged = stateFingerprint !== lastReportedStateRef.current; const timeThresholdMet = now - lastReportTime.current >= 10000; // Less frequent updates (10s) // Always update more frequently if we're the host to ensure guests stay in sync const shouldUpdateForHost = isHost && now - lastReportTime.current >= 1000; if (!hasStateChanged && !timeThresholdMet && !shouldUpdateForHost) return; // Prepare content information let contentTitle = "Unknown content"; let contentType = "Unknown"; if (meta) { if (meta.type === "movie") { contentTitle = meta.title; contentType = "Movie"; } else if (meta.type === "show" && meta.episode) { contentTitle = `${meta.title} - S${meta.season?.number || 0}E${meta.episode.number || 0}`; contentType = "TV Show"; } } // Send player status to backend API const sendStatusToBackend = async () => { try { await sendPlayerStatus(backendUrl, account, { userId, roomCode, isHost, content: { title: contentTitle, type: contentType, tmdbId: meta?.tmdbId ? Number(meta.tmdbId) : 0, seasonId: meta?.season?.tmdbId ? Number(meta.season.tmdbId) : undefined, episodeId: meta?.episode?.tmdbId ? Number(meta.episode.tmdbId) : undefined, seasonNumber: meta?.season?.number, episodeNumber: meta?.episode?.number, }, player: { isPlaying: latestStatus.isPlaying, isPaused: latestStatus.isPaused, isLoading: latestStatus.isLoading, hasPlayedOnce: latestStatus.hasPlayedOnce, time: latestStatus.time, duration: latestStatus.duration, // volume: latestStatus.volume, playbackRate: latestStatus.playbackRate, buffered: latestStatus.buffered, }, }); // Update last report time and fingerprint lastReportTime.current = now; lastReportedStateRef.current = stateFingerprint; // eslint-disable-next-line no-console console.log("Sent player status update to backend", { time: new Date().toISOString(), isPlaying: latestStatus.isPlaying, currentTime: Math.floor(latestStatus.time), userId, content: contentTitle, roomCode, }); } catch (error) { console.error("Failed to send player status to backend", error); } }; sendStatusToBackend(); }, [ latestStatus, statusHistory.length, userId, account, meta, watchPartyEnabled, roomCode, isHost, backendUrl, ]); return null; }