"use client"; import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, } from "react"; import type { ActionContext, ImmersiveAction, ImmersiveFeedHandle, ImmersiveFeedState, ImmersiveTheme, MediaItem, } from "../types/index.js"; import { resolveIndex } from "../utils/resolveIndex.js"; import { clamp } from "../utils/clamp.js"; /** * Props accepted by ``. * * The provider owns feed state (items, activeIndex, isOpen, isMuted) and * exposes it to descendants via `useImmersiveFeed()`. It is safe to render * even when the fullscreen viewer is not present. */ export interface ImmersiveFeedProviderProps { items: MediaItem[]; /** Initial mute state. Default `true` (required for autoplay on most browsers). */ initiallyMuted?: boolean; /** Initial open state. Default `false`. */ initiallyOpen?: boolean; /** Initial active index. Default `0`. */ initialIndex?: number; /** Actions rendered on the fullscreen viewer's right rail. Default `[]` (no rail). */ actions?: ImmersiveAction[]; /** * Optional theme applied via CSS custom properties on the portal root. * Consumers using `@page-speed/skins` may pass token values directly. */ theme?: ImmersiveTheme; /** Called after the active index changes, whether by gesture, keyboard, or `next()`/`prev()`. */ onIndexChange?: (index: number, item: MediaItem) => void; /** Called when the fullscreen viewer opens. */ onOpen?: (item: MediaItem) => void; /** Called when the fullscreen viewer closes. */ onClose?: () => void; /** * Called when the browser refuses autoplay (typically iOS Safari without a * prior user gesture). Consumers should show a "tap to play" affordance. */ onAutoplayBlocked?: (item: MediaItem) => void; children?: React.ReactNode; } interface FeedContextValue extends ImmersiveFeedState { actions: ImmersiveAction[]; theme: ImmersiveTheme | undefined; open: (idOrIndex: string | number) => void; close: () => void; next: () => void; prev: () => void; seek: (index: number) => void; setMuted: (m: boolean | ((prev: boolean) => boolean)) => void; reportAutoplayBlocked: (item: MediaItem) => void; // Convenience derived state, matching @page-speed/lightbox's useGalleryState // shape so consumers who know one library know the other. currentItem: MediaItem | null; prevItem: MediaItem | null; nextItem: MediaItem | null; canNext: boolean; canPrev: boolean; } const FeedContext = createContext(null); /** * Provider that owns feed state. Wrap ``, ``, * and `` with this. When using `` you don't * need to render this yourself. */ export const ImmersiveFeedProvider = React.forwardRef< ImmersiveFeedHandle, ImmersiveFeedProviderProps >(function ImmersiveFeedProvider( { items, initiallyMuted = true, initiallyOpen = false, initialIndex = 0, actions = [], theme, onIndexChange, onOpen, onClose, onAutoplayBlocked, children, }, ref, ) { const [activeIndex, setActiveIndex] = useState(() => clamp(initialIndex, 0, Math.max(0, items.length - 1)), ); const [isOpen, setIsOpen] = useState(initiallyOpen); const [isMuted, setIsMutedState] = useState(initiallyMuted); // Latest-refs so callbacks stay referentially stable while still reading // the freshest handler set (avoids re-subscribes on unrelated re-renders). const itemsRef = useRef(items); itemsRef.current = items; const onIndexChangeRef = useRef(onIndexChange); onIndexChangeRef.current = onIndexChange; const onOpenRef = useRef(onOpen); onOpenRef.current = onOpen; const onCloseRef = useRef(onClose); onCloseRef.current = onClose; const onAutoplayBlockedRef = useRef(onAutoplayBlocked); onAutoplayBlockedRef.current = onAutoplayBlocked; const open = useCallback((target: string | number) => { const list = itemsRef.current; if (list.length === 0) return; const i = resolveIndex(list, target); if (i < 0) return; setActiveIndex(i); setIsOpen(true); // Request sound on for the takeover — open() is invoked from a user // gesture, which satisfies the browser's autoplay-with-sound policy for // *this* media session, but not for the specific