[251122] fix: DetailPaneel->ProductAllSection Focus-4
🕐 커밋 시간: 2025. 11. 22. 16:46:50 📊 변경 통계: • 총 파일: 7개 • 추가: +184줄 • 삭제: -1320줄 📁 추가된 파일: + com.twin.app.shoptime/src/hooks/useDetailFocus/index.js + com.twin.app.shoptime/src/hooks/useDetailFocus/useDetailFocus.js 📝 수정된 파일: ~ com.twin.app.shoptime/src/views/DetailPanel/ProductAllSection/ProductAllSection.jsx 🗑️ 삭제된 파일: - com.twin.app.shoptime/DEBUG_MODE_IMPLEMENTATION.md - com.twin.app.shoptime/MEDIAPANEL_CLEANUP_IMPROVEMENTS.md - com.twin.app.shoptime/TIMER_CLEANUP_SUMMARY.md - com.twin.app.shoptime/[251116]_video_state_management_design.md 🔧 함수 변경 내용: 📄 com.twin.app.shoptime/src/views/DetailPanel/ProductAllSection/ProductAllSection.jsx (javascript): 🔄 Modified: extractProductMeta() ❌ Deleted: tryFocusUp() 📄 com.twin.app.shoptime/DEBUG_MODE_IMPLEMENTATION.md (md파일): ❌ Deleted: Before(), After() 📄 com.twin.app.shoptime/MEDIAPANEL_CLEANUP_IMPROVEMENTS.md (md파일): ❌ Deleted: useCallback(), showControls(), areControlsVisible(), toggleControls(), useLayoutEffect(), useEffect(), clearTimeout(), dispatch(), forEach(), getVideoNode(), addEventListener() 📄 com.twin.app.shoptime/TIMER_CLEANUP_SUMMARY.md (md파일): ❌ Deleted: clearTimeout(), clearAllVideoTimers(), pause(), useEffect(), setTimeout(), useCallback(), resume(), dispatch(), stopPropagation(), preventDefault(), disconnect(), updateProportionLoaded(), setInterval(), clearInterval(), useRef() 📄 com.twin.app.shoptime/[251116]_video_state_management_design.md (md파일): ❌ Deleted: curry(), dispatch(), useSelector() 🔧 주요 변경 내용: • 핵심 비즈니스 로직 개선 • 개발 문서 및 가이드 개선 Performance: 코드 최적화로 성능 개선 기대
This commit is contained in:
@@ -1,221 +0,0 @@
|
||||
# DEBUG_MODE 조건부 로깅 구현 완료
|
||||
|
||||
**작업 일시**: 2025-11-12
|
||||
**작업 범위**: ProductVideo.v2.jsx, MediaPanel.jsx
|
||||
|
||||
---
|
||||
|
||||
## 📋 작업 개요
|
||||
|
||||
ProductVideo.v2.jsx와 MediaPanel.jsx의 모든 로그 출력을 `DEBUG_MODE = true/false` 플래그로 제어할 수 있도록 구현했습니다.
|
||||
|
||||
---
|
||||
|
||||
## ✅ 구현 내용
|
||||
|
||||
### 1. DEBUG_MODE 설정
|
||||
|
||||
각 파일의 최상단에 DEBUG_MODE 상수를 추가합니다:
|
||||
|
||||
```javascript
|
||||
// ✅ DEBUG 모드 설정
|
||||
const DEBUG_MODE = true; // false로 설정하면 모든 로그 비활성화
|
||||
```
|
||||
|
||||
**설정 변경 방법:**
|
||||
- 프로덕션: `const DEBUG_MODE = false;` 로 변경
|
||||
- 개발/테스트: `const DEBUG_MODE = true;` 유지
|
||||
|
||||
### 2. debugLog 헬퍼 함수
|
||||
|
||||
DEBUG_MODE를 검사하는 래퍼 함수를 구현합니다:
|
||||
|
||||
```javascript
|
||||
// ✅ DEBUG_MODE 기반 console 래퍼
|
||||
const debugLog = (...args) => {
|
||||
if (DEBUG_MODE) {
|
||||
console.log(...args);
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
**특징:**
|
||||
- `console.log(...)` 대신 `debugLog(...)` 사용
|
||||
- DEBUG_MODE가 false이면 로그 출력 안 됨
|
||||
- 성능 오버헤드 거의 없음 (조건 체크만 수행)
|
||||
|
||||
### 3. console 메서드별 처리
|
||||
|
||||
| 메서드 | 처리 방식 | 파일 |
|
||||
|--------|----------|------|
|
||||
| `console.log()` | `debugLog()` 로 변경 | ProductVideo.v2.jsx, MediaPanel.jsx |
|
||||
| `console.warn()` | `if (DEBUG_MODE) console.warn()` | ProductVideo.v2.jsx, MediaPanel.jsx |
|
||||
| `console.error()` | `if (DEBUG_MODE) console.error()` | ProductVideo.v2.jsx |
|
||||
|
||||
---
|
||||
|
||||
## 📊 변경 통계
|
||||
|
||||
### ProductVideo.v2.jsx
|
||||
```
|
||||
- console.log() → debugLog(): 약 40+ 개
|
||||
- console.warn() → if (DEBUG_MODE) console.warn(): 2개
|
||||
- console.error() → if (DEBUG_MODE) console.error(): 1개
|
||||
```
|
||||
|
||||
### MediaPanel.jsx
|
||||
```
|
||||
- console.log() → debugLog(): 약 10+ 개
|
||||
- console.warn() → if (DEBUG_MODE) console.warn(): 1개
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 사용 방법
|
||||
|
||||
### DEBUG 로그 활성화 (개발 모드)
|
||||
```javascript
|
||||
const DEBUG_MODE = true; // ✅ 모든 로그 출력됨
|
||||
```
|
||||
|
||||
### DEBUG 로그 비활성화 (프로덕션)
|
||||
```javascript
|
||||
const DEBUG_MODE = false; // ❌ 모든 로그 숨김
|
||||
```
|
||||
|
||||
### 한 줄 변경으로 전체 로깅 제어
|
||||
각 파일의 두 번째 줄만 변경하면 됩니다:
|
||||
|
||||
**ProductVideo.v2.jsx Line 36**
|
||||
```javascript
|
||||
const DEBUG_MODE = true; // 변경: true ↔ false
|
||||
```
|
||||
|
||||
**MediaPanel.jsx Line 25**
|
||||
```javascript
|
||||
const DEBUG_MODE = true; // 변경: true ↔ false
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 💡 장점
|
||||
|
||||
1. **성능 최적화**
|
||||
- 프로덕션에서 로그 오버헤드 제거
|
||||
- 조건 검사만 수행 (콘솔 I/O 없음)
|
||||
|
||||
2. **개발 편의성**
|
||||
- 한 줄 변경으로 전체 로깅 제어
|
||||
- 파일 수정 없이 ENV 변수로 제어 가능 (향후)
|
||||
|
||||
3. **디버깅 용이**
|
||||
- 필요할 때만 로그 활성화
|
||||
- 로그 양 제어로 콘솔 지저분함 방지
|
||||
|
||||
4. **유지보수 편함**
|
||||
- 기존 console 호출 그대로 유지
|
||||
- 로그 코드 삭제 불필요
|
||||
|
||||
---
|
||||
|
||||
## 🔧 향후 개선 사항
|
||||
|
||||
### 1. 환경 변수 기반 설정
|
||||
```javascript
|
||||
const DEBUG_MODE = process.env.REACT_APP_DEBUG === 'true';
|
||||
```
|
||||
|
||||
### 2. 세부 로그 레벨 구분
|
||||
```javascript
|
||||
const LOG_LEVEL = {
|
||||
ERROR: 0,
|
||||
WARN: 1,
|
||||
INFO: 2,
|
||||
DEBUG: 3,
|
||||
};
|
||||
|
||||
const debugLog = (level, ...args) => {
|
||||
if (LOG_LEVEL[level] <= getCurrentLogLevel()) {
|
||||
console.log(...args);
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### 3. Redux DevTools 통합
|
||||
```javascript
|
||||
const debugLog = (...args) => {
|
||||
if (DEBUG_MODE) {
|
||||
console.log(...args);
|
||||
// Redux DevTools 에 추가 정보 기록
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ 검증 항목
|
||||
|
||||
- [x] ProductVideo.v2.jsx: 모든 console.log → debugLog 변경
|
||||
- [x] ProductVideo.v2.jsx: console.warn/error 조건부 처리
|
||||
- [x] MediaPanel.jsx: 모든 console.log → debugLog 변경
|
||||
- [x] MediaPanel.jsx: console.warn 조건부 처리
|
||||
- [x] debugLog 함수 올바르게 구현 (무한 루프 방지)
|
||||
- [x] DEBUG_MODE 설정 가능
|
||||
|
||||
---
|
||||
|
||||
## 🚀 다음 단계
|
||||
|
||||
1. **사용자 테스트**
|
||||
- DEBUG_MODE = true일 때 모든 로그 정상 출력 확인
|
||||
- DEBUG_MODE = false일 때 모든 로그 숨겨지는지 확인
|
||||
|
||||
2. **성능 테스트**
|
||||
- 프로덕션 모드에서 성능 개선 확인
|
||||
|
||||
3. **ENV 변수 연동**
|
||||
- `.env.development`, `.env.production` 설정
|
||||
- 빌드 시 자동으로 DEBUG_MODE 설정
|
||||
|
||||
---
|
||||
|
||||
## 📝 코드 예시
|
||||
|
||||
### Before (수정 전)
|
||||
```javascript
|
||||
console.log('🎬 [handleThumbnailClick] 썸네일 클릭됨', {...});
|
||||
console.warn('[ProductVideoV2] 비디오 정지 실패:', error);
|
||||
console.error('🖥️ [toggleControls] 디스패치 에러:', error);
|
||||
```
|
||||
|
||||
### After (수정 후)
|
||||
```javascript
|
||||
debugLog('🎬 [handleThumbnailClick] 썸네일 클릭됨', {...});
|
||||
if (DEBUG_MODE) console.warn('[ProductVideoV2] 비디오 정지 실패:', error);
|
||||
if (DEBUG_MODE) console.error('🖥️ [toggleControls] 디스패치 에러:', error);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📌 주의사항
|
||||
|
||||
1. **주석 처리된 로그**
|
||||
- 기존의 주석 처리된 console.log는 유지됨
|
||||
- 필요시 나중에 삭제 가능
|
||||
|
||||
2. **debugLog 함수 위치**
|
||||
- 컴포넌트 함수 외부에 선언됨
|
||||
- 매번 새로 생성되지 않음 (성능 최적화)
|
||||
|
||||
3. **프로덕션 배포**
|
||||
- 배포 전에 DEBUG_MODE를 false로 반드시 변경할 것
|
||||
|
||||
---
|
||||
|
||||
## ✨ 결론
|
||||
|
||||
ProductVideo.v2.jsx와 MediaPanel.jsx의 모든 로그 출력을 DEBUG_MODE 플래그로 제어할 수 있도록 구현완료.
|
||||
이를 통해 개발/테스트 중에는 디버깅 정보를 쉽게 확인할 수 있으며,
|
||||
프로덕션 환경에서는 로그 오버헤드를 제거하여 성능을 향상시킬 수 있습니다.
|
||||
|
||||
**작업 상태**: ✅ 완료
|
||||
@@ -1,430 +0,0 @@
|
||||
# MediaPanel.jsx 메모리 누수 방지 및 클린업 개선
|
||||
|
||||
**작업 일시**: 2025-11-12
|
||||
**파일**: MediaPanel.jsx
|
||||
**상태**: ✅ 완료 (코드 수정만, git/npm 미실행)
|
||||
|
||||
---
|
||||
|
||||
## 📋 작업 개요
|
||||
|
||||
MediaPanel.jsx의 메모리 누수를 방지하고 안전한 리소스 정리를 위해 다음과 같은 개선사항을 추가했습니다:
|
||||
|
||||
- ✅ 안전한 비디오 플레이어 메서드 호출 래퍼
|
||||
- ✅ 강화된 컴포넌트 언마운트 클린업
|
||||
- ✅ DOM 스타일 초기화 및 정리
|
||||
- ✅ 에러 처리 강화
|
||||
- ✅ 이벤트 리스너 추적 및 정리
|
||||
|
||||
---
|
||||
|
||||
## 🔧 주요 개선 사항
|
||||
|
||||
### 1. 안전한 메서드 호출 래퍼 (safePlayerCall)
|
||||
|
||||
**위치**: Line 107-117
|
||||
|
||||
```javascript
|
||||
// ✅ 안전한 비디오 플레이어 메서드 호출
|
||||
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;
|
||||
}, []);
|
||||
```
|
||||
|
||||
**장점:**
|
||||
- null/undefined 안전 검사
|
||||
- 메서드 존재 여부 확인
|
||||
- 에러 처리 통일
|
||||
- 트라이-캐치로 예외 처리
|
||||
|
||||
**사용 예:**
|
||||
```javascript
|
||||
safePlayerCall('play');
|
||||
safePlayerCall('toggleControls');
|
||||
const mediaState = safePlayerCall('getMediaState');
|
||||
```
|
||||
|
||||
### 2. 레퍼런스 추적 Ref 추가
|
||||
|
||||
**위치**: Line 64
|
||||
|
||||
```javascript
|
||||
const mediaEventListenersRef = useRef([]); // ✅ 미디어 이벤트 리스너 추적
|
||||
```
|
||||
|
||||
**목적:**
|
||||
- 등록된 이벤트 리스너 관리
|
||||
- 언마운트 시 모든 리스너 제거 가능
|
||||
- 메모리 누수 방지
|
||||
|
||||
### 3. isOnTop 변경 시 안전한 제어
|
||||
|
||||
**위치**: Line 178-188
|
||||
|
||||
**Before:**
|
||||
```javascript
|
||||
if (videoPlayer.current?.getMediaState()?.paused) {
|
||||
videoPlayer.current.play();
|
||||
}
|
||||
if (videoPlayer.current.areControlsVisible && !videoPlayer.current.areControlsVisible()) {
|
||||
videoPlayer.current.showControls();
|
||||
}
|
||||
```
|
||||
|
||||
**After:**
|
||||
```javascript
|
||||
// ✅ 안전한 메서드 호출로 null/undefined 체크
|
||||
const mediaState = safePlayerCall('getMediaState');
|
||||
if (mediaState?.paused) {
|
||||
safePlayerCall('play');
|
||||
}
|
||||
|
||||
const isControlsHidden = videoPlayer.current.areControlsVisible && !videoPlayer.current.areControlsVisible();
|
||||
if (isControlsHidden) {
|
||||
safePlayerCall('showControls');
|
||||
}
|
||||
```
|
||||
|
||||
**개선점:**
|
||||
- mediaState null 체크 강화
|
||||
- 모든 플레이어 호출을 안전한 래퍼로 통일
|
||||
- 에러 처리 일관성
|
||||
|
||||
### 4. 비디오 클릭 핸들러 개선
|
||||
|
||||
**위치**: Line 199-208
|
||||
|
||||
**Before:**
|
||||
```javascript
|
||||
if (videoPlayer.current && typeof videoPlayer.current.toggleControls === 'function') {
|
||||
videoPlayer.current.toggleControls();
|
||||
}
|
||||
```
|
||||
|
||||
**After:**
|
||||
```javascript
|
||||
safePlayerCall('toggleControls');
|
||||
```
|
||||
|
||||
**개선점:**
|
||||
- 코드 간결성
|
||||
- 에러 처리 통일
|
||||
|
||||
### 5. 뒤로가기 시 비디오 정지
|
||||
|
||||
**위치**: Line 212-213
|
||||
|
||||
```javascript
|
||||
// ✅ 뒤로가기 시 비디오 정지
|
||||
safePlayerCall('pause');
|
||||
```
|
||||
|
||||
**효과:**
|
||||
- 패널 닫을 때 비디오 자동 정지
|
||||
- 메모리 정리 시작
|
||||
|
||||
### 6. DOM 스타일 설정 및 정리
|
||||
|
||||
**위치**: Line 353-376
|
||||
|
||||
**Before:**
|
||||
```javascript
|
||||
useLayoutEffect(() => {
|
||||
const videoContainer = document.querySelector(`.${css.videoContainer}`);
|
||||
if (videoContainer && panelInfo.thumbnailUrl && !videoLoaded) {
|
||||
videoContainer.style.background = `url(${panelInfo.thumbnailUrl}) center center / contain no-repeat`;
|
||||
videoContainer.style.backgroundColor = 'black';
|
||||
}
|
||||
}, [panelInfo.thumbnailUrl, videoLoaded]);
|
||||
```
|
||||
|
||||
**After:**
|
||||
```javascript
|
||||
// ✅ 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) {
|
||||
try {
|
||||
videoContainer.style.background = '';
|
||||
videoContainer.style.backgroundColor = '';
|
||||
} catch (err) {
|
||||
// 스타일 초기화 실패는 무시
|
||||
}
|
||||
}
|
||||
};
|
||||
}, [panelInfo.thumbnailUrl, videoLoaded]);
|
||||
```
|
||||
|
||||
**개선점:**
|
||||
- 에러 처리 추가
|
||||
- cleanup 함수로 DOM 스타일 초기화
|
||||
- 메모리 누수 방지
|
||||
|
||||
### 7. mediainfoHandler 강화
|
||||
|
||||
**위치**: Line 280-326
|
||||
|
||||
**개선 사항:**
|
||||
- safePlayerCall 사용으로 null 안정성
|
||||
- hlsError 처리 강화
|
||||
- timeupdate 이벤트에서 mediaState 체크
|
||||
- error 이벤트에서 null 기본값 제공
|
||||
|
||||
```javascript
|
||||
case 'timeupdate': {
|
||||
const mediaState = safePlayerCall('getMediaState');
|
||||
if (mediaState) {
|
||||
setCurrentTime(mediaState.currentTime || 0); // ✅ 기본값 제공
|
||||
}
|
||||
break;
|
||||
}
|
||||
```
|
||||
|
||||
### 8. 컴포넌트 언마운트 시 전체 클린업 강화
|
||||
|
||||
**위치**: Line 382-429
|
||||
|
||||
**개선 사항:**
|
||||
|
||||
```javascript
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
// ✅ onEnded 타이머 정리
|
||||
if (onEndedTimerRef.current) {
|
||||
clearTimeout(onEndedTimerRef.current);
|
||||
onEndedTimerRef.current = null;
|
||||
}
|
||||
|
||||
// ✅ Redux 상태 정리
|
||||
dispatch(stopMediaAutoClose?.()) || null;
|
||||
|
||||
// ✅ 비디오 플레이어 정지 및 정리
|
||||
if (videoPlayer.current) {
|
||||
try {
|
||||
safePlayerCall('pause');
|
||||
safePlayerCall('hideControls');
|
||||
} catch (err) {
|
||||
if (DEBUG_MODE) console.warn('[MediaPanel] 비디오 정지 실패:', err);
|
||||
}
|
||||
videoPlayer.current = null; // ✅ ref 초기화
|
||||
}
|
||||
|
||||
// ✅ 이벤트 리스너 정리
|
||||
if (mediaEventListenersRef.current && mediaEventListenersRef.current.length > 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]);
|
||||
```
|
||||
|
||||
**정리 항목:**
|
||||
1. ✅ onEnded 타이머 정리
|
||||
2. ✅ Redux 상태 정리
|
||||
3. ✅ 비디오 플레이어 정지
|
||||
4. ✅ 플레이어 ref 초기화
|
||||
5. ✅ 이벤트 리스너 제거
|
||||
6. ✅ Spotlight 상태 복구
|
||||
|
||||
---
|
||||
|
||||
## 📊 변경 통계
|
||||
|
||||
| 항목 | 수량 |
|
||||
|------|------|
|
||||
| 새로운 Ref | 1개 (mediaEventListenersRef) |
|
||||
| 새로운 함수 | 1개 (safePlayerCall) |
|
||||
| 개선된 useEffect | 2개 |
|
||||
| 개선된 콜백 | 3개 |
|
||||
| 추가된 클린업 로직 | 6개 항목 |
|
||||
| 에러 처리 강화 | 4개 지점 |
|
||||
|
||||
---
|
||||
|
||||
## 🎯 효과
|
||||
|
||||
### 메모리 누수 방지
|
||||
- ✅ 타이머 명시적 정리
|
||||
- ✅ 이벤트 리스너 추적 및 정리
|
||||
- ✅ ref 초기화
|
||||
- ✅ Redux 상태 정리
|
||||
|
||||
### 안정성 향상
|
||||
- ✅ null/undefined 체크 강화
|
||||
- ✅ 에러 처리 통일
|
||||
- ✅ 존재하지 않는 메서드 호출 방지
|
||||
- ✅ 트라이-캐치 예외 처리
|
||||
|
||||
### 코드 품질 개선
|
||||
- ✅ 반복 코드 제거
|
||||
- ✅ 일관된 에러 처리
|
||||
- ✅ 명확한 주석
|
||||
- ✅ 안전한 디폴트값 사용
|
||||
|
||||
---
|
||||
|
||||
## 🔍 호환성 확인
|
||||
|
||||
### 기존 기능 보존
|
||||
- ✅ 비디오 재생/정지 동작 유지
|
||||
- ✅ controls 표시/숨김 로직 유지
|
||||
- ✅ modal ↔ fullscreen 전환 유지
|
||||
- ✅ onEnded 콜백 동작 유지
|
||||
- ✅ 이벤트 핸들러 동작 유지
|
||||
|
||||
### 추가 보호
|
||||
- ✅ null 참조 예외 방지
|
||||
- ✅ 잘못된 메서드 호출 방지
|
||||
- ✅ DOM 접근 에러 방지
|
||||
- ✅ 타이머 중복 정리 방지
|
||||
|
||||
---
|
||||
|
||||
## 📌 주의사항
|
||||
|
||||
### DEBUG_MODE 설정
|
||||
```javascript
|
||||
const DEBUG_MODE = false; // 프로덕션
|
||||
const DEBUG_MODE = true; // 개발/디버깅
|
||||
```
|
||||
|
||||
- DEBUG_MODE = false일 때: 모든 경고 로그 숨김
|
||||
- DEBUG_MODE = true일 때: 모든 디버그 로그 표시
|
||||
|
||||
### safePlayerCall 사용 규칙
|
||||
1. 존재하지 않을 수 있는 메서드만 사용
|
||||
2. 반환값이 필요하면 null 체크
|
||||
3. 항상 try-catch로 감싸짐
|
||||
|
||||
```javascript
|
||||
// ✅ Good
|
||||
const state = safePlayerCall('getMediaState');
|
||||
if (state) { /* ... */ }
|
||||
|
||||
// ✅ Good
|
||||
safePlayerCall('play');
|
||||
|
||||
// ❌ Bad - 존재하는 메서드는 직접 호출
|
||||
videoPlayer.current.getVideoNode();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚀 향후 개선 사항
|
||||
|
||||
1. **이벤트 리스너 자동 추적**
|
||||
```javascript
|
||||
const addTrackedListener = useCallback((element, event, handler) => {
|
||||
element.addEventListener(event, handler);
|
||||
mediaEventListenersRef.current.push({ element, event, handler });
|
||||
}, []);
|
||||
```
|
||||
|
||||
2. **성능 모니터링**
|
||||
- 메모리 사용량 로깅
|
||||
- 타이머 정리 시간 측정
|
||||
|
||||
3. **테스트 커버리지**
|
||||
- 반복 마운트/언마운트 테스트
|
||||
- 메모리 누수 테스트
|
||||
- 에러 케이스 테스트
|
||||
|
||||
---
|
||||
|
||||
## ✅ 검증 항목
|
||||
|
||||
- [x] 기존 기능 동작 확인
|
||||
- [x] 메모리 누수 방지 로직 추가
|
||||
- [x] null/undefined 안전성 강화
|
||||
- [x] 에러 처리 통일
|
||||
- [x] 클린업 함수 완성
|
||||
- [x] 주석 및 문서화 완료
|
||||
|
||||
---
|
||||
|
||||
## 📝 코드 예시
|
||||
|
||||
### safePlayerCall 사용 예
|
||||
```javascript
|
||||
// Before
|
||||
if (videoPlayer.current?.getMediaState()?.paused) {
|
||||
videoPlayer.current.play();
|
||||
}
|
||||
|
||||
// After
|
||||
const mediaState = safePlayerCall('getMediaState');
|
||||
if (mediaState?.paused) {
|
||||
safePlayerCall('play');
|
||||
}
|
||||
```
|
||||
|
||||
### 언마운트 클린업
|
||||
```javascript
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
// 타이머 정리
|
||||
if (onEndedTimerRef.current) {
|
||||
clearTimeout(onEndedTimerRef.current);
|
||||
}
|
||||
|
||||
// Redux 정리
|
||||
dispatch(stopMediaAutoClose?.());
|
||||
|
||||
// 플레이어 정리
|
||||
safePlayerCall('pause');
|
||||
videoPlayer.current = null;
|
||||
|
||||
// 리스너 정리
|
||||
mediaEventListenersRef.current.forEach(({ element, event, handler }) => {
|
||||
element?.removeEventListener?.(event, handler);
|
||||
});
|
||||
};
|
||||
}, [dispatch, safePlayerCall]);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✨ 결론
|
||||
|
||||
MediaPanel.jsx에 다음과 같은 메모리 누수 방지 및 클린업 기능을 추가했습니다:
|
||||
|
||||
1. **안전한 메서드 호출** - safePlayerCall 래퍼
|
||||
2. **강화된 클린업** - 6개 항목 정리
|
||||
3. **에러 처리** - 통일된 예외 처리
|
||||
4. **리스너 추적** - 이벤트 리스너 관리 준비
|
||||
5. **DOM 정리** - 스타일 초기화
|
||||
|
||||
이를 통해 장시간 사용 시에도 메모리 누수 없이 안정적으로 동작할 것으로 기대됩니다.
|
||||
|
||||
**작업 상태**: ✅ 완료 (코드 수정만, git/npm 미실행)
|
||||
@@ -1,398 +0,0 @@
|
||||
# 타이머 클린업 및 메모리 누수 방지 작업 완료 보고
|
||||
|
||||
**작업 일시**: 2025-11-12
|
||||
**작업 범위**: ProductVideo.v2.jsx, MediaPanel.jsx, MediaPlayer.v2.jsx
|
||||
|
||||
---
|
||||
|
||||
## 📋 작업 개요
|
||||
|
||||
비디오 플레이어 관련 컴포넌트들에서 타이머와 이벤트 리스너가 제대로 정리되지 않아 발생할 수 있는 메모리 누수를 방지하기 위해 다음 개선 작업을 수행했습니다:
|
||||
|
||||
- ✅ **setTimeout/setInterval 타이머의 명시적 정리**
|
||||
- ✅ **이벤트 리스너의 적절한 등록/해제**
|
||||
- ✅ **Ref를 통한 타이머 추적 및 정리**
|
||||
- ✅ **컴포넌트 언마운트 시 리소스 정리**
|
||||
|
||||
---
|
||||
|
||||
## 🔧 ProductVideo.v2.jsx 개선 사항
|
||||
|
||||
### 1. autoPlay 타이머 정리 강화
|
||||
**파일 위치**: Line 566-597
|
||||
|
||||
```javascript
|
||||
// Before
|
||||
return () => {
|
||||
if (autoPlayTimerRef.current) {
|
||||
clearTimeout(autoPlayTimerRef.current);
|
||||
autoPlayTimerRef.current = null;
|
||||
}
|
||||
clearAllVideoTimers();
|
||||
if (videoPlayerRef.current) {
|
||||
try {
|
||||
videoPlayerRef.current.pause();
|
||||
} catch (error) {
|
||||
console.warn('[ProductVideoV2] 비디오 정지 실패:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// After
|
||||
return () => {
|
||||
// ✅ autoPlay timer 정리
|
||||
if (autoPlayTimerRef.current) {
|
||||
clearTimeout(autoPlayTimerRef.current);
|
||||
autoPlayTimerRef.current = null;
|
||||
}
|
||||
// ✅ 전역 비디오 타이머 정리 (메모리 누수 방지)
|
||||
clearAllVideoTimers?.(); // Optional chaining 추가
|
||||
// ✅ 비디오 플레이어 정지
|
||||
if (videoPlayerRef.current) {
|
||||
try {
|
||||
videoPlayerRef.current.pause?.(); // Optional chaining 추가
|
||||
} catch (error) {
|
||||
console.warn('[ProductVideoV2] 비디오 정지 실패:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
**개선점**:
|
||||
- Optional chaining (`?.`) 추가로 null/undefined 체크 안정성 향상
|
||||
- `isPlaying` dependency 제거 (무한 루프 방지)
|
||||
- 명확한 주석으로 코드 가독성 개선
|
||||
|
||||
### 2. 전체화면 전환 시 타이머 정리
|
||||
**파일 위치**: Line 615-647
|
||||
|
||||
```javascript
|
||||
// Before
|
||||
useEffect(() => {
|
||||
if (isPlaying && videoPlayerRef.current) {
|
||||
// ...
|
||||
const timeoutId = setTimeout(() => {
|
||||
// ...
|
||||
}, 100);
|
||||
return () => clearTimeout(timeoutId);
|
||||
}
|
||||
}, [isFullscreen, isPlaying]);
|
||||
|
||||
// After
|
||||
useEffect(() => {
|
||||
if (isPlaying && videoPlayerRef.current) {
|
||||
// ...
|
||||
const timeoutId = setTimeout(() => {
|
||||
// ...
|
||||
}, 100);
|
||||
// ✅ cleanup: 타이머 정리
|
||||
return () => {
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
};
|
||||
}
|
||||
}, [isFullscreen, isPlaying]);
|
||||
```
|
||||
|
||||
**개선점**:
|
||||
- Null 체크 추가로 안정성 향상
|
||||
- 명확한 cleanup 함수 작성
|
||||
|
||||
### 3. 전역 document 이벤트 리스너 정리 명확화
|
||||
**파일 위치**: Line 504-537
|
||||
|
||||
**개선점**:
|
||||
- 명확한 주석으로 이벤트 리스너 등록/해제 의도 표명
|
||||
- cleanup 함수에서 일관된 이벤트 리스너 제거
|
||||
|
||||
---
|
||||
|
||||
## 🎬 MediaPanel.jsx 개선 사항
|
||||
|
||||
### 1. onEnded 타이머 관리 개선
|
||||
**파일 위치**: Line 52-53 (ref 추가), Line 285-308 (콜백 개선)
|
||||
|
||||
```javascript
|
||||
// Added ref for timer tracking
|
||||
const onEndedTimerRef = useRef(null); // ✅ onEnded 타이머 관리
|
||||
|
||||
// Before
|
||||
const onEnded = useCallback(
|
||||
(e) => {
|
||||
Spotlight.pause();
|
||||
setTimeout(() => {
|
||||
Spotlight.resume();
|
||||
dispatch(PanelActions.popPanel(panel_names.MEDIA_PANEL));
|
||||
}, 1500);
|
||||
e?.stopPropagation();
|
||||
e?.preventDefault();
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
// After
|
||||
const onEnded = useCallback(
|
||||
(e) => {
|
||||
Spotlight.pause();
|
||||
// ✅ 이전 타이머가 있으면 정리
|
||||
if (onEndedTimerRef.current) {
|
||||
clearTimeout(onEndedTimerRef.current);
|
||||
}
|
||||
// ✅ 새로운 타이머 저장 (cleanup 시 정리용)
|
||||
onEndedTimerRef.current = setTimeout(() => {
|
||||
Spotlight.resume();
|
||||
dispatch(PanelActions.popPanel(panel_names.MEDIA_PANEL));
|
||||
onEndedTimerRef.current = null;
|
||||
}, 1500);
|
||||
e?.stopPropagation();
|
||||
e?.preventDefault();
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
```
|
||||
|
||||
**개선점**:
|
||||
- useRef를 통한 타이머 추적으로 중복 호출 방지
|
||||
- 명시적 타이머 정리 로직
|
||||
|
||||
### 2. 컴포넌트 언마운트 시 타이머 정리
|
||||
**파일 위치**: Line 322-340 (신규 useEffect 추가)
|
||||
|
||||
```javascript
|
||||
// ✅ 컴포넌트 언마운트 시 모든 타이머 정리
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
// onEnded 타이머 정리
|
||||
if (onEndedTimerRef.current) {
|
||||
clearTimeout(onEndedTimerRef.current);
|
||||
onEndedTimerRef.current = null;
|
||||
}
|
||||
// ✅ 비디오 플레이어 정지
|
||||
if (videoPlayer.current) {
|
||||
try {
|
||||
videoPlayer.current.pause?.();
|
||||
} catch (error) {
|
||||
console.warn('[MediaPanel] 비디오 정지 실패:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
```
|
||||
|
||||
**개선점**:
|
||||
- 컴포넌트 언마운트 시 모든 타이머 정리
|
||||
- 비디오 플레이어 강제 정지로 리소스 누수 방지
|
||||
|
||||
### 3. Modal 스타일 설정 시 ResizeObserver 정리
|
||||
**파일 위치**: Line 114-171
|
||||
|
||||
```javascript
|
||||
// ✅ modal 스타일 설정
|
||||
useEffect(() => {
|
||||
let resizeObserver = null;
|
||||
// ... 스타일 설정 로직
|
||||
// ✅ cleanup: resize observer 정리
|
||||
return () => {
|
||||
if (resizeObserver) {
|
||||
resizeObserver.disconnect();
|
||||
}
|
||||
};
|
||||
}, [panelInfo, isOnTop]);
|
||||
```
|
||||
|
||||
**개선점**:
|
||||
- ResizeObserver 초기화로 미래 구현 시 메모리 누수 방지 준비
|
||||
|
||||
---
|
||||
|
||||
## 📹 MediaPlayer.v2.jsx 개선 사항
|
||||
|
||||
### 1. proportionLoaded 업데이트 타이머 최적화
|
||||
**파일 위치**: Line 411-431
|
||||
|
||||
```javascript
|
||||
// Before
|
||||
useEffect(() => {
|
||||
updateProportionLoaded();
|
||||
const interval = setInterval(() => {
|
||||
updateProportionLoaded();
|
||||
}, 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, [updateProportionLoaded]);
|
||||
|
||||
// After
|
||||
useEffect(() => {
|
||||
updateProportionLoaded();
|
||||
// ✅ 1초마다 업데이트 (비디오 재생 중일 때만)
|
||||
let intervalId = null;
|
||||
if (!paused) {
|
||||
intervalId = setInterval(() => {
|
||||
updateProportionLoaded();
|
||||
}, 1000);
|
||||
}
|
||||
// ✅ cleanup: interval 정리
|
||||
return () => {
|
||||
if (intervalId !== null) {
|
||||
clearInterval(intervalId);
|
||||
}
|
||||
};
|
||||
}, [updateProportionLoaded, paused]);
|
||||
```
|
||||
|
||||
**개선점**:
|
||||
- 비디오 일시정지 중에는 interval 생성하지 않음 (불필요한 타이머 제거)
|
||||
- `paused` dependency 추가로 상태 변화 감지
|
||||
- 명시적 null 체크로 정리 안정성 향상
|
||||
|
||||
### 2. 컴포넌트 언마운트 시 전체 cleanup 강화
|
||||
**파일 위치**: Line 433-454
|
||||
|
||||
```javascript
|
||||
// ✅ Cleanup: 컴포넌트 언마운트 시 모든 타이머 및 상태 정리
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
// ✅ controlsTimeoutRef 정리
|
||||
if (controlsTimeoutRef.current) {
|
||||
clearTimeout(controlsTimeoutRef.current);
|
||||
controlsTimeoutRef.current = null;
|
||||
}
|
||||
// ✅ 비디오 플레이어 정지
|
||||
if (videoRef.current) {
|
||||
try {
|
||||
videoRef.current.pause?.();
|
||||
} catch (error) {
|
||||
console.warn('[MediaPlayer.v2] 비디오 정지 실패:', error);
|
||||
}
|
||||
}
|
||||
// ✅ MediaPlayer 언마운트 시 Redux 상태 정리
|
||||
dispatch(stopMediaAutoClose());
|
||||
};
|
||||
}, [dispatch]);
|
||||
```
|
||||
|
||||
**개선점**:
|
||||
- 비디오 플레이어 강제 정지 추가
|
||||
- Optional chaining으로 안정성 향상
|
||||
- 에러 핸들링 추가
|
||||
|
||||
### 3. hideControls 메서드 주석 추가
|
||||
**파일 위치**: Line 290-299
|
||||
|
||||
**개선점**:
|
||||
- 타이머 정리 의도 명확화를 위한 주석 추가
|
||||
|
||||
---
|
||||
|
||||
## 🎯 핵심 개선 패턴
|
||||
|
||||
### 1. **Ref를 통한 타이머 추적**
|
||||
```javascript
|
||||
const timerRef = useRef(null);
|
||||
|
||||
const startTimer = () => {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
}
|
||||
timerRef.current = setTimeout(() => {
|
||||
// ...
|
||||
timerRef.current = null;
|
||||
}, delay);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
```
|
||||
|
||||
### 2. **Optional Chaining으로 안정성 향상**
|
||||
```javascript
|
||||
// Before
|
||||
videoRef.current.pause();
|
||||
|
||||
// After
|
||||
videoRef.current.pause?.();
|
||||
```
|
||||
|
||||
### 3. **조건부 타이머 생성**
|
||||
```javascript
|
||||
// Before - 항상 interval 생성
|
||||
const interval = setInterval(() => {
|
||||
updateProportionLoaded();
|
||||
}, 1000);
|
||||
|
||||
// After - 필요할 때만 생성
|
||||
let intervalId = null;
|
||||
if (!paused) {
|
||||
intervalId = setInterval(() => {
|
||||
updateProportionLoaded();
|
||||
}, 1000);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ 검증 항목
|
||||
|
||||
다음 항목들이 개선되었습니다:
|
||||
|
||||
- [x] **autoPlay 타이머** 정리 강화 (ProductVideo.v2.jsx)
|
||||
- [x] **전체화면 전환 타이머** 정리 (ProductVideo.v2.jsx)
|
||||
- [x] **Document 이벤트 리스너** 정리 명확화 (ProductVideo.v2.jsx)
|
||||
- [x] **onEnded 타이머** Ref 추적 (MediaPanel.jsx)
|
||||
- [x] **컴포넌트 언마운트 cleanup** 강화 (MediaPanel.jsx)
|
||||
- [x] **Modal 스타일 설정** ResizeObserver 정리 준비 (MediaPanel.jsx)
|
||||
- [x] **proportionLoaded 업데이트** 타이머 최적화 (MediaPlayer.v2.jsx)
|
||||
- [x] **전체 cleanup 함수** 강화 (MediaPlayer.v2.jsx)
|
||||
|
||||
---
|
||||
|
||||
## 🚀 다음 단계
|
||||
|
||||
### 권장 사항
|
||||
|
||||
1. **Redux Actions 검토**
|
||||
- `clearAllVideoTimers()` 액션이 실제로 모든 타이머를 정리하는지 확인
|
||||
- `startMediaAutoClose()`, `stopMediaAutoClose()` 타이머 정리 로직 검토
|
||||
|
||||
2. **VideoPlayer/Media 컴포넌트**
|
||||
- webOS Media 컴포넌트의 타이머 정리 로직 확인
|
||||
- TReactPlayer의 cleanup 로직 검토
|
||||
|
||||
3. **테스트**
|
||||
- 장시간 비디오 재생 후 메모리 사용량 모니터링
|
||||
- 여러 번 반복 재생/정지 시 메모리 누수 확인
|
||||
- 전체화면 전환 시 리소스 누수 확인
|
||||
|
||||
4. **성능 모니터링**
|
||||
- Chrome DevTools Memory tab에서 힙 스냅샷 비교
|
||||
- 컴포넌트 마운트/언마운트 반복 시 메모리 증감 확인
|
||||
|
||||
---
|
||||
|
||||
## 📝 주요 변경 요약
|
||||
|
||||
| 파일 | 변경 사항 | 라인 | 개선 효과 |
|
||||
|------|---------|------|---------|
|
||||
| ProductVideo.v2.jsx | autoPlay 타이머 정리 강화 | 566-597 | 메모리 누수 방지 |
|
||||
| ProductVideo.v2.jsx | 전체화면 전환 타이머 정리 | 615-647 | 타이머 중복 방지 |
|
||||
| ProductVideo.v2.jsx | Document 이벤트 리스너 정리 | 504-537 | 이벤트 리스너 누수 방지 |
|
||||
| MediaPanel.jsx | onEnded 타이머 Ref 추적 | 52-53, 285-308 | 타이머 중복 호출 방지 |
|
||||
| MediaPanel.jsx | 컴포넌트 언마운트 cleanup | 322-340 | 메모리 누수 방지 |
|
||||
| MediaPanel.jsx | Modal 스타일 ResizeObserver | 114-171 | 옵저버 정리 준비 |
|
||||
| MediaPlayer.v2.jsx | proportionLoaded 타이머 최적화 | 411-431 | 불필요한 타이머 제거 |
|
||||
| MediaPlayer.v2.jsx | 전체 cleanup 강화 | 433-454 | 메모리 누수 방지 |
|
||||
|
||||
---
|
||||
|
||||
## ✨ 결론
|
||||
|
||||
비디오 플레이어 관련 컴포넌트들의 타이머와 이벤트 리스너 정리를 체계적으로 개선했습니다.
|
||||
이를 통해 장시간 비디오 재생 시에도 메모리 누수 없이 안정적으로 동작할 것으로 기대됩니다.
|
||||
|
||||
**작업 상태**: ✅ 완료
|
||||
@@ -1,220 +0,0 @@
|
||||
# [251116] 새로운 비디오 상태 관리 시스템 구현
|
||||
|
||||
## 개요
|
||||
|
||||
기존의 videoPlayReducer는 유지하되, PlayerPanel과 VideoPlayer.js를 위한 새로운 비디오 상태 관리 시스템을 playerReducer에 구현한다. 재생 상태와 화면 상태를 분리하여 더 정밀한 비디오 상태 제어를 가능하게 한다.
|
||||
|
||||
## 설계 원칙
|
||||
|
||||
1. **기존 videoPlayReducer 유지**: 다른 컴포넌트에서 사용 중일 수 있으므로 그대로 둔다
|
||||
2. **playerReducer에 새로운 상태 시스템 구현**: PlayerPanel과 VideoPlayer.js 전용
|
||||
3. **이중 상태 관리**: 재생 상태(Playback Status) + 화면 상태(Display Status)
|
||||
4. **기존 패턴 따르기**: FP 스타일의 curry, get, set 활용
|
||||
|
||||
## 새로운 상태 구조
|
||||
|
||||
### 상수 정의 (playerActions.js)
|
||||
|
||||
```javascript
|
||||
// 재생 상태
|
||||
export const PLAYBACK_STATUS = {
|
||||
LOADING: 'loading', // 비디오 로딩 중
|
||||
LOAD_SUCCESS: 'load_success', // 비디오 로딩 성공
|
||||
LOAD_ERROR: 'load_error', // 비디오 로딩 오류
|
||||
PLAYING: 'playing', // 비디오 재생 중
|
||||
NOT_PLAYING: 'not_playing', // 비디오 재생 아님 (정지/일시정지)
|
||||
BUFFERING: 'buffering' // 버퍼링 중
|
||||
};
|
||||
|
||||
// 화면 상태
|
||||
export const DISPLAY_STATUS = {
|
||||
HIDDEN: 'hidden', // 화면에 안보임
|
||||
VISIBLE: 'visible', // 화면에 보임
|
||||
MINIMIZED: 'minimized', // 최소화됨
|
||||
FULLSCREEN: 'fullscreen' // 전체화면
|
||||
};
|
||||
```
|
||||
|
||||
### 초기 상태 (playerReducer)
|
||||
|
||||
```javascript
|
||||
// 기존 playerReducer 상태에 추가
|
||||
const initialState = {
|
||||
// ... 기존 상태들
|
||||
|
||||
playerVideoState: {
|
||||
// 현재 상태
|
||||
playback: PLAYBACK_STATUS.NOT_PLAYING,
|
||||
display: DISPLAY_STATUS.HIDDEN,
|
||||
videoId: null,
|
||||
progress: 0,
|
||||
error: null,
|
||||
timestamp: null
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
## 액션 타입 및 함수
|
||||
|
||||
### 액션 타입
|
||||
|
||||
```javascript
|
||||
export const PLAYER_VIDEO_ACTIONS = {
|
||||
// 재생 상태 액션
|
||||
SET_PLAYBACK_LOADING: 'SET_PLAYBACK_LOADING',
|
||||
SET_PLAYBACK_SUCCESS: 'SET_PLAYBACK_SUCCESS',
|
||||
SET_PLAYBACK_ERROR: 'SET_PLAYBACK_ERROR',
|
||||
SET_PLAYBACK_PLAYING: 'SET_PLAYBACK_PLAYING',
|
||||
SET_PLAYBACK_NOT_PLAYING: 'SET_PLAYBACK_NOT_PLAYING',
|
||||
SET_PLAYBACK_BUFFERING: 'SET_PLAYBACK_BUFFERING',
|
||||
|
||||
// 화면 상태 액션
|
||||
SET_DISPLAY_HIDDEN: 'SET_DISPLAY_HIDDEN',
|
||||
SET_DISPLAY_VISIBLE: 'SET_DISPLAY_VISIBLE',
|
||||
SET_DISPLAY_MINIMIZED: 'SET_DISPLAY_MINIMIZED',
|
||||
SET_DISPLAY_FULLSCREEN: 'SET_DISPLAY_FULLSCREEN',
|
||||
|
||||
// 복합 액션
|
||||
SET_VIDEO_LOADING: 'SET_VIDEO_LOADING',
|
||||
SET_VIDEO_PLAYING: 'SET_VIDEO_PLAYING',
|
||||
SET_VIDEO_STOPPED: 'SET_VIDEO_STOPPED',
|
||||
SET_VIDEO_MINIMIZED_PLAYING: 'SET_VIDEO_MINIMIZED_PLAYING',
|
||||
};
|
||||
```
|
||||
|
||||
### 액션 함수
|
||||
|
||||
```javascript
|
||||
// 기본 액션 함수들 (FP 스타일)
|
||||
export const setPlaybackLoading = curry((videoId, displayMode = 'visible') => ({
|
||||
type: PLAYER_VIDEO_ACTIONS.SET_VIDEO_LOADING,
|
||||
payload: {
|
||||
playback: PLAYBACK_STATUS.LOADING,
|
||||
display: displayMode,
|
||||
videoId,
|
||||
progress: 0,
|
||||
error: null,
|
||||
timestamp: Date.now()
|
||||
}
|
||||
}));
|
||||
|
||||
export const setPlaybackPlaying = curry((videoId, displayMode = 'fullscreen') => ({
|
||||
type: PLAYER_VIDEO_ACTIONS.SET_VIDEO_PLAYING,
|
||||
payload: {
|
||||
playback: PLAYBACK_STATUS.PLAYING,
|
||||
display: displayMode,
|
||||
videoId,
|
||||
progress: 100,
|
||||
error: null,
|
||||
timestamp: Date.now()
|
||||
}
|
||||
}));
|
||||
|
||||
export const setPlaybackError = curry((videoId, error) => ({
|
||||
type: PLAYER_VIDEO_ACTIONS.SET_PLAYBACK_ERROR,
|
||||
payload: {
|
||||
playback: PLAYBACK_STATUS.LOAD_ERROR,
|
||||
display: DISPLAY_STATUS.VISIBLE,
|
||||
videoId,
|
||||
error,
|
||||
progress: 0,
|
||||
timestamp: Date.now()
|
||||
}
|
||||
}));
|
||||
|
||||
export const setVideoStopped = () => ({
|
||||
type: PLAYER_VIDEO_ACTIONS.SET_VIDEO_STOPPED,
|
||||
payload: {
|
||||
playback: PLAYBACK_STATUS.NOT_PLAYING,
|
||||
display: DISPLAY_STATUS.HIDDEN,
|
||||
videoId: null,
|
||||
error: null,
|
||||
progress: 0,
|
||||
timestamp: Date.now()
|
||||
}
|
||||
}));
|
||||
```
|
||||
|
||||
## 상태 사용 예시
|
||||
|
||||
### PlayerPanel.jsx
|
||||
|
||||
```javascript
|
||||
import {
|
||||
setPlaybackLoading,
|
||||
setPlaybackPlaying,
|
||||
setPlaybackError,
|
||||
setVideoStopped
|
||||
} from '../actions/playerActions';
|
||||
|
||||
// 비디오 로딩 시작
|
||||
const handleVideoLoadStart = (videoId) => {
|
||||
dispatch(setPlaybackLoading(videoId, 'fullscreen'));
|
||||
};
|
||||
|
||||
// 비디오 재생 시작
|
||||
const handleVideoPlay = (videoId) => {
|
||||
dispatch(setPlaybackPlaying(videoId, 'fullscreen'));
|
||||
};
|
||||
|
||||
// 비디오 에러 발생
|
||||
const handleVideoError = (videoId, error) => {
|
||||
dispatch(setPlaybackError(videoId, error));
|
||||
};
|
||||
|
||||
// 상태 확인
|
||||
const videoState = useSelector(state => state.player.playerVideoState);
|
||||
const isLoading = videoState.playback === PLAYBACK_STATUS.LOADING;
|
||||
const isPlaying = videoState.playback === PLAYBACK_STATUS.PLAYING;
|
||||
const hasError = videoState.playback === PLAYBACK_STATUS.LOAD_ERROR;
|
||||
const isFullscreen = videoState.display === DISPLAY_STATUS.FULLSCREEN;
|
||||
```
|
||||
|
||||
### VideoPlayer.js
|
||||
|
||||
```javascript
|
||||
// 현재 상태에 따른 UI 렌더링
|
||||
const renderVideoState = () => {
|
||||
const { playback, display, error, progress } = videoState;
|
||||
|
||||
if (playback === PLAYBACK_STATUS.LOADING) {
|
||||
return <LoadingSpinner progress={progress} />;
|
||||
}
|
||||
|
||||
if (playback === PLAYBACK_STATUS.LOAD_ERROR) {
|
||||
return <ErrorMessage error={error} onRetry={handleRetry} />;
|
||||
}
|
||||
|
||||
if (playback === PLAYBACK_STATUS.BUFFERING) {
|
||||
return <BufferingIndicator />;
|
||||
}
|
||||
|
||||
if (playback === PLAYBACK_STATUS.PLAYING && display === DISPLAY_STATUS.FULLSCREEN) {
|
||||
return <VideoPlayer videoId={videoState.videoId} />;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
```
|
||||
|
||||
## 구현 순서
|
||||
|
||||
1. [ ] playerActions.js에 상수 및 액션 함수들 추가
|
||||
2. [ ] playerReducer.js에 초기 상태 및 핸들러들 추가
|
||||
3. [ ] PlayerPanel.jsx에서 새로운 상태 시스템으로 전환
|
||||
4. [ ] VideoPlayer.js에서 새로운 상태 시스템으로 전환
|
||||
5. [ ] 테스트 및 검증
|
||||
|
||||
## 장점
|
||||
|
||||
1. **정밀한 상태 제어**: 재생 상태와 화면 상태를 별도로 관리
|
||||
2. **명확한 상태 의미**: 각 상태가 명확한 의미를 가짐
|
||||
3. **확장성**: 새로운 상태 추가가 용이
|
||||
4. **유지보수성**: 기존 코드 영향 최소화
|
||||
5. **재사용성**: 다른 컴포넌트에서도 활용 가능
|
||||
|
||||
## 주의사항
|
||||
|
||||
- 기존 videoPlayReducer와 충돌하지 않도록 주의
|
||||
- PlayerPanel과 VideoPlayer.js에만 집중하여 구현
|
||||
- 기존 비디오 재생 로직과 호환성 유지
|
||||
1
com.twin.app.shoptime/src/hooks/useDetailFocus/index.js
Normal file
1
com.twin.app.shoptime/src/hooks/useDetailFocus/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './useDetailFocus';
|
||||
120
com.twin.app.shoptime/src/hooks/useDetailFocus/useDetailFocus.js
Normal file
120
com.twin.app.shoptime/src/hooks/useDetailFocus/useDetailFocus.js
Normal file
@@ -0,0 +1,120 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import Spotlight from '@enact/spotlight';
|
||||
|
||||
/**
|
||||
* useDetailFocus - 포커스 이동 보정용 Hook
|
||||
*
|
||||
* ProductAllSection의 복잡한 조건부 렌더링으로 인한 포커스 손실을 방지하기 위해
|
||||
* arrow key에 따라 다음 포커스 항목을 queue 형태로 관리하고,
|
||||
* 타이머로 포커스 이동을 수행합니다.
|
||||
*
|
||||
* useEffect의 의존성배열에 따라 타이머가 관리되고,
|
||||
* 컴포넌트 unmount 시 자동으로 cleanup됩니다.
|
||||
*
|
||||
* @param {number} delayMs - 포커스 이동 지연 시간 (기본값: 250ms)
|
||||
* @returns {Object} { enqueueFocus }
|
||||
*
|
||||
* @example
|
||||
* const { enqueueFocus } = useDetailFocus(250);
|
||||
*
|
||||
* const handleArrowDown = (e) => {
|
||||
* e.stopPropagation();
|
||||
* enqueueFocus('next-button-id');
|
||||
* };
|
||||
*/
|
||||
export default function useDetailFocus(delayMs = 500) {
|
||||
const focusQueueRef = useRef([]);
|
||||
const [queueTick, setQueueTick] = useState(0);
|
||||
const timerRef = useRef(null);
|
||||
|
||||
/**
|
||||
* 포커스 ID를 queue에 추가 (상태만 업데이트, 타이머는 useEffect에서 관리)
|
||||
* @param {string} focusId - 포커스할 요소의 ID
|
||||
*/
|
||||
const enqueueFocus = useCallback(
|
||||
(focusId) => {
|
||||
if (!focusId) {
|
||||
console.warn('[FocusDetail] ⚠️ focusId가 제공되지 않았습니다');
|
||||
return;
|
||||
}
|
||||
|
||||
// 큐에 추가하고 tick을 올려 useEffect를 트리거
|
||||
focusQueueRef.current.push(focusId);
|
||||
console.log(`[FocusDetail] 📋 Queue에 ID 추가: ${focusId} (${delayMs}ms 후 포커스)`);
|
||||
setQueueTick((tick) => tick + 1);
|
||||
},
|
||||
[delayMs]
|
||||
);
|
||||
|
||||
/**
|
||||
* focusQueue 상태에 따라 타이머 관리
|
||||
* focusQueue가 설정되면 타이머 시작
|
||||
* 컴포넌트 unmount 시 useEffect cleanup에서 자동으로 타이머 정리
|
||||
*/
|
||||
useEffect(() => {
|
||||
// queue에 아무것도 없으면 종료
|
||||
if (!focusQueueRef.current.length) {
|
||||
console.log(`[FocusDetail] 📭 Queue 비어있음 (포커스 보정 불필요)`);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// 기존 타이머가 있으면 취소
|
||||
if (timerRef.current) {
|
||||
console.log(
|
||||
`[FocusDetail] ⏹️ 기존 타이머 취소 - 대기 중인 Queue: ${focusQueueRef.current.join(',')}`
|
||||
);
|
||||
clearTimeout(timerRef.current);
|
||||
}
|
||||
|
||||
// 새로운 타이머 설정
|
||||
const targetId = focusQueueRef.current[focusQueueRef.current.length - 1]; // 마지막 ID
|
||||
console.log(`[FocusDetail] ⏱️ 타이머 시작 - ${delayMs}ms 후 포커스 이동: ${targetId}`);
|
||||
timerRef.current = setTimeout(() => {
|
||||
console.log(`[FocusDetail] ⏱️ 타이머 만료 - 포커스 이동 시도: ${targetId}`);
|
||||
|
||||
// 현재 포커스된 요소 확인
|
||||
const currentElement = Spotlight.getCurrent();
|
||||
const currentId = currentElement?.dataset?.spotlightId || currentElement?.id || 'unknown';
|
||||
console.log(`[FocusDetail] 📌 현재 포커스 상태: ${currentId}, 타깃: ${targetId}`);
|
||||
|
||||
try {
|
||||
const success = Spotlight.focus(targetId);
|
||||
|
||||
if (!success) {
|
||||
const afterElement = Spotlight.getCurrent();
|
||||
const afterId = afterElement?.dataset?.spotlightId || afterElement?.id || 'unknown';
|
||||
console.warn(`[FocusDetail] ❌ 포커스 이동 실패: ${targetId} (현재: ${afterId})`);
|
||||
console.warn(
|
||||
`[FocusDetail] 📋 요소 존재 확인: ${document.querySelector(`[data-spotlight-id="${targetId}"]`) ? '✅ 존재' : '❌ 없음'}`
|
||||
);
|
||||
} else {
|
||||
console.log(`[FocusDetail] ✅ 포커스 이동 성공: ${targetId}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[FocusDetail] 💥 포커스 이동 중 에러:', error);
|
||||
} finally {
|
||||
// 타이머 정리
|
||||
console.log(`[FocusDetail] 🧹 타이머 정리 - 처리 완료: ${targetId}`);
|
||||
timerRef.current = null;
|
||||
// Queue 초기화
|
||||
focusQueueRef.current = [];
|
||||
}
|
||||
}, delayMs);
|
||||
|
||||
// cleanup: 의존성배열 변경 또는 컴포넌트 unmount 시 타이머 정리
|
||||
return () => {
|
||||
if (timerRef.current) {
|
||||
console.log(
|
||||
`[FocusDetail] 🧹 useEffect cleanup - 대기 중인 타이머 취소 (Queue: ${focusQueueRef.current.join(',')})`
|
||||
);
|
||||
clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [queueTick, delayMs]);
|
||||
|
||||
return {
|
||||
enqueueFocus,
|
||||
focusQueue: focusQueueRef.current, // 디버깅용
|
||||
};
|
||||
}
|
||||
@@ -34,6 +34,7 @@ import TPopUp from '../../../components/TPopUp/TPopUp.jsx';
|
||||
import TVirtualGridList from '../../../components/TVirtualGridList/TVirtualGridList.jsx';
|
||||
import useReviews from '../../../hooks/useReviews/useReviews';
|
||||
import useScrollTo from '../../../hooks/useScrollTo';
|
||||
import useDetailFocus from '../../../hooks/useDetailFocus';
|
||||
import { BUYNOW_CONFIG } from '../../../utils/BuyNowConfig';
|
||||
import { panel_names } from '../../../utils/Config';
|
||||
import * as Config from '../../../utils/Config.js';
|
||||
@@ -81,7 +82,10 @@ const Container = SpotlightContainerDecorator(
|
||||
{
|
||||
enterTo: 'last-focused',
|
||||
preserveld: true,
|
||||
leaveFor: { right: 'content-scroller-container' },
|
||||
leaveFor: {
|
||||
right: 'content-scroller-container',
|
||||
up: 'spotlight-product-info-section-container',
|
||||
},
|
||||
spotlightDirection: 'vertical',
|
||||
},
|
||||
'div'
|
||||
@@ -156,6 +160,9 @@ export default function ProductAllSection({
|
||||
}) {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
// 포커스 이동 보정 Hook (0.25초 타이머)
|
||||
const { enqueueFocus } = useDetailFocus(500);
|
||||
|
||||
// Redux 상태
|
||||
const webOSVersion = useSelector((state) => state.common.appStatus.webOSVersion);
|
||||
const groupInfos = useSelector((state) => state.product.groupInfo);
|
||||
@@ -833,52 +840,81 @@ export default function ProductAllSection({
|
||||
[]
|
||||
);
|
||||
|
||||
// SHOP BY MOBILE 버튼에서 arrow up 시: BUY NOW > COUPON > BACK 순으로 이동, 포커스 이탈 보정
|
||||
// SHOP BY MOBILE 버튼에서 arrow up 시
|
||||
// focusUpMap을 사용해서 위쪽 버튼으로 이동
|
||||
// focusUpMap이 없으면 (첫 행) BackBtn으로 이동
|
||||
const handleSpotlightUpToBackButton = useCallback(
|
||||
(e) => {
|
||||
e.stopPropagation();
|
||||
console.log(`🛵 [FocusDetail] ShopByMobile - Arrow UP 감지`);
|
||||
|
||||
const tryFocusUp = () => {
|
||||
if (shopByMobileUpTarget && Spotlight.focus(shopByMobileUpTarget)) {
|
||||
return shopByMobileUpTarget;
|
||||
// focusUpMap에서 ShopByMobile의 위쪽 버튼을 찾음
|
||||
const targetId = focusUpMap[shopByMobileId];
|
||||
|
||||
if (targetId) {
|
||||
console.log(`🛵 [FocusDetail] ShopByMobile - Arrow UP 타깃: ${targetId}`);
|
||||
if (targetId === 'detail-buy-now-button') {
|
||||
console.log(`🛵 [FocusDetail] ShopByMobile - Arrow UP: BuyNow 버튼 있음`);
|
||||
} else if (targetId === 'detail-add-to-cart-button') {
|
||||
console.log(`🛵 [FocusDetail] ShopByMobile - Arrow UP: AddToCart 버튼 있음`);
|
||||
} else if (targetId === 'detail-coupon-button') {
|
||||
console.log(`🛵 [FocusDetail] ShopByMobile - Arrow UP: Coupon 버튼 있음`);
|
||||
}
|
||||
return 'spotlightId_backBtn';
|
||||
};
|
||||
|
||||
const targetId = tryFocusUp();
|
||||
|
||||
// 포커스가 바로 빠지는 케이스 보정
|
||||
setTimeout(() => {
|
||||
const current = Spotlight.getCurrent();
|
||||
const currentId = current?.dataset?.spotlightId;
|
||||
if (!current || currentId !== targetId) {
|
||||
tryFocusUp();
|
||||
// 즉시 포커스 시도
|
||||
if (!Spotlight.focus(targetId)) {
|
||||
enqueueFocus(targetId);
|
||||
}
|
||||
}, 0);
|
||||
} else {
|
||||
// focusUpMap에 없으면 (첫 행이거나 위쪽이 없음) BackBtn으로
|
||||
console.log(`🛵 [FocusDetail] ShopByMobile - Arrow UP: 위쪽 버튼 없음, BackBtn으로 이동`);
|
||||
if (!Spotlight.focus('spotlightId_backBtn')) {
|
||||
enqueueFocus('spotlightId_backBtn');
|
||||
}
|
||||
}
|
||||
},
|
||||
[shopByMobileUpTarget]
|
||||
[focusUpMap, shopByMobileId, enqueueFocus]
|
||||
);
|
||||
|
||||
// BUY NOW, ADD TO CART 버튼에서 arrow up 시: 항상 헤더 뒤로가기 버튼으로
|
||||
// BUY NOW, ADD TO CART 버튼에서 arrow up 시
|
||||
// Coupon 버튼이 있으면 Coupon으로, 없으면 Back으로 이동
|
||||
// 항상 Hook을 통해 포커스를 이동하도록 변경
|
||||
const handleSpotlightUpFromBuyButtons = useCallback(
|
||||
(e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
let targetId;
|
||||
if (promotions && promotions.length > 0) {
|
||||
Spotlight.focus('detail-coupon-button');
|
||||
// 쿠폰이 여러 개면 첫 번째 쿠폰 버튼으로 포커스
|
||||
targetId = `detail-coupon-button-0`;
|
||||
console.log(`💳 [FocusDetail] BuyNow/AddToCart - Arrow UP 감지 (Coupon 있음)`);
|
||||
console.log(`💳 [FocusDetail] BuyNow/AddToCart - Arrow UP 타깃 결정: ${targetId}`);
|
||||
} else {
|
||||
// 쿠폰 버튼이 없을 때 뒤로가기 버튼으로 포커스 이동 (특정 Chrome 버전에서의 포커스 문제 해결)
|
||||
setTimeout(() => {
|
||||
Spotlight.focus('spotlightId_backBtn');
|
||||
}, 0);
|
||||
targetId = 'spotlightId_backBtn';
|
||||
console.log(`💳 [FocusDetail] BuyNow/AddToCart - Arrow UP 감지 (Coupon 없음)`);
|
||||
console.log(`💳 [FocusDetail] BuyNow/AddToCart - Arrow UP 타깃 결정: Back Btn`);
|
||||
}
|
||||
|
||||
// 즉시 포커스 시도, 실패 시 보정 큐
|
||||
if (!Spotlight.focus(targetId)) {
|
||||
enqueueFocus(targetId);
|
||||
}
|
||||
},
|
||||
[promotions]
|
||||
[promotions, enqueueFocus]
|
||||
);
|
||||
|
||||
const handleSpotlightUpFromCouponButtons = useCallback((e) => {
|
||||
e.stopPropagation();
|
||||
Spotlight.focus('spotlightId_backBtn');
|
||||
}, []);
|
||||
// Coupon 버튼에서 arrow up 시: Container의 leaveFor 설정으로 처리
|
||||
// stopPropagation을 하지 않아서 Container가 up 방향으로 포커스를 이동하도록 함
|
||||
const handleSpotlightUpFromCouponButtons = useCallback(
|
||||
(e) => {
|
||||
e.preventDefault();
|
||||
console.log(`🎫 [FocusDetail] Coupon - Arrow UP 감지`);
|
||||
console.log(`🎫 [FocusDetail] Coupon - Arrow UP 처리: Container의 leaveFor(up) 설정 사용`);
|
||||
if (!Spotlight.focus('spotlightId_backBtn')) {
|
||||
enqueueFocus('spotlightId_backBtn');
|
||||
}
|
||||
},
|
||||
[enqueueFocus]
|
||||
);
|
||||
|
||||
// SHOP BY MOBILE ↑ 타깃 계산
|
||||
const shopByMobileUpTarget = useMemo(() => {
|
||||
@@ -893,11 +929,13 @@ export default function ProductAllSection({
|
||||
);
|
||||
|
||||
// 버튼 스택(위→아래) 구성: 실제 렌더링 순서에 맞춰 행(row) 단위로 설정
|
||||
const { focusDownMap, focusOrder, focusRows } = useMemo(() => {
|
||||
const { focusDownMap, focusUpMap, focusOrder, focusRows } = useMemo(() => {
|
||||
const rows = [];
|
||||
|
||||
if (promotions && promotions.length > 0) {
|
||||
rows.push(['detail-coupon-button']);
|
||||
// 쿠폰이 여러 개일 수 있으므로 각각 고유 ID로 추가
|
||||
const couponButtonIds = promotions.map((_, idx) => `detail-coupon-button-${idx}`);
|
||||
rows.push(couponButtonIds);
|
||||
}
|
||||
|
||||
if (isBillingProductVisible) {
|
||||
@@ -925,10 +963,22 @@ export default function ProductAllSection({
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
// 위 방향: 각 행의 모든 요소가 이전 행의 마지막 요소를 바라보도록 매핑
|
||||
const upMap = rows.reduce((acc, row, idx) => {
|
||||
if (idx > 0) {
|
||||
// 이전 행이 있으면 그 행의 마지막 요소로 매핑
|
||||
const prevRowLast = rows[idx - 1][rows[idx - 1].length - 1];
|
||||
row.forEach((id) => {
|
||||
acc[id] = prevRowLast;
|
||||
});
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
// order는 행을 평탄화한 순서
|
||||
const order = rows.flat();
|
||||
|
||||
return { focusDownMap: downMap, focusOrder: order, focusRows: rows };
|
||||
return { focusDownMap: downMap, focusUpMap: upMap, focusOrder: order, focusRows: rows };
|
||||
}, [isBillingProductVisible, panelInfo, isReviewDataComplete, promotions, shopByMobileId]);
|
||||
|
||||
// 공통 ↓ 이동 핸들러
|
||||
@@ -944,11 +994,12 @@ export default function ProductAllSection({
|
||||
if (!moved) {
|
||||
const fallback = focusOrder.find((id) => id !== currentId);
|
||||
if (fallback) {
|
||||
Spotlight.focus(fallback);
|
||||
// Hook을 통한 포커스 이동 보정 (0.5초 타이머로 재시도)
|
||||
enqueueFocus(fallback);
|
||||
}
|
||||
}
|
||||
},
|
||||
[focusDownMap, focusOrder]
|
||||
[focusDownMap, focusOrder, enqueueFocus]
|
||||
);
|
||||
|
||||
const handleSpotlightDown = useCallback((e) => {
|
||||
@@ -956,24 +1007,107 @@ export default function ProductAllSection({
|
||||
}, []);
|
||||
|
||||
// COUPON에서 아래로 이동 시
|
||||
const handleSpotlightDownFromCoupon = useMemo(
|
||||
() => buildSpotlightDownHandler('detail-coupon-button'),
|
||||
[buildSpotlightDownHandler]
|
||||
// 항상 Hook을 통해 포커스를 이동하도록 변경
|
||||
const handleSpotlightDownFromCoupon = useCallback(
|
||||
(e, currentCouponId) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
console.log(`🎫 [FocusDetail] Coupon - Arrow DOWN 감지 (ID: ${currentCouponId})`);
|
||||
|
||||
const nextId = focusDownMap[currentCouponId];
|
||||
console.log(`🎫 [FocusDetail] Coupon - Arrow DOWN 다음 타깃: ${nextId}`);
|
||||
|
||||
if (nextId) {
|
||||
// 항상 Hook을 통해 포커스를 이동
|
||||
enqueueFocus(nextId);
|
||||
} else {
|
||||
// 다음 행이 없으면 focusOrder에서 현재 쿠폰 버튼이 아닌 다른 버튼을 찾음
|
||||
const fallback = focusOrder.find((id) => !id.startsWith('detail-coupon-button'));
|
||||
console.log(`🎫 [FocusDetail] Coupon - Arrow DOWN focusDownMap에 없음, 폴백: ${fallback}`);
|
||||
if (fallback) {
|
||||
enqueueFocus(fallback);
|
||||
}
|
||||
}
|
||||
},
|
||||
[focusDownMap, focusOrder, enqueueFocus]
|
||||
);
|
||||
// SHOP BY MOBILE에서 아래로 이동 시
|
||||
const handleSpotlightDownFromShopByMobile = useMemo(
|
||||
() => buildSpotlightDownHandler(shopByMobileId),
|
||||
[buildSpotlightDownHandler, shopByMobileId]
|
||||
// 항상 Hook을 통해 포커스를 이동하도록 변경
|
||||
const handleSpotlightDownFromShopByMobile = useCallback(
|
||||
(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
console.log(`🛵 [FocusDetail] ShopByMobile - Arrow DOWN 감지`);
|
||||
|
||||
const nextId = focusDownMap[shopByMobileId];
|
||||
console.log(`🛵 [FocusDetail] ShopByMobile - Arrow DOWN 다음 타깃: ${nextId}`);
|
||||
|
||||
if (nextId) {
|
||||
// 항상 Hook을 통해 포커스를 이동
|
||||
enqueueFocus(nextId);
|
||||
} else {
|
||||
const fallback = focusOrder.find((id) => id !== shopByMobileId);
|
||||
console.log(
|
||||
`🛵 [FocusDetail] ShopByMobile - Arrow DOWN focusDownMap에 없음, 폴백: ${fallback}`
|
||||
);
|
||||
if (fallback) {
|
||||
enqueueFocus(fallback);
|
||||
}
|
||||
}
|
||||
},
|
||||
[shopByMobileId, focusDownMap, focusOrder, enqueueFocus]
|
||||
);
|
||||
|
||||
// BUY NOW / ADD TO CART에서 아래로 이동 시
|
||||
const handleSpotlightDownFromBuyNow = useMemo(
|
||||
() => buildSpotlightDownHandler('detail-buy-now-button'),
|
||||
[buildSpotlightDownHandler]
|
||||
// BUY NOW에서 아래로 이동 시
|
||||
// 항상 Hook을 통해 포커스를 이동하도록 변경
|
||||
const handleSpotlightDownFromBuyNow = useCallback(
|
||||
(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
console.log(`💳 [FocusDetail] BuyNow - Arrow DOWN 감지`);
|
||||
|
||||
const nextId = focusDownMap['detail-buy-now-button'];
|
||||
console.log(`💳 [FocusDetail] BuyNow - Arrow DOWN 다음 타깃: ${nextId}`);
|
||||
|
||||
if (nextId) {
|
||||
// 항상 Hook을 통해 포커스를 이동
|
||||
enqueueFocus(nextId);
|
||||
} else {
|
||||
const fallback = focusOrder.find((id) => id !== 'detail-buy-now-button');
|
||||
console.log(`💳 [FocusDetail] BuyNow - Arrow DOWN focusDownMap에 없음, 폴백: ${fallback}`);
|
||||
if (fallback) {
|
||||
enqueueFocus(fallback);
|
||||
}
|
||||
}
|
||||
},
|
||||
[focusDownMap, focusOrder, enqueueFocus]
|
||||
);
|
||||
const handleSpotlightDownFromAddToCart = useMemo(
|
||||
() => buildSpotlightDownHandler('detail-add-to-cart-button'),
|
||||
[buildSpotlightDownHandler]
|
||||
|
||||
// ADD TO CART에서 아래로 이동 시
|
||||
// 항상 Hook을 통해 포커스를 이동하도록 변경
|
||||
const handleSpotlightDownFromAddToCart = useCallback(
|
||||
(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
console.log(`💳 [FocusDetail] AddToCart - Arrow DOWN 감지`);
|
||||
|
||||
const nextId = focusDownMap['detail-add-to-cart-button'];
|
||||
console.log(`💳 [FocusDetail] AddToCart - Arrow DOWN 다음 타깃: ${nextId}`);
|
||||
|
||||
if (nextId) {
|
||||
// 항상 Hook을 통해 포커스를 이동
|
||||
enqueueFocus(nextId);
|
||||
} else {
|
||||
const fallback = focusOrder.find((id) => id !== 'detail-add-to-cart-button');
|
||||
console.log(
|
||||
`💳 [FocusDetail] AddToCart - Arrow DOWN focusDownMap에 없음, 폴백: ${fallback}`
|
||||
);
|
||||
if (fallback) {
|
||||
enqueueFocus(fallback);
|
||||
}
|
||||
}
|
||||
},
|
||||
[focusDownMap, focusOrder, enqueueFocus]
|
||||
);
|
||||
|
||||
const onFavoriteFlagChanged = useCallback(
|
||||
@@ -1276,6 +1410,8 @@ export default function ProductAllSection({
|
||||
scrollExpandTimerRef.current = null;
|
||||
}
|
||||
|
||||
// 🔽 useDetailFocus 포커스 타이머는 Hook의 useEffect cleanup에서 자동으로 정리됨
|
||||
|
||||
console.log('[ProductAllSection] cleanup 완료 on unmount');
|
||||
};
|
||||
}, []);
|
||||
@@ -1337,6 +1473,7 @@ export default function ProductAllSection({
|
||||
|
||||
{userNumber &&
|
||||
promotions.map((promotion, idx) => {
|
||||
const couponButtonId = `detail-coupon-button-${idx}`;
|
||||
return (
|
||||
<div className={css.couponContainer} key={idx}>
|
||||
<div className={css.couponTitleText}>
|
||||
@@ -1346,14 +1483,14 @@ export default function ProductAllSection({
|
||||
</div>
|
||||
</div>
|
||||
<TButton
|
||||
spotlightId="detail-coupon-button"
|
||||
spotlightId={couponButtonId}
|
||||
className={css.couponButton}
|
||||
onClick={() => {
|
||||
handleCouponClick(idx, promotion);
|
||||
}}
|
||||
onSpotlightUp={handleSpotlightUpFromCouponButtons}
|
||||
onSpotlightDown={handleSpotlightDownFromCoupon}
|
||||
data-spotlight-next-down={focusDownMap['detail-coupon-button']}
|
||||
onSpotlightDown={(e) => handleSpotlightDownFromCoupon(e, couponButtonId)}
|
||||
data-spotlight-next-down={focusDownMap[couponButtonId]}
|
||||
size="detail_very_small"
|
||||
>
|
||||
<div className={css.couponText}>COUPON</div>
|
||||
|
||||
Reference in New Issue
Block a user