import React, { useCallback, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, } from 'react'; import classNames from 'classnames'; import { useDispatch } from 'react-redux'; import Spotlight from '@enact/spotlight'; import SpotlightContainerDecorator from '@enact/spotlight/SpotlightContainerDecorator'; import { sendBroadCast } from '../../actions/commonActions'; import { pauseModalMedia, resumeModalMedia, switchMediaToFullscreen, } from '../../actions/mediaActions'; import * as PanelActions from '../../actions/panelActions'; import TPanel from '../../components/TPanel/TPanel'; import Media from '../../components/VideoPlayer/Media'; import TReactPlayer from '../../components/VideoPlayer/TReactPlayer'; import MediaPlayerV2 from '../../components/VideoPlayer/MediaPlayer.v2'; import usePrevious from '../../hooks/usePrevious'; import { panel_names } from '../../utils/Config'; import css from './MediaPanel.module.less'; // ✅ DEBUG 모드 설정 const DEBUG_MODE = false; // ✅ DEBUG_MODE 기반 console 래퍼 const debugLog = (...args) => { if (DEBUG_MODE) { console.log(...args); } }; const Container = SpotlightContainerDecorator( { enterTo: 'default-element', preserveld: true }, 'div' ); const YOUTUBECONFIG = { playerVars: { controls: 0, autoplay: 1, disablekb: 1, enablejsapi: 1, listType: 'user_uploads', fs: 0, rel: 0, showinfo: 0, loop: 0, iv_load_policy: 3, modestbranding: 1, wmode: 'opaque', cc_lang_pref: 'en', cc_load_policy: 0, playsinline: 1, }, }; const MediaPanel = React.forwardRef( ({ isTabActivated, panelInfo, isOnTop, spotlightId, ...props }, ref) => { const dispatch = useDispatch(); const videoPlayer = useRef(null); const onEndedTimerRef = useRef(null); // ✅ onEnded 타이머 관리 const mediaEventListenersRef = useRef([]); // ✅ 미디어 이벤트 리스너 추적 const [modalStyle, setModalStyle] = React.useState({}); const [modalScale, setModalScale] = React.useState(1); const [currentTime, setCurrentTime] = React.useState(0); const [videoLoaded, setVideoLoaded] = React.useState(false); const [isSubtitleActive, setIsSubtitleActive] = React.useState(true); const panelInfoRef = usePrevious(panelInfo); // modal/full screen에 따른 일시정지/재생 처리 useEffect(() => { // debugLog('[MediaPanel] ========== isOnTop useEffect =========='); // debugLog('[MediaPanel] isOnTop:', isOnTop); // debugLog('[MediaPanel] panelInfo:', JSON.stringify(panelInfo, null, 2)); if (panelInfo && panelInfo.modal) { if (!isOnTop) { // debugLog('[MediaPanel] Not on top - pausing video'); dispatch(pauseModalMedia()); } else if (isOnTop && panelInfo.isPaused) { // debugLog('[MediaPanel] Back on top - resuming video'); dispatch(resumeModalMedia()); } } }, [isOnTop, panelInfo, dispatch]); // videoPlayer ref를 통한 직접 제어 useEffect(() => { if (panelInfo?.modal && videoPlayer.current) { if (panelInfo.isPaused) { // debugLog('[MediaPanel] Executing pause via videoPlayer.current'); videoPlayer.current.pause(); } else if (panelInfo.isPaused === false) { // debugLog('[MediaPanel] Executing play via videoPlayer.current'); videoPlayer.current.play(); } } }, [panelInfo?.isPaused, panelInfo?.modal]); useEffect(() => { if (!videoPlayer.current) return; if (!isYoutube) return; if (panelInfo?.modal) return; videoPlayer.current.showControls?.(); }, [isYoutube, panelInfo?.modal]); const getPlayer = useCallback((ref) => { videoPlayer.current = ref; }, []); // ✅ 안전한 비디오 플레이어 메서드 호출 const safePlayerCall = useCallback((methodName, ...args) => { if (videoPlayer.current && typeof videoPlayer.current[methodName] === 'function') { try { return videoPlayer.current[methodName](...args); } catch (err) { if (DEBUG_MODE) console.warn(`[MediaPanel] ${methodName} 호출 실패:`, err); } } return null; }, []); const playViaRef = useCallback(() => safePlayerCall('play'), [safePlayerCall]); const pauseViaRef = useCallback(() => safePlayerCall('pause'), [safePlayerCall]); const seekViaRef = useCallback( (seconds = 0) => safePlayerCall('seek', seconds), [safePlayerCall] ); const showControlsViaRef = useCallback(() => safePlayerCall('showControls'), [safePlayerCall]); const hideControlsViaRef = useCallback(() => safePlayerCall('hideControls'), [safePlayerCall]); const toggleControlsViaRef = useCallback( () => safePlayerCall('toggleControls'), [safePlayerCall] ); const getMediaStateViaRef = useCallback( () => safePlayerCall('getMediaState'), [safePlayerCall] ); useImperativeHandle( ref, () => ({ play: playViaRef, pause: pauseViaRef, seek: seekViaRef, showControls: showControlsViaRef, hideControls: hideControlsViaRef, toggleControls: toggleControlsViaRef, getMediaState: getMediaStateViaRef, getInternalPlayer: () => videoPlayer.current, }), [ playViaRef, pauseViaRef, seekViaRef, showControlsViaRef, hideControlsViaRef, toggleControlsViaRef, getMediaStateViaRef, ] ); // VideoPlayer가 MEDIA 타입에서 setIsVODPaused를 호출하므로 더미 함수 제공 const setIsVODPaused = useCallback(() => { // MediaPanel에서는 VOD pause 상태 관리 불필요 (단순 재생만) }, []); // PlayerOverlayContents에서 필요한 더미 함수들 const setSideContentsVisible = useCallback(() => { // MediaPanel에서는 사이드 컨텐츠 사용 안 함 }, []); const handleIndicatorDownClick = useCallback(() => { // MediaPanel에서는 indicator 사용 안 함 }, []); const handleIndicatorUpClick = useCallback(() => { // MediaPanel에서는 indicator 사용 안 함 }, []); // ✅ modal 스타일 설정 useEffect(() => { let resizeObserver = null; if (panelInfo.modal && panelInfo.modalContainerId) { // modal 모드: modalContainerId 기반으로 위치와 크기 계산 const node = document.querySelector(`[data-spotlight-id="${panelInfo.modalContainerId}"]`); if (node) { const { width, height, top, left } = node.getBoundingClientRect(); // ProductVideo의 padding(6px * 2)과 추가 여유를 고려하여 크기 조정 // 비디오가 오른쪽으로 넘치지 않도록 충분한 여유 확보 const paddingOffset = 6 * 2; // padding 양쪽 const extraMargin = 6 * 2; // 추가 여유 (포커스 테두리 + 비디오 비율 고려) const totalOffset = paddingOffset + extraMargin; // 24px const adjustedWidth = width - totalOffset; const adjustedHeight = height - totalOffset; const adjustedTop = top + totalOffset / 2; const adjustedLeft = left + totalOffset / 2; const style = { width: adjustedWidth + 'px', height: adjustedHeight + 'px', maxWidth: adjustedWidth + 'px', maxHeight: adjustedHeight + 'px', top: adjustedTop + 'px', left: adjustedLeft + 'px', position: 'fixed', overflow: 'hidden', // visible → hidden으로 변경하여 넘치는 부분 숨김 }; setModalStyle(style); let scale = 1; if (typeof window === 'object') { scale = adjustedWidth / window.innerWidth; setModalScale(scale); } } else { setModalStyle(panelInfo.modalStyle || {}); setModalScale(panelInfo.modalScale || 1); } } else if (isOnTop && !panelInfo.modal && !panelInfo.isMinimized && videoPlayer.current) { // ✅ 안전한 메서드 호출로 null/undefined 체크 const mediaState = safePlayerCall('getMediaState'); if (mediaState?.paused) { safePlayerCall('play'); } const isControlsHidden = videoPlayer.current.areControlsVisible && !videoPlayer.current.areControlsVisible(); if (isControlsHidden) { safePlayerCall('showControls'); } } // ✅ cleanup: resize observer 정리 return () => { if (resizeObserver) { resizeObserver.disconnect(); } }; }, [panelInfo, isOnTop]); // ✅ 비디오 클릭 시 modal → fullscreen 전환 또는 controls 토글 const onVideoClick = useCallback(() => { if (panelInfo.modal) { // debugLog('[MediaPanel] Video clicked - switching to fullscreen'); dispatch(switchMediaToFullscreen()); } else { // 비디오 클릭 시 controls 토글 safePlayerCall('toggleControls'); } }, [dispatch, panelInfo.modal, safePlayerCall]); const onClickBack = useCallback( (ev) => { // ✅ 뒤로가기 시 비디오 정지 safePlayerCall('pause'); // modal에서 full로 전환된 경우 다시 modal로 돌아감 if (panelInfo.modalContainerId && !panelInfo.modal) { // 다시 modal로 돌리는 로직은 startVideoPlayer 액션을 사용할 수도 있지만 // 여기서는 단순히 패널을 pop dispatch(PanelActions.popPanel()); ev?.stopPropagation(); return; } if (!panelInfo.modal) { dispatch(PanelActions.popPanel()); ev?.stopPropagation(); return; } }, [dispatch, panelInfo, safePlayerCall] ); const currentPlayingUrl = useMemo(() => { return panelInfo?.showUrl; }, [panelInfo?.showUrl]); const isYoutube = useMemo(() => { if (currentPlayingUrl && currentPlayingUrl.includes('youtu')) { return true; } return false; }, [currentPlayingUrl]); const currentSubtitleUrl = useMemo(() => { return panelInfo?.subtitle; }, [panelInfo?.subtitle]); const reactPlayerSubtitleConfig = useMemo(() => { if (isSubtitleActive && currentSubtitleUrl) { return { file: { attributes: { crossOrigin: 'true', }, tracks: [{ kind: 'subtitles', src: currentSubtitleUrl, default: true }], }, youtube: YOUTUBECONFIG, }; } else { return { youtube: YOUTUBECONFIG, }; } }, [currentSubtitleUrl, isSubtitleActive]); const videoType = useMemo(() => { if (currentPlayingUrl) { if (currentPlayingUrl.toLowerCase().endsWith('.mp4')) { return 'video/mp4'; } else if (currentPlayingUrl.toLowerCase().endsWith('.mpd')) { return 'application/dash+xml'; } else if (currentPlayingUrl.toLowerCase().endsWith('.m3u8')) { return 'application/mpegurl'; } } return 'application/mpegurl'; }, [currentPlayingUrl]); const videoThumbnailUrl = useMemo(() => { return panelInfo?.thumbnailUrl; }, [panelInfo?.thumbnailUrl]); const mediainfoHandler = useCallback( (ev) => { const type = ev.type; if (type !== 'timeupdate' && type !== 'durationchange') { debugLog('mediainfoHandler....', type, ev, videoPlayer.current?.getMediaState()); } // ✅ hlsError 처리 강화 if (ev === 'hlsError') { const mediaState = safePlayerCall('getMediaState'); if (mediaState && isNaN(Number(mediaState.playbackRate))) { dispatch( sendBroadCast({ type: 'videoError', moreInfo: { reason: 'hlsError' }, }) ); return; } } switch (type) { case 'timeupdate': { const mediaState = safePlayerCall('getMediaState'); if (mediaState) { setCurrentTime(mediaState.currentTime || 0); } break; } case 'error': { const mediaState = safePlayerCall('getMediaState'); const errorInfo = mediaState?.error || 'unknown'; dispatch( sendBroadCast({ type: 'videoError', moreInfo: { reason: errorInfo }, }) ); break; } case 'loadeddata': { setVideoLoaded(true); break; } default: break; } }, [dispatch, safePlayerCall] ); const onEnded = useCallback( (e) => { debugLog('[MediaPanel] Video ended'); // console.log('[🔥UNIQUE_MEDIA_ENDED🔥] MediaPanel onEnded triggered - will pop after 1500ms'); // continuousPlay는 MediaPlayer(VideoPlayer) 컴포넌트 내부에서 loop 속성으로 처리 // onEnded가 호출되면 loop=false 인 경우이므로 패널을 닫음 Spotlight.pause(); // ✅ 이전 타이머가 있으면 정리 if (onEndedTimerRef.current) { clearTimeout(onEndedTimerRef.current); } // ✅ 새로운 타이머 저장 (cleanup 시 정리용) onEndedTimerRef.current = setTimeout(() => { // console.log('[🔥UNIQUE_MEDIA_ENDED🔥] Executing popPanel(MEDIA_PANEL) after 1500ms'); Spotlight.resume(); dispatch(PanelActions.popPanel(panel_names.MEDIA_PANEL)); onEndedTimerRef.current = null; }, 1500); e?.stopPropagation(); e?.preventDefault(); }, [dispatch] ); // ✅ useLayoutEffect: DOM 스타일 설정 (메모리 누수 방지) useLayoutEffect(() => { const videoContainer = document.querySelector(`.${css.videoContainer}`); if (videoContainer && panelInfo.thumbnailUrl && !videoLoaded) { try { videoContainer.style.background = `url(${panelInfo.thumbnailUrl}) center/contain no-repeat`; videoContainer.style.backgroundColor = 'black'; } catch (err) { if (DEBUG_MODE) console.warn('[MediaPanel] 썸네일 스타일 설정 실패:', err); } } // ✅ cleanup: 컴포넌트 언마운트 시 DOM 스타일 초기화 return () => { if (videoContainer) { try { videoContainer.style.background = ''; videoContainer.style.backgroundColor = ''; } catch (err) { // 스타일 초기화 실패는 무시 } } }; }, [panelInfo.thumbnailUrl, videoLoaded]); useEffect(() => { setVideoLoaded(false); }, [currentPlayingUrl]); // ✅ 컴포넌트 언마운트 시 모든 타이머 및 리소스 정리 useEffect(() => { return () => { // ✅ onEnded 타이머 정리 if (onEndedTimerRef.current) { clearTimeout(onEndedTimerRef.current); onEndedTimerRef.current = null; } // ✅ 비디오 플레이어 정지 및 정리 if (videoPlayer.current) { try { // 재생 중이면 정지 safePlayerCall('pause'); // controls 타임아웃 정리 safePlayerCall('hideControls'); } catch (err) { if (DEBUG_MODE) console.warn('[MediaPanel] 비디오 정지 실패:', err); } // ref 초기화 videoPlayer.current = null; } // ✅ 이벤트 리스너 정리 if (mediaEventListenersRef.current && mediaEventListenersRef.current.length > 0) { mediaEventListenersRef.current.forEach(({ element, event, handler }) => { try { element?.removeEventListener?.(event, handler); } catch (err) { // 리스너 제거 실패는 무시 } }); mediaEventListenersRef.current = []; } // ✅ Spotlight 상태 초기화 try { Spotlight.resume?.(); } catch (err) { // Spotlight 초기화 실패는 무시 } }; }, [dispatch, safePlayerCall]); // debugLog('[MediaPanel] ========== Rendering =========='); // debugLog('[MediaPanel] isOnTop:', isOnTop); // debugLog('[MediaPanel] panelInfo.modal:', panelInfo.modal); // debugLog('[MediaPanel] panelInfo.isMinimized:', panelInfo.isMinimized); // debugLog('[MediaPanel] panelInfo.isPaused:', panelInfo.isPaused); // debugLog('[MediaPanel] currentPlayingUrl:', currentPlayingUrl); // debugLog('[MediaPanel] hasVideoPlayer:', !!videoPlayer.current); // classNames 적용 상태 확인 // debugLog('[MediaPanel] ========== ClassNames Analysis =========='); // debugLog('[MediaPanel] css.videoContainer:', css.videoContainer); // debugLog('[MediaPanel] Condition [panelInfo.modal && !panelInfo.isMinimized]:', panelInfo.modal && !panelInfo.isMinimized); // debugLog('[MediaPanel] css.modal:', css.modal); // debugLog('[MediaPanel] Condition [panelInfo.isMinimized]:', panelInfo.isMinimized); // debugLog('[MediaPanel] css["modal-minimized"]:', css['modal-minimized']); // debugLog('[MediaPanel] Condition [!isOnTop]:', !isOnTop); // debugLog('[MediaPanel] css.background:', css.background); const appliedClassNames = classNames( css.videoContainer, panelInfo.modal && !panelInfo.isMinimized && css.modal, panelInfo.isMinimized && css['modal-minimized'], !isOnTop && css.background ); // debugLog('[MediaPanel] Final Applied ClassNames:', appliedClassNames); // debugLog('[MediaPanel] modalStyle:', modalStyle); // debugLog('[MediaPanel] modalScale:', modalScale); // debugLog('[MediaPanel] ==============================================='); // minimized 상태일 때는 spotlightRestrict 해제 (포커스 이동 허용) const containerSpotlightRestrict = panelInfo.isMinimized ? 'none' : 'self-only'; const shouldDisableIframeInteraction = isYoutube && !panelInfo.modal; return ( {currentPlayingUrl && (
{typeof window === 'object' && window.PalmSystem && ( )} {isSubtitleActive && !panelInfo.modal && typeof window === 'object' && window.PalmSystem && currentSubtitleUrl && }
)}
); } ); export default React.memo(MediaPanel);