[251112] feat: ProductVideroV2 Continuous Video Playing

🕐 커밋 시간: 2025. 11. 12. 15:28:31

📊 변경 통계:
  • 총 파일: 2개
  • 추가: +67줄
  • 삭제: -31줄

📝 수정된 파일:
  ~ com.twin.app.shoptime/src/views/DetailPanel/ProductContentSection/ProductVideo/ProductVideo.module.less
  ~ com.twin.app.shoptime/src/views/DetailPanel/ProductContentSection/ProductVideo/ProductVideo.v2.jsx

🔧 주요 변경 내용:
  • 소규모 기능 개선
This commit is contained in:
2025-11-12 15:28:33 +09:00
parent 16718c4753
commit 1166311e4b
2 changed files with 236 additions and 163 deletions

View File

@@ -143,6 +143,16 @@
pointer-events: auto; pointer-events: auto;
} }
.hideWhileFullscreen {
visibility: hidden;
pointer-events: none;
}
.videoPortalHost {
width: 100%;
height: 100%;
}
// 전체화면 container (Portal, body에 항상 존재) // 전체화면 container (Portal, body에 항상 존재)
.fullscreenContainer { .fullscreenContainer {
position: fixed; position: fixed;

View File

@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useMemo, useState, useRef } from 'react'; import React, { useCallback, useEffect, useLayoutEffect, useMemo, useState, useRef } from 'react';
import ReactDOM from 'react-dom'; import ReactDOM from 'react-dom';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import Spottable from '@enact/spotlight/Spottable'; import Spottable from '@enact/spotlight/Spottable';
@@ -24,7 +24,11 @@ import {
stopMediaAutoClose, stopMediaAutoClose,
resetMediaAutoClose, resetMediaAutoClose,
} from '../../../../actions/mediaOverlayActions'; } from '../../../../actions/mediaOverlayActions';
import { pauseFullscreenVideo, resumeFullscreenVideo, clearAllVideoTimers } from '../../../../actions/playActions'; import {
pauseFullscreenVideo,
resumeFullscreenVideo,
clearAllVideoTimers,
} from '../../../../actions/playActions';
import css from './ProductVideo.module.less'; import css from './ProductVideo.module.less';
const SpottableComponent = Spottable('div'); const SpottableComponent = Spottable('div');
@@ -80,6 +84,16 @@ export function ProductVideoV2({
const videoPlayerWrapperRef = useRef(null); const videoPlayerWrapperRef = useRef(null);
const normalContainerRef = useRef(null); const normalContainerRef = useRef(null);
const fullscreenContainerRef = useRef(null); const fullscreenContainerRef = useRef(null);
const videoPortalHostRef = useRef(null);
const ensurePortalHost = useCallback(() => {
if (!videoPortalHostRef.current && typeof document !== 'undefined') {
const host = document.createElement('div');
host.className = css.videoPortalHost;
videoPortalHostRef.current = host;
}
return videoPortalHostRef.current;
}, []);
// 비디오 재생 가능 여부 체크 // 비디오 재생 가능 여부 체크
const canPlayVideo = useMemo(() => { const canPlayVideo = useMemo(() => {
@@ -149,7 +163,8 @@ export function ProductVideoV2({
}, [canPlayVideo, isPlaying]); }, [canPlayVideo, isPlaying]);
// 썸네일 클릭 핸들러 - 비디오 재생 시작 + Redux dispatch + MediaPlayer 메서드 호출 // 썸네일 클릭 핸들러 - 비디오 재생 시작 + Redux dispatch + MediaPlayer 메서드 호출
const handleThumbnailClick = useCallback((e) => { const handleThumbnailClick = useCallback(
(e) => {
console.log('🎬 [handleThumbnailClick] 썸네일 클릭됨', { console.log('🎬 [handleThumbnailClick] 썸네일 클릭됨', {
canPlayVideo, canPlayVideo,
isPlaying, isPlaying,
@@ -175,7 +190,9 @@ export function ProductVideoV2({
videoPlayerRef.current?.showControls?.(); videoPlayerRef.current?.showControls?.();
}, 100); }, 100);
} }
}, [canPlayVideo, isPlaying, dispatch]); },
[canPlayVideo, isPlaying, dispatch]
);
// 비디오 종료 핸들러 - 썸네일로 복귀 + Redux cleanup + MediaPlayer 메서드 호출 // 비디오 종료 핸들러 - 썸네일로 복귀 + Redux cleanup + MediaPlayer 메서드 호출
const handleVideoEnded = useCallback(() => { const handleVideoEnded = useCallback(() => {
@@ -394,16 +411,20 @@ export function ProductVideoV2({
}, [mediaOverlayState.controls?.visible, dispatch]); }, [mediaOverlayState.controls?.visible, dispatch]);
// 전체 화면 컨테이너용 마우스 다운 핸들러 (Capture Phase) // 전체 화면 컨테이너용 마우스 다운 핸들러 (Capture Phase)
const handleFullscreenContainerMouseDownCapture = useCallback((e) => { const handleFullscreenContainerMouseDownCapture = useCallback(
(e) => {
if (!isPlaying || !isFullscreen) return; if (!isPlaying || !isFullscreen) return;
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
// toggleOverlayVisibility(); // toggleOverlayVisibility();
}, [isPlaying, isFullscreen, toggleOverlayVisibility]); },
[isPlaying, isFullscreen, toggleOverlayVisibility]
);
// 전체 화면 키보드 핸들러 // 전체 화면 키보드 핸들러
const handleFullscreenKeyDown = useCallback((e) => { const handleFullscreenKeyDown = useCallback(
(e) => {
if (!isPlaying || !isFullscreen) return; if (!isPlaying || !isFullscreen) return;
// ESC 키만 오버레이 토글로 처리 // ESC 키만 오버레이 토글로 처리
@@ -421,7 +442,9 @@ export function ProductVideoV2({
// Enter 키는 preventDefault하지 않고 기본 동작 허용 // Enter 키는 preventDefault하지 않고 기본 동작 허용
return; return;
} }
}, [isPlaying, isFullscreen, toggleOverlayVisibility]); },
[isPlaying, isFullscreen, toggleOverlayVisibility]
);
// 마우스 다운 (클릭) 이벤트 - capture phase에서 처리 // 마우스 다운 (클릭) 이벤트 - capture phase에서 처리
const handleVideoPlayerMouseDown = useCallback( const handleVideoPlayerMouseDown = useCallback(
@@ -466,7 +489,8 @@ export function ProductVideoV2({
useEffect(() => { useEffect(() => {
const handleDocumentClick = (e) => { const handleDocumentClick = (e) => {
// ProductVideoV2 관련 요소인 경우만 로깅 // ProductVideoV2 관련 요소인 경우만 로깅
const isVideoElement = e.target?.closest('[class*="videoContainer"]') || const isVideoElement =
e.target?.closest('[class*="videoContainer"]') ||
e.target?.closest('[class*="videoPlayer"]') || e.target?.closest('[class*="videoPlayer"]') ||
e.target?.closest('[class*="videoThumbnail"]'); e.target?.closest('[class*="videoThumbnail"]');
@@ -475,11 +499,14 @@ export function ProductVideoV2({
eventPhase: e.eventPhase, eventPhase: e.eventPhase,
bubbles: e.bubbles, bubbles: e.bubbles,
target: e.target?.className, target: e.target?.className,
eventPath: e.composedPath?.().slice(0, 6).map(el => ({ eventPath: e
.composedPath?.()
.slice(0, 6)
.map((el) => ({
tag: el.tagName, tag: el.tagName,
className: el.className, className: el.className,
id: el.id id: el.id,
})) })),
}); });
} }
}; };
@@ -490,6 +517,36 @@ export function ProductVideoV2({
}; };
}, []); }, []);
useLayoutEffect(() => {
if (!isPlaying) return;
const host = ensurePortalHost();
const target = isFullscreen ? fullscreenContainerRef.current : normalContainerRef.current;
if (host && target && host.parentNode !== target) {
target.appendChild(host);
}
}, [isPlaying, isFullscreen, ensurePortalHost]);
useEffect(() => {
if (isPlaying) return;
const host = videoPortalHostRef.current;
if (host?.parentNode) {
host.parentNode.removeChild(host);
}
}, [isPlaying]);
useEffect(() => {
return () => {
const host = videoPortalHostRef.current;
if (host?.parentNode) {
host.parentNode.removeChild(host);
}
videoPortalHostRef.current = null;
};
}, []);
// autoPlay 기능: 컴포넌트 마운트 후 500ms 후 자동 재생 // autoPlay 기능: 컴포넌트 마운트 후 500ms 후 자동 재생
useEffect(() => { useEffect(() => {
if (autoPlay && canPlayVideo && !isPlaying) { if (autoPlay && canPlayVideo && !isPlaying) {
@@ -595,17 +652,10 @@ export function ProductVideoV2({
const containerProps = useMemo(() => { const containerProps = useMemo(() => {
const baseProps = { const baseProps = {
onClick: handleVideoContainerClick, // 모든 상태에서 동일하게 적용 onClick: handleVideoContainerClick,
}; };
if (isFullscreen) { if (isPlaying) {
return {
...baseProps,
spotlightRestrict: 'self-only', // 포커스가 밖으로 나가지 않도록
spotlightId: 'product-video-v2-fullscreen',
onSpotlightDown: handleSpotlightDown, // 전체화면에서도 Down 키 동작
};
} else if (isPlaying) {
return { return {
...baseProps, ...baseProps,
spotlightId: 'product-video-v2-playing', spotlightId: 'product-video-v2-playing',
@@ -615,15 +665,23 @@ export function ProductVideoV2({
} else { } else {
return baseProps; return baseProps;
} }
}, [isFullscreen, isPlaying, handleVideoContainerClick, handleSpotlightDown, handleContainerKeyDown]); }, [
isFullscreen,
isPlaying,
handleVideoContainerClick,
handleSpotlightDown,
handleContainerKeyDown,
]);
// ⚠️ 간단한 해결책: 비디오 영역 클릭 시 직접 동작 실행 // ⚠️ 간단한 해결책: 비디오 영역 클릭 시 직접 동작 실행
const handleContainerClickFallback = useCallback((e) => { const handleContainerClickFallback = useCallback(
const isThumbnailArea = e.target?.closest('[class*="videoThumbnail"]') || (e) => {
const isThumbnailArea =
e.target?.closest('[class*="videoThumbnail"]') ||
e.target?.closest('[class*="playButton"]'); e.target?.closest('[class*="playButton"]');
const isVideoPlayerArea = e.target?.closest('[class*="videoPlayer"]') || const isVideoPlayerArea =
e.target?.closest('[class*="VideoPlayer"]'); e.target?.closest('[class*="videoPlayer"]') || e.target?.closest('[class*="VideoPlayer"]');
// 썸네일 클릭: 비디오 재생 시작 // 썸네일 클릭: 비디오 재생 시작
if (isThumbnailArea && !isPlaying) { if (isThumbnailArea && !isPlaying) {
@@ -636,11 +694,14 @@ export function ProductVideoV2({
console.log('🎬 [handleContainerClickFallback] 비디오 클릭 → 전체화면 토글'); console.log('🎬 [handleContainerClickFallback] 비디오 클릭 → 전체화면 토글');
toggleFullscreen(); toggleFullscreen();
} }
}, [isPlaying, isFullscreen, handleThumbnailClick, toggleFullscreen]); },
[isPlaying, isFullscreen, handleThumbnailClick, toggleFullscreen]
);
// ⚠️ 핵심: TScrollerDetail이 capture phase에서 이벤트를 막으므로, // ⚠️ 핵심: TScrollerDetail이 capture phase에서 이벤트를 막으므로,
// ProductVideoV2 컨테이너 전체에서 capture phase를 다시 열어줌 (Hooks는 early return 전에) // ProductVideoV2 컨테이너 전체에서 capture phase를 다시 열어줌 (Hooks는 early return 전에)
const handleContainerMouseDownCapture = useCallback((e) => { const handleContainerMouseDownCapture = useCallback(
(e) => {
// 비디오 영역인지 확인 // 비디오 영역인지 확인
const isVideoArea = e.currentTarget === containerRef.current; const isVideoArea = e.currentTarget === containerRef.current;
@@ -664,11 +725,17 @@ export function ProductVideoV2({
} else { } else {
// 전체 화면 모드에서는 오버레이(컨트롤)만 토글 // 전체 화면 모드에서는 오버레이(컨트롤)만 토글
console.log('🖥️ [ProductVideoV2.OVERLAY-Toggle] 전체 화면 모드 - 오버레이 토글 시작'); console.log('🖥️ [ProductVideoV2.OVERLAY-Toggle] 전체 화면 모드 - 오버레이 토글 시작');
console.log('🖥️ [ProductVideoV2.OVERLAY-Toggle] 현재 오버레이 상태:', overlayState.controls?.visible); console.log(
'🖥️ [ProductVideoV2.OVERLAY-Toggle] 현재 오버레이 상태:',
overlayState.controls?.visible
);
try { try {
const result = dispatch(toggleControls()); const result = dispatch(toggleControls());
console.log('🖥️ [ProductVideoV2.OVERLAY-Toggle] toggleControls 액션 디스패치 결과:', result); console.log(
'🖥️ [ProductVideoV2.OVERLAY-Toggle] toggleControls 액션 디스패치 결과:',
result
);
// 디스패치 후 상태 변화 확인 (setTimeout으로 비동기 처리) // 디스패치 후 상태 변화 확인 (setTimeout으로 비동기 처리)
setTimeout(() => { setTimeout(() => {
@@ -678,7 +745,9 @@ export function ProductVideoV2({
console.error('🖥️ [ProductVideoV2.OVERLAY-ERROR] toggleControls 디스패치 에러:', error); console.error('🖥️ [ProductVideoV2.OVERLAY-ERROR] toggleControls 디스패치 에러:', error);
} }
} }
}, [isPlaying, isFullscreen, dispatch, toggleControls]); },
[isPlaying, isFullscreen, dispatch, toggleControls]
);
// Early return: 비디오 재생 불가능한 경우 // Early return: 비디오 재생 불가능한 경우
if (!canPlayVideo) return null; if (!canPlayVideo) return null;
@@ -687,17 +756,16 @@ export function ProductVideoV2({
// 전체화면: SpotlightContainer (self-only) // 전체화면: SpotlightContainer (self-only)
// 일반 재생: SpottableComponent (포커스 가능) // 일반 재생: SpottableComponent (포커스 가능)
// 썸네일: div // 썸네일: div
const ContainerComponent = isFullscreen const ContainerComponent = isPlaying ? SpottableComponent : 'div';
? SpotlightContainer
: isPlaying
? SpottableComponent
: 'div';
// VideoPlayer 컴포넌트 생성 함수 // VideoPlayer 컴포넌트 생성 함수
const renderVideoPlayer = () => { const renderVideoPlayer = () => {
if (!isPlaying) return null; if (!isPlaying) return null;
return ( const host = ensurePortalHost();
if (!host) return null;
return ReactDOM.createPortal(
<div <div
ref={videoPlayerWrapperRef} ref={videoPlayerWrapperRef}
className={`${css.videoPlayerWrapper} ${isFullscreen ? css.fullscreenPlayer : ''}`} className={`${css.videoPlayerWrapper} ${isFullscreen ? css.fullscreenPlayer : ''}`}
@@ -717,7 +785,8 @@ export function ProductVideoV2({
onMouseDownCapture={(e) => { onMouseDownCapture={(e) => {
// ⚠️ 핵심: VideoPlayer 내부(실제 비디오 영역)에서만 이벤트를 살리고, // ⚠️ 핵심: VideoPlayer 내부(실제 비디오 영역)에서만 이벤트를 살리고,
// 그 외 wrapper 영역에서만 preventDefault // 그 외 wrapper 영역에서만 preventDefault
const isVideoElement = e.target?.closest('[class*="videoPlayer"]') || const isVideoElement =
e.target?.closest('[class*="videoPlayer"]') ||
e.target?.closest('video') || e.target?.closest('video') ||
e.target?.closest('[class*="react-player"]'); e.target?.closest('[class*="react-player"]');
@@ -777,7 +846,8 @@ export function ProductVideoV2({
<track kind="subtitles" src={productInfo?.prdtMediaSubtitlUrl} default /> <track kind="subtitles" src={productInfo?.prdtMediaSubtitlUrl} default />
)} )}
</VideoPlayer> </VideoPlayer>
</div> </div>,
host
); );
}; };
@@ -787,7 +857,7 @@ export function ProductVideoV2({
<ContainerComponent <ContainerComponent
{...containerProps} {...containerProps}
ref={containerRef} ref={containerRef}
className={`${css.videoContainer} ${isFullscreen ? css.fullscreen : ''}`} className={`${css.videoContainer} ${isFullscreen ? css.hideWhileFullscreen : ''}`}
onMouseDownCapture={handleContainerMouseDownCapture} onMouseDownCapture={handleContainerMouseDownCapture}
onClick={handleContainerClickFallback} onClick={handleContainerClickFallback}
> >
@@ -818,8 +888,7 @@ export function ProductVideoV2({
</div> </div>
</div> </div>
</SpottableComponent> </SpottableComponent>
) : !isFullscreen ? ( ) : (
// 일반 재생 모드: VideoPlayer를 직접 렌더링
<div <div
ref={normalContainerRef} ref={normalContainerRef}
className={css.normalPlayerContainer} className={css.normalPlayerContainer}
@@ -833,9 +902,8 @@ export function ProductVideoV2({
handleVideoPlayerClick(e); handleVideoPlayerClick(e);
}} }}
onMouseDownCapture={(e) => { onMouseDownCapture={(e) => {
// ⚠️ 핵심: VideoPlayer 영역 내에서만 이벤트를 살리고, const isVideoPlayerArea =
// 그 외 영역(스크롤 영역)에서만 preventDefault e.target?.closest('[class*="videoPlayer"]') ||
const isVideoPlayerArea = e.target?.closest('[class*="videoPlayer"]') ||
e.target?.closest('[class*="VideoPlayer"]'); e.target?.closest('[class*="VideoPlayer"]');
console.log('🎬 [normalContainerRef] onMouseDownCapture 실행됨', { console.log('🎬 [normalContainerRef] onMouseDownCapture 실행됨', {
@@ -845,23 +913,21 @@ export function ProductVideoV2({
target: e.target?.className, target: e.target?.className,
}); });
// VideoPlayer가 아닌 영역(스크롤 영역)에서만 preventDefault
if (!isVideoPlayerArea) { if (!isVideoPlayerArea) {
e.preventDefault(); e.preventDefault();
console.log('🛑 [normalContainerRef] preventDefault - 스크롤 영역에서의 클릭'); console.log('🛑 [normalContainerRef] preventDefault - 스크롤 영역에서의 클릭');
} else { } else {
// VideoPlayer 영역이면 이벤트를 전파시켜 click이 정상 발생하도록
console.log('✅ [normalContainerRef] 이벤트 전파 허용 - VideoPlayer 영역'); console.log('✅ [normalContainerRef] 이벤트 전파 허용 - VideoPlayer 영역');
} }
}} }}
> />
{renderVideoPlayer()} )}
</div>
) : null}
</ContainerComponent> </ContainerComponent>
{/* 전체화면 container (Portal) */} {/* 전체화면 container (Portal) */}
{isPlaying && isFullscreen && ReactDOM.createPortal( {isPlaying &&
isFullscreen &&
ReactDOM.createPortal(
<SpotlightContainer <SpotlightContainer
spotlightRestrict="self-only" spotlightRestrict="self-only"
spotlightId="product-video-v2-fullscreen-portal" spotlightId="product-video-v2-fullscreen-portal"
@@ -869,15 +935,12 @@ export function ProductVideoV2({
onKeyDown={handleFullscreenKeyDown} onKeyDown={handleFullscreenKeyDown}
// onClick={handleFullscreenClick} // onClick={handleFullscreenClick}
> >
<div <div ref={fullscreenContainerRef} className={css.fullscreenContainer} />
ref={fullscreenContainerRef}
className={css.fullscreenContainer}
>
{renderVideoPlayer()}
</div>
</SpotlightContainer>, </SpotlightContainer>,
document.body document.body
)} )}
{renderVideoPlayer()}
</> </>
); );
} }