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:
@@ -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")
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
368
src/components/messageStream/MessageInlineVideo.tsx
Normal file
368
src/components/messageStream/MessageInlineVideo.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
|
postId={postId}
|
||||||
ref={videoRef}
|
attachment={attachment}
|
||||||
src={attachment.url}
|
leadingLabel={duration}
|
||||||
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}
|
|
||||||
attachment={attachment}
|
|
||||||
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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user