[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:
@@ -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 추가
|
||||
@@ -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
|
||||
|
||||
@@ -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' ? (
|
||||
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}`}
|
||||
|
||||
@@ -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 모달에서 사용되는 스타일
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
// Default export: 기존 ProductVideo (modal 방식)
|
||||
export { default } from './ProductVideo';
|
||||
|
||||
// Named export: ProductVideoV2 (내장 방식)
|
||||
export { default as ProductVideoV2 } from './ProductVideo.v2';
|
||||
Reference in New Issue
Block a user