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