2026-05-25 05:25:57 +08:00
|
|
|
import {
|
|
|
|
|
createContext,
|
|
|
|
|
useCallback,
|
|
|
|
|
useContext,
|
|
|
|
|
useEffect,
|
|
|
|
|
useRef,
|
|
|
|
|
useState,
|
|
|
|
|
type PropsWithChildren,
|
|
|
|
|
} from "react";
|
|
|
|
|
import { createPortal } from "react-dom";
|
|
|
|
|
import { X } from "lucide-react";
|
|
|
|
|
import type { Attachment } from "../../../types/post";
|
2026-05-30 02:25:01 +08:00
|
|
|
import { AttachmentDownloadPill } from "../AttachmentDownloadPill";
|
|
|
|
|
import { MessageInlineVideo } from "../MessageInlineVideo";
|
|
|
|
|
|
|
|
|
|
type OnClose = (finalTime: number) => void;
|
2026-05-25 05:25:57 +08:00
|
|
|
|
|
|
|
|
type PlayerState = {
|
|
|
|
|
attachment: Attachment;
|
|
|
|
|
currentTime: number;
|
2026-05-30 02:25:01 +08:00
|
|
|
onClose?: OnClose;
|
|
|
|
|
/** Post the video belongs to, needed for the download pill. */
|
|
|
|
|
postId?: string;
|
2026-05-25 05:25:57 +08:00
|
|
|
} | null;
|
|
|
|
|
|
|
|
|
|
type Ctx = {
|
2026-05-30 02:25:01 +08:00
|
|
|
/**
|
|
|
|
|
* Open the fullscreen player. `onClose` (optional) is invoked with the
|
|
|
|
|
* playhead at the moment the user dismisses the overlay, so callers can
|
|
|
|
|
* sync the original inline `<video>` back to the time the user actually
|
|
|
|
|
* watched until.
|
|
|
|
|
*/
|
|
|
|
|
openVideo: (
|
|
|
|
|
attachment: Attachment,
|
|
|
|
|
currentTime?: number,
|
|
|
|
|
onClose?: OnClose,
|
|
|
|
|
postId?: string,
|
|
|
|
|
) => void;
|
2026-05-25 05:25:57 +08:00
|
|
|
closeVideo: () => void;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const VideoPlayerContext = createContext<Ctx | null>(null);
|
|
|
|
|
|
|
|
|
|
export function useVideoPlayer(): Ctx {
|
|
|
|
|
const ctx = useContext(VideoPlayerContext);
|
|
|
|
|
if (!ctx)
|
|
|
|
|
throw new Error("useVideoPlayer must be used inside VideoPlayerProvider");
|
|
|
|
|
return ctx;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function VideoPlayerProvider({ children }: PropsWithChildren) {
|
|
|
|
|
const [state, setState] = useState<PlayerState>(null);
|
|
|
|
|
|
|
|
|
|
const openVideo = useCallback(
|
2026-05-30 02:25:01 +08:00
|
|
|
(
|
|
|
|
|
attachment: Attachment,
|
|
|
|
|
currentTime = 0,
|
|
|
|
|
onClose?: OnClose,
|
|
|
|
|
postId?: string,
|
|
|
|
|
) => setState({ attachment, currentTime, onClose, postId }),
|
2026-05-25 05:25:57 +08:00
|
|
|
[],
|
|
|
|
|
);
|
|
|
|
|
const closeVideo = useCallback(() => setState(null), []);
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<VideoPlayerContext.Provider value={{ openVideo, closeVideo }}>
|
|
|
|
|
{children}
|
|
|
|
|
{state ? (
|
|
|
|
|
<PlayerView
|
|
|
|
|
attachment={state.attachment}
|
|
|
|
|
startAt={state.currentTime}
|
2026-05-30 02:25:01 +08:00
|
|
|
postId={state.postId}
|
|
|
|
|
onClose={(finalTime) => {
|
|
|
|
|
state.onClose?.(finalTime);
|
|
|
|
|
setState(null);
|
|
|
|
|
}}
|
2026-05-25 05:25:57 +08:00
|
|
|
/>
|
|
|
|
|
) : null}
|
|
|
|
|
</VideoPlayerContext.Provider>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function PlayerView({
|
|
|
|
|
attachment,
|
|
|
|
|
startAt,
|
2026-05-30 02:25:01 +08:00
|
|
|
postId,
|
2026-05-25 05:25:57 +08:00
|
|
|
onClose,
|
|
|
|
|
}: {
|
|
|
|
|
attachment: Attachment;
|
|
|
|
|
startAt: number;
|
2026-05-30 02:25:01 +08:00
|
|
|
postId?: string;
|
|
|
|
|
onClose: (finalTime: number) => void;
|
2026-05-25 05:25:57 +08:00
|
|
|
}) {
|
2026-05-30 02:25:01 +08:00
|
|
|
// Track the playhead reported by the embedded `MessageInlineVideo` so we
|
|
|
|
|
// can hand it back to the caller when the user dismisses the overlay.
|
|
|
|
|
const lastTimeRef = useRef<number>(startAt);
|
|
|
|
|
|
|
|
|
|
const close = useCallback(() => {
|
|
|
|
|
const finalTime = Number.isFinite(lastTimeRef.current)
|
|
|
|
|
? lastTimeRef.current
|
|
|
|
|
: startAt;
|
|
|
|
|
onClose(finalTime);
|
|
|
|
|
}, [onClose, startAt]);
|
2026-05-25 05:25:57 +08:00
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const onKey = (e: KeyboardEvent) => {
|
2026-05-30 02:25:01 +08:00
|
|
|
if (e.key === "Escape") close();
|
2026-05-25 05:25:57 +08:00
|
|
|
};
|
|
|
|
|
window.addEventListener("keydown", onKey);
|
2026-05-30 01:09:19 +08:00
|
|
|
|
|
|
|
|
// iOS-compatible scroll lock: pin the body in place at the current scroll
|
|
|
|
|
// offset, then restore both styles and scroll position on cleanup. Plain
|
|
|
|
|
// `overflow: hidden` doesn't work on iOS Safari and can reset scroll to 0.
|
|
|
|
|
const scrollY = window.scrollY;
|
|
|
|
|
const body = document.body;
|
|
|
|
|
const prev = {
|
|
|
|
|
position: body.style.position,
|
|
|
|
|
top: body.style.top,
|
|
|
|
|
left: body.style.left,
|
|
|
|
|
right: body.style.right,
|
|
|
|
|
width: body.style.width,
|
|
|
|
|
overflow: body.style.overflow,
|
|
|
|
|
};
|
|
|
|
|
body.style.position = "fixed";
|
|
|
|
|
body.style.top = `-${scrollY}px`;
|
|
|
|
|
body.style.left = "0";
|
|
|
|
|
body.style.right = "0";
|
|
|
|
|
body.style.width = "100%";
|
|
|
|
|
body.style.overflow = "hidden";
|
|
|
|
|
|
2026-05-25 05:25:57 +08:00
|
|
|
return () => {
|
|
|
|
|
window.removeEventListener("keydown", onKey);
|
2026-05-30 01:09:19 +08:00
|
|
|
body.style.position = prev.position;
|
|
|
|
|
body.style.top = prev.top;
|
|
|
|
|
body.style.left = prev.left;
|
|
|
|
|
body.style.right = prev.right;
|
|
|
|
|
body.style.width = prev.width;
|
|
|
|
|
body.style.overflow = prev.overflow;
|
|
|
|
|
window.scrollTo(0, scrollY);
|
2026-05-25 05:25:57 +08:00
|
|
|
};
|
2026-05-30 02:25:01 +08:00
|
|
|
}, [close]);
|
2026-05-25 05:25:57 +08:00
|
|
|
|
|
|
|
|
return createPortal(
|
|
|
|
|
<div
|
|
|
|
|
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/95"
|
2026-05-30 02:25:01 +08:00
|
|
|
onClick={close}
|
2026-05-25 05:25:57 +08:00
|
|
|
role="dialog"
|
|
|
|
|
aria-modal="true"
|
|
|
|
|
>
|
2026-05-30 02:25:01 +08:00
|
|
|
{postId ? (
|
|
|
|
|
<AttachmentDownloadPill
|
|
|
|
|
postId={postId}
|
|
|
|
|
attachment={attachment}
|
|
|
|
|
size="lg"
|
|
|
|
|
className="absolute left-4 top-4 z-10"
|
|
|
|
|
/>
|
|
|
|
|
) : null}
|
2026-05-25 05:25:57 +08:00
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={(e) => {
|
|
|
|
|
e.stopPropagation();
|
2026-05-30 02:25:01 +08:00
|
|
|
close();
|
2026-05-25 05:25:57 +08:00
|
|
|
}}
|
2026-05-30 02:25:01 +08:00
|
|
|
className="absolute right-4 top-4 z-10 flex h-11 w-11 items-center justify-center rounded-full bg-white/10 text-white shadow-lg ring-1 ring-white/15 backdrop-blur-md transition hover:bg-white/20"
|
2026-05-25 05:25:57 +08:00
|
|
|
aria-label="Close"
|
|
|
|
|
>
|
2026-05-30 02:25:01 +08:00
|
|
|
<X className="h-6 w-6" />
|
2026-05-25 05:25:57 +08:00
|
|
|
</button>
|
2026-05-30 02:25:01 +08:00
|
|
|
<div
|
|
|
|
|
className="relative h-full w-full"
|
2026-05-25 05:25:57 +08:00
|
|
|
onClick={(e) => e.stopPropagation()}
|
2026-05-30 02:25:01 +08:00
|
|
|
>
|
|
|
|
|
<MessageInlineVideo
|
|
|
|
|
postId={postId ?? ""}
|
|
|
|
|
attachment={attachment}
|
|
|
|
|
initialTime={startAt}
|
|
|
|
|
autoPlay
|
|
|
|
|
hideDownload
|
|
|
|
|
hideFullscreen
|
|
|
|
|
size="lg"
|
|
|
|
|
onTimeUpdate={(t) => {
|
|
|
|
|
lastTimeRef.current = t;
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
2026-05-25 05:25:57 +08:00
|
|
|
</div>,
|
|
|
|
|
document.body,
|
|
|
|
|
);
|
|
|
|
|
}
|