Files
Arkie-Library-Frontend/src/components/messageStream/MessageInlineVideo.tsx

384 lines
12 KiB
TypeScript
Raw Normal View History

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.
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";
2026-06-01 16:35:40 +08:00
import { useVideoPreviewSource } from "./hooks/useVideoPreviewSource";
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.
2026-05-30 02:25:01 +08:00
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);
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.
2026-05-30 02:25:01 +08:00
const t = TOKENS[size];
2026-06-01 16:35:40 +08:00
const videoSrc = useVideoPreviewSource(attachment);
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.
2026-05-30 02:25:01 +08:00
useEffect(() => {
const v = videoRef.current;
if (!v) return;
const onPlay = () => {
setIsPlaying(true);
// Real playback advances the fill smoothly again; re-enable transitions.
setSnapProgress(false);
};
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.
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
// 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);
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.
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}
2026-06-01 16:35:40 +08:00
src={videoSrc}
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.
2026-05-30 02:25:01 +08:00
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}`}
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.
2026-05-30 02:25:01 +08:00
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"
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.
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>
</>
);
}