"use client"; import React, { useCallback, useEffect, useMemo, useRef, useState, } from "react"; import { Video } from "@page-speed/video"; import { Img } from "@page-speed/img"; import type { ImmersiveAction, MediaItem } from "../types/index.js"; import { useImmersiveFeed, useActionContext } from "./ImmersiveFeedProvider.js"; import { ImmersivePortal } from "../portal/ImmersivePortal.js"; import { useScrollLock } from "../hooks/useScrollLock.js"; import { useVerticalPagerGestures } from "../hooks/useVerticalPagerGestures.js"; import { useKeyboardShortcuts } from "../hooks/useKeyboardShortcuts.js"; import { prefersReducedMotion } from "../utils/prefersReducedMotion.js"; import { ImmersiveViewerHeader } from "./ImmersiveViewerHeader.js"; import { ImmersiveViewerActions } from "./ImmersiveViewerActions.js"; import { ImmersiveViewerCaption } from "./ImmersiveViewerCaption.js"; /** * Props for the fullscreen immersive viewer. * * The viewer draws its state from ``. Rendering * `` when the provider's `isOpen` is `false` results in * a no-op — nothing is portalled to the DOM. */ export interface ImmersiveViewerProps { /** Brand label shown in the caption card (e.g. "Encapsa", "Carlos O'Brien's"). */ brandName?: string; /** Custom brand icon; falls back to a generic sparkle. */ brandIcon?: React.ReactNode; /** Custom mount container; defaults to document.body. */ container?: HTMLElement | null; /** Aria label for the dialog. Default: `"Immersive video viewer"`. */ ariaLabel?: string; /** Labels used in header controls (i18n). */ labels?: { close?: string; soundOn?: string; muted?: string; swipeForNext?: string; }; /** Custom header renderer. Advanced. */ renderHeader?: (props: { activeIndex1Based: number; total: number; muted: boolean; onClose: () => void; onToggleMute: () => void; }) => React.ReactNode; /** Custom actions renderer. Advanced. */ renderActions?: (props: { item: MediaItem; actions: ImmersiveAction[]; }) => React.ReactNode; /** Custom caption renderer. Advanced. */ renderCaption?: (item: MediaItem) => React.ReactNode; /** Show/hide the "Swipe for next" hint at the bottom. Default: true. */ showSwipeHint?: boolean; } const TRANSITION_MS = 320; /** * The fullscreen TikTok/Reels/Shorts-style vertical video viewer. * * Portals into `document.body` with a scoped CSS root so consumer styles do * not bleed in. Renders 3 videos at a time (previous, active, next), driven * by a transform-based pager with pointer gestures + keyboard controls. */ export function ImmersiveViewer({ brandName, brandIcon, container, ariaLabel = "Immersive video viewer", labels, renderHeader, renderActions, renderCaption, showSwipeHint = true, }: ImmersiveViewerProps) { const feed = useImmersiveFeed(); const { items, activeIndex, isOpen, isMuted, actions, theme, close, next, prev, seek, setMuted, reportAutoplayBlocked, } = feed; const actionContext = useActionContext(); const total = items.length; const activeItem = items[activeIndex]; const [progress, setProgress] = useState(0); // 0..1 const [swipeHintDismissed, setSwipeHintDismissed] = useState(false); const videoRefs = useRef>(new Map()); // Monotonically increasing counter bumped every time a