mirror of
https://github.com/p-stream/p-stream.git
synced 2026-03-11 17:55:33 +00:00
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:
parent
455daf6090
commit
fb3bc161ce
16 changed files with 1509 additions and 22 deletions
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
|
|
|
|||
147
src/backend/player/status.ts
Normal file
147
src/backend/player/status.ts
Normal 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();
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
147
src/components/player/atoms/WatchPartyStatus.tsx
Normal file
147
src/components/player/atoms/WatchPartyStatus.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
295
src/components/player/atoms/settings/WatchPartyView.tsx
Normal file
295
src/components/player/atoms/settings/WatchPartyView.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
146
src/components/player/hooks/usePlayerStatusPolling.ts
Normal file
146
src/components/player/hooks/usePlayerStatusPolling.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
51
src/components/player/internals/WatchPartyResetter.tsx
Normal file
51
src/components/player/internals/WatchPartyResetter.tsx
Normal 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
|
||||
}
|
||||
260
src/components/player/internals/WebhookReporter.tsx
Normal file
260
src/components/player/internals/WebhookReporter.tsx
Normal 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;
|
||||
}
|
||||
327
src/hooks/useWatchPartySync.ts
Normal file
327
src/hooks/useWatchPartySync.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
65
src/stores/watchParty.ts
Normal 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",
|
||||
},
|
||||
),
|
||||
);
|
||||
Loading…
Reference in a new issue