// src/hooks/useVideoPlay/useVideoPlay.js import { useCallback, useRef, useState, useEffect, useMemo } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { startVideoPlayerNew, stopBannerVideo, stopAndHideVideo, hidePlayerVideo, PLAYBACK_STATUS, } from '../../actions/playActions'; import fp from '../../utils/fp.js'; import { videoState } from './videoState.js'; /** * useVideoPlay Hook - 안전한 동영상 재생 제어 * * 주요 기능: * - 동영상 재생/중지 안전 제어 * - 중복 재생 방지 * - 재생 상태 추적 * - 오류 처리 및 복구 * - useFocusHistory와 연동 지원 */ /** * 동영상 재생 제어 훅 * @param {object} options - 옵션 설정 * @param {boolean} options.enableLogging - 로깅 활성화 여부 * @param {string} options.logPrefix - 로그 접두사 * @param {boolean} options.autoErrorRecovery - 자동 오류 복구 여부 * @returns {object} 동영상 재생 제어 인터페이스 */ export const useVideoPlay = (options = {}) => { const { enableLogging = process.env.NODE_ENV === 'development', logPrefix = '[useVideoPlay]', autoErrorRecovery = true, } = options; const dispatch = useDispatch(); // 로그 함수 const log = (message) => enableLogging && console.log(`${logPrefix} ${message}`); // 🔽 Redux 상태 구독 const currentOwnerId = useSelector((state) => state.home.playerControl?.ownerId); const bannerDataList = useSelector((state) => state.home.bannerData?.bannerInfos); const bannerVisibility = useSelector((state) => state.home.bannerVisibility); const reduxVideoPlayState = useSelector((state) => state.play.videoPlayState); // 🔽 [단순화] 현재 재생 중인 배너 가져오기 const getCurrentPlayingBanner = useCallback(() => { return videoState.getCurrentPlaying(); }, []); // 🔽 [단순화] 마지막으로 재생된 배너 가져오기 const getLastPlayedBanner = useCallback(() => { return videoState.getLastPlayedBanner(); }, []); // 🔽 로컬 상태 관리 (단순화) const [errorCount, setErrorCount] = useState(0); // 🔽 타이머 및 참조 관리 const playDelayTimerRef = useRef(null); const retryTimerRef = useRef(null); // 🔽 Redux 기반 실제 재생 상태 const isVideoPlaying = reduxVideoPlayState?.isPlaying === true && reduxVideoPlayState?.playback === PLAYBACK_STATUS.PLAYING; // 🔽 전역 videoState가 실제 재생 상태와 불일치할 때 정리 useEffect(() => { if (!isVideoPlaying && videoState.getCurrentPlaying()) { videoState.setCurrentPlaying(null); } }, [isVideoPlaying]); // 🔽 [유틸리티] 배너 가용성 검사 (0부터 시작 통일) const isBannerAvailable = useCallback( (bannerId) => { try { if (!bannerDataList || bannerDataList.length === 0) { return false; } // banner0/banner1 -> bannerDataList[0], banner2/banner3 -> bannerDataList[1] const bannerIndex = ['banner0', 'banner1'].includes(bannerId) ? 0 : 1; const bannerData = bannerDataList[bannerIndex]; if (!bannerData) return false; if (['banner0', 'banner1'].includes(bannerId)) { // banner0/banner1: 첫 번째 배너의 첫 번째 상세 정보 const detailInfo = fp.get('bannerDetailInfos[0]', bannerData); return !!fp.get('showUrl', detailInfo); } else if (['banner2', 'banner3'].includes(bannerId)) { // banner2/banner3: randomIndex에 따른 상세 정보 const randomIndex = fp.get('randomIndex', bannerData) || 0; const detailInfo = fp.get(`bannerDetailInfos[${randomIndex}]`, bannerData); return !!fp.get('showUrl', detailInfo); } return false; } catch (error) { log(`배너 가용성 검사 오류: ${error.message}, bannerId: ${bannerId}`); return false; } }, [bannerDataList, enableLogging, logPrefix] ); // 🔽 [핵심] 안전한 동영상 재생 const playVideo = useCallback( (bannerId, options = {}) => { const { delay = 0, force = false, reason = 'manual' } = options; try { // 입력값 검증 if (!bannerId || typeof bannerId !== 'string') { return Promise.resolve(false); } // 지원되는 배너 확인 (0부터 시작 통일) if (!['banner0', 'banner1'].includes(bannerId)) { return Promise.resolve(false); } // 배너 가용성 확인 if (!isBannerAvailable(bannerId)) { return Promise.resolve(false); } // 🔽 [개선] 중복 재생 방지 - Redux 상태 기준으로 체크 const isCurrentlyPlaying = currentOwnerId === `${bannerId}_player`; // 🔽 [정확한 중복 방지] 실제 Redux에서 재생 중일 때만 skip if (!force && isCurrentlyPlaying) { log(`🎬 ${bannerId} 이미 재생 중 - skip`); return Promise.resolve(true); } return new Promise((resolve) => { // 기존 타이머 정리 if (playDelayTimerRef.current) { clearTimeout(playDelayTimerRef.current); } const executePlay = () => { try { console.log(`[DEBUG-playVideo] 🎬 ${bannerId} 재생 시작 (${reason})`); log(`🎬 ${bannerId} 재생 시작 (${reason})`); // 🔽 [단순화] 전역 상태 업데이트 videoState.setCurrentPlaying(bannerId); // Redux 액션 dispatch - bannerId를 modalContainerId로 사용 dispatch( startVideoPlayerNew({ bannerId, modal: true, modalContainerId: bannerId, force, }) ); // 성공 상태 업데이트 setErrorCount(0); // 성공 시 오류 카운트 초기화 resolve(true); } catch (error) { log(`playVideo 실행 오류: ${error.message}, bannerId: ${bannerId}`); // 오류 카운트 증가 setErrorCount((prev) => prev + 1); // 자동 복구 시도 if (autoErrorRecovery && errorCount < 3) { log(`자동 복구 시도 ${errorCount + 1}/3`); retryTimerRef.current = setTimeout( () => { playVideo(bannerId, { ...options, force: true, reason: 'auto-retry' }); }, 1000 * Math.pow(2, errorCount) ); // 지수 백오프 } resolve(false); } }; // 지연 실행 또는 즉시 실행 if (delay > 0) { playDelayTimerRef.current = setTimeout(executePlay, delay); } else { executePlay(); } }); } catch (error) { log( `playVideo 전체 오류: ${error.message}, bannerId: ${bannerId}, options: ${JSON.stringify(options)}` ); return Promise.resolve(false); } }, [ (bannerId) => isBannerAvailable(bannerId), currentOwnerId, dispatch, enableLogging, logPrefix, autoErrorRecovery, errorCount, ] ); // 🔽 [핵심] 안전한 동영상 중지 - 파라미터 없이 사용 가능 const stopVideo = useCallback( (options = {}) => { const { reason = 'manual' } = options; try { // 현재 재생 중인 배너 파악 const currentPlayingBanner = getCurrentPlayingBanner(); if (!currentPlayingBanner) { log('🛑 재생 중인 비디오 없음 - skip'); return Promise.resolve(true); } log(`🛑 ${currentPlayingBanner} 중지 (${reason})`); // Redux 액션 dispatch - 모든 비디오 중지 및 숨김 dispatch(stopAndHideVideo()); // 🔽 [단순화] 전역 상태 업데이트 videoState.setCurrentPlaying(null); return Promise.resolve(true); } catch (error) { log(`stopVideo 오류: ${error.message}, options: ${JSON.stringify(options)}`); return Promise.resolve(false); } }, [getCurrentPlayingBanner, dispatch, enableLogging, logPrefix] ); // 🔽 [새로운 기능] 비디오를 완전히 중지하지 않고 소리만 나오도록 안보이게 함 const hideVideo = useCallback( (options = {}) => { const { reason = 'hide' } = options; try { // 현재 재생 중인 배너 파악 const currentPlayingBanner = getCurrentPlayingBanner(); if (!currentPlayingBanner) { log('👁️‍🗨️ 재생 중인 비디오 없음 - skip'); return Promise.resolve(true); } log(`👁️‍🗨️ ${currentPlayingBanner} 숨김 (${reason}) - 소리 유지`); // Redux 액션 dispatch - 비디오 숨김 (일시정지 + 모달 숨김) dispatch(hidePlayerVideo()); // 🔽 [중요] 전역 상태는 유지 (재생 상태는 그대로) // videoState.setCurrentPlaying(null); // <- 숨김 상태이므로 재생 상태 유지 return Promise.resolve(true); } catch (error) { log(`hideVideo 오류: ${error.message}, options: ${JSON.stringify(options)}`); return Promise.resolve(false); } }, [getCurrentPlayingBanner, dispatch, enableLogging, logPrefix] ); // 🔽 [새로운] 비디오 재시작 - 마지막으로 재생된 배너로 재시작 (기본 banner0) const restartVideo = useCallback( (bannerId = null, options = {}) => { const { reason = 'restart' } = options; return new Promise((resolve) => { try { // 🔥 핵심: bannerId가 주어지면 그걸 사용, 없으면 마지막 재생된 배너, 없으면 banner0 const targetBannerId = bannerId || getLastPlayedBanner() || 'banner0'; console.log(`[DEBUG-restartVideo] ♾️ ${targetBannerId} 재시작 (${reason})`); log(`♾️ ${targetBannerId} 재시작 (${reason})`); // 대상 배너를 다시 재생 (force 옵션으로 중복 방지 해제) playVideo(targetBannerId, { force: true, reason }) .then(resolve) .catch(() => resolve(false)); } catch (error) { log( `restartVideo 오류: ${error.message}, bannerId: ${bannerId}, options: ${JSON.stringify(options)}` ); resolve(false); } }); }, [getLastPlayedBanner, playVideo, enableLogging, logPrefix] ); // 🔽 [편의 함수] 포커스 정책 기반 재생 제어 const applyVideoPolicy = useCallback( (policy) => { try { if (!policy || typeof policy !== 'object') { log(`applyVideoPolicy: 잘못된 정책 - ${JSON.stringify(policy)}`); return Promise.resolve(false); } const { videoTarget, transition, confidence = 1.0 } = policy; if (videoTarget) { // 동영상 재생 return playVideo(videoTarget, { delay: confidence < 0.8 ? 200 : 100, // 신뢰도가 낮으면 지연 reason: transition || 'policy', }); } else { // 동영상 중지 return stopVideo(null, { reason: transition || 'policy', }); } } catch (error) { log(`applyVideoPolicy 오류: ${error.message}, policy: ${JSON.stringify(policy)}`); return Promise.resolve(false); } }, [playVideo, stopVideo, enableLogging, logPrefix] ); // 🔽 정리 작업 useEffect(() => { return () => { // 컴포넌트 언마운트 시 타이머 정리 if (playDelayTimerRef.current) { clearTimeout(playDelayTimerRef.current); } if (retryTimerRef.current) { clearTimeout(retryTimerRef.current); } }; }, []); // 🔽 현재 상태 계산 const isPlaying = currentOwnerId && currentOwnerId.endsWith('_player'); const currentBanner = currentOwnerId ? currentOwnerId.replace('_player', '') : null; // 🔽 [최적화] 배너 가용성 메모이제이션 (반복 계산 방지) const bannerAvailability = useMemo( () => ({ banner0: isBannerAvailable('banner0'), banner1: isBannerAvailable('banner1'), }), [isBannerAvailable] ); // 🔽 디버그 정보 (단순화) const getDebugInfo = useCallback( () => ({ currentOwnerId, currentBanner, isPlaying, errorCount, videoState: videoState.getDebugInfo(), bannerAvailability, }), [currentOwnerId, currentBanner, isPlaying, errorCount, bannerAvailability] ); return { // 🎯 핵심 함수 playVideo, stopVideo, hideVideo, // 🔽 [새로운] 비디오 숨김 (소리 유지) restartVideo, // 🔽 [새로운] 파라미터 없이 재시작 applyVideoPolicy, // 📊 상태 정보 getCurrentPlayingBanner, getLastPlayedBanner, isPlaying, isVideoPlaying, currentBanner, bannerVisibility, videoPlayState: reduxVideoPlayState, bannerAvailability, // ✅ [최적화] 메모이제이션된 배너 가용성 // 🔍 유틸리티 isBannerAvailable, getDebugInfo, // 📈 통계 errorCount, }; }; export default useVideoPlay;