feat(video): cross-platform inline player + polished overlay controls
- MessageInlineVideo (new): custom-controlled inline video that disables
the iOS Safari / Chromium native overlays entirely and reimplements
the essentials: tap-to-play, centered play affordance while paused,
bottom bar with play/pause + current time + drag-to-scrub progress
bar + remaining time + fullscreen. Pointer events with pointer
capture cover both mouse and touch scrubbing, including dragging
past the bar's bounds. The element listens to 'seeked' as well as
'timeupdate' so external currentTime writes paint the bar even when
the video is paused, and the goFullscreen callback synchronously
syncs React state on close so the inline progress reflects the user's
fullscreen playhead with no perceptible delay.
- VideoBubble: replace the inline <video controls> with
MessageInlineVideo and thread postId through openVideo so the
fullscreen overlay can attach the download pill to the right post.
- VideoPlayer overlay: replace its <video controls> with
MessageInlineVideo size='lg', removing the iOS native arrows / PiP /
mute / overflow controls. The overlay supplies its own large
download pill and a beefier close button.
- AttachmentDownloadPill: new 'size' prop ('sm' default 30 px, 'lg'
44 px with 22 px icon and text-[14px]) for overlay surfaces where
the affordance can breathe and should feel touch-friendly.
- ImageLightbox: drop the inline LightboxDownloadButton and use the
shared AttachmentDownloadPill size='lg' instead, with a matching
larger close button. Unused imports cleaned up.
This commit is contained in:
@@ -8,14 +8,11 @@ import {
|
||||
type PropsWithChildren,
|
||||
} from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { ChevronLeft, ChevronRight, LoaderCircle, X } from "lucide-react";
|
||||
import { ChevronLeft, ChevronRight, X } from "lucide-react";
|
||||
import type { Attachment } from "../../../types/post";
|
||||
import { DownloadCloudIcon } from "../../icons/DownloadCloudIcon";
|
||||
import { useI18n } from "../../../i18n";
|
||||
import { useToast } from "../../Toast";
|
||||
import { AttachmentDownloadPill } from "../AttachmentDownloadPill";
|
||||
import { BubbleImage } from "../BubbleImage";
|
||||
import { autolink } from "../utils/autolink";
|
||||
import { downloadAttachment } from "../utils/downloadFile";
|
||||
|
||||
type LightboxState = {
|
||||
images: Attachment[];
|
||||
@@ -78,50 +75,6 @@ export function ImageLightboxProvider({ children }: PropsWithChildren) {
|
||||
);
|
||||
}
|
||||
|
||||
function LightboxDownloadButton({
|
||||
postId,
|
||||
attachment,
|
||||
}: {
|
||||
postId: string;
|
||||
attachment: Attachment;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const { showToast } = useToast();
|
||||
const [isDownloading, setIsDownloading] = useState(false);
|
||||
|
||||
const handleDownload = async () => {
|
||||
if (isDownloading) return;
|
||||
setIsDownloading(true);
|
||||
try {
|
||||
await downloadAttachment(postId, attachment.id, attachment.filename);
|
||||
} catch {
|
||||
showToast(t("downloadFail"), "error");
|
||||
} finally {
|
||||
setIsDownloading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDownload();
|
||||
}}
|
||||
disabled={isDownloading}
|
||||
className="flex h-10 w-10 items-center justify-center rounded-full bg-white/10 text-white transition hover:bg-white/20 disabled:cursor-wait"
|
||||
aria-label={isDownloading ? t("downloading") : t("download")}
|
||||
aria-busy={isDownloading}
|
||||
>
|
||||
{isDownloading ? (
|
||||
<LoaderCircle className="h-5 w-5 animate-spin" strokeWidth={2.3} />
|
||||
) : (
|
||||
<DownloadCloudIcon className="h-5 w-5" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function Filmstrip({
|
||||
images,
|
||||
index,
|
||||
@@ -261,15 +214,20 @@ function LightboxView({
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{postId ? (
|
||||
<LightboxDownloadButton postId={postId} attachment={current} />
|
||||
<AttachmentDownloadPill
|
||||
postId={postId}
|
||||
attachment={current}
|
||||
size="lg"
|
||||
className=""
|
||||
/>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex h-10 w-10 items-center justify-center rounded-full bg-white/10 text-white transition hover:bg-white/20"
|
||||
className="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"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
<X className="h-6 w-6" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -10,14 +10,32 @@ import {
|
||||
import { createPortal } from "react-dom";
|
||||
import { X } from "lucide-react";
|
||||
import type { Attachment } from "../../../types/post";
|
||||
import { AttachmentDownloadPill } from "../AttachmentDownloadPill";
|
||||
import { MessageInlineVideo } from "../MessageInlineVideo";
|
||||
|
||||
type OnClose = (finalTime: number) => void;
|
||||
|
||||
type PlayerState = {
|
||||
attachment: Attachment;
|
||||
currentTime: number;
|
||||
onClose?: OnClose;
|
||||
/** Post the video belongs to, needed for the download pill. */
|
||||
postId?: string;
|
||||
} | null;
|
||||
|
||||
type Ctx = {
|
||||
openVideo: (attachment: Attachment, currentTime?: number) => void;
|
||||
/**
|
||||
* 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;
|
||||
closeVideo: () => void;
|
||||
};
|
||||
|
||||
@@ -34,8 +52,12 @@ export function VideoPlayerProvider({ children }: PropsWithChildren) {
|
||||
const [state, setState] = useState<PlayerState>(null);
|
||||
|
||||
const openVideo = useCallback(
|
||||
(attachment: Attachment, currentTime = 0) =>
|
||||
setState({ attachment, currentTime }),
|
||||
(
|
||||
attachment: Attachment,
|
||||
currentTime = 0,
|
||||
onClose?: OnClose,
|
||||
postId?: string,
|
||||
) => setState({ attachment, currentTime, onClose, postId }),
|
||||
[],
|
||||
);
|
||||
const closeVideo = useCallback(() => setState(null), []);
|
||||
@@ -47,7 +69,11 @@ export function VideoPlayerProvider({ children }: PropsWithChildren) {
|
||||
<PlayerView
|
||||
attachment={state.attachment}
|
||||
startAt={state.currentTime}
|
||||
onClose={closeVideo}
|
||||
postId={state.postId}
|
||||
onClose={(finalTime) => {
|
||||
state.onClose?.(finalTime);
|
||||
setState(null);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</VideoPlayerContext.Provider>
|
||||
@@ -57,17 +83,28 @@ export function VideoPlayerProvider({ children }: PropsWithChildren) {
|
||||
function PlayerView({
|
||||
attachment,
|
||||
startAt,
|
||||
postId,
|
||||
onClose,
|
||||
}: {
|
||||
attachment: Attachment;
|
||||
startAt: number;
|
||||
onClose: () => void;
|
||||
postId?: string;
|
||||
onClose: (finalTime: number) => void;
|
||||
}) {
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
// 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]);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
if (e.key === "Escape") close();
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
|
||||
@@ -101,42 +138,51 @@ function PlayerView({
|
||||
body.style.overflow = prev.overflow;
|
||||
window.scrollTo(0, scrollY);
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
const v = videoRef.current;
|
||||
if (!v) return;
|
||||
if (startAt > 0) v.currentTime = startAt;
|
||||
v.play().catch(() => {});
|
||||
}, [startAt]);
|
||||
}, [close]);
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/95"
|
||||
onClick={onClose}
|
||||
onClick={close}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
{postId ? (
|
||||
<AttachmentDownloadPill
|
||||
postId={postId}
|
||||
attachment={attachment}
|
||||
size="lg"
|
||||
className="absolute left-4 top-4 z-10"
|
||||
/>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
close();
|
||||
}}
|
||||
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"
|
||||
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"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
<X className="h-6 w-6" />
|
||||
</button>
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={attachment.url}
|
||||
poster={attachment.posterUrl}
|
||||
controls
|
||||
playsInline
|
||||
className="max-h-[92vh] max-w-[96vw] outline-none"
|
||||
<div
|
||||
className="relative h-full w-full"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
>
|
||||
<MessageInlineVideo
|
||||
postId={postId ?? ""}
|
||||
attachment={attachment}
|
||||
initialTime={startAt}
|
||||
autoPlay
|
||||
hideDownload
|
||||
hideFullscreen
|
||||
size="lg"
|
||||
onTimeUpdate={(t) => {
|
||||
lastTimeRef.current = t;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user