terry-staging #11

Merged
terry merged 37 commits from terry-staging into main 2026-05-29 19:29:58 +00:00
5 changed files with 500 additions and 130 deletions
Showing only changes of commit 8646b51b6c - Show all commits

View File

@@ -20,6 +20,14 @@ type AttachmentDownloadPillProps = {
* Defaults to false (fixed 30px) for standalone images/videos. * Defaults to false (fixed 30px) for standalone images/videos.
*/ */
adaptive?: boolean; adaptive?: boolean;
/**
* Visual size. `sm` (default, 30 px tall) for inline tiles where space is
* tight. `lg` (44 px tall with a 24 px icon and 14 px text) for overlay
* surfaces like the image lightbox or fullscreen video player where the
* affordance can breathe and should feel touch-friendly.
* Ignored when `adaptive` is set.
*/
size?: "sm" | "lg";
}; };
export function AttachmentDownloadPill({ export function AttachmentDownloadPill({
@@ -28,6 +36,7 @@ export function AttachmentDownloadPill({
leadingLabel, leadingLabel,
className = "absolute left-2 top-2", className = "absolute left-2 top-2",
adaptive = false, adaptive = false,
size = "sm",
}: AttachmentDownloadPillProps) { }: AttachmentDownloadPillProps) {
const { t } = useI18n(); const { t } = useI18n();
const { showToast } = useToast(); const { showToast } = useToast();
@@ -46,52 +55,56 @@ export function AttachmentDownloadPill({
} }
}; };
const isLg = !adaptive && size === "lg";
const fontCls = adaptive
? "text-[clamp(10px,7cqw,12px)]"
: isLg
? "text-[14px] font-medium"
: "text-[12px]";
const squareCls = adaptive
? "h-[clamp(22px,18cqw,30px)] w-[clamp(22px,18cqw,30px)]"
: isLg
? "h-[44px] w-[44px]"
: "h-[30px] w-[30px]";
const iconCls = adaptive
? "h-[clamp(13px,11cqw,18px)] w-[clamp(13px,11cqw,18px)]"
: isLg
? "h-[22px] w-[22px]"
: "h-[18px] w-[18px]";
const textBoxCls = adaptive
? "h-[clamp(22px,18cqw,30px)] px-[clamp(6px,6cqw,10px)]"
: isLg
? "h-[44px] px-4"
: "h-[30px] px-2.5";
return ( return (
<button <button
type="button" type="button"
onClick={handleDownload} onClick={handleDownload}
disabled={isDownloading} disabled={isDownloading}
className={`group z-10 inline-flex overflow-hidden rounded-full bg-black/80 ${ className={`group z-10 inline-flex overflow-hidden rounded-full bg-black/80 ${fontCls} text-white shadow-lg ring-1 ring-inset ring-white/20 backdrop-blur-md transition hover:bg-black/90 disabled:cursor-wait ${className}`}
adaptive ? "text-[clamp(10px,7cqw,12px)]" : "text-[12px]"
} text-white shadow-lg ring-1 ring-inset ring-white/20 backdrop-blur-md transition hover:bg-black/90 disabled:cursor-wait ${className}`}
aria-label={ aria-label={
isDownloading ? t("downloading") : `Download ${attachment.filename}` isDownloading ? t("downloading") : `Download ${attachment.filename}`
} }
aria-busy={isDownloading} aria-busy={isDownloading}
> >
<span <span
className={`flex items-center justify-center bg-[#545454]/50 transition group-hover:bg-[#545454]/70 ${ className={`flex items-center justify-center bg-[#545454]/50 transition group-hover:bg-[#545454]/70 ${squareCls}`}
adaptive
? "h-[clamp(22px,18cqw,30px)] w-[clamp(22px,18cqw,30px)]"
: "h-[30px] w-[30px]"
}`}
> >
{isDownloading ? ( {isDownloading ? (
<LoaderCircle <LoaderCircle
className={`${ className={`${iconCls} animate-spin`}
adaptive
? "h-[clamp(13px,11cqw,18px)] w-[clamp(13px,11cqw,18px)]"
: "h-[18px] w-[18px]"
} animate-spin`}
strokeWidth={2.3} strokeWidth={2.3}
/> />
) : ( ) : (
<DownloadCloudIcon <DownloadCloudIcon className={iconCls} />
className={
adaptive
? "h-[clamp(13px,11cqw,18px)] w-[clamp(13px,11cqw,18px)]"
: "h-[18px] w-[18px]"
}
/>
)} )}
</span> </span>
<span <span className={`flex items-center gap-1 ${textBoxCls}`}>
className={`flex items-center gap-0.5 ${
adaptive
? "h-[clamp(22px,18cqw,30px)] px-[clamp(6px,6cqw,10px)]"
: "h-[30px] px-2.5"
}`}
>
{isDownloading ? ( {isDownloading ? (
t("downloading") t("downloading")
) : ( ) : (

View File

@@ -0,0 +1,368 @@
import { Maximize2, Pause, Play } from "lucide-react";
import {
useCallback,
useEffect,
useRef,
useState,
type MouseEvent as ReactMouseEvent,
type PointerEvent as ReactPointerEvent,
} from "react";
import type { Attachment } from "../../types/post";
import { AttachmentDownloadPill } from "./AttachmentDownloadPill";
import { useVideoPlayer } from "./overlays/VideoPlayer";
function pad2(n: number): string {
return String(Math.floor(n)).padStart(2, "0");
}
function formatClock(sec: number): string {
if (!Number.isFinite(sec) || sec < 0) return "0:00";
const m = Math.floor(sec / 60);
const s = Math.floor(sec % 60);
return `${m}:${pad2(s)}`;
}
type Size = "sm" | "lg";
type SizeTokens = {
/** Padding / gap of the bottom controls bar. */
bar: string;
/** Square hit area of the play/pause and fullscreen buttons. */
btn: string;
/** Lucide icon inside the bar buttons. */
btnIcon: string;
/** Center play affordance shown while paused. */
centerBox: string;
/** Lucide icon inside the centered play affordance. */
centerIcon: string;
/** Hit area of the scrub bar wrapper. */
scrubRow: string;
/** Visible fill rail. */
scrubRail: string;
/** Drag handle dot. */
scrubHandle: string;
/** Time text. */
timeText: string;
};
const TOKENS: Record<Size, SizeTokens> = {
sm: {
bar: "gap-2 px-3 pb-2 pt-6 text-[12px]",
btn: "h-7 w-7",
btnIcon: "h-4 w-4",
centerBox: "h-14 w-14 md:h-16 md:w-16",
centerIcon: "h-6 w-6",
scrubRow: "h-5",
scrubRail: "h-1",
scrubHandle: "h-3 w-3",
timeText: "",
},
lg: {
bar: "gap-3 px-5 pb-4 pt-10 text-[14px]",
btn: "h-10 w-10",
btnIcon: "h-5 w-5",
centerBox: "h-20 w-20 md:h-24 md:w-24",
centerIcon: "h-8 w-8",
scrubRow: "h-7",
scrubRail: "h-1.5",
scrubHandle: "h-4 w-4",
timeText: "text-[14px]",
},
};
/**
* Cross-platform inline video player with custom controls. Disables every
* native control overlay (iOS Safari's `playsInline` UI is otherwise
* impossible to fully tame via CSS) and reimplements the essentials:
*
* - Centered play affordance while paused.
* - Bottom bar with play/pause, current time, scrub bar, total time, and
* a fullscreen button.
* - Tap on the video toggles play/pause.
* - Click or drag on the scrub bar seeks live to that point (mouse +
* touch, via pointer events with pointer capture).
*
* The download pill is preserved at the top-left so it lives alongside the
* existing visual language of the message bubbles.
*/
export function MessageInlineVideo({
postId,
attachment,
initialTime = 0,
autoPlay = true,
leadingLabel,
hideDownload = false,
hideFullscreen = false,
onTimeUpdate,
size = "sm",
}: {
postId: string;
attachment: Attachment;
initialTime?: number;
autoPlay?: boolean;
leadingLabel?: string;
/** Suppress the top-left download pill (overlay supplies its own). */
hideDownload?: boolean;
/** Suppress the fullscreen button (when we are already in fullscreen). */
hideFullscreen?: boolean;
/** Reports the playhead to the parent on every native `timeupdate`. */
onTimeUpdate?: (currentTime: number) => void;
/**
* `sm` (default) for bubble-sized inline players. `lg` for surfaces like
* the fullscreen overlay where controls should feel touch-friendly.
*/
size?: Size;
}) {
const { openVideo } = useVideoPlayer();
const videoRef = useRef<HTMLVideoElement>(null);
const scrubRef = useRef<HTMLDivElement>(null);
const [isPlaying, setIsPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(initialTime);
const [duration, setDuration] = useState(attachment.durationSec ?? 0);
const [isScrubbing, setIsScrubbing] = useState(false);
const t = TOKENS[size];
useEffect(() => {
const v = videoRef.current;
if (!v) return;
const onPlay = () => setIsPlaying(true);
const onPause = () => setIsPlaying(false);
const onTime = () => {
setCurrentTime(v.currentTime);
onTimeUpdate?.(v.currentTime);
};
// `seeked` fires even when the video is paused, so an external
// `currentTime = ...` (e.g. syncing back from fullscreen) reaches state
// immediately instead of waiting for the next `timeupdate`.
const onSeeked = () => {
setCurrentTime(v.currentTime);
onTimeUpdate?.(v.currentTime);
};
const onMeta = () => {
if (Number.isFinite(v.duration)) setDuration(v.duration);
if (initialTime > 0) {
try {
v.currentTime = initialTime;
} catch {
// Ignore out-of-range seeks.
}
}
};
v.addEventListener("play", onPlay);
v.addEventListener("pause", onPause);
v.addEventListener("timeupdate", onTime);
v.addEventListener("seeked", onSeeked);
v.addEventListener("loadedmetadata", onMeta);
return () => {
v.removeEventListener("play", onPlay);
v.removeEventListener("pause", onPause);
v.removeEventListener("timeupdate", onTime);
v.removeEventListener("seeked", onSeeked);
v.removeEventListener("loadedmetadata", onMeta);
};
}, [initialTime, onTimeUpdate]);
const togglePlay = useCallback(() => {
const v = videoRef.current;
if (!v) return;
if (v.paused) v.play().catch(() => {});
else v.pause();
}, []);
const seekToClientX = useCallback((clientX: number) => {
const el = scrubRef.current;
const v = videoRef.current;
if (!el || !v || !Number.isFinite(v.duration)) return;
const rect = el.getBoundingClientRect();
const ratio = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
const next = ratio * v.duration;
try {
v.currentTime = next;
} catch {
// Ignore out-of-range seeks.
}
setCurrentTime(next);
}, []);
const onScrubPointerDown = useCallback(
(e: ReactPointerEvent<HTMLDivElement>) => {
e.preventDefault();
e.currentTarget.setPointerCapture(e.pointerId);
setIsScrubbing(true);
seekToClientX(e.clientX);
},
[seekToClientX],
);
const onScrubPointerMove = useCallback(
(e: ReactPointerEvent<HTMLDivElement>) => {
if (!isScrubbing) return;
seekToClientX(e.clientX);
},
[isScrubbing, seekToClientX],
);
const onScrubPointerEnd = useCallback(
(e: ReactPointerEvent<HTMLDivElement>) => {
if (!isScrubbing) return;
try {
e.currentTarget.releasePointerCapture(e.pointerId);
} catch {
// Pointer may already have been released.
}
setIsScrubbing(false);
},
[isScrubbing],
);
const goFullscreen = useCallback(
(e: ReactMouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
const v = videoRef.current;
if (!v) return;
const resumeAt = Number.isFinite(v.currentTime) ? v.currentTime : 0;
v.pause();
openVideo(
attachment,
resumeAt,
(finalTime) => {
const inline = videoRef.current;
if (!inline || !Number.isFinite(finalTime)) return;
// Update React state synchronously so the progress bar paints the
// new playhead in the next frame, before the <video> seek round-
// trip emits its own events (paused videos don't fire timeupdate
// and `seeked` can lag ~hundreds of ms).
setCurrentTime(finalTime);
onTimeUpdate?.(finalTime);
const apply = () => {
try {
inline.currentTime = finalTime;
} catch {
// Ignore out-of-range seeks.
}
};
if (inline.readyState >= 1) apply();
else inline.addEventListener("loadedmetadata", apply, { once: true });
},
postId,
);
},
[attachment, openVideo, postId],
);
const progressPct = duration > 0 ? (currentTime / duration) * 100 : 0;
const remaining = Math.max(0, duration - currentTime);
const handleOffset = size === "lg" ? 8 : 6;
return (
<>
<video
ref={videoRef}
src={attachment.url}
poster={attachment.posterUrl}
playsInline
autoPlay={autoPlay}
onClick={togglePlay}
className="absolute inset-0 h-full w-full object-contain"
/>
{hideDownload ? null : (
<AttachmentDownloadPill
postId={postId}
attachment={attachment}
leadingLabel={leadingLabel}
className="absolute left-2 top-2 z-20"
/>
)}
{!isPlaying ? (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
togglePlay();
}}
className="absolute inset-0 z-10 flex items-center justify-center"
aria-label="Play"
>
<span
className={`flex items-center justify-center rounded-full bg-black/60 text-white shadow-lg ring-1 ring-white/15 backdrop-blur-md transition group-hover:bg-black/70 ${t.centerBox}`}
>
<Play className={`translate-x-0.5 fill-white ${t.centerIcon}`} />
</span>
</button>
) : null}
<div
className={`absolute inset-x-0 bottom-0 z-20 flex items-center bg-gradient-to-t from-black/85 via-black/45 to-transparent leading-none text-white ${t.bar}`}
onClick={(e) => e.stopPropagation()}
>
<button
type="button"
onClick={togglePlay}
className={`flex shrink-0 items-center justify-center text-white transition hover:scale-105 ${t.btn}`}
aria-label={isPlaying ? "Pause" : "Play"}
>
{isPlaying ? (
<Pause className={`fill-white ${t.btnIcon}`} />
) : (
<Play className={`translate-x-0.5 fill-white ${t.btnIcon}`} />
)}
</button>
<span className={`shrink-0 tabular-nums text-white ${t.timeText}`}>
{formatClock(currentTime)}
</span>
<div
ref={scrubRef}
role="slider"
aria-label="Seek"
aria-valuemin={0}
aria-valuemax={duration || 0}
aria-valuenow={currentTime}
tabIndex={0}
onPointerDown={onScrubPointerDown}
onPointerMove={onScrubPointerMove}
onPointerUp={onScrubPointerEnd}
onPointerCancel={onScrubPointerEnd}
className={`group relative min-w-0 flex-1 cursor-pointer touch-none select-none ${t.scrubRow}`}
>
<div
className={`absolute inset-x-0 top-1/2 -translate-y-1/2 overflow-hidden rounded-full bg-white/25 ${t.scrubRail}`}
>
<div
className={`h-full bg-white ${
isScrubbing ? "" : "transition-[width] duration-150 ease-out"
}`}
style={{ width: `${progressPct}%` }}
/>
</div>
<div
aria-hidden
className={`pointer-events-none absolute top-1/2 -translate-y-1/2 rounded-full bg-white shadow-lg transition-transform duration-150 ease-out ${
isScrubbing ? "scale-125" : "scale-100"
} ${t.scrubHandle}`}
style={{ left: `calc(${progressPct}% - ${handleOffset}px)` }}
/>
</div>
<span className={`shrink-0 tabular-nums text-white/80 ${t.timeText}`}>
-{formatClock(remaining)}
</span>
{hideFullscreen ? null : (
<button
type="button"
onClick={goFullscreen}
className={`flex shrink-0 items-center justify-center text-white transition hover:scale-105 ${t.btn}`}
aria-label="Fullscreen"
>
<Maximize2 className={t.btnIcon} strokeWidth={2.2} />
</button>
)}
</div>
</>
);
}

View File

@@ -1,10 +1,11 @@
import { LoaderCircle, Play, X } from "lucide-react"; import { LoaderCircle, Play, X } from "lucide-react";
import { DownloadCloudIcon } from "../../icons/DownloadCloudIcon"; import { DownloadCloudIcon } from "../../icons/DownloadCloudIcon";
import { useEffect, useRef, useState } from "react"; import { useEffect, useState } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { useI18n } from "../../../i18n"; import { useI18n } from "../../../i18n";
import type { Attachment, Post } from "../../../types/post"; import type { Attachment, Post } from "../../../types/post";
import { AttachmentDownloadPill } from "../AttachmentDownloadPill"; import { AttachmentDownloadPill } from "../AttachmentDownloadPill";
import { MessageInlineVideo } from "../MessageInlineVideo";
import { useVideoPlayer } from "../overlays/VideoPlayer"; import { useVideoPlayer } from "../overlays/VideoPlayer";
import { autolink } from "../utils/autolink"; import { autolink } from "../utils/autolink";
import { CollapsibleText } from "../CollapsibleText"; import { CollapsibleText } from "../CollapsibleText";
@@ -56,7 +57,6 @@ function VideoAttachmentCard({
}) { }) {
const { openVideo } = useVideoPlayer(); const { openVideo } = useVideoPlayer();
const [playing, setPlaying] = useState(false); const [playing, setPlaying] = useState(false);
const videoRef = useRef<HTMLVideoElement>(null);
const posterUrl = attachment.posterUrl ?? attachment.thumbnailUrl; const posterUrl = attachment.posterUrl ?? attachment.thumbnailUrl;
const duration = formatDuration(attachment.durationSec); const duration = formatDuration(attachment.durationSec);
const previewVideoUrl = attachment.url.includes("#") const previewVideoUrl = attachment.url.includes("#")
@@ -73,26 +73,11 @@ function VideoAttachmentCard({
style={compact ? undefined : { aspectRatio: videoRatio(attachment) }} style={compact ? undefined : { aspectRatio: videoRatio(attachment) }}
> >
{playing && !compact ? ( {playing && !compact ? (
<> <MessageInlineVideo
<video
ref={videoRef}
src={attachment.url}
poster={attachment.posterUrl}
controls
controlsList="nodownload noplaybackrate noremoteplayback"
disablePictureInPicture
playsInline
autoPlay
onClick={(e) => e.stopPropagation()}
className="ark-message-video absolute inset-0 h-full w-full"
/>
<AttachmentDownloadPill
postId={postId} postId={postId}
attachment={attachment} attachment={attachment}
leadingLabel={duration} leadingLabel={duration}
className="absolute left-2 top-2 z-20"
/> />
</>
) : ( ) : (
<> <>
{posterUrl ? ( {posterUrl ? (
@@ -141,7 +126,7 @@ function VideoAttachmentCard({
type="button" type="button"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
if (compact) openVideo(attachment, 0); if (compact) openVideo(attachment, 0, undefined, postId);
else setPlaying(true); else setPlaying(true);
}} }}
className="absolute inset-0 flex items-center justify-center" className="absolute inset-0 flex items-center justify-center"
@@ -370,7 +355,7 @@ export function VideoBubble({ post }: { post: Post }) {
onClose={() => setListOpen(false)} onClose={() => setListOpen(false)}
onPick={(att) => { onPick={(att) => {
setListOpen(false); setListOpen(false);
openVideo(att, 0); openVideo(att, 0, undefined, post.id);
}} }}
/> />
) : null} ) : null}

View File

@@ -8,14 +8,11 @@ import {
type PropsWithChildren, type PropsWithChildren,
} from "react"; } from "react";
import { createPortal } from "react-dom"; 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 type { Attachment } from "../../../types/post";
import { DownloadCloudIcon } from "../../icons/DownloadCloudIcon"; import { AttachmentDownloadPill } from "../AttachmentDownloadPill";
import { useI18n } from "../../../i18n";
import { useToast } from "../../Toast";
import { BubbleImage } from "../BubbleImage"; import { BubbleImage } from "../BubbleImage";
import { autolink } from "../utils/autolink"; import { autolink } from "../utils/autolink";
import { downloadAttachment } from "../utils/downloadFile";
type LightboxState = { type LightboxState = {
images: Attachment[]; 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({ function Filmstrip({
images, images,
index, index,
@@ -261,15 +214,20 @@ function LightboxView({
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{postId ? ( {postId ? (
<LightboxDownloadButton postId={postId} attachment={current} /> <AttachmentDownloadPill
postId={postId}
attachment={current}
size="lg"
className=""
/>
) : null} ) : null}
<button <button
type="button" type="button"
onClick={onClose} 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" aria-label="Close"
> >
<X className="h-5 w-5" /> <X className="h-6 w-6" />
</button> </button>
</div> </div>
</div> </div>

View File

@@ -10,14 +10,32 @@ import {
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { X } from "lucide-react"; import { X } from "lucide-react";
import type { Attachment } from "../../../types/post"; import type { Attachment } from "../../../types/post";
import { AttachmentDownloadPill } from "../AttachmentDownloadPill";
import { MessageInlineVideo } from "../MessageInlineVideo";
type OnClose = (finalTime: number) => void;
type PlayerState = { type PlayerState = {
attachment: Attachment; attachment: Attachment;
currentTime: number; currentTime: number;
onClose?: OnClose;
/** Post the video belongs to, needed for the download pill. */
postId?: string;
} | null; } | null;
type Ctx = { 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; closeVideo: () => void;
}; };
@@ -34,8 +52,12 @@ export function VideoPlayerProvider({ children }: PropsWithChildren) {
const [state, setState] = useState<PlayerState>(null); const [state, setState] = useState<PlayerState>(null);
const openVideo = useCallback( 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), []); const closeVideo = useCallback(() => setState(null), []);
@@ -47,7 +69,11 @@ export function VideoPlayerProvider({ children }: PropsWithChildren) {
<PlayerView <PlayerView
attachment={state.attachment} attachment={state.attachment}
startAt={state.currentTime} startAt={state.currentTime}
onClose={closeVideo} postId={state.postId}
onClose={(finalTime) => {
state.onClose?.(finalTime);
setState(null);
}}
/> />
) : null} ) : null}
</VideoPlayerContext.Provider> </VideoPlayerContext.Provider>
@@ -57,17 +83,28 @@ export function VideoPlayerProvider({ children }: PropsWithChildren) {
function PlayerView({ function PlayerView({
attachment, attachment,
startAt, startAt,
postId,
onClose, onClose,
}: { }: {
attachment: Attachment; attachment: Attachment;
startAt: number; 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(() => { useEffect(() => {
const onKey = (e: KeyboardEvent) => { const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose(); if (e.key === "Escape") close();
}; };
window.addEventListener("keydown", onKey); window.addEventListener("keydown", onKey);
@@ -101,42 +138,51 @@ function PlayerView({
body.style.overflow = prev.overflow; body.style.overflow = prev.overflow;
window.scrollTo(0, scrollY); window.scrollTo(0, scrollY);
}; };
}, [onClose]); }, [close]);
useEffect(() => {
const v = videoRef.current;
if (!v) return;
if (startAt > 0) v.currentTime = startAt;
v.play().catch(() => {});
}, [startAt]);
return createPortal( return createPortal(
<div <div
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/95" className="fixed inset-0 z-[100] flex items-center justify-center bg-black/95"
onClick={onClose} onClick={close}
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
> >
{postId ? (
<AttachmentDownloadPill
postId={postId}
attachment={attachment}
size="lg"
className="absolute left-4 top-4 z-10"
/>
) : null}
<button <button
type="button" type="button"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); 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" aria-label="Close"
> >
<X className="h-5 w-5" /> <X className="h-6 w-6" />
</button> </button>
<video <div
ref={videoRef} className="relative h-full w-full"
src={attachment.url}
poster={attachment.posterUrl}
controls
playsInline
className="max-h-[92vh] max-w-[96vw] outline-none"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
>
<MessageInlineVideo
postId={postId ?? ""}
attachment={attachment}
initialTime={startAt}
autoPlay
hideDownload
hideFullscreen
size="lg"
onTimeUpdate={(t) => {
lastTimeRef.current = t;
}}
/> />
</div>
</div>, </div>,
document.body, document.body,
); );