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";
|
|
|
|
|
|
|
|
|
|
type PlayerState = {
|
|
|
|
|
attachment: Attachment;
|
|
|
|
|
currentTime: number;
|
|
|
|
|
} | null;
|
|
|
|
|
|
|
|
|
|
type Ctx = {
|
|
|
|
|
openVideo: (attachment: Attachment, currentTime?: number) => void;
|
|
|
|
|
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(
|
|
|
|
|
(attachment: Attachment, currentTime = 0) =>
|
|
|
|
|
setState({ attachment, currentTime }),
|
|
|
|
|
[],
|
|
|
|
|
);
|
|
|
|
|
const closeVideo = useCallback(() => setState(null), []);
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<VideoPlayerContext.Provider value={{ openVideo, closeVideo }}>
|
|
|
|
|
{children}
|
|
|
|
|
{state ? (
|
|
|
|
|
<PlayerView
|
|
|
|
|
attachment={state.attachment}
|
|
|
|
|
startAt={state.currentTime}
|
|
|
|
|
onClose={closeVideo}
|
|
|
|
|
/>
|
|
|
|
|
) : null}
|
|
|
|
|
</VideoPlayerContext.Provider>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function PlayerView({
|
|
|
|
|
attachment,
|
|
|
|
|
startAt,
|
|
|
|
|
onClose,
|
|
|
|
|
}: {
|
|
|
|
|
attachment: Attachment;
|
|
|
|
|
startAt: number;
|
|
|
|
|
onClose: () => void;
|
|
|
|
|
}) {
|
|
|
|
|
const videoRef = useRef<HTMLVideoElement>(null);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const onKey = (e: KeyboardEvent) => {
|
|
|
|
|
if (e.key === "Escape") onClose();
|
|
|
|
|
};
|
|
|
|
|
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
|
|
|
};
|
|
|
|
|
}, [onClose]);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const v = videoRef.current;
|
|
|
|
|
if (!v) return;
|
|
|
|
|
if (startAt > 0) v.currentTime = startAt;
|
|
|
|
|
v.play().catch(() => {});
|
|
|
|
|
}, [startAt]);
|
|
|
|
|
|
|
|
|
|
return createPortal(
|
|
|
|
|
<div
|
|
|
|
|
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/95"
|
|
|
|
|
onClick={onClose}
|
|
|
|
|
role="dialog"
|
|
|
|
|
aria-modal="true"
|
|
|
|
|
>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={(e) => {
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
onClose();
|
|
|
|
|
}}
|
|
|
|
|
className="absolute right-4 top-4 z-10 flex h-10 w-10 items-center justify-center rounded-full bg-white/10 text-white transition hover:bg-white/20"
|
|
|
|
|
aria-label="Close"
|
|
|
|
|
>
|
|
|
|
|
<X className="h-5 w-5" />
|
|
|
|
|
</button>
|
|
|
|
|
<video
|
|
|
|
|
ref={videoRef}
|
|
|
|
|
src={attachment.url}
|
|
|
|
|
poster={attachment.posterUrl}
|
|
|
|
|
controls
|
|
|
|
|
playsInline
|
|
|
|
|
className="max-h-[92vh] max-w-[96vw] outline-none"
|
|
|
|
|
onClick={(e) => e.stopPropagation()}
|
|
|
|
|
/>
|
|
|
|
|
</div>,
|
|
|
|
|
document.body,
|
|
|
|
|
);
|
|
|
|
|
}
|