[251013] feat:ProductVideoV2 , react-portal적용

🕐 커밋 시간: 2025. 10. 13. 15:15:13

📊 변경 통계:
  • 총 파일: 6개
  • 추가: +77줄
  • 삭제: -448줄

📁 추가된 파일:
  + com.twin.app.shoptime/src/views/DetailPanel/ProductContentSection/ProductVideo/ProductVideo.v2.jsx
  + com.twin.app.shoptime/src/views/DetailPanel/ProductContentSection/ProductVideo/index.js

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

🗑️ 삭제된 파일:
  - com.twin.app.shoptime/[251013]_RollingUnit_리렌더링_분석_및_해결방법.md

🔧 함수 변경 내용:
  📄 com.twin.app.shoptime/src/views/DetailPanel/ProductAllSection/ProductAllSection.jsx (javascript):
    🔄 Modified: extractProductMeta()
  📄 com.twin.app.shoptime/src/views/DetailPanel/ProductContentSection/ProductVideo/ProductVideo.module.less (unknown):
     Added: global()
  📄 com.twin.app.shoptime/[251013]_RollingUnit_리렌더링_분석_및_해결방법.md (md파일):
     Deleted: Chain(), handleItemFocus(), useCallback(), renderItem(), JustForSwitchBanner(), useState(), useEffect(), cancelAnimationFrame(), requestAnimationFrame(), doSendLogGNB(), onItemFocus(), useRef(), current(), JustForYouBanner(), RollingUnit()

🔧 주요 변경 내용:
  • 핵심 비즈니스 로직 개선
  • 개발 문서 및 가이드 개선

Performance: 코드 최적화로 성능 개선 기대
This commit is contained in:
2025-10-13 15:15:15 +09:00
parent 599796696c
commit 1bd22683d8
6 changed files with 387 additions and 448 deletions

View File

@@ -1,416 +0,0 @@
# [251013] RollingUnit 리렌더링 분석 및 해결방법
## 📋 문제 요약
- **증상**: HomeBanner의 RollingUnit 컴포넌트가 계속 리렌더링됨
- **특이사항**:
- RandomUnit은 정상 작동
- JustForYou 배너 관련 위치에서 특히 심함
- RollingUnit의 자동 순환(10초 롤링)이 제대로 작동하지 않음
- **원인**: OptionalTerms(선택약관) 관련 코드 추가 후 발생
---
## 🔍 근본 원인 분석
### 1. 불안정한 Props Chain (HomePanel → HomeBanner → RollingUnit)
**문제 흐름:**
```
HomePanel.jsx
↓ nowShelf 상태 변경
↓ doSendLogGNB 재생성 (의존성: nowShelf, panelInfo.nowShelf, pageSpotIds)
↓ handleItemFocus 재생성 (의존성: doSendLogGNB)
↓ handleItemFocus(el.shptmApphmDspyOptCd) 호출 → 매번 새 함수 생성
HomeBanner.jsx
↓ _handleItemFocus 재생성 (의존성: handleItemFocus)
↓ renderItem 재생성 (의존성: _handleItemFocus, _handleShelfFocus, bannerDataList)
RollingUnit.jsx
↓ 새로운 props 수신
↓ 리렌더링 발생!
```
**코드 위치:**
- `HomePanel.jsx` 라인 220-270
- `HomeBanner.jsx` 라인 81-85, 348-352, 497-542
---
### 2. OptionalTerms 상태와 조건부 렌더링 문제 ⚠️ **핵심 원인**
**문제 코드:** `HomeBanner.jsx` 라인 628-680
```javascript
const renderLayout = useCallback(() => {
switch (selectTemplate) {
case 'DSP00201': {
return (
<>
<ContainerBasic className={css.smallBox}>
{renderItem(0, true, true)}
{renderItem(1, true, true)}
</ContainerBasic>
{renderItem(2, false, false)}
{/* ⚠️ 문제 부분: introTermsAgree 상태에 의존 */}
{introTermsAgree === 'Y' ? (
<div className={css.imgBox}>
<JustForSwitchBanner
renderItem={renderItem}
handleShelfFocus={_handleShelfFocus}
handleItemFocus={_handleItemFocus}
isHorizontal={false}
spotlightId={'banner3'}
/>
</div>
) : (
renderItem(3, false, false)
)}
</>
);
}
}
return null;
}, [selectTemplate, renderItem, renderSimpleVideoContainer]);
// ❌ 문제: introTermsAgree가 의존성 배열에 없음!
```
**문제점:**
1. `introTermsAgree`가 JSX 내부에서 참조되지만 의존성 배열에 없음
2. `introTermsAgree` 변경 시 React가 최적화를 제대로 하지 못함
3. 클로저 문제로 인해 예상치 못한 리렌더링 발생
---
### 3. JustForSwitchBanner의 구조적 문제
**문제 코드:** `JustForYouBanner.jsx` 라인 154-230
```javascript
export default function JustForSwitchBanner({
renderItem, // ← HomeBanner에서 받은 renderItem
handleShelfFocus, // ← 계속 변하는 함수
handleItemFocus, // ← 계속 변하는 함수
isHorizontal,
spotlightId,
}) {
const [currentIndex, setCurrentIndex] = useState(0);
const onFocus = useCallback(() => {
if (handleItemFocus) {
handleItemFocus(); // ⚠️ 매번 새로운 함수 참조!
}
}, [handleItemFocus]); // ← 의존성: 계속 변경됨
return (
<>
<SpottableComponent onClick={handlePrev} />
{currentIndex === 0 ? (
<JustForYouBanner
onFocus={onFocus} // ← 계속 변하는 콜백!
/>
) : (
renderItem(3, false) // ⚠️ RollingUnit 렌더링!
)}
<SpottableComponent onClick={handleNext} />
</>
);
}
```
**문제의 연쇄 반응:**
```
handleItemFocus 변경
→ JustForSwitchBanner의 onFocus 재생성
→ JustForYouBanner 리렌더링
→ currentIndex === 1일 때 renderItem(3) 호출
→ RollingUnit 리렌더링!
```
---
### 4. RollingUnit 자동 롤링이 작동하지 않는 이유
**코드 위치:** `RollingUnit.jsx` 라인 463-487
```javascript
// 10초 롤링
useEffect(() => {
lastIndexRef.current = rollingDataLength - 1;
previousTimeRef.current = undefined; // ⚠️ 타이머 리셋!
if (rollingDataLength <= 1 || unitHasFocus) {
doRollingRef.current = false;
window.cancelAnimationFrame(requestRef.current);
return;
}
doRollingRef.current = true;
requestRef.current = window.requestAnimationFrame(animate);
return () => {
doRollingRef.current = false;
window.cancelAnimationFrame(requestRef.current);
};
}, [rollingDataLength, unitHasFocus]);
```
**문제:**
- RollingUnit이 계속 리렌더링되면 이 `useEffect`가 계속 실행됨
- `previousTimeRef.current = undefined`로 타이머가 계속 리셋됨
- `requestAnimationFrame`이 취소되고 다시 시작됨
- **결과: 10초 타이머가 초기화되어 자동 롤링이 작동하지 않음**
---
### 5. RandomUnit은 왜 괜찮은가?
**RandomUnit의 경우:**
```javascript
// HomeBanner.jsx - renderLayout (DSP00201)
<ContainerBasic className={css.smallBox}>
{renderItem(0, true, true)} // ← Rolling 또는 Random
{renderItem(1, true, true)} // ← 주로 Random (고정 위치)
</ContainerBasic>
```
**RandomUnit은:**
- ✅ 고정된 위치(index 0, 1)에서 렌더링
- ✅ 조건부 렌더링 없음
-`introTermsAgree` 상태와 무관
- ✅ JustForSwitchBanner를 통하지 않음
- ✅ 안정적인 props만 받음
---
## 💡 해결방법
### ✅ 해결방법 1: renderLayout 의존성 배열에 introTermsAgree 추가 (적용됨)
**파일:** `HomeBanner.jsx` 라인 514
**변경 전:**
```javascript
}, [selectTemplate, renderItem, renderSimpleVideoContainer]);
```
**변경 후:**
```javascript
}, [selectTemplate, renderItem, renderSimpleVideoContainer, introTermsAgree]);
```
**효과:**
- `introTermsAgree` 변경 시 `renderLayout`이 명시적으로 재생성됨
- React가 변경사항을 정확히 추적할 수 있음
- 클로저 문제 해결
---
### 🔧 추가 권장 해결방법
#### 해결방법 2: HomePanel의 handleItemFocus 최적화
**파일:** `HomePanel.jsx` 라인 265-270
**현재 문제:**
```javascript
const handleItemFocus = useCallback(
(containerId, location, title) => () => {
doSendLogGNB(containerId, location, title);
},
[doSendLogGNB]
);
// 렌더링 시 매번 새 함수 생성
<HomeBanner
handleItemFocus={handleItemFocus(el.shptmApphmDspyOptCd)}
/>
```
**해결방법:**
```javascript
// 1. 함수를 직접 전달하지 말고 파라미터를 전달
<HomeBanner
containerId={el.shptmApphmDspyOptCd}
location={el.expsOrd}
title={el.shptmApphmDspyOptNm}
onItemFocus={handleItemFocus} // 안정적인 함수 참조
/>
// 2. HomeBanner에서 필요할 때 호출
const _handleItemFocus = useCallback(() => {
if (onItemFocus) {
onItemFocus(containerId, location, title);
}
}, [onItemFocus, containerId, location, title]);
```
---
#### 해결방법 3: JustForSwitchBanner 최적화
**파일:** `JustForYouBanner.jsx` 라인 189-195
**현재 문제:**
```javascript
const onFocus = useCallback(() => {
if (handleItemFocus) {
handleItemFocus();
}
}, [handleItemFocus]); // ← 계속 변경됨
```
**해결방법 A: useRef 사용**
```javascript
const handleItemFocusRef = useRef(handleItemFocus);
useEffect(() => {
handleItemFocusRef.current = handleItemFocus;
}, [handleItemFocus]);
const onFocus = useCallback(() => {
if (handleItemFocusRef.current) {
handleItemFocusRef.current();
}
}, []); // ← 의존성 없음, 안정적
```
**해결방법 B: React.memo로 JustForYouBanner 감싸기**
```javascript
const JustForYouBanner = React.memo(function JustForYouBanner({
onClick,
spotlightId,
onFocus,
isHorizontal,
popupVisible,
activePopup,
}) {
// ... 기존 코드
}, (prevProps, nextProps) => {
// onFocus가 변경되어도 리렌더링하지 않음
return (
prevProps.spotlightId === nextProps.spotlightId &&
prevProps.isHorizontal === nextProps.isHorizontal &&
prevProps.popupVisible === nextProps.popupVisible &&
prevProps.activePopup === nextProps.activePopup
);
});
```
---
#### 해결방법 4: doSendLogGNB 의존성 최적화
**파일:** `HomePanel.jsx` 라인 220-263
**현재 문제:**
```javascript
const doSendLogGNB = useCallback(
(containerId, location = null, title = null) => {
// ... 로직
},
[pageSpotIds, nowShelf, panelInfo.nowShelf] // ← 자주 변경됨
);
```
**해결방법: useRef 사용**
```javascript
const nowShelfRef = useRef(nowShelf);
const panelInfoRef = useRef(panelInfo.nowShelf);
useEffect(() => {
nowShelfRef.current = nowShelf;
panelInfoRef.current = panelInfo.nowShelf;
}, [nowShelf, panelInfo.nowShelf]);
const doSendLogGNB = useCallback(
(containerId, location = null, title = null) => {
// nowShelfRef.current 사용
if (containerId !== nowShelfRef.current && location && title) {
// ... 로직
}
},
[pageSpotIds] // ← 의존성 감소
);
```
---
#### 해결방법 5: RollingUnit을 React.memo로 감싸기
**파일:** `RollingUnit.jsx` 마지막 줄
**현재:**
```javascript
export default function RollingUnit({ ... }) {
// ... 코드
}
```
**해결방법:**
```javascript
function RollingUnit({ ... }) {
// ... 기존 코드
}
export default React.memo(RollingUnit, (prevProps, nextProps) => {
// props가 실제로 변경되었을 때만 리렌더링
return (
prevProps.bannerData === nextProps.bannerData &&
prevProps.spotlightId === nextProps.spotlightId &&
prevProps.isHorizontal === nextProps.isHorizontal &&
prevProps.videoPlayerable === nextProps.videoPlayerable
// handleItemFocus, handleShelfFocus는 비교하지 않음
);
});
```
---
## 📊 우선순위별 적용 순서
### 1단계 (필수) ✅ 완료
- [x] `renderLayout` 의존성 배열에 `introTermsAgree` 추가
### 2단계 (권장)
- [ ] `doSendLogGNB` 의존성 최적화 (useRef 사용)
- [ ] `handleItemFocus` 구조 개선 (파라미터 전달 방식)
### 3단계 (선택)
- [ ] `JustForSwitchBanner` 최적화 (useRef 또는 React.memo)
- [ ] `RollingUnit`을 React.memo로 감싸기
---
## 🎯 예상 효과
### 1단계 적용 후:
-`introTermsAgree` 변경 시 명시적 리렌더링
- ✅ 클로저 문제 해결
- ⚠️ 여전히 props chain으로 인한 불필요한 리렌더링 가능
### 2-3단계 적용 후:
- ✅ 불필요한 리렌더링 대폭 감소
- ✅ RollingUnit 자동 롤링 정상 작동
- ✅ 성능 개선
- ✅ 안정적인 컴포넌트 동작
---
## 📝 테스트 체크리스트
- [ ] RollingUnit이 불필요하게 리렌더링되지 않는지 확인
- [ ] RollingUnit의 10초 자동 롤링이 정상 작동하는지 확인
- [ ] OptionalTerms 동의 전/후 배너 전환이 정상적인지 확인
- [ ] JustForYou 배너와 RollingUnit 간 전환이 부드러운지 확인
- [ ] RandomUnit이 여전히 정상 작동하는지 확인
- [ ] 포커스 이동 시 로그가 정상적으로 전송되는지 확인
---
## 📅 작성 정보
- **날짜**: 2025-10-13
- **분석 대상**: RollingUnit 리렌더링 문제
- **주요 원인**: OptionalTerms 상태 + JustForSwitchBanner 조건부 렌더링
- **적용된 해결방법**: renderLayout 의존성 배열에 introTermsAgree 추가

View File

@@ -590,30 +590,6 @@ export default function DetailPanel({ panelInfo, isOnTop, spotlightId }) {
[panelPrdtId, productData]
);
// ===== 파트너사별 배경 이미지 설정 로직 (현재 비활성화) =====
// thumbnailUrl960을 사용하여 파트너사별로 다른 배경 이미지를 설정하는 기능
// Pink Pong 등 특정 파트너사에서만 thumbnailUrl960 데이터가 있어서 배경이 변경됨
// 현재는 고정 배경(detailPanelBg)만 사용하기 위해 주석 처리
/*
useLayoutEffect(() => {
const shouldSetBackground = fp.pipe(
() => ({ imageUrl, containerRef }),
({ imageUrl, containerRef }) =>
fp.isNotNil(imageUrl) && fp.isNotNil(containerRef.current)
)();
if (shouldSetBackground) {
containerRef.current.style.setProperty("--bg-url", `url('${imageUrl}')`);
}
}, [imageUrl]);
*/
// console.log('productDataSource :', productDataSource);
// 언마운트 시 인덱스 초기화가 필요하면:
// useEffect(() => () => setSelectedIndex(0), [])
const handleProductAllSectionReady = useCallback(() => {
const spotTime = setTimeout(() => {
Spotlight.focus(SpotlightIds.DETAIL_SHOPBYMOBILE);
@@ -625,8 +601,6 @@ export default function DetailPanel({ panelInfo, isOnTop, spotlightId }) {
return (
<div ref={containerRef}>
{/* 배경 이미지 및 그라데이션 컴포넌트 - 모든 콘텐츠 뒤에 렌더링 */}
{/* launchedFromPlayer: PlayerPanel에서 진입 시 true, 다른 패널에서 진입 시 false/undefined */}
<DetailPanelBackground launchedFromPlayer={panelLaunchedFromPlayer} />
<TPanel

View File

@@ -50,6 +50,7 @@ import TScrollerDetail from '../components/TScroller/TScrollerDetail';
import ProductDescription from '../ProductContentSection/ProductDescription/ProductDescription';
import ProductDetail from '../ProductContentSection/ProductDetail/ProductDetail.new';
import ProductVideo from '../ProductContentSection/ProductVideo/ProductVideo';
import { ProductVideoV2 } from '../ProductContentSection/ProductVideo';
import UserReviews from '../ProductContentSection/UserReviews/UserReviews';
// import ViewAllReviewsButton from '../ProductContentSection/UserReviews/ViewAllReviewsButton';
import YouMayAlsoLike from '../ProductContentSection/YouMayAlsoLike/YouMayAlsoLike';
@@ -141,6 +142,9 @@ export default function ProductAllSection({
// YouMayLike 데이터는 API 응답 시간이 걸리므로 직접 구독
const youmaylikeData = useSelector((state) => state.main.youmaylikeData);
// ProductVideo 버전 관리 (1: 기존 modal 방식, 2: 내장 방식)
const [productVideoVersion, setProductVideoVersion] = useState(2);
// const [currentHeight, setCurrentHeight] = useState(0);
//하단부분까지 갔을때 체크용
const [documentHeight, setDocumentHeight] = useState(0);
@@ -391,6 +395,18 @@ export default function ProductAllSection({
return items;
}, [productData]);
// renderItems에 Video가 존재하는지 확인하는 boolean 상태
const hasVideo = useMemo(() => {
return (
renderItems && renderItems.length > 0 && renderItems.some((item) => item.type === 'video')
);
}, [renderItems]);
// ProductVideo (version 1) 전용: 포커스 관리 및 modal 동작을 위한 플래그
const isProductVideoV1Active = useMemo(() => {
return hasVideo && productVideoVersion === 1;
}, [hasVideo, productVideoVersion]);
const handleShopByMobileOpen = useCallback(
pipe(() => true, setMobileSendPopupOpen),
[]
@@ -714,12 +730,22 @@ export default function ProductAllSection({
{renderItems.length > 0 ? (
renderItems.map((item, index) =>
item.type === 'video' ? (
<ProductVideo
key="product-video-0"
productInfo={productData}
videoUrl={item.url}
thumbnailUrl={item.thumbnail}
/>
productVideoVersion === 1 ? (
<ProductVideo
key="product-video-0"
productInfo={productData}
videoUrl={item.url}
thumbnailUrl={item.thumbnail}
/>
) : (
<ProductVideoV2
key="product-video-v2-0"
productInfo={productData}
videoUrl={item.url}
thumbnailUrl={item.thumbnail}
autoPlay={true}
/>
)
) : (
<ProductDetail
key={`product-detail-${index}`}

View File

@@ -80,6 +80,53 @@
}
}
// VideoPlayer 래퍼 (ProductVideoV2에서 사용)
.videoPlayerWrapper {
position: relative;
width: 100%;
height: 100%;
border-radius: 8px;
overflow: hidden;
background-color: @COLOR_BLACK;
// VideoPlayer가 컨테이너에 꽉 차도록
:global(.videoPlayer) {
width: 100% !important;
height: 100% !important;
}
}
// 전체화면 모드 (ProductVideoV2 엔터키 토글)
.fullscreen {
position: fixed !important;
top: 0 !important;
left: 0 !important;
width: 1920px !important;
height: 1080px !important;
max-width: 1920px !important;
z-index: 99999 !important; // 최상위 레이어
margin: 0 !important;
border-radius: 0 !important;
background-color: @COLOR_BLACK;
.videoPlayerWrapper {
border-radius: 0;
}
// 전체화면 모드에서는 포커스가 밖으로 나가지 않도록
// Spotlight container가 이 영역만 관리
}
.fullscreenPlayer {
border-radius: 0 !important;
}
.videoThumbnailContainer {
width: 100%;
height: 100%;
cursor: pointer;
}
// 동영상 모달 클래스 (PlayerPanel에서 사용)
.videoModal {
// PlayerPanel 모달에서 사용되는 스타일

View File

@@ -0,0 +1,303 @@
import React, { useCallback, useEffect, useMemo, useState, useRef } from 'react';
import ReactDOM from 'react-dom';
import Spottable from '@enact/spotlight/Spottable';
import SpotlightContainerDecorator from '@enact/spotlight/SpotlightContainerDecorator';
import CustomImage from '../../../../components/CustomImage/CustomImage';
import { VideoPlayer } from '../../../../components/VideoPlayer/MediaPlayer';
import Media from '../../../../components/VideoPlayer/Media';
import TReactPlayer from '../../../../components/VideoPlayer/TReactPlayer';
import playImg from '../../../../../assets/images/btn/btn-play-thumb-nor.png';
import css from './ProductVideo.module.less';
const SpottableComponent = Spottable('div');
const SpotlightContainer = SpotlightContainerDecorator(
{
enterTo: 'default-element',
preserveId: 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,
},
};
export default function ProductVideoV2({ productInfo, videoUrl, thumbnailUrl, autoPlay = false }) {
const [isPlaying, setIsPlaying] = useState(false);
const [focused, setFocused] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false);
const videoPlayerRef = useRef(null);
const autoPlayTimerRef = useRef(null);
const containerRef = useRef(null);
// 비디오 재생 가능 여부 체크
const canPlayVideo = useMemo(() => {
return Boolean(productInfo?.prdtMediaUrl);
}, [productInfo]);
// YouTube 비디오 체크
const isYoutube = useMemo(() => {
const url = productInfo?.prdtMediaUrl;
if (url && url.includes('youtu')) {
return true;
}
return false;
}, [productInfo?.prdtMediaUrl]);
// 비디오 타입 결정
const videoType = useMemo(() => {
const url = productInfo?.prdtMediaUrl;
if (url) {
if (url.toLowerCase().endsWith('.mp4')) {
return 'video/mp4';
} else if (url.toLowerCase().endsWith('.mpd')) {
return 'application/dash+xml';
} else if (url.toLowerCase().endsWith('.m3u8')) {
return 'application/mpegurl';
}
}
return 'application/mpegurl';
}, [productInfo?.prdtMediaUrl]);
// 자막 설정
const reactPlayerSubtitleConfig = useMemo(() => {
const subtitleUrl = productInfo?.prdtMediaSubtitlUrl;
if (subtitleUrl) {
return {
file: {
attributes: {
crossOrigin: 'true',
},
tracks: [{ kind: 'subtitles', src: subtitleUrl, default: true }],
},
youtube: YOUTUBECONFIG,
};
} else {
return {
youtube: YOUTUBECONFIG,
};
}
}, [productInfo?.prdtMediaSubtitlUrl]);
// VideoPlayer ref 설정
const getPlayer = useCallback((ref) => {
videoPlayerRef.current = ref;
}, []);
// 포커스 이벤트 핸들러
const videoContainerOnFocus = useCallback(() => {
if (canPlayVideo && !isPlaying) {
setFocused(true);
}
}, [canPlayVideo, isPlaying]);
const videoContainerOnBlur = useCallback(() => {
if (canPlayVideo && !isPlaying) {
setFocused(false);
}
}, [canPlayVideo, isPlaying]);
// 썸네일 클릭 핸들러 - 비디오 재생 시작
const handleThumbnailClick = useCallback(() => {
if (canPlayVideo && !isPlaying) {
setIsPlaying(true);
}
}, [canPlayVideo, isPlaying]);
// 비디오 종료 핸들러 - 썸네일로 복귀
const handleVideoEnded = useCallback(() => {
setIsPlaying(false);
setIsFullscreen(false); // 전체화면도 해제
}, []);
// Back 버튼 핸들러 - 전체화면 해제 또는 비디오 종료
const handleBackButton = useCallback(() => {
if (isFullscreen) {
// 전체화면이면 일반 모드로
setIsFullscreen(false);
} else if (isPlaying) {
// 일반 모드에서 재생 중이면 썸네일로
setIsPlaying(false);
}
}, [isFullscreen, isPlaying]);
// 더미 함수들 (VideoPlayer가 요구하는 props)
const setIsVODPaused = useCallback(() => {}, []);
const setSideContentsVisible = useCallback(() => {}, []);
const handleIndicatorDownClick = useCallback(() => {}, []);
const handleIndicatorUpClick = useCallback(() => {}, []);
const setIsSubtitleActive = useCallback(() => {}, []);
const setCurrentTime = useCallback(() => {}, []);
// 전체화면 토글 핸들러
const toggleFullscreen = useCallback(() => {
setIsFullscreen((prev) => !prev);
}, []);
// 키보드 이벤트 핸들러: 비디오 재생 중 엔터키로 전체화면 토글
const handleKeyDown = useCallback(
(e) => {
// 비디오가 재생 중일 때만
if (!isPlaying) return;
// 엔터키(13) 또는 OK 버튼
if (e.keyCode === 13 || e.key === 'Enter') {
e.preventDefault();
e.stopPropagation();
toggleFullscreen();
}
},
[isPlaying, toggleFullscreen]
);
// 키보드 이벤트 리스너 등록 (window 레벨, Portal에서도 작동)
useEffect(() => {
if (isPlaying) {
window.addEventListener('keydown', handleKeyDown, true); // capture phase
return () => {
window.removeEventListener('keydown', handleKeyDown, true);
};
}
}, [isPlaying, handleKeyDown]);
// autoPlay 기능: 컴포넌트 마운트 후 500ms 후 자동 재생
useEffect(() => {
if (autoPlay && canPlayVideo && !isPlaying) {
autoPlayTimerRef.current = setTimeout(() => {
setIsPlaying(true);
}, 500);
}
// cleanup: unmount 시 timer 해제
return () => {
if (autoPlayTimerRef.current) {
clearTimeout(autoPlayTimerRef.current);
autoPlayTimerRef.current = null;
}
};
}, [autoPlay, canPlayVideo, isPlaying]);
if (!canPlayVideo) return null;
// 컨테이너 컴포넌트 결정
// 전체화면: SpotlightContainer (self-only)
// 일반 재생: SpottableComponent (포커스 가능)
// 썸네일: div
const ContainerComponent = isFullscreen
? SpotlightContainer
: isPlaying
? SpottableComponent
: 'div';
const containerProps = isFullscreen
? {
spotlightRestrict: 'self-only', // 포커스가 밖으로 나가지 않도록
spotlightId: 'product-video-v2-fullscreen',
}
: isPlaying
? {
spotlightId: 'product-video-v2-playing',
// 일반 재생 모드: 컨테이너가 포커스 받음
}
: {};
// 비디오 플레이어 컨텐츠
const videoContent = (
<ContainerComponent
{...containerProps}
ref={containerRef}
className={`${css.videoContainer} ${isFullscreen ? css.fullscreen : ''}`}
>
{!isPlaying ? (
// 썸네일 + 재생 버튼 표시
<SpottableComponent
className={css.videoThumbnailContainer}
onClick={handleThumbnailClick}
onFocus={videoContainerOnFocus}
onBlur={videoContainerOnBlur}
spotlightId="product-video-v2-thumbnail"
aria-label={`${productInfo?.prdtNm} 동영상 재생`}
>
<div className={css.videoThumbnailWrapper}>
<CustomImage
src={thumbnailUrl}
alt={`${productInfo?.prdtNm} 동영상 썸네일`}
className={css.videoThumbnail}
/>
<div className={css.playButtonOverlay}>
<img src={playImg} alt="재생" />
</div>
</div>
</SpottableComponent>
) : (
// VideoPlayer 내장 표시
<div className={`${css.videoPlayerWrapper} ${isFullscreen ? css.fullscreenPlayer : ''}`}>
<VideoPlayer
setApiProvider={getPlayer}
disabled={false}
onEnded={handleVideoEnded}
onBackButton={handleBackButton}
noAutoPlay={false}
autoCloseTimeout={3000}
spotlightDisabled={!isFullscreen}
isYoutube={isYoutube}
src={productInfo?.prdtMediaUrl}
reactPlayerConfig={reactPlayerSubtitleConfig}
thumbnailUrl={thumbnailUrl}
title={productInfo?.prdtNm}
videoComponent={
(typeof window === 'object' && !window.PalmSystem) || isYoutube ? TReactPlayer : Media
}
type="MEDIA"
panelInfo={{ modal: false }}
captionEnable={false}
setIsSubtitleActive={setIsSubtitleActive}
setCurrentTime={setCurrentTime}
setIsVODPaused={setIsVODPaused}
playListInfo={[]}
selectedIndex={0}
videoVerticalVisible={false}
sideContentsVisible={false}
setSideContentsVisible={setSideContentsVisible}
handleIndicatorDownClick={handleIndicatorDownClick}
handleIndicatorUpClick={handleIndicatorUpClick}
>
{typeof window === 'object' && window.PalmSystem && (
<source src={productInfo?.prdtMediaUrl} type={videoType} />
)}
{productInfo?.prdtMediaSubtitlUrl &&
typeof window === 'object' &&
window.PalmSystem && (
<track kind="subtitles" src={productInfo?.prdtMediaSubtitlUrl} default />
)}
</VideoPlayer>
</div>
)}
</ContainerComponent>
);
// 전체화면일 때는 Portal로 body에 직접 렌더링
if (isFullscreen) {
return ReactDOM.createPortal(videoContent, document.body);
}
// 일반 모드일 때는 그냥 렌더링
return videoContent;
}

View File

@@ -0,0 +1,5 @@
// Default export: 기존 ProductVideo (modal 방식)
export { default } from './ProductVideo';
// Named export: ProductVideoV2 (내장 방식)
export { default as ProductVideoV2 } from './ProductVideo.v2';