Files
Arkie-Library-Frontend/src/components/messageStream/MessageInlineVideo.tsx
2026-06-01 16:35:40 +08:00

384 lines
12 KiB
TypeScript

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 { useVideoPreviewSource } from "./hooks/useVideoPreviewSource";
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);
// 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);
const t = TOKENS[size];
const videoSrc = useVideoPreviewSource(attachment);
useEffect(() => {
const v = videoRef.current;
if (!v) return;
const onPlay = () => {
setIsPlaying(true);
// Real playback advances the fill smoothly again; re-enable transitions.
setSnapProgress(false);
};
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). Snap the fill to the new
// position so it doesn't sweep up from its pre-fullscreen width.
setSnapProgress(true);
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={videoSrc}
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={`message-stream-noncopyable-control 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 || snapProgress
? ""
: "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>
</>
);
}