From fb3bc161ce5ee8c69c76f2df9a6edeeeedca966f Mon Sep 17 00:00:00 2001 From: Pas <74743263+Pasithea0@users.noreply.github.com> Date: Sat, 17 May 2025 20:04:06 -0600 Subject: [PATCH] Merge: Add Watch Party commit 6034a1ebeaa97a97552dc249f97cc935dcbd1cd6 Author: Pas <74743263+Pasithea0@users.noreply.github.com> Date: Sat May 17 20:02:36 2025 -0600 update stuff commit 91d1370668a3e05fed3687ffef697a96c28a0b2c Author: Pas <74743263+Pasithea0@users.noreply.github.com> Date: Sat May 17 19:47:34 2025 -0600 Update Downloads.tsx commit 1c25c88175abebe761d27194f22eec9fd3bcf2e1 Author: Pas <74743263+Pasithea0@users.noreply.github.com> Date: Sat May 17 19:46:27 2025 -0600 clean some more stuff commit d6c24e76348c135f3c1ae0444491ff0b302eca2e Author: Pas <74743263+Pasithea0@users.noreply.github.com> Date: Sat May 17 19:38:07 2025 -0600 clean this again commit 6511de68a1b1397e4884dfc6e6f0599497b9afee Author: Pas <74743263+Pasithea0@users.noreply.github.com> Date: Sat May 17 19:30:08 2025 -0600 clean up a bit commit 467358c1f50c1555b42f9ae8d22f955ebc9bba1b Author: Pas <74743263+Pasithea0@users.noreply.github.com> Date: Sat May 17 17:45:04 2025 -0600 validate content commit 59d4a1665b32bdf4ca453859816a2245b184b025 Author: Pas <74743263+Pasithea0@users.noreply.github.com> Date: Sat May 17 17:35:22 2025 -0600 add auto join link commit 6f3c334d2157f1c82f9d26e9a7db49371e6a2b5e Author: Pas <74743263+Pasithea0@users.noreply.github.com> Date: Sat May 17 17:08:35 2025 -0600 watch partyyyy commit 1497377692fba86ea1ef40191dbaa648abc38e2e Author: Pas <74743263+Pasithea0@users.noreply.github.com> Date: Sat May 17 13:56:04 2025 -0600 add watch party view stuff commit 80003f78d0adf6071d4f2c6b6a6d8fdcb2aa204a Author: Pas <74743263+Pasithea0@users.noreply.github.com> Date: Sat May 17 13:14:07 2025 -0600 init sending webhooks testing on a discord hook commit f5293c2eae5d5a12be6153ab37e50214289e20b6 Author: Pas <74743263+Pasithea0@users.noreply.github.com> Date: Sat May 17 12:31:41 2025 -0600 remove duplicate class commit 7871162e6b2c580eb2bcb9c8abc5ecc19d706a06 Author: Pas <74743263+Pasithea0@users.noreply.github.com> Date: Sat May 17 12:25:49 2025 -0600 update legacy button commit a2948f3aa1b7481a3ac5b69e2e4dab71552816de Author: Pas <74743263+Pasithea0@users.noreply.github.com> Date: Sat May 17 12:21:52 2025 -0600 move watchparty to new atoms menu --- src/assets/locales/en.json | 30 +- src/backend/player/status.ts | 147 ++++++++ src/components/media/MediaBookmark.tsx | 2 +- src/components/player/atoms/Settings.tsx | 6 + .../player/atoms/WatchPartyStatus.tsx | 147 ++++++++ .../player/atoms/settings/Downloads.tsx | 2 +- .../player/atoms/settings/SettingsMenu.tsx | 21 +- .../player/atoms/settings/WatchPartyView.tsx | 295 ++++++++++++++++ src/components/player/base/Container.tsx | 6 + .../player/hooks/usePlayerStatusPolling.ts | 146 ++++++++ .../player/internals/WatchPartyResetter.tsx | 51 +++ .../player/internals/WebhookReporter.tsx | 260 ++++++++++++++ src/hooks/useWatchPartySync.ts | 327 ++++++++++++++++++ src/pages/PlayerView.tsx | 22 +- src/pages/parts/player/PlayerPart.tsx | 4 +- src/stores/watchParty.ts | 65 ++++ 16 files changed, 1509 insertions(+), 22 deletions(-) create mode 100644 src/backend/player/status.ts create mode 100644 src/components/player/atoms/WatchPartyStatus.tsx create mode 100644 src/components/player/atoms/settings/WatchPartyView.tsx create mode 100644 src/components/player/hooks/usePlayerStatusPolling.ts create mode 100644 src/components/player/internals/WatchPartyResetter.tsx create mode 100644 src/components/player/internals/WebhookReporter.tsx create mode 100644 src/hooks/useWatchPartySync.ts create mode 100644 src/stores/watchParty.ts diff --git a/src/assets/locales/en.json b/src/assets/locales/en.json index 35cad4a1..a3becdcc 100644 --- a/src/assets/locales/en.json +++ b/src/assets/locales/en.json @@ -530,7 +530,8 @@ }, "watchparty": { "watchpartyItem": "Watch Party", - "notice": "Watch Party might not be available for some sources" + "notice": "Watch Party might not be available for some sources", + "legacyWatchparty": "Use legacy Watch Party" } }, "metadata": { @@ -931,5 +932,32 @@ "success": "success", "failure": "Failed to fetch a 'VIP' stream. Token is invalid or API is down!" } + }, + "watchParty": { + "status": { + "inSync": "In sync", + "outOfSync": "Out of sync" + }, + "alone": "Alone", + "withCount": "With {{count}} others", + "isHost": "You are hosting a watch party", + "isGuest": "You are a guest in a watch party", + "hosting": "Hosting", + "watching": "Watching", + "syncing": "Syncing...", + "behindHost": "Behind host by {{seconds}} seconds", + "aheadOfHost": "Ahead of host by {{seconds}} seconds", + "showStatusOverlay": "Show status overlay", + "leaveWatchParty": "Leave Watch Party", + "shareCode": "Share this code with friends (click to copy)", + "connectedAsGuest": "Connected to watch party as guest", + "hostParty": "Host a Watch Party", + "joinParty": "Join a Watch Party", + "viewers": "Viewers ({{count}})", + "copyCode": "Click to copy", + "join": "Join", + "cancel": "Cancel", + "contentMismatch": "Cannot join watch party: The content does not match the host's content.", + "episodeMismatch": "Cannot join watch party: You are watching a different episode than the host." } } diff --git a/src/backend/player/status.ts b/src/backend/player/status.ts new file mode 100644 index 00000000..735b8d6a --- /dev/null +++ b/src/backend/player/status.ts @@ -0,0 +1,147 @@ +import { AccountWithToken } from "@/stores/auth"; + +interface PlayerState { + isPlaying: boolean; + isPaused: boolean; + isLoading: boolean; + hasPlayedOnce: boolean; + time: number; + duration: number; + volume?: number; + playbackRate: number; + buffered: number; +} + +interface ContentInfo { + title: string; + type: string; + tmdbId?: number; + seasonNumber?: number; + episodeNumber?: number; + seasonId?: number; + episodeId?: number; +} + +interface PlayerStatusRequest { + userId: string; + roomCode: string; + isHost: boolean; + content: ContentInfo; + player: PlayerState; +} + +interface PlayerStatusResponse { + success: boolean; + timestamp: number; +} + +interface UserStatusResponse { + userId: string; + roomCode: string; + statuses: Array<{ + userId: string; + roomCode: string; + isHost: boolean; + content: ContentInfo; + player: PlayerState; + timestamp: number; + }>; +} + +interface RoomStatusesResponse { + roomCode: string; + users: Record< + string, + Array<{ + userId: string; + roomCode: string; + isHost: boolean; + content: ContentInfo; + player: PlayerState; + timestamp: number; + }> + >; +} + +/** + * Send player status update to the backend + */ +export async function sendPlayerStatus( + backendUrl: string | null, + account: AccountWithToken | null, + data: PlayerStatusRequest, +): Promise { + if (!backendUrl) { + throw new Error("Backend URL not set"); + } + + const response = await fetch(`${backendUrl}/api/player/status`, { + method: "POST", + headers: { + "Content-Type": "application/json", + ...(account ? { Authorization: `Bearer ${account.token}` } : {}), + }, + body: JSON.stringify(data), + }); + + if (!response.ok) { + throw new Error(`Failed to send player status: ${response.statusText}`); + } + + return response.json(); +} + +/** + * Get player status for a specific user in a room + */ +export async function getUserPlayerStatus( + backendUrl: string | null, + account: AccountWithToken | null, + userId: string, + roomCode: string, +): Promise { + if (!backendUrl) { + throw new Error("Backend URL not set"); + } + + const response = await fetch( + `${backendUrl}/api/player/status?userId=${encodeURIComponent( + userId, + )}&roomCode=${encodeURIComponent(roomCode)}`, + { + headers: account ? { Authorization: `Bearer ${account.token}` } : {}, + }, + ); + + if (!response.ok) { + throw new Error(`Failed to get user player status: ${response.statusText}`); + } + + return response.json(); +} + +/** + * Get status for all users in a room + */ +export async function getRoomStatuses( + backendUrl: string | null, + account: AccountWithToken | null, + roomCode: string, +): Promise { + if (!backendUrl) { + throw new Error("Backend URL not set"); + } + + const response = await fetch( + `${backendUrl}/api/player/status?roomCode=${encodeURIComponent(roomCode)}`, + { + headers: account ? { Authorization: `Bearer ${account.token}` } : {}, + }, + ); + + if (!response.ok) { + throw new Error(`Failed to get room statuses: ${response.statusText}`); + } + + return response.json(); +} diff --git a/src/components/media/MediaBookmark.tsx b/src/components/media/MediaBookmark.tsx index 014a3615..cdd558bc 100644 --- a/src/components/media/MediaBookmark.tsx +++ b/src/components/media/MediaBookmark.tsx @@ -47,7 +47,7 @@ export function MediaBookmarkButton({ media }: MediaBookmarkProps) { > ); diff --git a/src/components/player/atoms/Settings.tsx b/src/components/player/atoms/Settings.tsx index 63e132f2..7866d55b 100644 --- a/src/components/player/atoms/Settings.tsx +++ b/src/components/player/atoms/Settings.tsx @@ -23,6 +23,7 @@ import { PlaybackSettingsView } from "./settings/PlaybackSettingsView"; import { QualityView } from "./settings/QualityView"; import { SettingsMenu } from "./settings/SettingsMenu"; import SourceCaptionsView from "./settings/SourceCaptionsView"; +import { WatchPartyView } from "./settings/WatchPartyView"; function SettingsOverlay({ id }: { id: string }) { const [chosenSourceId, setChosenSourceId] = useState(null); @@ -134,6 +135,11 @@ function SettingsOverlay({ id }: { id: string }) { + + + + + ); diff --git a/src/components/player/atoms/WatchPartyStatus.tsx b/src/components/player/atoms/WatchPartyStatus.tsx new file mode 100644 index 00000000..2fb17fe4 --- /dev/null +++ b/src/components/player/atoms/WatchPartyStatus.tsx @@ -0,0 +1,147 @@ +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; + +import { Button } from "@/components/buttons/Button"; +import { Icon, Icons } from "@/components/Icon"; +import { useWatchPartySync } from "@/hooks/useWatchPartySync"; +import { useWatchPartyStore } from "@/stores/watchParty"; + +export function WatchPartyStatus() { + const { t } = useTranslation(); + const { enabled, roomCode, isHost, showStatusOverlay } = useWatchPartyStore(); + const [expanded, setExpanded] = useState(false); + const [showNotification, setShowNotification] = useState(false); + const [lastUserCount, setLastUserCount] = useState(1); + + const { + roomUsers, + hostUser, + isBehindHost, + isAheadOfHost, + timeDifferenceFromHost, + syncWithHost, + isSyncing, + userCount, + } = useWatchPartySync(); + + // Show notification when users join + useEffect(() => { + if (userCount > lastUserCount) { + setShowNotification(true); + const timer = setTimeout(() => setShowNotification(false), 3000); + return () => clearTimeout(timer); + } + setLastUserCount(userCount); + }, [userCount, lastUserCount]); + + // If watch party is not enabled or overlay is hidden, don't show anything + if (!enabled || !roomCode || !showStatusOverlay) return null; + + // Toggle expanded state + const handleToggleExpanded = () => { + setExpanded(!expanded); + }; + + return ( +
+
+
+ + + {isHost ? t("watchParty.hosting") : t("watchParty.watching")} + +
+ + {roomCode} + +
+ +
+
+ +
+ + {roomUsers.length <= 1 + ? t("watchParty.alone") + : t("watchParty.withCount", { count: roomUsers.length - 1 })} + + + {/* Sync status indicator */} + {!isHost && hostUser && ( +
+
+ + {isBehindHost || isAheadOfHost + ? t("watchParty.status.outOfSync") + : t("watchParty.status.inSync")} + +
+ )} +
+ + {expanded && roomUsers.length > 1 && ( +
+
Viewers:
+
+ {roomUsers.map((user) => ( +
+ + + + {user.userId.substring(0, 8)}... + + + + {user.player.duration > 0 + ? `${Math.floor((user.player.time / user.player.duration) * 100)}%` + : `${Math.floor(user.player.time)}s`} + +
+ ))} +
+
+ )} + + {!isHost && hostUser && (isBehindHost || isAheadOfHost) && ( +
+ +
+ )} +
+ ); +} diff --git a/src/components/player/atoms/settings/Downloads.tsx b/src/components/player/atoms/settings/Downloads.tsx index 7b2a814b..77294727 100644 --- a/src/components/player/atoms/settings/Downloads.tsx +++ b/src/components/player/atoms/settings/Downloads.tsx @@ -80,7 +80,7 @@ export function DownloadView({ id }: { id: string }) { {t("player.menus.downloads.title")} -
+
{sourceType === "hls" ? (
diff --git a/src/components/player/atoms/settings/SettingsMenu.tsx b/src/components/player/atoms/settings/SettingsMenu.tsx index 2ed59b79..bbaf4277 100644 --- a/src/components/player/atoms/settings/SettingsMenu.tsx +++ b/src/components/player/atoms/settings/SettingsMenu.tsx @@ -6,17 +6,13 @@ import { Toggle } from "@/components/buttons/Toggle"; import { Icon, Icons } from "@/components/Icon"; import { useCaptions } from "@/components/player/hooks/useCaptions"; import { Menu } from "@/components/player/internals/ContextMenu"; -import { Paragraph } from "@/components/utils/Text"; import { useOverlayRouter } from "@/hooks/useOverlayRouter"; import { usePlayerStore } from "@/stores/player/store"; import { qualityToString } from "@/stores/player/utils/qualities"; import { useSubtitleStore } from "@/stores/subtitles"; import { getPrettyLanguageNameFromLocale } from "@/utils/language"; -import { useDownloadLink } from "./Downloads"; - export function SettingsMenu({ id }: { id: string }) { - const downloadUrl = useDownloadLink(); const { t } = useTranslation(); const router = useOverlayRouter(id); const currentQuality = usePlayerStore((s) => s.currentQuality); @@ -50,14 +46,6 @@ export function SettingsMenu({ id }: { id: string }) { const downloadable = source?.type === "file" || source?.type === "hls"; - const handleWatchPartyClick = () => { - if (downloadUrl) { - const watchPartyUrl = `https://www.watchparty.me/create?video=${encodeURIComponent( - downloadUrl, - )}`; - window.open(watchPartyUrl); - } - }; return ( @@ -97,15 +85,14 @@ export function SettingsMenu({ id }: { id: string }) { + router.navigate(downloadable ? "/watchparty" : "/download/unable") + } rightSide={} className={downloadable ? "opacity-100" : "opacity-50"} > - {t("player.menus.watchparty.watchpartyItem")} + {t("player.menus.watchparty.watchpartyItem")} (Beta) - - {t("player.menus.watchparty.notice")} - diff --git a/src/components/player/atoms/settings/WatchPartyView.tsx b/src/components/player/atoms/settings/WatchPartyView.tsx new file mode 100644 index 00000000..d4c67f99 --- /dev/null +++ b/src/components/player/atoms/settings/WatchPartyView.tsx @@ -0,0 +1,295 @@ +/* eslint-disable no-alert */ +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; + +import { Button } from "@/components/buttons/Button"; +import { Icon, Icons } from "@/components/Icon"; +import { Spinner } from "@/components/layout/Spinner"; +import { Menu } from "@/components/player/internals/ContextMenu"; +import { useOverlayRouter } from "@/hooks/useOverlayRouter"; +import { useWatchPartySync } from "@/hooks/useWatchPartySync"; +import { useWatchPartyStore } from "@/stores/watchParty"; + +import { useDownloadLink } from "./Downloads"; + +export function WatchPartyView({ id }: { id: string }) { + const router = useOverlayRouter(id); + const { t } = useTranslation(); + const downloadUrl = useDownloadLink(); + const [joinCode, setJoinCode] = useState(""); + const [showJoinInput, setShowJoinInput] = useState(false); + const [isJoining, setIsJoining] = useState(false); + + // Watch party store access + const { + enabled, + roomCode, + isHost, + enableAsHost, + enableAsGuest, + disable, + showStatusOverlay, + setShowStatusOverlay, + } = useWatchPartyStore(); + + // Watch party sync data + const { roomUsers } = useWatchPartySync(); + + // Listen for validation status events + useEffect(() => { + const handleValidation = () => { + setIsJoining(false); + }; + + window.addEventListener( + "watchparty:validation", + handleValidation as EventListener, + ); + + return () => { + window.removeEventListener( + "watchparty:validation", + handleValidation as EventListener, + ); + }; + }, []); + + // Reset joining state when watch party is disabled + useEffect(() => { + if (!enabled) { + setIsJoining(false); + } + }, [enabled]); + + const handlelegacyWatchPartyClick = () => { + if (downloadUrl) { + const watchPartyUrl = `https://www.watchparty.me/create?video=${encodeURIComponent( + downloadUrl, + )}`; + window.open(watchPartyUrl); + } + }; + + const handleHostParty = () => { + enableAsHost(); + setShowJoinInput(false); + }; + + const handleJoinParty = () => { + if (joinCode.length === 4) { + setIsJoining(true); + enableAsGuest(joinCode); + setShowJoinInput(false); + } + }; + + const handleDisableParty = () => { + disable(); + setShowJoinInput(false); + setJoinCode(""); + }; + + const handleCopyCode = () => { + if (roomCode) { + // Create URL with watchparty parameter + const url = new URL(window.location.href); + url.searchParams.set("watchparty", roomCode); + navigator.clipboard.writeText(url.toString()); + } + }; + + const toggleStatusOverlay = () => { + setShowStatusOverlay(!showStatusOverlay); + }; + + return ( + <> + router.navigate("/")}> + {t("player.menus.watchparty.watchpartyItem")} (Beta) + + +
+ + {t("player.menus.watchparty.notice")} + + + {enabled ? ( +
+ {isJoining ? ( +
+ +

+ {t("watchParty.validating")} +

+
+ ) : ( + <> +
+
+ + {isHost + ? t("watchParty.isHost") + : t("watchParty.isGuest")} + +
+
+ { + if (e.target instanceof HTMLInputElement) { + e.target.select(); + } + }} + /> +
+

+ {isHost + ? t("watchParty.shareCode") + : t("watchParty.connectedAsGuest")} +

+
+ + {roomUsers.length > 1 && ( +
+
+ + {t("watchParty.viewers", { count: roomUsers.length })} + +
+
+ {roomUsers.map((user) => ( +
+ + + + {user.userId.substring(0, 8)}... + + + + {user.player.duration > 0 + ? `${Math.floor((user.player.time / user.player.duration) * 100)}%` + : `${Math.floor(user.player.time)}s`} + +
+ ))} +
+
+ )} + +
+
+ + {t("watchParty.showStatusOverlay")} + +
+ + )} +
+ ) : ( +
+ {showJoinInput ? ( +
+ + setJoinCode( + e.target.value.replace(/[^0-9]/g, "").slice(0, 4), + ) + } + /> +
+ + +
+
+ ) : ( +
+ + +
+ )} +
+ )} + + + + } + > + {t("player.menus.watchparty.legacyWatchparty")} + +
+ + + ); +} diff --git a/src/components/player/base/Container.tsx b/src/components/player/base/Container.tsx index 2a68ae31..a90c604c 100644 --- a/src/components/player/base/Container.tsx +++ b/src/components/player/base/Container.tsx @@ -1,6 +1,7 @@ import { ReactNode, RefObject, useEffect, useRef } from "react"; import { OverlayDisplay } from "@/components/overlays/OverlayDisplay"; +import { WatchPartyStatus } from "@/components/player/atoms/WatchPartyStatus"; import { CastingInternal } from "@/components/player/internals/CastingInternal"; import { HeadUpdater } from "@/components/player/internals/HeadUpdater"; import { KeyboardEvents } from "@/components/player/internals/KeyboardEvents"; @@ -10,6 +11,8 @@ import { ProgressSaver } from "@/components/player/internals/ProgressSaver"; import { ThumbnailScraper } from "@/components/player/internals/ThumbnailScraper"; import { VideoClickTarget } from "@/components/player/internals/VideoClickTarget"; import { VideoContainer } from "@/components/player/internals/VideoContainer"; +import { WatchPartyResetter } from "@/components/player/internals/WatchPartyResetter"; +import { WebhookReporter } from "@/components/player/internals/WebhookReporter"; import { PlayerHoverState } from "@/stores/player/slices/interface"; import { usePlayerStore } from "@/stores/player/store"; @@ -93,7 +96,10 @@ export function Container(props: PlayerProps) { + +
+ {props.children} diff --git a/src/components/player/hooks/usePlayerStatusPolling.ts b/src/components/player/hooks/usePlayerStatusPolling.ts new file mode 100644 index 00000000..61d55f2f --- /dev/null +++ b/src/components/player/hooks/usePlayerStatusPolling.ts @@ -0,0 +1,146 @@ +import { useCallback, useEffect, useRef, useState } from "react"; + +import { usePlayerStore } from "@/stores/player/store"; + +interface PlayerStatusData { + isPlaying: boolean; + isPaused: boolean; + isLoading: boolean; + hasPlayedOnce: boolean; + volume: number; + playbackRate: number; + time: number; + duration: number; + buffered: number; + timestamp: number; // When this data point was captured +} + +interface PlayerStatusPollingResult { + /** Array of player status data points collected when state changes */ + statusHistory: PlayerStatusData[]; + /** The most recent player status data point */ + latestStatus: PlayerStatusData | null; + /** Clear the status history */ + clearHistory: () => void; + /** Force an immediate update of the status */ + forceUpdate: () => void; +} + +/** + * Hook that polls player status and progress, but only records changes + * when there are meaningful differences in the player state + * + * @param maxHistory Maximum number of history entries to keep (default: 10) + */ +export function usePlayerStatusPolling( + maxHistory: number = 10, +): PlayerStatusPollingResult { + const [statusHistory, setStatusHistory] = useState([]); + const previousStateRef = useRef(null); + const lastUpdateTimeRef = useRef(0); + + // Get the current playing state and progress + const mediaPlaying = usePlayerStore((s) => s.mediaPlaying); + const progress = usePlayerStore((s) => s.progress); + + // Create a function to update the history + const updateHistory = useCallback(() => { + const now = Date.now(); + const currentStatus: PlayerStatusData = { + isPlaying: mediaPlaying.isPlaying, + isPaused: mediaPlaying.isPaused, + isLoading: mediaPlaying.isLoading, + hasPlayedOnce: mediaPlaying.hasPlayedOnce, + volume: mediaPlaying.volume, + playbackRate: mediaPlaying.playbackRate, + time: progress.time, + duration: progress.duration, + buffered: progress.buffered, + timestamp: now, + }; + + // Check if this is the first record + const isFirstRecord = previousStateRef.current === null; + if (isFirstRecord) { + setStatusHistory([currentStatus]); + previousStateRef.current = currentStatus; + lastUpdateTimeRef.current = now; + return currentStatus; + } + + // At this point we've confirmed previousStateRef.current is not null + const prevState = previousStateRef.current!; // Non-null assertion + const timeSinceLastUpdate = now - lastUpdateTimeRef.current; + + // Determine if we should record this update + const hasPlaybackStateChanged = + prevState.isPlaying !== currentStatus.isPlaying || + prevState.isPaused !== currentStatus.isPaused || + prevState.isLoading !== currentStatus.isLoading; + + const hasPlaybackRateChanged = + prevState.playbackRate !== currentStatus.playbackRate; + + const hasTimeChangedDuringPlayback = + currentStatus.isPlaying && + timeSinceLastUpdate >= 4000 && + Math.abs(prevState.time - currentStatus.time) > 1; + + const hasDurationChanged = + Math.abs(prevState.duration - currentStatus.duration) > 1; + + const periodicUpdateDuringPlayback = + currentStatus.isPlaying && timeSinceLastUpdate >= 10000; + + // Update if any significant changes detected + const shouldUpdate = + hasPlaybackStateChanged || + hasPlaybackRateChanged || + hasTimeChangedDuringPlayback || + hasDurationChanged || + periodicUpdateDuringPlayback; + + if (shouldUpdate) { + setStatusHistory((prev) => { + const newHistory = [...prev, currentStatus]; + return newHistory.length > maxHistory + ? newHistory.slice(newHistory.length - maxHistory) + : newHistory; + }); + + previousStateRef.current = currentStatus; + lastUpdateTimeRef.current = now; + } + + return currentStatus; + }, [mediaPlaying, progress, maxHistory]); + + const clearHistory = useCallback(() => { + setStatusHistory([]); + previousStateRef.current = null; + lastUpdateTimeRef.current = 0; + }, []); + + useEffect(() => { + // Initial update + updateHistory(); + + // Set up polling interval at 2 seconds + const interval = setInterval(() => { + if (mediaPlaying.hasPlayedOnce) { + updateHistory(); + } + }, 2000); + + // Clean up on unmount + return () => clearInterval(interval); + }, [updateHistory, mediaPlaying.hasPlayedOnce]); + + return { + statusHistory, + latestStatus: + statusHistory.length > 0 ? statusHistory[statusHistory.length - 1] : null, + clearHistory, + forceUpdate: updateHistory, + }; +} diff --git a/src/components/player/internals/WatchPartyResetter.tsx b/src/components/player/internals/WatchPartyResetter.tsx new file mode 100644 index 00000000..b239b703 --- /dev/null +++ b/src/components/player/internals/WatchPartyResetter.tsx @@ -0,0 +1,51 @@ +import { useEffect, useMemo, useRef } from "react"; + +import { usePlayerStore } from "@/stores/player/store"; +import { useWatchPartyStore } from "@/stores/watchParty"; + +/** + * Component that detects when the player exits or media changes + * and resets the watch party state + */ +export function WatchPartyResetter() { + const meta = usePlayerStore((s) => s.meta); + const { disable } = useWatchPartyStore(); + + // Store the current meta to track changes + const previousMetaRef = useRef(null); + + // Memoize the metaId calculation + const metaId = useMemo(() => { + if (!meta) return null; + + return meta.type === "show" + ? `${meta.type}-${meta.tmdbId}-s${meta.season?.tmdbId || "0"}-e${meta.episode?.tmdbId || "0"}` + : `${meta.type}-${meta.tmdbId}`; + }, [meta]); + + useEffect(() => { + // If meta exists but has changed, reset watch party + if ( + metaId && + previousMetaRef.current && + metaId !== previousMetaRef.current + ) { + // eslint-disable-next-line no-console + console.log("Media changed, disabling watch party:", { + previous: previousMetaRef.current, + current: metaId, + }); + disable(); + } + + // Update the ref with current meta + previousMetaRef.current = metaId; + + // Also reset when component unmounts (player exited) + return () => { + disable(); + }; + }, [metaId, disable]); + + return null; // This component doesn't render anything +} diff --git a/src/components/player/internals/WebhookReporter.tsx b/src/components/player/internals/WebhookReporter.tsx new file mode 100644 index 00000000..86d9fa07 --- /dev/null +++ b/src/components/player/internals/WebhookReporter.tsx @@ -0,0 +1,260 @@ +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; +} diff --git a/src/hooks/useWatchPartySync.ts b/src/hooks/useWatchPartySync.ts new file mode 100644 index 00000000..51aa70bd --- /dev/null +++ b/src/hooks/useWatchPartySync.ts @@ -0,0 +1,327 @@ +/* eslint-disable no-console */ +import { useCallback, useEffect, useRef, useState } from "react"; + +// import { getRoomStatuses, getUserPlayerStatus } from "@/backend/player/status"; +import { getRoomStatuses } from "@/backend/player/status"; +import { useBackendUrl } from "@/hooks/auth/useBackendUrl"; +import { useAuthStore } from "@/stores/auth"; +import { usePlayerStore } from "@/stores/player/store"; +import { useWatchPartyStore } from "@/stores/watchParty"; + +interface RoomUser { + userId: string; + isHost: boolean; + lastUpdate: number; + player: { + isPlaying: boolean; + isPaused: boolean; + time: number; + duration: number; + }; + content: { + title: string; + type: string; + tmdbId?: number; + seasonId?: number; + episodeId?: number; + seasonNumber?: number; + episodeNumber?: number; + }; +} + +interface WatchPartySyncResult { + // All users in the room + roomUsers: RoomUser[]; + // The host user (if any) + hostUser: RoomUser | null; + // Whether our player is behind the host + isBehindHost: boolean; + // Whether our player is ahead of the host + isAheadOfHost: boolean; + // Seconds difference from host (positive means ahead, negative means behind) + timeDifferenceFromHost: number; + // Function to sync with host + syncWithHost: () => void; + // Whether we are currently syncing + isSyncing: boolean; + // Manually refresh room data + refreshRoomData: () => Promise; + // Current user count in room + userCount: number; +} + +/** + * Hook for syncing with other users in a watch party room + */ +export function useWatchPartySync( + syncThresholdSeconds = 5, +): WatchPartySyncResult { + const [roomUsers, setRoomUsers] = useState([]); + const [isSyncing, setIsSyncing] = useState(false); + const [userCount, setUserCount] = useState(1); + + // Refs for tracking state + const syncStateRef = useRef({ + lastUserCount: 1, + previousHostPlaying: null as boolean | null, + previousHostTime: null as number | null, + lastSyncTime: 0, + syncInProgress: false, + checkedUrlParams: false, + prevRoomUsers: [] as RoomUser[], + }); + + // Get our auth and backend info + const account = useAuthStore((s) => s.account); + const backendUrl = useBackendUrl(); + + // Get player store functions + const display = usePlayerStore((s) => s.display); + const currentTime = usePlayerStore((s) => s.progress.time); + const isPlaying = usePlayerStore((s) => s.mediaPlaying.isPlaying); + + // Get watch party state + const { roomCode, isHost, enabled, enableAsGuest } = useWatchPartyStore(); + + // Check URL parameters for watch party code + useEffect(() => { + if (syncStateRef.current.checkedUrlParams) return; + + try { + const params = new URLSearchParams(window.location.search); + const watchPartyCode = params.get("watchparty"); + + if (watchPartyCode && !enabled && watchPartyCode.length === 4) { + enableAsGuest(watchPartyCode); + } + + syncStateRef.current.checkedUrlParams = true; + } catch (error) { + console.error("Failed to check URL parameters for watch party:", error); + } + }, [enabled, enableAsGuest]); + + // Find the host user in the room + const hostUser = roomUsers.find((user) => user.isHost) || null; + + // Calculate predicted host time by accounting for elapsed time since update + const getPredictedHostTime = useCallback(() => { + if (!hostUser) return 0; + + const millisecondsSinceUpdate = Date.now() - hostUser.lastUpdate; + const secondsSinceUpdate = millisecondsSinceUpdate / 1000; + + return hostUser.player.isPlaying && !hostUser.player.isPaused + ? hostUser.player.time + secondsSinceUpdate + : hostUser.player.time; + }, [hostUser]); + + // Calculate time difference from host + const timeDifferenceFromHost = hostUser + ? currentTime - getPredictedHostTime() + : 0; + + // Determine if we're ahead or behind the host + const isBehindHost = + hostUser && !isHost && timeDifferenceFromHost < -syncThresholdSeconds; + const isAheadOfHost = + hostUser && !isHost && timeDifferenceFromHost > syncThresholdSeconds; + + // Function to sync with host + const syncWithHost = useCallback(() => { + if (!hostUser || isHost || !display || syncStateRef.current.syncInProgress) + return; + + syncStateRef.current.syncInProgress = true; + setIsSyncing(true); + + const predictedHostTime = getPredictedHostTime(); + display.setTime(predictedHostTime); + + setTimeout(() => { + if (hostUser.player.isPlaying && !hostUser.player.isPaused) { + display.play(); + } else { + display.pause(); + } + + setTimeout(() => { + setIsSyncing(false); + syncStateRef.current.syncInProgress = false; + }, 500); + + syncStateRef.current.lastSyncTime = Date.now(); + }, 200); + }, [hostUser, isHost, display, getPredictedHostTime]); + + // Combined effect for syncing time and play/pause state + useEffect(() => { + if (!hostUser || isHost || !display || syncStateRef.current.syncInProgress) + return; + + const state = syncStateRef.current; + const hostIsPlaying = + hostUser.player.isPlaying && !hostUser.player.isPaused; + const predictedHostTime = getPredictedHostTime(); + const difference = currentTime - predictedHostTime; + + // Handle time sync + const activeThreshold = isPlaying ? 2 : 5; + const needsTimeSync = Math.abs(difference) > activeThreshold; + + // Handle play state sync + const needsPlayStateSync = + state.previousHostPlaying !== null && + state.previousHostPlaying !== hostIsPlaying; + + // Handle time jumps + const needsJumpSync = + state.previousHostTime !== null && + Math.abs(hostUser.player.time - state.previousHostTime) > 5; + + // Sync if needed + if ((needsTimeSync || needsPlayStateSync || needsJumpSync) && !isSyncing) { + state.syncInProgress = true; + setIsSyncing(true); + + // Sync time first + display.setTime(predictedHostTime); + + // Then sync play state after a short delay + setTimeout(() => { + if (hostIsPlaying) { + display.play(); + } else { + display.pause(); + } + + // Clear syncing flags + setTimeout(() => { + setIsSyncing(false); + state.syncInProgress = false; + }, 500); + }, 200); + } + + // Update state refs + state.previousHostPlaying = hostIsPlaying; + state.previousHostTime = hostUser.player.time; + }, [ + hostUser, + isHost, + currentTime, + display, + isSyncing, + getPredictedHostTime, + isPlaying, + ]); + + // Function to refresh room data + const refreshRoomData = useCallback(async () => { + if (!enabled || !roomCode || !backendUrl) return; + + try { + const response = await getRoomStatuses(backendUrl, account, roomCode); + const users: RoomUser[] = []; + + // Process each user's latest status + Object.entries(response.users).forEach( + ([userIdFromResponse, statuses]) => { + if (statuses.length > 0) { + // Get the latest status (sort by timestamp DESC) + const latestStatus = [...statuses].sort( + (a, b) => b.timestamp - a.timestamp, + )[0]; + + users.push({ + userId: userIdFromResponse, + isHost: latestStatus.isHost, + lastUpdate: latestStatus.timestamp, + player: { + isPlaying: latestStatus.player.isPlaying, + isPaused: latestStatus.player.isPaused, + time: latestStatus.player.time, + duration: latestStatus.player.duration, + }, + content: { + title: latestStatus.content.title, + type: latestStatus.content.type, + tmdbId: latestStatus.content.tmdbId, + seasonId: latestStatus.content.seasonId, + episodeId: latestStatus.content.episodeId, + seasonNumber: latestStatus.content.seasonNumber, + episodeNumber: latestStatus.content.episodeNumber, + }, + }); + } + }, + ); + + // Sort users with host first, then by lastUpdate + users.sort((a, b) => { + if (a.isHost && !b.isHost) return -1; + if (!a.isHost && b.isHost) return 1; + return b.lastUpdate - a.lastUpdate; + }); + + // Update user count if changed + const newUserCount = users.length; + if (newUserCount !== syncStateRef.current.lastUserCount) { + setUserCount(newUserCount); + syncStateRef.current.lastUserCount = newUserCount; + } + + // Update room users + syncStateRef.current.prevRoomUsers = users; + setRoomUsers(users); + } catch (error) { + console.error("Failed to refresh room data:", error); + } + }, [backendUrl, account, roomCode, enabled]); + + // Periodically refresh room data + useEffect(() => { + // Store reference to current syncState for cleanup + const syncState = syncStateRef.current; + + if (!enabled || !roomCode) { + setRoomUsers([]); + setUserCount(1); + + // Reset all state + syncState.lastUserCount = 1; + syncState.prevRoomUsers = []; + syncState.previousHostPlaying = null; + syncState.previousHostTime = null; + return; + } + + // Initial fetch + refreshRoomData(); + + // Set up interval - refresh every 2 seconds + const interval = setInterval(refreshRoomData, 2000); + + return () => { + clearInterval(interval); + setRoomUsers([]); + setUserCount(1); + + // Use captured reference from outer scope + syncState.previousHostPlaying = null; + syncState.previousHostTime = null; + }; + }, [enabled, roomCode, refreshRoomData]); + + return { + roomUsers, + hostUser, + isBehindHost: !!isBehindHost, + isAheadOfHost: !!isAheadOfHost, + timeDifferenceFromHost, + syncWithHost, + isSyncing, + refreshRoomData, + userCount, + }; +} diff --git a/src/pages/PlayerView.tsx b/src/pages/PlayerView.tsx index 5282d9b3..61df9f6a 100644 --- a/src/pages/PlayerView.tsx +++ b/src/pages/PlayerView.tsx @@ -1,5 +1,5 @@ import { RunOutput } from "@movie-web/providers"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { Navigate, useLocation, @@ -12,6 +12,7 @@ import { usePlayer } from "@/components/player/hooks/usePlayer"; import { usePlayerMeta } from "@/components/player/hooks/usePlayerMeta"; import { convertProviderCaption } from "@/components/player/utils/captions"; import { convertRunoutputToSource } from "@/components/player/utils/convertRunoutputToSource"; +import { useOverlayRouter } from "@/hooks/useOverlayRouter"; import { ScrapingItems, ScrapingSegment } from "@/hooks/useProviderScrape"; import { useQueryParam } from "@/hooks/useQueryParams"; import { MetaPart } from "@/pages/parts/player/MetaPart"; @@ -46,6 +47,8 @@ export function RealPlayerView() { } = usePlayer(); const { setPlayerMeta, scrapeMedia } = usePlayerMeta(); const backUrl = useLastNonPlayerLink(); + const router = useOverlayRouter("settings"); + const openedWatchPartyRef = useRef(false); const paramsData = JSON.stringify({ media: params.media, @@ -54,8 +57,25 @@ export function RealPlayerView() { }); useEffect(() => { reset(); + // Reset watch party state when media changes + openedWatchPartyRef.current = false; }, [paramsData, reset]); + // Auto-open watch party menu if URL contains watchparty parameter + useEffect(() => { + if (openedWatchPartyRef.current) return; + + if (status === playerStatus.PLAYING) { + const urlParams = new URLSearchParams(window.location.search); + if (urlParams.has("watchparty")) { + setTimeout(() => { + router.navigate("/watchparty"); + openedWatchPartyRef.current = true; + }, 1000); + } + } + }, [status, router]); + const metaChange = useCallback( (meta: PlayerMeta) => { if (meta?.type === "show") diff --git a/src/pages/parts/player/PlayerPart.tsx b/src/pages/parts/player/PlayerPart.tsx index 9a423e68..1462ec57 100644 --- a/src/pages/parts/player/PlayerPart.tsx +++ b/src/pages/parts/player/PlayerPart.tsx @@ -1,4 +1,4 @@ -import { ReactNode, useEffect, useRef, useState } from "react"; +import { ReactNode, useRef, useState } from "react"; import IosPwaLimitations from "@/components/buttons/IosPwaLimitations"; import { BrandPill } from "@/components/layout/BrandPill"; @@ -9,6 +9,7 @@ import { Widescreen } from "@/components/player/atoms/Widescreen"; import { useShouldShowControls } from "@/components/player/hooks/useShouldShowControls"; import { useSkipTime } from "@/components/player/hooks/useSkipTime"; import { useIsMobile } from "@/hooks/useIsMobile"; +import { useOverlayRouter } from "@/hooks/useOverlayRouter"; import { PlayerMeta, playerStatus } from "@/stores/player/slices/source"; import { usePlayerStore } from "@/stores/player/store"; @@ -26,6 +27,7 @@ export function PlayerPart(props: PlayerPartProps) { const status = usePlayerStore((s) => s.status); const { isMobile } = useIsMobile(); const isLoading = usePlayerStore((s) => s.mediaPlaying.isLoading); + const router = useOverlayRouter("settings"); const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent); const isIOSPWA = diff --git a/src/stores/watchParty.ts b/src/stores/watchParty.ts new file mode 100644 index 00000000..ac314c5f --- /dev/null +++ b/src/stores/watchParty.ts @@ -0,0 +1,65 @@ +import { create } from "zustand"; +import { persist } from "zustand/middleware"; + +interface WatchPartyStore { + // Whether the watch party feature is enabled + enabled: boolean; + // The room code for the watch party (4 digits) + roomCode: string | null; + // If the user is hosting (true) or joining (false) + isHost: boolean; + // Whether to show the status overlay on the player + showStatusOverlay: boolean; + // Enable watch party with a new room code + enableAsHost(): void; + // Enable watch party by joining an existing room + enableAsGuest(code: string): void; + // Disable watch party + disable(): void; + // Set status overlay visibility + setShowStatusOverlay(show: boolean): void; +} + +// Generate a random 4-digit code +const generateRoomCode = (): string => { + return Math.floor(1000 + Math.random() * 9000).toString(); +}; + +export const useWatchPartyStore = create()( + persist( + (set) => ({ + enabled: false, + roomCode: null, + isHost: false, + showStatusOverlay: false, + + enableAsHost: () => + set(() => ({ + enabled: true, + roomCode: generateRoomCode(), + isHost: true, + })), + + enableAsGuest: (code: string) => + set(() => ({ + enabled: true, + roomCode: code, + isHost: false, + })), + + disable: () => + set(() => ({ + enabled: false, + roomCode: null, + })), + + setShowStatusOverlay: (show: boolean) => + set(() => ({ + showStatusOverlay: show, + })), + }), + { + name: "watch-party-storage", + }, + ), +);