import { useState, useEffect, useRef, useCallback, MutableRefObject } from 'react'; import { useStore } from '../services/store'; import { IMedia } from '../types'; import { EventMapKeys } from '../types/events'; // Animation constants shared by all player components export const PLAYER_ANIMATION_DURATION_MS = 300; export const PLAYER_ANIMATION_DURATION_SEC = PLAYER_ANIMATION_DURATION_MS / 1000; // Duration of the slide transition between posts export const SLIDE_DURATION_MS = 260; export interface UsePlayerNavigationProps { post: IMedia; onClose: () => void; onPostChange: (post: IMedia | null) => void; openedEventName?: EventMapKeys; closedEventName?: EventMapKeys; navigationEventName?: EventMapKeys; swipeDirection?: 'horizontal' | 'vertical'; } export interface UsePlayerNavigationResult { currentDisplayPost: IMedia; nextDisplayPost: IMedia | null; prevDisplayPost: IMedia | null; isMuted: boolean; isPlaying: boolean; isVisible: boolean; isClosing: boolean; showNextIndicator: boolean; thumbnailImage: string | null | undefined; hasUserInteractedRef: MutableRefObject; slideOffset: number; isSliding: boolean; /** CSS axis used for the slide transition — 'X' for horizontal, 'Y' for vertical. */ slideAxis: 'X' | 'Y'; handleClose: () => void; handleNextPost: () => void; handlePreviousPost: () => void; toggleMute: () => void; togglePlay: () => void; } // Current post with its immediate neighbours, always updated atomically. interface NavState { current: IMedia; prev: IMedia | null; next: IMedia | null; } const getNeighbours = ( medias: IMedia[], index: number, ): Pick => ({ prev: index > 0 ? medias[index - 1] : null, next: index < medias.length - 1 ? medias[index + 1] : null, }); /** * Shared hook for player navigation logic. * Used by both MobilePlayer and DesktopProductListPlayer. */ const usePlayerNavigation = ({ post, onClose, onPostChange, openedEventName = 'mobilePlayerOpened', closedEventName = 'mobilePlayerClosed', navigationEventName = 'mobilePlayerNavigation', swipeDirection = 'vertical', }: UsePlayerNavigationProps): UsePlayerNavigationResult => { const slideAxis: 'X' | 'Y' = swipeDirection === 'vertical' ? 'Y' : 'X'; const store = useStore(); const medias = store.data.content.medias; // current/prev/next live in one object so they always update in the same render, // preventing any transient frame where two slides share the same post id. const [nav, setNav] = useState(() => ({ current: post, ...getNeighbours(medias, post.postIndex), })); const [isMuted, setIsMuted] = useState(true); const [isPlaying, setIsPlaying] = useState(true); const [isVisible, setIsVisible] = useState(false); const [isClosing, setIsClosing] = useState(false); const [slideOffset, setSlideOffset] = useState(0); const [isSliding, setIsSliding] = useState(false); const hasUserInteractedRef = useRef(false); const isSlidingRef = useRef(false); // Animate in on mount and fire opened event. useEffect(() => { store.triggerEvent(openedEventName, { post: nav.current }, store.data.id); const timer = setTimeout(() => setIsVisible(true), 50); return () => clearTimeout(timer); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // Sync when the post prop is changed from outside (e.g. thumbnail click). useEffect(() => { if (post.id !== nav.current.id) { setNav({ current: post, ...getNeighbours(medias, post.postIndex) }); setIsPlaying(true); hasUserInteractedRef.current = true; } // eslint-disable-next-line react-hooks/exhaustive-deps }, [post]); // Prevent background scrolling. useEffect(() => { const originalOverflow = document.body.style.overflow; document.body.style.overflow = 'hidden'; return () => { document.body.style.overflow = originalOverflow; }; }, []); // Shared slide logic — direction 'right' means going to next, 'left' to previous. const navigateTo = useCallback(( target: IMedia, direction: 'left' | 'right', ) => { if (isSlidingRef.current) return; hasUserInteractedRef.current = true; isSlidingRef.current = true; setIsSliding(true); setSlideOffset(direction === 'right' ? -100 : 100); store.triggerEvent(navigationEventName, { direction, post: target }, store.data.id); setTimeout(() => { setNav({ current: target, ...getNeighbours(medias, target.postIndex) }); setIsPlaying(true); onPostChange(target); setSlideOffset(0); setIsSliding(false); isSlidingRef.current = false; }, SLIDE_DURATION_MS); }, [medias, onPostChange, store, navigationEventName]); const handleClose = useCallback(() => { store.triggerEvent(closedEventName, { post: nav.current }, store.data.id); setIsClosing(true); setTimeout(onClose, PLAYER_ANIMATION_DURATION_MS); }, [onClose, nav.current, store, closedEventName]); const handleNextPost = useCallback(() => { if (nav.next) navigateTo(nav.next, 'right'); else handleClose(); }, [nav.next, navigateTo, handleClose]); const handlePreviousPost = useCallback(() => { if (nav.prev) navigateTo(nav.prev, 'left'); }, [nav.prev, navigateTo]); const toggleMute = useCallback(() => setIsMuted((prev) => !prev), []); const togglePlay = useCallback(() => setIsPlaying((prev) => !prev), []); const showNextIndicator = !hasUserInteractedRef.current && nav.next !== null; const thumbnailImage = nav.current.type === 'video' && nav.current.thumbnail ? nav.current.thumbnail : nav.current.image; return { currentDisplayPost: nav.current, nextDisplayPost: nav.next, prevDisplayPost: nav.prev, isMuted, isPlaying, isVisible, isClosing, showNextIndicator, thumbnailImage, hasUserInteractedRef, slideOffset, isSliding, slideAxis, handleClose, handleNextPost, handlePreviousPost, toggleMute, togglePlay, }; }; export default usePlayerNavigation;