2026-05-30 02:25:01 +08:00
|
|
|
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);
|
2026-05-30 16:54:03 +08:00
|
|
|
// When we programmatically seek (e.g. syncing the playhead back from the
|
|
|
|
|
// fullscreen overlay) the progress fill should jump straight to the watched
|
|
|
|
|
// position instead of sweeping up from its old width via the CSS transition.
|
|
|
|
|
// Cleared as soon as real playback resumes so live progress stays smooth.
|
|
|
|
|
const [snapProgress, setSnapProgress] = useState(false);
|
2026-05-30 02:25:01 +08:00
|
|
|
|
|
|
|
|
const t = TOKENS[size];
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const v = videoRef.current;
|
|
|
|
|
if (!v) return;
|
2026-05-30 16:54:03 +08:00
|
|
|
const onPlay = () => {
|
|
|
|
|
setIsPlaying(true);
|
|
|
|
|
// Real playback advances the fill smoothly again; re-enable transitions.
|
|
|
|
|
setSnapProgress(false);
|
|
|
|
|
};
|
2026-05-30 02:25:01 +08:00
|
|
|
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
|
2026-05-30 16:54:03 +08:00
|
|
|
// and `seeked` can lag ~hundreds of ms). Snap the fill to the new
|
|
|
|
|
// position so it doesn't sweep up from its pre-fullscreen width.
|
|
|
|
|
setSnapProgress(true);
|
2026-05-30 02:25:01 +08:00
|
|
|
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 ${
|
2026-05-30 16:54:03 +08:00
|
|
|
isScrubbing || snapProgress
|
|
|
|
|
? ""
|
|
|
|
|
: "transition-[width] duration-150 ease-out"
|
2026-05-30 02:25:01 +08:00
|
|
|
}`}
|
|
|
|
|
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>
|
|
|
|
|
</>
|
|
|
|
|
);
|
|
|
|
|
}
|