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
This commit is contained in:
Pas 2025-05-17 20:04:06 -06:00
parent 455daf6090
commit fb3bc161ce
16 changed files with 1509 additions and 22 deletions

View file

@ -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."
}
}

View file

@ -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<PlayerStatusResponse> {
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<UserStatusResponse> {
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<RoomStatusesResponse> {
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();
}

View file

@ -47,7 +47,7 @@ export function MediaBookmarkButton({ media }: MediaBookmarkProps) {
>
<IconPatch
icon={isBookmarked ? Icons.BOOKMARK : Icons.BOOKMARK_OUTLINE}
className={`${buttonOpacityClass} p-2 opacity-75 transition-opacity transition-transform duration-300 hover:scale-110 hover:cursor-pointer`}
className={`${buttonOpacityClass} p-2 opacity-75 transition-opacity duration-300 hover:scale-110 hover:cursor-pointer`}
/>
</div>
);

View file

@ -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<string | null>(null);
@ -134,6 +135,11 @@ function SettingsOverlay({ id }: { id: string }) {
</Menu.Card>
</OverlayPage>
<DownloadRoutes id={id} />
<OverlayPage id={id} path="/watchparty" width={343} height={460}>
<Menu.CardWithScrollable>
<WatchPartyView id={id} />
</Menu.CardWithScrollable>
</OverlayPage>
</OverlayRouter>
</Overlay>
);

View file

@ -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 (
<div
className={`absolute top-2 right-2 z-50 p-2 bg-mediaCard-shadow bg-opacity-70 backdrop-blur-sm rounded-md text-white text-xs
flex flex-col items-end gap-1 max-w-[250px] transition-all duration-300
${showNotification ? "ring-1 ring-buttons-purple shadow-lg shadow-buttons-purple" : ""}`}
>
<div className="flex gap-2 w-full justify-between items-center">
<div className="flex items-center gap-2">
<Icon icon={Icons.WATCH_PARTY} className="w-4 h-4" />
<span className="font-bold pr-1">
{isHost ? t("watchParty.hosting") : t("watchParty.watching")}
</span>
</div>
<span className="text-type-logo font-mono tracking-wider">
{roomCode}
</span>
</div>
<div className="w-full text-type-secondary flex justify-between items-center space-x-2">
<div className="cursor-pointer" onClick={handleToggleExpanded}>
<Icon
icon={expanded ? Icons.CHEVRON_DOWN : Icons.CHEVRON_RIGHT}
className="w-3 h-3"
/>
</div>
<span>
{roomUsers.length <= 1
? t("watchParty.alone")
: t("watchParty.withCount", { count: roomUsers.length - 1 })}
</span>
{/* Sync status indicator */}
{!isHost && hostUser && (
<div className="flex items-center gap-1">
<div
className={`w-2 h-2 rounded-full ${
isBehindHost || isAheadOfHost ? "bg-red-500" : "bg-green-500"
}`}
/>
<span className="text-xs">
{isBehindHost || isAheadOfHost
? t("watchParty.status.outOfSync")
: t("watchParty.status.inSync")}
</span>
</div>
)}
</div>
{expanded && roomUsers.length > 1 && (
<div className="w-full mt-1 border-t border-mediaCard-hoverBackground pt-1">
<div className="text-xs text-type-secondary mb-1">Viewers:</div>
<div className="space-y-1 max-h-24 overflow-y-auto">
{roomUsers.map((user) => (
<div
key={user.userId}
className="flex items-center justify-between text-xs"
>
<span className="flex items-center gap-1">
<Icon
icon={user.isHost ? Icons.RISING_STAR : Icons.USER}
className={`w-3 h-3 ${user.isHost ? "text-onboarding-best" : ""}`}
/>
<span className={user.isHost ? "text-onboarding-best" : ""}>
{user.userId.substring(0, 8)}...
</span>
</span>
<span className="text-type-secondary">
{user.player.duration > 0
? `${Math.floor((user.player.time / user.player.duration) * 100)}%`
: `${Math.floor(user.player.time)}s`}
</span>
</div>
))}
</div>
</div>
)}
{!isHost && hostUser && (isBehindHost || isAheadOfHost) && (
<div className="mt-1 w-full">
<Button
theme="secondary"
className="text-xs py-1 px-2 bg-buttons-purple bg-opacity-50 hover:bg-buttons-purpleHover hover:bg-opacity-80 w-full flex items-center justify-center gap-1"
onClick={syncWithHost}
disabled={isSyncing}
>
<Icon icon={Icons.CLOCK} className="w-3 h-3" />
<span className="whitespace-nowrap">
{isSyncing
? t("watchParty.syncing")
: isBehindHost
? t("watchParty.behindHost", {
seconds: Math.abs(Math.round(timeDifferenceFromHost)),
})
: t("watchParty.aheadOfHost", {
seconds: Math.round(timeDifferenceFromHost),
})}
</span>
</Button>
</div>
)}
</div>
);
}

View file

@ -80,7 +80,7 @@ export function DownloadView({ id }: { id: string }) {
{t("player.menus.downloads.title")}
</Menu.BackLink>
<Menu.Section>
<div>
<div className="mb-4">
{sourceType === "hls" ? (
<div className="mb-6">
<Menu.Paragraph marginClass="mb-6">

View file

@ -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 (
<Menu.Card>
<Menu.SectionTitle>
@ -97,15 +85,14 @@ export function SettingsMenu({ id }: { id: string }) {
</Menu.Link>
<Menu.Link
clickable
onClick={handleWatchPartyClick}
onClick={() =>
router.navigate(downloadable ? "/watchparty" : "/download/unable")
}
rightSide={<Icon className="text-xl" icon={Icons.WATCH_PARTY} />}
className={downloadable ? "opacity-100" : "opacity-50"}
>
{t("player.menus.watchparty.watchpartyItem")}
{t("player.menus.watchparty.watchpartyItem")} (Beta)
</Menu.Link>
<Paragraph className="text-xs">
{t("player.menus.watchparty.notice")}
</Paragraph>
</Menu.Section>
<Menu.SectionTitle>

View file

@ -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 (
<>
<Menu.BackLink onClick={() => router.navigate("/")}>
{t("player.menus.watchparty.watchpartyItem")} (Beta)
</Menu.BackLink>
<Menu.Section>
<div className="pb-4">
<Menu.Paragraph marginClass="text-xs text-type-secondary mb-4">
{t("player.menus.watchparty.notice")}
</Menu.Paragraph>
{enabled ? (
<div className="space-y-4">
{isJoining ? (
<div className="text-center py-4">
<Spinner />
<p className="text-sm text-type-secondary">
{t("watchParty.validating")}
</p>
</div>
) : (
<>
<div className="flex flex-col gap-2">
<div className="text-center">
<span className="text-sm text-type-secondary">
{isHost
? t("watchParty.isHost")
: t("watchParty.isGuest")}
</span>
</div>
<div
className="flex items-center justify-center p-3 bg-mediaCard-hoverBackground rounded-lg border border-mediaCard-hoverAccent border-opacity-20 cursor-pointer transition-all duration-300 hover:bg-mediaCard-hoverShadow group"
onClick={handleCopyCode}
title={t("watchParty.copyCode")}
>
<input
type="text"
readOnly
value={roomCode || ""}
className="bg-transparent border-none text-center text-2xl font-mono tracking-widest w-full outline-none cursor-pointer text-type-logo"
onClick={(e) => {
if (e.target instanceof HTMLInputElement) {
e.target.select();
}
}}
/>
</div>
<p className="text-xs text-center text-type-secondary">
{isHost
? t("watchParty.shareCode")
: t("watchParty.connectedAsGuest")}
</p>
</div>
{roomUsers.length > 1 && (
<div className="bg-mediaCard-hoverBackground rounded-lg p-3 border border-mediaCard-hoverAccent border-opacity-20">
<div className="flex justify-between items-center mb-2">
<span className="text-sm font-medium text-white">
{t("watchParty.viewers", { count: roomUsers.length })}
</span>
</div>
<div className="max-h-32 overflow-y-auto space-y-1">
{roomUsers.map((user) => (
<div
key={user.userId}
className="flex items-center justify-between text-xs py-1"
>
<span className="flex items-center gap-1">
<Icon
icon={
user.isHost ? Icons.RISING_STAR : Icons.USER
}
className={`w-3 h-3 ${user.isHost ? "text-onboarding-best" : "text-type-secondary"}`}
/>
<span
className={
user.isHost
? "text-onboarding-best"
: "text-type-secondary"
}
>
{user.userId.substring(0, 8)}...
</span>
</span>
<span className="text-type-secondary">
{user.player.duration > 0
? `${Math.floor((user.player.time / user.player.duration) * 100)}%`
: `${Math.floor(user.player.time)}s`}
</span>
</div>
))}
</div>
</div>
)}
<div className="flex flex-col space-y-4">
<div className="flex items-center justify-between bg-mediaCard-hoverBackground rounded-lg p-3 border border-mediaCard-hoverAccent border-opacity-20">
<span className="text-white">
{t("watchParty.showStatusOverlay")}
</span>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
className="sr-only peer"
checked={showStatusOverlay}
onChange={toggleStatusOverlay}
/>
<div className="w-9 h-5 bg-mediaCard-hoverBackground rounded-full peer peer-checked:bg-buttons-purple peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-mediaCard-hoverAccent after:border after:rounded-full after:h-4 after:w-4 after:transition-all" />
</label>
</div>
<Button
className="w-full"
theme="danger"
onClick={handleDisableParty}
>
{t("watchParty.leaveWatchParty")}
</Button>
</div>
</>
)}
</div>
) : (
<div className="space-y-4">
{showJoinInput ? (
<div className="space-y-2">
<input
type="text"
maxLength={4}
className="w-full p-2 text-center text-2xl tracking-widest bg-mediaCard-hoverBackground border border-mediaCard-hoverAccent border-opacity-20 rounded-lg text-type-logo"
placeholder="0000"
value={joinCode}
onChange={(e) =>
setJoinCode(
e.target.value.replace(/[^0-9]/g, "").slice(0, 4),
)
}
/>
<div className="flex space-x-2">
<Button
className="w-full"
theme="secondary"
onClick={() => setShowJoinInput(false)}
>
{t("watchParty.cancel")}
</Button>
<Button
className="w-full"
theme="purple"
onClick={handleJoinParty}
disabled={joinCode.length !== 4}
>
{t("watchParty.join")}
</Button>
</div>
</div>
) : (
<div className="space-y-4">
<Button
className="w-full"
theme="purple"
onClick={handleHostParty}
>
{t("watchParty.hostParty")}
</Button>
<Button
className="w-full"
theme="secondary"
onClick={() => setShowJoinInput(true)}
>
{t("watchParty.joinParty")}
</Button>
</div>
)}
</div>
)}
<Menu.Divider />
<Menu.Link
clickable
onClick={handlelegacyWatchPartyClick}
rightSide={<Icon className="text-xl" icon={Icons.WATCH_PARTY} />}
>
{t("player.menus.watchparty.legacyWatchparty")}
</Menu.Link>
</div>
</Menu.Section>
</>
);
}

View file

@ -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) {
<ProgressSaver />
<KeyboardEvents />
<MediaSession />
<WebhookReporter />
<WatchPartyResetter />
<div className="relative h-screen overflow-hidden">
<WatchPartyStatus />
<VideoClickTarget showingControls={props.showingControls} />
<HeadUpdater />
{props.children}

View file

@ -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<PlayerStatusData[]>([]);
const previousStateRef = useRef<PlayerStatusData | null>(null);
const lastUpdateTimeRef = useRef<number>(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,
};
}

View file

@ -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<string | null>(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
}

View file

@ -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<number>(0);
const lastReportedStateRef = useRef<string>("");
const contentValidatedRef = useRef<boolean>(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;
}

View file

@ -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<void>;
// 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<RoomUser[]>([]);
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,
};
}

View file

@ -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<boolean>(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")

View file

@ -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 =

65
src/stores/watchParty.ts Normal file
View file

@ -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<WatchPartyStore>()(
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",
},
),
);