diff --git a/com.twin.app.shoptime/src/views/MediaPanel/MediaPanel.jsx b/com.twin.app.shoptime/src/views/MediaPanel/MediaPanel.jsx index 17ce6b55..7935dd60 100644 --- a/com.twin.app.shoptime/src/views/MediaPanel/MediaPanel.jsx +++ b/com.twin.app.shoptime/src/views/MediaPanel/MediaPanel.jsx @@ -1,4 +1,11 @@ -import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react'; +import React, { + useCallback, + useEffect, + useImperativeHandle, + useLayoutEffect, + useMemo, + useRef, +} from 'react'; import classNames from 'classnames'; import { useDispatch } from 'react-redux'; @@ -56,498 +63,542 @@ const YOUTUBECONFIG = { }, }; -const MediaPanel = ({ isTabActivated, panelInfo, isOnTop, spotlightId, ...props }) => { - const dispatch = useDispatch(); +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 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); + const panelInfoRef = usePrevious(panelInfo); - // modal/full screen에 따른 일시정지/재생 처리 - useEffect(() => { - // debugLog('[MediaPanel] ========== isOnTop useEffect =========='); - // debugLog('[MediaPanel] isOnTop:', isOnTop); - // debugLog('[MediaPanel] panelInfo:', JSON.stringify(panelInfo, null, 2)); + // 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; - }, []); - - // 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); + 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()); } - } 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'); + }, [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]); - const isControlsHidden = - videoPlayer.current.areControlsVisible && !videoPlayer.current.areControlsVisible(); - if (isControlsHidden) { - safePlayerCall('showControls'); + 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; + }, []); - // ✅ cleanup: resize observer 정리 - return () => { - if (resizeObserver) { - resizeObserver.disconnect(); - } - }; - }, [panelInfo, isOnTop]); + 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] + ); - // ✅ 비디오 클릭 시 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]); + 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, + ] + ); - const onClickBack = useCallback( - (ev) => { - // ✅ 뒤로가기 시 비디오 정지 - safePlayerCall('pause'); + // VideoPlayer가 MEDIA 타입에서 setIsVODPaused를 호출하므로 더미 함수 제공 + const setIsVODPaused = useCallback(() => { + // MediaPanel에서는 VOD pause 상태 관리 불필요 (단순 재생만) + }, []); - // modal에서 full로 전환된 경우 다시 modal로 돌아감 - if (panelInfo.modalContainerId && !panelInfo.modal) { - // 다시 modal로 돌리는 로직은 startVideoPlayer 액션을 사용할 수도 있지만 - // 여기서는 단순히 패널을 pop - dispatch(PanelActions.popPanel()); - ev?.stopPropagation(); - return; - } + // PlayerOverlayContents에서 필요한 더미 함수들 + const setSideContentsVisible = useCallback(() => { + // MediaPanel에서는 사이드 컨텐츠 사용 안 함 + }, []); - if (!panelInfo.modal) { - dispatch(PanelActions.popPanel()); - ev?.stopPropagation(); - return; - } - }, - [dispatch, panelInfo, safePlayerCall] - ); + const handleIndicatorDownClick = useCallback(() => { + // MediaPanel에서는 indicator 사용 안 함 + }, []); - const currentPlayingUrl = useMemo(() => { - return panelInfo?.showUrl; - }, [panelInfo?.showUrl]); + const handleIndicatorUpClick = useCallback(() => { + // MediaPanel에서는 indicator 사용 안 함 + }, []); - const isYoutube = useMemo(() => { - if (currentPlayingUrl && currentPlayingUrl.includes('youtu')) { - return true; - } - return false; - }, [currentPlayingUrl]); + // ✅ modal 스타일 설정 + useEffect(() => { + let resizeObserver = null; - const currentSubtitleUrl = useMemo(() => { - return panelInfo?.subtitle; - }, [panelInfo?.subtitle]); + 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(); - 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]); + // ProductVideo의 padding(6px * 2)과 추가 여유를 고려하여 크기 조정 + // 비디오가 오른쪽으로 넘치지 않도록 충분한 여유 확보 + const paddingOffset = 6 * 2; // padding 양쪽 + const extraMargin = 6 * 2; // 추가 여유 (포커스 테두리 + 비디오 비율 고려) + const totalOffset = paddingOffset + extraMargin; // 24px - 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 adjustedWidth = width - totalOffset; + const adjustedHeight = height - totalOffset; + const adjustedTop = top + totalOffset / 2; + const adjustedLeft = left + totalOffset / 2; - 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 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 && isNaN(Number(mediaState.playbackRate))) { - dispatch( - sendBroadCast({ - type: 'videoError', - moreInfo: { reason: 'hlsError' }, - }) - ); + 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; } - } - switch (type) { - case 'timeupdate': { + 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) { - setCurrentTime(mediaState.currentTime || 0); + if (mediaState && isNaN(Number(mediaState.playbackRate))) { + dispatch( + sendBroadCast({ + type: 'videoError', + moreInfo: { reason: 'hlsError' }, + }) + ); + return; } - break; } - case 'error': { - const mediaState = safePlayerCall('getMediaState'); - const errorInfo = mediaState?.error || 'unknown'; - dispatch( - sendBroadCast({ - type: 'videoError', - moreInfo: { reason: errorInfo }, - }) - ); - break; + + 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; } - case 'loadeddata': { - setVideoLoaded(true); - break; + }, + [dispatch, safePlayerCall] + ); + + const onEnded = useCallback( + (e) => { + debugLog('[MediaPanel] Video ended'); + // continuousPlay는 MediaPlayer(VideoPlayer) 컴포넌트 내부에서 loop 속성으로 처리 + // onEnded가 호출되면 loop=false 인 경우이므로 패널을 닫음 + Spotlight.pause(); + + // ✅ 이전 타이머가 있으면 정리 + if (onEndedTimerRef.current) { + clearTimeout(onEndedTimerRef.current); } - default: - break; - } - }, - [dispatch, safePlayerCall] - ); - const onEnded = useCallback( - (e) => { - debugLog('[MediaPanel] Video ended'); - // continuousPlay는 MediaPlayer(VideoPlayer) 컴포넌트 내부에서 loop 속성으로 처리 - // onEnded가 호출되면 loop=false 인 경우이므로 패널을 닫음 - Spotlight.pause(); + // ✅ 새로운 타이머 저장 (cleanup 시 정리용) + onEndedTimerRef.current = setTimeout(() => { + Spotlight.resume(); + dispatch(PanelActions.popPanel(panel_names.MEDIA_PANEL)); + onEndedTimerRef.current = null; + }, 1500); - // ✅ 이전 타이머가 있으면 정리 - if (onEndedTimerRef.current) { - clearTimeout(onEndedTimerRef.current); - } + e?.stopPropagation(); + e?.preventDefault(); + }, + [dispatch] + ); - // ✅ 새로운 타이머 저장 (cleanup 시 정리용) - onEndedTimerRef.current = setTimeout(() => { - 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 center / contain no-repeat`; - videoContainer.style.backgroundColor = 'black'; - } catch (err) { - if (DEBUG_MODE) console.warn('[MediaPanel] 썸네일 스타일 설정 실패:', err); - } - } - - // ✅ cleanup: 컴포넌트 언마운트 시 DOM 스타일 초기화 - return () => { - if (videoContainer) { + // ✅ useLayoutEffect: DOM 스타일 설정 (메모리 누수 방지) + useLayoutEffect(() => { + const videoContainer = document.querySelector(`.${css.videoContainer}`); + if (videoContainer && panelInfo.thumbnailUrl && !videoLoaded) { try { - videoContainer.style.background = ''; - videoContainer.style.backgroundColor = ''; + videoContainer.style.background = `url(${panelInfo.thumbnailUrl}) center center / contain no-repeat`; + videoContainer.style.backgroundColor = 'black'; } catch (err) { - // 스타일 초기화 실패는 무시 + if (DEBUG_MODE) console.warn('[MediaPanel] 썸네일 스타일 설정 실패:', 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 }) => { + // ✅ cleanup: 컴포넌트 언마운트 시 DOM 스타일 초기화 + return () => { + if (videoContainer) { try { - element?.removeEventListener?.(event, handler); + videoContainer.style.background = ''; + videoContainer.style.backgroundColor = ''; } catch (err) { - // 리스너 제거 실패는 무시 + // 스타일 초기화 실패는 무시 } - }); - mediaEventListenersRef.current = []; - } + } + }; + }, [panelInfo.thumbnailUrl, videoLoaded]); - // ✅ Spotlight 상태 초기화 - try { - Spotlight.resume?.(); - } catch (err) { - // Spotlight 초기화 실패는 무시 - } - }; - }, [dispatch, safePlayerCall]); + useEffect(() => { + setVideoLoaded(false); + }, [currentPlayingUrl]); - // 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); + // ✅ 컴포넌트 언마운트 시 모든 타이머 및 리소스 정리 + useEffect(() => { + return () => { + // ✅ onEnded 타이머 정리 + if (onEndedTimerRef.current) { + clearTimeout(onEndedTimerRef.current); + onEndedTimerRef.current = null; + } - // 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); + // ✅ 비디오 플레이어 정지 및 정리 + if (videoPlayer.current) { + try { + // 재생 중이면 정지 + safePlayerCall('pause'); - 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] ==============================================='); + // controls 타임아웃 정리 + safePlayerCall('hideControls'); + } catch (err) { + if (DEBUG_MODE) console.warn('[MediaPanel] 비디오 정지 실패:', err); + } - // minimized 상태일 때는 spotlightRestrict 해제 (포커스 이동 허용) - const containerSpotlightRestrict = panelInfo.isMinimized ? 'none' : 'self-only'; - const shouldDisableIframeInteraction = isYoutube && !panelInfo.modal; + // ref 초기화 + videoPlayer.current = null; + } - return ( - - 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 && ( - + + {currentPlayingUrl && ( +
} - -
- )} -
- - ); -}; + > + + {typeof window === 'object' && window.PalmSystem && ( + + )} + {isSubtitleActive && + !panelInfo.modal && + typeof window === 'object' && + window.PalmSystem && + currentSubtitleUrl && } + +
+ )} +
+
+ ); + } +); export default React.memo(MediaPanel);