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 = { 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(null); const scrubRef = useRef(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) => { e.preventDefault(); e.currentTarget.setPointerCapture(e.pointerId); setIsScrubbing(true); seekToClientX(e.clientX); }, [seekToClientX], ); const onScrubPointerMove = useCallback( (e: ReactPointerEvent) => { if (!isScrubbing) return; seekToClientX(e.clientX); }, [isScrubbing, seekToClientX], ); const onScrubPointerEnd = useCallback( (e: ReactPointerEvent) => { if (!isScrubbing) return; try { e.currentTarget.releasePointerCapture(e.pointerId); } catch { // Pointer may already have been released. } setIsScrubbing(false); }, [isScrubbing], ); const goFullscreen = useCallback( (e: ReactMouseEvent) => { 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