[251006] feat: useFocusHistory,useVideoMove Migration
🕐 커밋 시간: 2025. 10. 06. 15:22:49 📊 변경 통계: • 총 파일: 27개 • 추가: +151줄 • 삭제: -21줄 📁 추가된 파일: + com.twin.app.shoptime/src/actions/videoPlayActions.js + com.twin.app.shoptime/src/hooks/useFocusHistory/index.js + com.twin.app.shoptime/src/hooks/useFocusHistory/useFocusHistory.js + com.twin.app.shoptime/src/hooks/useVideoPlay/index.js + com.twin.app.shoptime/src/hooks/useVideoPlay/useVideoPlay.complete.js + com.twin.app.shoptime/src/hooks/useVideoPlay/useVideoPlay.final.js + com.twin.app.shoptime/src/hooks/useVideoPlay/useVideoPlay.fixed.js + com.twin.app.shoptime/src/hooks/useVideoPlay/useVideoPlay.js + com.twin.app.shoptime/src/hooks/useVideoPlay/useVideoPlay.old.js + com.twin.app.shoptime/src/hooks/useVideoPlay/useVideoPlay.opus-improved.js + com.twin.app.shoptime/src/hooks/useVideoPlay/useVideoPlay.simple.js + com.twin.app.shoptime/src/hooks/useVideoPlay/videoState.js + com.twin.app.shoptime/src/hooks/useVideoTransition/index.js + com.twin.app.shoptime/src/hooks/useVideoTransition/useVideoMove.bak.js + com.twin.app.shoptime/src/hooks/useVideoTransition/useVideoMove.brief.js + com.twin.app.shoptime/src/hooks/useVideoTransition/useVideoMove.complete.js + com.twin.app.shoptime/src/hooks/useVideoTransition/useVideoMove.fixed.js + com.twin.app.shoptime/src/hooks/useVideoTransition/useVideoMove.js + com.twin.app.shoptime/src/hooks/useVideoTransition/useVideoMove.original.js + com.twin.app.shoptime/src/hooks/useVideoTransition/useVideoMove.simple.js + com.twin.app.shoptime/src/hooks/useVideoTransition/useVideoTransition.js + com.twin.app.shoptime/src/reducers/videoPlayReducer.js 📝 수정된 파일: ~ com.twin.app.shoptime/src/components/TabLayout/TabLayout.jsx ~ com.twin.app.shoptime/src/store/store.js ~ com.twin.app.shoptime/src/utils/domUtils.js ~ com.twin.app.shoptime/src/views/HomePanel/HomeBanner/HomeBanner.jsx ~ com.twin.app.shoptime/src/views/HomePanel/HomePanel.jsx 🔧 함수 변경 내용: 📊 Function-level changes summary across 27 files: • Functions added: 55 • Functions modified: 0 • Functions deleted: 0 📋 By language: • javascript: 27 files, 55 function changes 🔧 주요 변경 내용: • UI 컴포넌트 아키텍처 개선 • 핵심 비즈니스 로직 개선 • 공통 유틸리티 함수 최적화
This commit is contained in:
94
com.twin.app.shoptime/src/actions/videoPlayActions.js
Normal file
94
com.twin.app.shoptime/src/actions/videoPlayActions.js
Normal file
@@ -0,0 +1,94 @@
|
||||
// Video Play State Actions
|
||||
// 주의: 이 액션들은 실제 비디오 재생을 제어하지 않고, Redux 상태만 업데이트합니다.
|
||||
// 실제 비디오 제어는 playActions.js의 startVideoPlayer(), finishVideoPreview() 등을 사용하세요.
|
||||
import { curry } from '../utils/fp';
|
||||
|
||||
export const VIDEO_PLAY_ACTIONS = {
|
||||
UPDATE_VIDEO_STATE: 'UPDATE_VIDEO_STATE',
|
||||
SET_VIDEO_STOPPED: 'SET_VIDEO_STOPPED',
|
||||
SET_VIDEO_BANNER: 'SET_VIDEO_BANNER',
|
||||
SET_VIDEO_FULLSCREEN: 'SET_VIDEO_FULLSCREEN',
|
||||
SET_VIDEO_MINIMIZED: 'SET_VIDEO_MINIMIZED',
|
||||
};
|
||||
|
||||
// Video Play States
|
||||
export const VIDEO_STATES = {
|
||||
STOPPED: 'stopped', // 비디오 멈춤 & 플레이어 안보임
|
||||
BANNER: 'banner', // 배너 크기로 재생 (modal = true)
|
||||
FULLSCREEN: 'fullscreen', // 전체화면 재생 (modal = false)
|
||||
MINIMIZED: 'minimized', // 1px 크기로 재생 (modal = true)
|
||||
};
|
||||
|
||||
/**
|
||||
* 비디오 상태를 직접 업데이트 (상태만 저장, 실제 재생 제어 X)
|
||||
* @param {string} state - VIDEO_STATES 중 하나
|
||||
* @param {object} videoInfo - 비디오 관련 추가 정보 (optional)
|
||||
*/
|
||||
export const updateVideoState = curry((state, videoInfo = {}) => ({
|
||||
type: VIDEO_PLAY_ACTIONS.UPDATE_VIDEO_STATE,
|
||||
payload: {
|
||||
state,
|
||||
videoInfo,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
}));
|
||||
|
||||
/**
|
||||
* 비디오 상태를 'stopped'로 업데이트 (상태만 저장, 실제 정지 X)
|
||||
*/
|
||||
export const setVideoStopped = () => ({
|
||||
type: VIDEO_PLAY_ACTIONS.SET_VIDEO_STOPPED,
|
||||
payload: {
|
||||
state: VIDEO_STATES.STOPPED,
|
||||
videoInfo: {},
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* 비디오 상태를 'banner'로 업데이트 (상태만 저장, 실제 재생 X)
|
||||
* @param {object} videoInfo - { modalContainerId, showUrl, thumbnail, etc. }
|
||||
*/
|
||||
export const setVideoBanner = curry((videoInfo) => ({
|
||||
type: VIDEO_PLAY_ACTIONS.SET_VIDEO_BANNER,
|
||||
payload: {
|
||||
state: VIDEO_STATES.BANNER,
|
||||
videoInfo: {
|
||||
...videoInfo,
|
||||
modal: true,
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
}));
|
||||
|
||||
/**
|
||||
* 비디오 상태를 'fullscreen'으로 업데이트 (상태만 저장, 실제 전환 X)
|
||||
* @param {object} videoInfo - { showUrl, thumbnail, etc. }
|
||||
*/
|
||||
export const setVideoFullscreen = curry((videoInfo) => ({
|
||||
type: VIDEO_PLAY_ACTIONS.SET_VIDEO_FULLSCREEN,
|
||||
payload: {
|
||||
state: VIDEO_STATES.FULLSCREEN,
|
||||
videoInfo: {
|
||||
...videoInfo,
|
||||
modal: false,
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
}));
|
||||
|
||||
/**
|
||||
* 비디오 상태를 'minimized'로 업데이트 (상태만 저장, 실제 최소화 X)
|
||||
* @param {object} videoInfo - { showUrl, thumbnail, etc. }
|
||||
*/
|
||||
export const setVideoMinimized = curry((videoInfo) => ({
|
||||
type: VIDEO_PLAY_ACTIONS.SET_VIDEO_MINIMIZED,
|
||||
payload: {
|
||||
state: VIDEO_STATES.MINIMIZED,
|
||||
videoInfo: {
|
||||
...videoInfo,
|
||||
modal: true,
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
}));
|
||||
@@ -2,7 +2,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import classNames from 'classnames';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
// import { useFocusHistory } from '../../hooks/useFocusHistory/useFocusHistory';
|
||||
import { useFocusHistory } from '../../hooks/useFocusHistory/useFocusHistory';
|
||||
|
||||
//아이콘
|
||||
import { Job } from '@enact/core/util';
|
||||
@@ -107,11 +107,11 @@ export default function TabLayout({ topPanelName, onTabActivated, panelInfo }) {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
// 🔽 GNB 포커스 추적 - 전역 상태 사용 (HomeBanner, HomePanel과 동일한 인스턴스)
|
||||
// const focusHistory = useFocusHistory({
|
||||
// enableLogging: true,
|
||||
// useGlobalState: true,
|
||||
// logPrefix: '[FocusHistory]',
|
||||
// });
|
||||
const focusHistory = useFocusHistory({
|
||||
enableLogging: true,
|
||||
useGlobalState: true,
|
||||
logPrefix: '[FocusHistory]',
|
||||
});
|
||||
const [mainExpanded, setMainExpanded] = useState(false);
|
||||
const [mainSelectedIndex, setMainSelectedIndex] = useState(-1);
|
||||
const [secondDepthReduce, setSecondDepthReduce] = useState(false);
|
||||
|
||||
3
com.twin.app.shoptime/src/hooks/useFocusHistory/index.js
Normal file
3
com.twin.app.shoptime/src/hooks/useFocusHistory/index.js
Normal file
@@ -0,0 +1,3 @@
|
||||
// src/hooks/useFocusHistory/index.js
|
||||
|
||||
export { useFocusHistory, default } from './useFocusHistory.js';
|
||||
@@ -0,0 +1,772 @@
|
||||
// src/hooks/useFocusHistory/useFocusHistory.js
|
||||
|
||||
import { useRef, useCallback, useState, useMemo } from 'react';
|
||||
import fp from '../../utils/fp.js';
|
||||
|
||||
/**
|
||||
* useFocusHistory Hook - 경량화된 포커스 히스토리 관리
|
||||
* - 어떤 string 식별자든 받을 수 있는 범용적 구조
|
||||
* - 현재 포커스와 이전 포커스 2개 상태만 추적 (Ring Buffer)
|
||||
* - 전역 상태 관리 가능
|
||||
* - 매우 간단한 인터페이스 제공
|
||||
*/
|
||||
|
||||
// 🔽 [확장] Enhanced Ring Buffer - 10개 히스토리 + 패턴 인식 + 정책 엔진
|
||||
const createFocusRingBuffer = () => {
|
||||
const BUFFER_SIZE = 10;
|
||||
const buffer = new Array(BUFFER_SIZE).fill(null); // 10개 히스토리
|
||||
let head = -1; // Current position pointer
|
||||
let size = 0; // Current elements count (0-10)
|
||||
|
||||
// 🔽 [개선] 포커스 항목 삽입 - 원본 ID 그대로 사용 (0부터 시작 통일)
|
||||
const enqueue = (focusId) => {
|
||||
// 중복 삽입 방지 - 원본 ID로 직접 비교
|
||||
const currentFocusId = size > 0 ? buffer[head] : null;
|
||||
|
||||
if (size > 0 && currentFocusId === focusId) {
|
||||
return { inserted: false, policy: null }; // 중복이므로 삽입하지 않음
|
||||
}
|
||||
|
||||
// 다음 위치로 이동하고 원본 ID 저장
|
||||
head = (head + 1) % BUFFER_SIZE;
|
||||
buffer[head] = focusId;
|
||||
|
||||
if (size < BUFFER_SIZE) {
|
||||
size++;
|
||||
}
|
||||
|
||||
// 🔑 [신기능] 패턴 분석 후 정책 반환
|
||||
const policy = calculateVideoPolicy();
|
||||
|
||||
return { inserted: true, policy }; // 삽입 성공 + 정책 반환
|
||||
};
|
||||
|
||||
// 🔽 [수정] 현재 포커스 가져오기 (10개 버퍼)
|
||||
const getCurrent = () => {
|
||||
return size > 0 ? buffer[head] : null;
|
||||
};
|
||||
|
||||
// 🔽 [수정] 이전 포커스 가져오기 (10개 버퍼)
|
||||
const getPrevious = () => {
|
||||
if (size < 2) return null;
|
||||
const prevIndex = (head - 1 + BUFFER_SIZE) % BUFFER_SIZE;
|
||||
return buffer[prevIndex];
|
||||
};
|
||||
|
||||
// 🔽 [수정] 전체 히스토리 가져오기 (10개 - 최신 순서로 반환)
|
||||
const getHistory = () => {
|
||||
if (size === 0) return [];
|
||||
|
||||
const history = [];
|
||||
for (let i = 0; i < Math.min(size, BUFFER_SIZE); i++) {
|
||||
const index = (head - i + BUFFER_SIZE) % BUFFER_SIZE;
|
||||
history.push(buffer[index]);
|
||||
}
|
||||
return history; // [current, previous, older, oldest, ...]
|
||||
};
|
||||
|
||||
// 🔽 [편의 함수] 특정 거리의 히스토리 가져오기
|
||||
const getHistoryAt = (distance) => {
|
||||
if (distance >= size || distance < 0) return null;
|
||||
const index = (head - distance + BUFFER_SIZE) % BUFFER_SIZE;
|
||||
return buffer[index];
|
||||
};
|
||||
|
||||
// 🔽 [수정] 전체 상태 가져오기 (확장)
|
||||
const getState = () => ({
|
||||
current: getCurrent(),
|
||||
previous: getPrevious(),
|
||||
history: getHistory(),
|
||||
hasTransition: size >= 2 && getCurrent() !== getPrevious(),
|
||||
size,
|
||||
head,
|
||||
});
|
||||
|
||||
// 🔽 [수정] 상태 초기화 (10개 버퍼)
|
||||
const clear = () => {
|
||||
buffer.fill(null);
|
||||
head = -1;
|
||||
size = 0;
|
||||
};
|
||||
|
||||
// 🔽 [개선] 패턴 인식 엔진 - 더 깊은 히스토리 분석
|
||||
const detectPattern = () => {
|
||||
const history = getHistory(); // [current, previous, older, oldest, ...]
|
||||
const current = history[0];
|
||||
const previous = history[1];
|
||||
|
||||
if (!current) {
|
||||
return { pattern: 'no-focus', videoTarget: null, confidence: 0 };
|
||||
}
|
||||
|
||||
// 🔽 [로그] banner 간 이동 패턴 분석
|
||||
// if (previous && (previous.startsWith('banner') && current.startsWith('banner'))) {
|
||||
// console.log('[BannerFlow] 포커스 이동:', {
|
||||
// from: previous,
|
||||
// to: current,
|
||||
// history: history.slice(0, 5),
|
||||
// timestamp: new Date().toISOString()
|
||||
// });
|
||||
// }
|
||||
|
||||
// 직접 포커스 (banner1, banner2)
|
||||
if (current === 'banner1') {
|
||||
if (previous === 'icons') {
|
||||
console.log('[DEBUG] 🔄 icons → banner1 복원 패턴');
|
||||
return {
|
||||
pattern: 'restore-banner1',
|
||||
videoTarget: 'banner1',
|
||||
confidence: 1.0,
|
||||
shouldShowBorder: true,
|
||||
};
|
||||
}
|
||||
console.log('[DEBUG] 🎯 banner1 직접 포커스 패턴');
|
||||
return {
|
||||
pattern: 'direct-banner1',
|
||||
videoTarget: 'banner1',
|
||||
confidence: 1.0,
|
||||
shouldShowBorder: true,
|
||||
};
|
||||
}
|
||||
if (current === 'banner2') {
|
||||
if (previous === 'icons') {
|
||||
console.log('[DEBUG] 🔄 icons → banner2 복원 패턴');
|
||||
return {
|
||||
pattern: 'restore-banner2',
|
||||
videoTarget: 'banner2',
|
||||
confidence: 1.0,
|
||||
shouldShowBorder: true,
|
||||
};
|
||||
}
|
||||
console.log('[DEBUG] 🎯 banner2 직접 포커스 패턴');
|
||||
return {
|
||||
pattern: 'direct-banner2',
|
||||
videoTarget: 'banner2',
|
||||
confidence: 1.0,
|
||||
shouldShowBorder: true,
|
||||
};
|
||||
}
|
||||
|
||||
// icons 포커스 처리
|
||||
if (current === 'icons') {
|
||||
// console.log('[BannerFlow] 🛑 icons 포커스 - 동영상 중지');
|
||||
return {
|
||||
pattern: 'icons-stop',
|
||||
videoTarget: null,
|
||||
confidence: 1.0,
|
||||
shouldShowBorder: false,
|
||||
reason: 'icons 포커스로 동영상 중지',
|
||||
};
|
||||
}
|
||||
|
||||
// 🔽 [개선] 간접 포커스 (banner3, banner4) - 더 깊은 히스토리 확인
|
||||
if (current === 'banner3' || current === 'banner4') {
|
||||
console.log(`[DEBUG] 🔍 간접 포커스 (${current}) - 히스토리 분석 시작`);
|
||||
console.log(`[DEBUG] 전체 히스토리:`, history);
|
||||
|
||||
// 히스토리에서 가장 최근의 banner1 또는 banner2 찾기
|
||||
let lastVideoBanner = null;
|
||||
let lastVideoBannerDistance = -1;
|
||||
|
||||
for (let i = 1; i < Math.min(history.length, 10); i++) {
|
||||
if (history[i] === 'banner1' || history[i] === 'banner2') {
|
||||
lastVideoBanner = history[i];
|
||||
lastVideoBannerDistance = i;
|
||||
console.log(`[DEBUG] 발견된 비디오 배너: ${lastVideoBanner} (거리: ${i})`);
|
||||
break; // 가장 최근 것만 찾으면 됨
|
||||
}
|
||||
}
|
||||
|
||||
if (lastVideoBanner) {
|
||||
console.log(`[DEBUG] 🔄 간접 포커스 유지 패턴:`, {
|
||||
current: current,
|
||||
maintainTarget: lastVideoBanner,
|
||||
distance: lastVideoBannerDistance,
|
||||
reason: `${current} 포커스, ${lastVideoBannerDistance}단계 이전 ${lastVideoBanner} 유지`,
|
||||
});
|
||||
return {
|
||||
pattern: `maintain-${lastVideoBanner}`,
|
||||
videoTarget: lastVideoBanner,
|
||||
confidence: Math.max(0.7, 1.0 - lastVideoBannerDistance * 0.1), // 거리가 멀수록 신뢰도 감소
|
||||
shouldShowBorder: false,
|
||||
reason: `${current} 포커스, ${lastVideoBannerDistance}단계 이전 ${lastVideoBanner} 유지`,
|
||||
};
|
||||
} else {
|
||||
console.log(`[DEBUG] ❓ 간접 포커스 - 히스토리 없음:`, {
|
||||
current: current,
|
||||
history: history.slice(0, 5),
|
||||
reason: '비디오 배너 히스토리 없음, 기본값 banner1 사용',
|
||||
});
|
||||
// 비디오 배너 히스토리가 없으면 기본값
|
||||
return {
|
||||
pattern: 'default-banner1',
|
||||
videoTarget: 'banner1',
|
||||
confidence: 0.5,
|
||||
shouldShowBorder: false,
|
||||
reason: `${current} 포커스, 비디오 배너 히스토리 없음 - 기본값 banner1`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 기타 포커스 (icons, gnb 등)
|
||||
return {
|
||||
pattern: 'other-focus',
|
||||
videoTarget: null,
|
||||
confidence: 0,
|
||||
shouldShowBorder: false,
|
||||
reason: `비배너 포커스: ${current}`,
|
||||
};
|
||||
};
|
||||
|
||||
// 🔽 [신기능] 비디오 정책 계산
|
||||
const calculateVideoPolicy = () => {
|
||||
const patternResult = detectPattern();
|
||||
|
||||
return {
|
||||
videoTarget: patternResult.videoTarget,
|
||||
shouldShowBorder: patternResult.shouldShowBorder,
|
||||
transition: patternResult.pattern,
|
||||
confidence: patternResult.confidence,
|
||||
reason: patternResult.reason,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
};
|
||||
|
||||
// 🔽 [개선] 디버깅 정보
|
||||
const getDebugInfo = () => {
|
||||
const history = getHistory();
|
||||
const pattern = detectPattern();
|
||||
|
||||
return {
|
||||
buffer: [...buffer],
|
||||
head,
|
||||
size,
|
||||
history: history,
|
||||
historyLabeled: {
|
||||
current: history[0] || null,
|
||||
previous: history[1] || null,
|
||||
older: history[2] || null,
|
||||
oldest: history[3] || null,
|
||||
full: history,
|
||||
},
|
||||
pattern,
|
||||
policy: calculateVideoPolicy(),
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
// 기존 기능
|
||||
enqueue,
|
||||
getCurrent,
|
||||
getPrevious,
|
||||
getState,
|
||||
clear,
|
||||
|
||||
// 🔽 [신기능] 확장 기능
|
||||
getHistory,
|
||||
getHistoryAt, // 🔽 [추가] 특정 거리 히스토리
|
||||
detectPattern,
|
||||
calculateVideoPolicy,
|
||||
getDebugInfo,
|
||||
};
|
||||
};
|
||||
|
||||
// 🔽 [개선] globalThis 기반 안전한 전역 상태 관리
|
||||
// HMR(Hot Module Reload) 및 크로스 플랫폼 호환성 보장
|
||||
let globalFocusBuffer = null;
|
||||
|
||||
// 전역 버퍼 네임스페이스 상수
|
||||
const GLOBAL_BUFFER_KEY = '__FOCUS_BUFFER__';
|
||||
|
||||
/**
|
||||
* 전역 버퍼 유효성 검증
|
||||
* @param {*} buffer - 검증할 버퍼 객체
|
||||
* @returns {boolean} 유효한 버퍼인지 여부
|
||||
*/
|
||||
const isValidBuffer = (buffer) => {
|
||||
try {
|
||||
return (
|
||||
buffer &&
|
||||
typeof buffer === 'object' &&
|
||||
typeof buffer.enqueue === 'function' &&
|
||||
typeof buffer.getCurrent === 'function' &&
|
||||
typeof buffer.getState === 'function' &&
|
||||
typeof buffer.clear === 'function'
|
||||
);
|
||||
} catch (error) {
|
||||
console.warn('[FocusHistory] 버퍼 유효성 검증 실패:', error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 안전한 전역 객체 접근 함수 (Chromium 68 호환)
|
||||
* @returns {object} 전역 객체 (폴백 포함)
|
||||
*/
|
||||
const getGlobalObject = () => {
|
||||
try {
|
||||
// 🔽 [Chromium 68 호환] window 우선 (webOS TV 환경)
|
||||
if (typeof window !== 'undefined') return window;
|
||||
|
||||
// Node.js 환경
|
||||
if (typeof global !== 'undefined') return global;
|
||||
|
||||
// Web Worker 환경
|
||||
// eslint-disable-next-line no-undef
|
||||
if (typeof self !== 'undefined') return self;
|
||||
|
||||
// 🔽 [제거] globalThis는 Chromium 68에서 지원하지 않음
|
||||
// if (typeof globalThis !== 'undefined') return globalThis;
|
||||
|
||||
// 최후의 수단 - 빈 객체
|
||||
console.warn('[FocusHistory] 전역 객체 접근 불가, 빈 객체 사용');
|
||||
return {};
|
||||
} catch (error) {
|
||||
console.error('[FocusHistory] 전역 객체 접근 오류:', error);
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 전역 버퍼 복원 시도
|
||||
* @returns {object|null} 복원된 버퍼 또는 null
|
||||
*/
|
||||
const attemptBufferRestore = () => {
|
||||
try {
|
||||
const globalObj = getGlobalObject();
|
||||
const existingBuffer = globalObj[GLOBAL_BUFFER_KEY];
|
||||
|
||||
if (isValidBuffer(existingBuffer)) {
|
||||
console.log('[FocusHistory] ✅ 기존 전역 버퍼 복원 성공');
|
||||
return existingBuffer;
|
||||
} else if (existingBuffer) {
|
||||
console.warn('[FocusHistory] ⚠️ 손상된 전역 버퍼 발견, 제거 후 재생성');
|
||||
delete globalObj[GLOBAL_BUFFER_KEY];
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[FocusHistory] 버퍼 복원 시도 실패:', error);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* 전역 버퍼 생성 및 등록
|
||||
* @returns {object} 생성된 버퍼
|
||||
*/
|
||||
const createAndRegisterBuffer = () => {
|
||||
try {
|
||||
const newBuffer = createFocusRingBuffer();
|
||||
const globalObj = getGlobalObject();
|
||||
|
||||
// 전역 객체에 안전하게 등록
|
||||
if (globalObj && typeof globalObj === 'object') {
|
||||
globalObj[GLOBAL_BUFFER_KEY] = newBuffer;
|
||||
|
||||
// 개발 환경에서 디버깅 편의성 제공
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
// 추가 접근 경로 제공 (하위 호환성)
|
||||
globalObj.globalFocusBuffer = newBuffer;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[FocusHistory] 🆕 새 전역 버퍼 생성 및 등록 완료');
|
||||
return newBuffer;
|
||||
} catch (error) {
|
||||
console.error('[FocusHistory] 버퍼 생성 및 등록 실패:', error);
|
||||
// 최후의 수단 - 로컬 버퍼라도 반환
|
||||
return createFocusRingBuffer();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 안전한 전역 버퍼 가져오기/생성 함수
|
||||
* HMR, 모듈 재로드, 크로스 플랫폼 환경에서 안정적으로 동작
|
||||
* @returns {object} 전역 포커스 버퍼 인스턴스
|
||||
*/
|
||||
const getOrCreateGlobalBuffer = () => {
|
||||
try {
|
||||
// 1단계: 이미 로드된 버퍼가 있고 유효한지 확인
|
||||
if (isValidBuffer(globalFocusBuffer)) {
|
||||
return globalFocusBuffer;
|
||||
}
|
||||
|
||||
// 2단계: 전역 객체에서 기존 버퍼 복원 시도
|
||||
const restoredBuffer = attemptBufferRestore();
|
||||
if (restoredBuffer) {
|
||||
globalFocusBuffer = restoredBuffer;
|
||||
return globalFocusBuffer;
|
||||
}
|
||||
|
||||
// 3단계: 새 버퍼 생성 및 등록
|
||||
globalFocusBuffer = createAndRegisterBuffer();
|
||||
return globalFocusBuffer;
|
||||
} catch (error) {
|
||||
console.error('[FocusHistory] 전역 버퍼 초기화 실패:', error);
|
||||
|
||||
// 최후의 수단: 최소한의 로컬 버퍼라도 제공
|
||||
try {
|
||||
if (!globalFocusBuffer) {
|
||||
globalFocusBuffer = createFocusRingBuffer();
|
||||
}
|
||||
return globalFocusBuffer;
|
||||
} catch (fallbackError) {
|
||||
console.error('[FocusHistory] 폴백 버퍼 생성도 실패:', fallbackError);
|
||||
// 더미 버퍼 반환 (앱 크래시 방지)
|
||||
return {
|
||||
enqueue: () => ({ inserted: false, policy: null }),
|
||||
getCurrent: () => null,
|
||||
getPrevious: () => null,
|
||||
getState: () => ({
|
||||
current: null,
|
||||
previous: null,
|
||||
history: [],
|
||||
hasTransition: false,
|
||||
size: 0,
|
||||
head: -1,
|
||||
}),
|
||||
clear: () => {},
|
||||
getHistory: () => [],
|
||||
getHistoryAt: () => null,
|
||||
detectPattern: () => ({ pattern: 'error', videoTarget: null, confidence: 0 }),
|
||||
calculateVideoPolicy: () => ({
|
||||
videoTarget: null,
|
||||
shouldShowBorder: false,
|
||||
transition: 'error',
|
||||
confidence: 0,
|
||||
}),
|
||||
getDebugInfo: () => ({ error: 'Buffer creation failed' }),
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const useFocusHistory = (options = {}) => {
|
||||
const {
|
||||
enableLogging = process.env.NODE_ENV === 'development',
|
||||
useGlobalState = true, // 전역 상태 사용 여부
|
||||
logPrefix = '[focusHistory]',
|
||||
} = options;
|
||||
|
||||
// 강제 리렌더링을 위한 상태
|
||||
const [, forceUpdate] = useState({});
|
||||
const triggerUpdate = useCallback(() => forceUpdate({}), []);
|
||||
|
||||
// 로컬 버퍼 참조
|
||||
const localBufferRef = useRef(null);
|
||||
|
||||
// 🔽 [개선] 안전한 버퍼 초기화 로직
|
||||
const buffer = useMemo(() => {
|
||||
try {
|
||||
if (useGlobalState) {
|
||||
// 전역 버퍼 사용: 안전한 초기화 함수 호출
|
||||
return getOrCreateGlobalBuffer();
|
||||
} else {
|
||||
// 로컬 버퍼 사용: 안전한 로컬 버퍼 생성
|
||||
if (!localBufferRef.current) {
|
||||
try {
|
||||
localBufferRef.current = createFocusRingBuffer();
|
||||
} catch (error) {
|
||||
console.error('[FocusHistory] 로컬 버퍼 생성 실패:', error);
|
||||
// 더미 버퍼로 폴백
|
||||
localBufferRef.current = {
|
||||
enqueue: () => ({ inserted: false, policy: null }),
|
||||
getCurrent: () => null,
|
||||
getPrevious: () => null,
|
||||
getState: () => ({
|
||||
current: null,
|
||||
previous: null,
|
||||
history: [],
|
||||
hasTransition: false,
|
||||
size: 0,
|
||||
head: -1,
|
||||
}),
|
||||
clear: () => {},
|
||||
getHistory: () => [],
|
||||
getHistoryAt: () => null,
|
||||
detectPattern: () => ({ pattern: 'error', videoTarget: null, confidence: 0 }),
|
||||
calculateVideoPolicy: () => ({
|
||||
videoTarget: null,
|
||||
shouldShowBorder: false,
|
||||
transition: 'error',
|
||||
confidence: 0,
|
||||
}),
|
||||
getDebugInfo: () => ({ error: 'Local buffer creation failed' }),
|
||||
};
|
||||
}
|
||||
}
|
||||
return localBufferRef.current;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[FocusHistory] 버퍼 초기화 전체 실패:', error);
|
||||
// 최후의 더미 버퍼
|
||||
return {
|
||||
enqueue: () => ({ inserted: false, policy: null }),
|
||||
getCurrent: () => null,
|
||||
getPrevious: () => null,
|
||||
getState: () => ({
|
||||
current: null,
|
||||
previous: null,
|
||||
history: [],
|
||||
hasTransition: false,
|
||||
size: 0,
|
||||
head: -1,
|
||||
}),
|
||||
clear: () => {},
|
||||
getHistory: () => [],
|
||||
getHistoryAt: () => null,
|
||||
detectPattern: () => ({ pattern: 'error', videoTarget: null, confidence: 0 }),
|
||||
calculateVideoPolicy: () => ({
|
||||
videoTarget: null,
|
||||
shouldShowBorder: false,
|
||||
transition: 'error',
|
||||
confidence: 0,
|
||||
}),
|
||||
getDebugInfo: () => ({ error: 'Complete buffer initialization failed' }),
|
||||
};
|
||||
}
|
||||
}, [useGlobalState]);
|
||||
|
||||
// 🔽 [개선] 안전한 포커스 히스토리 추가 + 정책 엔진 통합
|
||||
const enqueue = useCallback(
|
||||
(focusId) => {
|
||||
try {
|
||||
// 입력값 검증
|
||||
if (!focusId || typeof focusId !== 'string') {
|
||||
console.warn(`${logPrefix} Invalid focus ID:`, focusId);
|
||||
return { inserted: false, policy: null };
|
||||
}
|
||||
|
||||
// 버퍼 유효성 재검증
|
||||
if (!isValidBuffer(buffer)) {
|
||||
console.error(`${logPrefix} 버퍼가 손상됨, enqueue 실패`);
|
||||
return { inserted: false, policy: null };
|
||||
}
|
||||
|
||||
const previousFocus = buffer.getCurrent(); // 현재가 이전이 됨
|
||||
const result = buffer.enqueue(focusId); // { inserted, policy }
|
||||
|
||||
if (result.inserted) {
|
||||
// 상태 변경 시 리렌더링 트리거
|
||||
triggerUpdate();
|
||||
|
||||
if (enableLogging) {
|
||||
const current = buffer.getCurrent();
|
||||
const previous = buffer.getPrevious();
|
||||
const policy = result.policy;
|
||||
|
||||
// 🔽 [향상된 로깅] 패턴과 정책 정보 포함
|
||||
if (previous && current && previous !== current) {
|
||||
console.log(`${logPrefix} 🎯 ${previous} → ${current}`);
|
||||
console.log(`${logPrefix} 📋 buffer:`, buffer.getHistory());
|
||||
} else {
|
||||
console.log(`${logPrefix} 🎯 초기 포커스: ${current}`);
|
||||
console.log(`${logPrefix} 📋 buffer:`, buffer.getHistory());
|
||||
}
|
||||
|
||||
// 디버그 모드에서는 전체 히스토리 표시
|
||||
// if (process.env.NODE_ENV === 'development') {
|
||||
// const debugInfo = buffer.getDebugInfo();
|
||||
// console.log(`${logPrefix} 🔍 디버그:`, debugInfo);
|
||||
// }
|
||||
}
|
||||
}
|
||||
|
||||
return result; // { inserted, policy } 반환
|
||||
} catch (error) {
|
||||
console.error(`${logPrefix} enqueue 실행 중 오류:`, error, { focusId });
|
||||
// 오류 발생 시 안전한 기본값 반환
|
||||
return { inserted: false, policy: null };
|
||||
}
|
||||
},
|
||||
[buffer, enableLogging, logPrefix, triggerUpdate]
|
||||
);
|
||||
|
||||
// 🔽 [개선] 안전한 상태 가져오기
|
||||
const getQueueState = useCallback(() => {
|
||||
try {
|
||||
if (!isValidBuffer(buffer)) {
|
||||
console.warn(`${logPrefix} getQueueState: 버퍼 무효`);
|
||||
return {
|
||||
current: null,
|
||||
previous: null,
|
||||
history: [],
|
||||
haTransition: false,
|
||||
size: 0,
|
||||
head: -1,
|
||||
};
|
||||
}
|
||||
return buffer.getState();
|
||||
} catch (error) {
|
||||
console.error(`${logPrefix} getQueueState 오류:`, error);
|
||||
return {
|
||||
current: null,
|
||||
previous: null,
|
||||
history: [],
|
||||
hasTransition: false,
|
||||
size: 0,
|
||||
head: -1,
|
||||
};
|
||||
}
|
||||
}, [buffer, logPrefix]);
|
||||
|
||||
// 🔽 [개선] 안전한 상태 초기화
|
||||
const clearHistory = useCallback(() => {
|
||||
try {
|
||||
if (!isValidBuffer(buffer)) {
|
||||
console.warn(`${logPrefix} clearHistory: 버퍼 무효`);
|
||||
return;
|
||||
}
|
||||
buffer.clear();
|
||||
triggerUpdate(); // 상태 변경 시 리렌더링 트리거
|
||||
if (enableLogging) {
|
||||
console.log(`${logPrefix} 히스토리 초기화됨`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`${logPrefix} clearHistory 오류:`, error);
|
||||
}
|
||||
}, [buffer, enableLogging, logPrefix, triggerUpdate]);
|
||||
|
||||
// 🔽 [개선] 안전한 현재 상태 가져오기
|
||||
const currentState = useMemo(() => {
|
||||
try {
|
||||
if (!isValidBuffer(buffer)) {
|
||||
return {
|
||||
current: null,
|
||||
previous: null,
|
||||
history: [],
|
||||
hasTransition: false,
|
||||
size: 0,
|
||||
head: -1,
|
||||
};
|
||||
}
|
||||
return buffer.getState();
|
||||
} catch (error) {
|
||||
console.error(`${logPrefix} currentState 계산 오류:`, error);
|
||||
return {
|
||||
current: null,
|
||||
previous: null,
|
||||
history: [],
|
||||
hasTransition: false,
|
||||
size: 0,
|
||||
head: -1,
|
||||
};
|
||||
}
|
||||
}, [buffer, logPrefix]);
|
||||
|
||||
// 🔽 [개선] 안전한 확장된 메서드들
|
||||
const getHistory = useCallback(() => {
|
||||
try {
|
||||
return isValidBuffer(buffer) ? buffer.getHistory() : [];
|
||||
} catch (error) {
|
||||
console.error(`${logPrefix} getHistory 오류:`, error);
|
||||
return [];
|
||||
}
|
||||
}, [buffer, logPrefix]);
|
||||
|
||||
const getHistoryAt = useCallback(
|
||||
(distance) => {
|
||||
try {
|
||||
return isValidBuffer(buffer) ? buffer.getHistoryAt(distance) : null;
|
||||
} catch (error) {
|
||||
console.error(`${logPrefix} getHistoryAt 오류:`, error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
[buffer, logPrefix]
|
||||
);
|
||||
|
||||
const detectPattern = useCallback(() => {
|
||||
try {
|
||||
return isValidBuffer(buffer)
|
||||
? buffer.detectPattern()
|
||||
: { pattern: 'error', videoTarget: null, confidence: 0 };
|
||||
} catch (error) {
|
||||
console.error(`${logPrefix} detectPattern 오류:`, error);
|
||||
return { pattern: 'error', videoTarget: null, confidence: 0 };
|
||||
}
|
||||
}, [buffer, logPrefix]);
|
||||
|
||||
const calculateVideoPolicy = useCallback(() => {
|
||||
try {
|
||||
return isValidBuffer(buffer)
|
||||
? buffer.calculateVideoPolicy()
|
||||
: { videoTarget: null, shouldShowBorder: false, transition: 'error', confidence: 0 };
|
||||
} catch (error) {
|
||||
console.error(`${logPrefix} calculateVideoPolicy 오류:`, error);
|
||||
return { videoTarget: null, shouldShowBorder: false, transition: 'error', confidence: 0 };
|
||||
}
|
||||
}, [buffer, logPrefix]);
|
||||
|
||||
const getDebugInfo = useCallback(() => {
|
||||
try {
|
||||
return isValidBuffer(buffer)
|
||||
? buffer.getDebugInfo()
|
||||
: { error: 'Buffer invalid or unavailable' };
|
||||
} catch (error) {
|
||||
console.error(`${logPrefix} getDebugInfo 오류:`, error);
|
||||
return { error: 'getDebugInfo failed', details: error.message };
|
||||
}
|
||||
}, [buffer, logPrefix]);
|
||||
|
||||
// 🔽 [추가] getQueue - 최신 상태의 큐를 최신순(맨 앞)으로 반환
|
||||
const getQueue = useCallback(() => {
|
||||
try {
|
||||
if (!isValidBuffer(buffer)) {
|
||||
return []; // 빈 배열 반환
|
||||
}
|
||||
return buffer.getHistory(); // 이미 최신순으로 정렬됨 [current, previous, older, oldest, ...]
|
||||
} catch (error) {
|
||||
console.error(`${logPrefix} getQueue 오류:`, error);
|
||||
return [];
|
||||
}
|
||||
}, [buffer, logPrefix]);
|
||||
|
||||
// 🔽 [추가] getQueueSafe - 최대 2개까지만 안전하게 가져오기 (undefined 방지)
|
||||
const getQueueSafe = useCallback(() => {
|
||||
try {
|
||||
const queue = getQueue(); // 기본 큐 가져오기
|
||||
|
||||
// FP 방식으로 안전하게 최대 2개까지만 가져오기
|
||||
return fp.pipe(
|
||||
fp.defaultTo([]), // null/undefined 방지
|
||||
(arr) => fp.slice(0, 2, arr), // 최대 2개까지만
|
||||
fp.map(fp.defaultTo(null)) // 각 항목도 null로 안전하게 처리
|
||||
)(queue);
|
||||
} catch (error) {
|
||||
console.error(`${logPrefix} getQueueSafe 오류:`, error);
|
||||
return []; // 에러 시 빈 배열
|
||||
}
|
||||
}, [getQueue, logPrefix]);
|
||||
|
||||
return {
|
||||
// 핵심 함수들
|
||||
enqueue,
|
||||
getQueueState,
|
||||
clearHistory,
|
||||
|
||||
// 🔽 [신기능] 확장 함수들
|
||||
getHistory,
|
||||
getHistoryAt, // 🔽 [추가] 특정 거리 히스토리
|
||||
getQueue, // 🔽 [추가] 최신 순으로 정렬된 큐
|
||||
getQueueSafe, // 🔽 [추가] 최대 2개까지만 안전하게 가져오기
|
||||
detectPattern,
|
||||
calculateVideoPolicy,
|
||||
getDebugInfo,
|
||||
|
||||
// 편의 속성들 (실시간 업데이트)
|
||||
curFocused: currentState.current,
|
||||
preFocused: currentState.previous,
|
||||
hasTransition: currentState.hasTransition,
|
||||
history: currentState.history, // 🔽 [추가] 전체 히스토리
|
||||
|
||||
// 배열 형태로 반환
|
||||
focusTransition:
|
||||
currentState.previous && currentState.current
|
||||
? [currentState.previous, currentState.current]
|
||||
: currentState.current
|
||||
? [null, currentState.current]
|
||||
: [null, null],
|
||||
};
|
||||
};
|
||||
|
||||
export default useFocusHistory;
|
||||
3
com.twin.app.shoptime/src/hooks/useVideoPlay/index.js
Normal file
3
com.twin.app.shoptime/src/hooks/useVideoPlay/index.js
Normal file
@@ -0,0 +1,3 @@
|
||||
// src/hooks/useVideoPlay/index.js
|
||||
|
||||
export { useVideoPlay, default } from './useVideoPlay';
|
||||
@@ -0,0 +1,107 @@
|
||||
// src/hooks/useVideoPlay/useVideoPlay.js
|
||||
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { startBannerVideo, stopAndHideVideo } from '../../actions/playActions';
|
||||
|
||||
/**
|
||||
* useVideoPlay Hook - 완전한 동영상 재생 제어 (모든 기능 작동)
|
||||
*
|
||||
* 핵심 기능:
|
||||
* - playVideo: 동영상 재생 (force 옵션 지원, 중복 재생 방지)
|
||||
* - stopVideo: 동영상 중지 (마지막 재생 배너 기억)
|
||||
* - restartVideo: 마지막 재생 배너로 재시작 (force: true로 중복 방지 해제)
|
||||
*/
|
||||
export const useVideoPlay = (options = {}) => {
|
||||
const { enableLogging = false, logPrefix = '[useVideoPlay]' } = options;
|
||||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
// 🔥 [핵심1] 현재 재생 중인 배너를 useRef로 추적 (중복 재생 방지용)
|
||||
const currentPlayingBannerRef = useRef(null);
|
||||
|
||||
// 🔥 [핵심2] 마지막 재생 배너를 useRef로 유지 (중지해도 유지)
|
||||
const lastPlayedBannerRef = useRef(null);
|
||||
|
||||
// Redux에서 현재 재생 상태 확인 (참고용)
|
||||
const currentOwnerId = useSelector((state) => state.home.playerControl?.ownerId);
|
||||
|
||||
// 🔽 [핵심] 동영상 재생 - force 옵션 지원, 중복 재생 방지
|
||||
const playVideo = useCallback(
|
||||
(bannerId, playOptions = {}) => {
|
||||
const { force = false, reason = 'manual' } = playOptions;
|
||||
|
||||
if (!bannerId || typeof bannerId !== 'string') {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
|
||||
// 지원되는 배너만 허용 (banner0, banner1)
|
||||
if (!['banner0', 'banner1'].includes(bannerId)) {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
|
||||
// 🔥 [핵심] 중복 재생 방지 - force가 false이고 같은 배너이면 skip
|
||||
if (!force && currentPlayingBannerRef.current === bannerId) {
|
||||
if (enableLogging) {
|
||||
console.log(`${logPrefix} 🔄 ${bannerId} 이미 재생 중 - skip`);
|
||||
}
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
if (enableLogging) {
|
||||
console.log(`${logPrefix} 🎬 ${bannerId} 재생 (force: ${force}, reason: ${reason})`);
|
||||
}
|
||||
|
||||
// 🔥 [핵심] 현재 재생 중인 배너와 마지막 재생 배너 모두 기록
|
||||
currentPlayingBannerRef.current = bannerId;
|
||||
lastPlayedBannerRef.current = bannerId;
|
||||
|
||||
// Redux 액션 dispatch
|
||||
dispatch(startBannerVideo(bannerId, { modalContainerId: bannerId }));
|
||||
return Promise.resolve(true);
|
||||
},
|
||||
[dispatch, enableLogging, logPrefix]
|
||||
);
|
||||
|
||||
// 🔽 [핵심] 동영상 중지 - 현재 재생은 클리어, 마지막 재생은 유지
|
||||
const stopVideo = useCallback(() => {
|
||||
if (enableLogging) {
|
||||
console.log(`${logPrefix} 🛑 중지 (마지막 재생: ${lastPlayedBannerRef.current} 유지)`);
|
||||
}
|
||||
|
||||
// 🔥 [핵심] 현재 재생 중인 배너만 클리어, 마지막 재생은 유지
|
||||
currentPlayingBannerRef.current = null;
|
||||
// lastPlayedBannerRef.current는 유지! (재시작을 위해)
|
||||
|
||||
// Redux 액션 dispatch
|
||||
dispatch(stopAndHideVideo());
|
||||
return Promise.resolve(true);
|
||||
}, [dispatch, enableLogging, logPrefix]);
|
||||
|
||||
// 🔽 [핵심] 재시작 - 마지막 재생 배너로 force: true 재생
|
||||
const restartVideo = useCallback(() => {
|
||||
const lastBannerId = lastPlayedBannerRef.current;
|
||||
|
||||
if (!lastBannerId) {
|
||||
if (enableLogging) {
|
||||
console.log(`${logPrefix} ♾️ 재시작할 배너 없음 - skip`);
|
||||
}
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
|
||||
if (enableLogging) {
|
||||
console.log(`${logPrefix} ♾️ ${lastBannerId} 재시작`);
|
||||
}
|
||||
|
||||
// 🔥 [핵심] force: true로 중복 재생 방지 해제하여 재시작
|
||||
return playVideo(lastBannerId, { force: true, reason: 'restart' });
|
||||
}, [playVideo, enableLogging, logPrefix]);
|
||||
|
||||
return {
|
||||
playVideo,
|
||||
stopVideo,
|
||||
restartVideo,
|
||||
};
|
||||
};
|
||||
|
||||
export default useVideoPlay;
|
||||
@@ -0,0 +1,58 @@
|
||||
// src/hooks/useVideoPlay/useVideoPlay.js
|
||||
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { startBannerVideo, stopAndHideVideo } from '../../actions/playActions';
|
||||
|
||||
export const useVideoPlay = (options = {}) => {
|
||||
const { enableLogging = false, logPrefix = '[useVideoPlay]' } = options;
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const currentOwnerId = useSelector((state) => state.home.playerControl?.ownerId);
|
||||
const lastPlayedBannerRef = useRef(null);
|
||||
|
||||
const playVideo = useCallback(
|
||||
(bannerId, playOptions = {}) => {
|
||||
const { force = false } = playOptions;
|
||||
|
||||
if (!bannerId || typeof bannerId !== 'string') {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
|
||||
if (!['banner0', 'banner1'].includes(bannerId)) {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
|
||||
// 마지막 재생 배너 기록
|
||||
lastPlayedBannerRef.current = bannerId;
|
||||
|
||||
// Redux 액션 dispatch
|
||||
dispatch(startBannerVideo(bannerId, { modalContainerId: bannerId }));
|
||||
return Promise.resolve(true);
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
const stopVideo = useCallback(() => {
|
||||
dispatch(stopAndHideVideo());
|
||||
return Promise.resolve(true);
|
||||
}, [dispatch]);
|
||||
|
||||
const restartVideo = useCallback(() => {
|
||||
const lastBannerId = lastPlayedBannerRef.current;
|
||||
|
||||
if (!lastBannerId) {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
|
||||
return playVideo(lastBannerId, { force: true, reason: 'restart' });
|
||||
}, [playVideo]);
|
||||
|
||||
return {
|
||||
playVideo,
|
||||
stopVideo,
|
||||
restartVideo,
|
||||
};
|
||||
};
|
||||
|
||||
export default useVideoPlay;
|
||||
@@ -0,0 +1,89 @@
|
||||
// src/hooks/useVideoPlay/useVideoPlay.js
|
||||
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { startBannerVideo, stopAndHideVideo } from '../../actions/playActions';
|
||||
|
||||
/**
|
||||
* useVideoPlay Hook - 간소화된 동영상 재생 제어 (restartVideo 수정)
|
||||
*
|
||||
* 핵심 기능:
|
||||
* - playVideo: 동영상 재생 (options 지원)
|
||||
* - stopVideo: 동영상 중지 (마지막 재생 배너 기억)
|
||||
* - restartVideo: 마지막 재생 배너로 재시작
|
||||
*/
|
||||
export const useVideoPlay = (options = {}) => {
|
||||
const { enableLogging = false, logPrefix = '[useVideoPlay]' } = options;
|
||||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
// 🔥 [핵심] 마지막 재생 배너를 useRef로 유지 (중지해도 유지)
|
||||
const lastPlayedBannerRef = useRef(null);
|
||||
|
||||
// Redux에서 현재 재생 상태 확인
|
||||
const currentOwnerId = useSelector((state) => state.home.playerControl?.ownerId);
|
||||
|
||||
// 🔽 [핵심] 동영상 재생 - options 파라미터 지원
|
||||
const playVideo = useCallback(
|
||||
(bannerId, playOptions = {}) => {
|
||||
if (!bannerId || typeof bannerId !== 'string') {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
|
||||
// 지원되는 배너만 허용 (banner0, banner1)
|
||||
if (!['banner0', 'banner1'].includes(bannerId)) {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
|
||||
if (enableLogging) {
|
||||
console.log(`${logPrefix} 🎬 ${bannerId} 재생`, playOptions);
|
||||
}
|
||||
|
||||
// 🔥 [핵심] 마지막 재생 배너 기록 (재시작을 위해)
|
||||
lastPlayedBannerRef.current = bannerId;
|
||||
|
||||
// Redux 액션 dispatch
|
||||
dispatch(startBannerVideo(bannerId, { modalContainerId: bannerId }));
|
||||
return Promise.resolve(true);
|
||||
},
|
||||
[dispatch, enableLogging, logPrefix]
|
||||
);
|
||||
|
||||
// 🔽 [핵심] 동영상 중지 - 마지막 재생 배너는 유지
|
||||
const stopVideo = useCallback(() => {
|
||||
if (enableLogging) {
|
||||
console.log(`${logPrefix} 🛑 중지 (마지막 재생: ${lastPlayedBannerRef.current})`);
|
||||
}
|
||||
|
||||
// Redux 액션 dispatch (마지막 재생 배너는 useRef에 보존됨)
|
||||
dispatch(stopAndHideVideo());
|
||||
return Promise.resolve(true);
|
||||
}, [dispatch, enableLogging, logPrefix]);
|
||||
|
||||
// 🔽 [핵심] 재시작 - useRef에서 마지막 재생 배너 추출
|
||||
const restartVideo = useCallback(() => {
|
||||
const lastBannerId = lastPlayedBannerRef.current;
|
||||
|
||||
if (!lastBannerId) {
|
||||
if (enableLogging) {
|
||||
console.log(`${logPrefix} ♾️ 재시작할 배너 없음 - skip`);
|
||||
}
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
|
||||
if (enableLogging) {
|
||||
console.log(`${logPrefix} ♾️ ${lastBannerId} 재시작`);
|
||||
}
|
||||
|
||||
// 🔥 [핵심] force: true 옵션으로 중복 재생 방지 해제
|
||||
return playVideo(lastBannerId, { force: true, reason: 'restart' });
|
||||
}, [playVideo, enableLogging, logPrefix]);
|
||||
|
||||
return {
|
||||
playVideo,
|
||||
stopVideo,
|
||||
restartVideo,
|
||||
};
|
||||
};
|
||||
|
||||
export default useVideoPlay;
|
||||
377
com.twin.app.shoptime/src/hooks/useVideoPlay/useVideoPlay.js
Normal file
377
com.twin.app.shoptime/src/hooks/useVideoPlay/useVideoPlay.js
Normal file
@@ -0,0 +1,377 @@
|
||||
// src/hooks/useVideoPlay/useVideoPlay.js
|
||||
|
||||
import { useCallback, useRef, useState, useEffect, useMemo } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import {
|
||||
startBannerVideo,
|
||||
stopBannerVideo,
|
||||
stopAndHideVideo,
|
||||
hidePlayerVideo,
|
||||
} from '../../actions/playActions';
|
||||
import fp from '../../utils/fp.js';
|
||||
import { videoState } from './videoState.js';
|
||||
|
||||
/**
|
||||
* useVideoPlay Hook - 안전한 동영상 재생 제어
|
||||
*
|
||||
* 주요 기능:
|
||||
* - 동영상 재생/중지 안전 제어
|
||||
* - 중복 재생 방지
|
||||
* - 재생 상태 추적
|
||||
* - 오류 처리 및 복구
|
||||
* - useFocusHistory와 연동 지원
|
||||
*/
|
||||
|
||||
/**
|
||||
* 동영상 재생 제어 훅
|
||||
* @param {object} options - 옵션 설정
|
||||
* @param {boolean} options.enableLogging - 로깅 활성화 여부
|
||||
* @param {string} options.logPrefix - 로그 접두사
|
||||
* @param {boolean} options.autoErrorRecovery - 자동 오류 복구 여부
|
||||
* @returns {object} 동영상 재생 제어 인터페이스
|
||||
*/
|
||||
export const useVideoPlay = (options = {}) => {
|
||||
const {
|
||||
enableLogging = process.env.NODE_ENV === 'development',
|
||||
logPrefix = '[useVideoPlay]',
|
||||
autoErrorRecovery = true,
|
||||
} = options;
|
||||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
// 로그 함수
|
||||
const log = (message) => enableLogging && console.log(`${logPrefix} ${message}`);
|
||||
|
||||
// 🔽 Redux 상태 구독
|
||||
const currentOwnerId = useSelector((state) => state.home.playerControl?.ownerId);
|
||||
const bannerDataList = useSelector((state) => state.home.bannerData?.bannerInfos);
|
||||
const bannerVisibility = useSelector((state) => state.home.bannerVisibility);
|
||||
|
||||
// 🔽 [단순화] 현재 재생 중인 배너 가져오기
|
||||
const getCurrentPlayingBanner = useCallback(() => {
|
||||
return videoState.getCurrentPlaying();
|
||||
}, []);
|
||||
|
||||
// 🔽 [단순화] 마지막으로 재생된 배너 가져오기
|
||||
const getLastPlayedBanner = useCallback(() => {
|
||||
return videoState.getLastPlayedBanner();
|
||||
}, []);
|
||||
|
||||
// 🔽 로컬 상태 관리 (단순화)
|
||||
const [errorCount, setErrorCount] = useState(0);
|
||||
|
||||
// 🔽 타이머 및 참조 관리
|
||||
const playDelayTimerRef = useRef(null);
|
||||
const retryTimerRef = useRef(null);
|
||||
|
||||
// 🔽 [유틸리티] 배너 가용성 검사 (0부터 시작 통일)
|
||||
const isBannerAvailable = useCallback(
|
||||
(bannerId) => {
|
||||
try {
|
||||
if (!bannerDataList || bannerDataList.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// banner0/banner1 -> bannerDataList[0], banner2/banner3 -> bannerDataList[1]
|
||||
const bannerIndex = ['banner0', 'banner1'].includes(bannerId) ? 0 : 1;
|
||||
const bannerData = bannerDataList[bannerIndex];
|
||||
|
||||
if (!bannerData) return false;
|
||||
|
||||
if (['banner0', 'banner1'].includes(bannerId)) {
|
||||
// banner0/banner1: 첫 번째 배너의 첫 번째 상세 정보
|
||||
const detailInfo = fp.get('bannerDetailInfos[0]', bannerData);
|
||||
return !!fp.get('showUrl', detailInfo);
|
||||
} else if (['banner2', 'banner3'].includes(bannerId)) {
|
||||
// banner2/banner3: randomIndex에 따른 상세 정보
|
||||
const randomIndex = fp.get('randomIndex', bannerData) || 0;
|
||||
const detailInfo = fp.get(`bannerDetailInfos[${randomIndex}]`, bannerData);
|
||||
return !!fp.get('showUrl', detailInfo);
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (error) {
|
||||
log(`배너 가용성 검사 오류: ${error.message}, bannerId: ${bannerId}`);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[bannerDataList, enableLogging, logPrefix]
|
||||
);
|
||||
|
||||
// 🔽 [핵심] 안전한 동영상 재생
|
||||
const playVideo = useCallback(
|
||||
(bannerId, options = {}) => {
|
||||
const { delay = 0, force = false, reason = 'manual' } = options;
|
||||
|
||||
try {
|
||||
// 입력값 검증
|
||||
if (!bannerId || typeof bannerId !== 'string') {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
|
||||
// 지원되는 배너 확인 (0부터 시작 통일)
|
||||
if (!['banner0', 'banner1'].includes(bannerId)) {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
|
||||
// 배너 가용성 확인
|
||||
if (!isBannerAvailable(bannerId)) {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
|
||||
// 🔽 [개선] 중복 재생 방지 - Redux 상태 기준으로 체크
|
||||
const isCurrentlyPlaying = currentOwnerId === `${bannerId}_player`;
|
||||
|
||||
// 🔽 [정확한 중복 방지] 실제 Redux에서 재생 중일 때만 skip
|
||||
if (!force && isCurrentlyPlaying) {
|
||||
log(`🎬 ${bannerId} 이미 재생 중 - skip`);
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
// 기존 타이머 정리
|
||||
if (playDelayTimerRef.current) {
|
||||
clearTimeout(playDelayTimerRef.current);
|
||||
}
|
||||
|
||||
const executePlay = () => {
|
||||
try {
|
||||
console.log(`[DEBUG-playVideo] 🎬 ${bannerId} 재생 시작 (${reason})`);
|
||||
log(`🎬 ${bannerId} 재생 시작 (${reason})`);
|
||||
|
||||
// 🔽 [단순화] 전역 상태 업데이트
|
||||
videoState.setCurrentPlaying(bannerId);
|
||||
|
||||
// Redux 액션 dispatch - bannerId를 modalContainerId로 사용
|
||||
dispatch(startBannerVideo(bannerId, { modalContainerId: bannerId, force }));
|
||||
|
||||
// 성공 상태 업데이트
|
||||
setErrorCount(0); // 성공 시 오류 카운트 초기화
|
||||
|
||||
resolve(true);
|
||||
} catch (error) {
|
||||
log(`playVideo 실행 오류: ${error.message}, bannerId: ${bannerId}`);
|
||||
|
||||
// 오류 카운트 증가
|
||||
setErrorCount((prev) => prev + 1);
|
||||
|
||||
// 자동 복구 시도
|
||||
if (autoErrorRecovery && errorCount < 3) {
|
||||
log(`자동 복구 시도 ${errorCount + 1}/3`);
|
||||
|
||||
retryTimerRef.current = setTimeout(
|
||||
() => {
|
||||
playVideo(bannerId, { ...options, force: true, reason: 'auto-retry' });
|
||||
},
|
||||
1000 * Math.pow(2, errorCount)
|
||||
); // 지수 백오프
|
||||
}
|
||||
|
||||
resolve(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 지연 실행 또는 즉시 실행
|
||||
if (delay > 0) {
|
||||
playDelayTimerRef.current = setTimeout(executePlay, delay);
|
||||
} else {
|
||||
executePlay();
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
log(
|
||||
`playVideo 전체 오류: ${error.message}, bannerId: ${bannerId}, options: ${JSON.stringify(options)}`
|
||||
);
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
},
|
||||
[
|
||||
(bannerId) => isBannerAvailable(bannerId),
|
||||
currentOwnerId,
|
||||
dispatch,
|
||||
enableLogging,
|
||||
logPrefix,
|
||||
autoErrorRecovery,
|
||||
errorCount,
|
||||
]
|
||||
);
|
||||
|
||||
// 🔽 [핵심] 안전한 동영상 중지 - 파라미터 없이 사용 가능
|
||||
const stopVideo = useCallback(
|
||||
(options = {}) => {
|
||||
const { reason = 'manual' } = options;
|
||||
|
||||
try {
|
||||
// 현재 재생 중인 배너 파악
|
||||
const currentPlayingBanner = getCurrentPlayingBanner();
|
||||
|
||||
if (!currentPlayingBanner) {
|
||||
log('🛑 재생 중인 비디오 없음 - skip');
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
log(`🛑 ${currentPlayingBanner} 중지 (${reason})`);
|
||||
|
||||
// Redux 액션 dispatch - 모든 비디오 중지 및 숨김
|
||||
dispatch(stopAndHideVideo());
|
||||
|
||||
// 🔽 [단순화] 전역 상태 업데이트
|
||||
videoState.setCurrentPlaying(null);
|
||||
|
||||
return Promise.resolve(true);
|
||||
} catch (error) {
|
||||
log(`stopVideo 오류: ${error.message}, options: ${JSON.stringify(options)}`);
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
},
|
||||
[getCurrentPlayingBanner, dispatch, enableLogging, logPrefix]
|
||||
);
|
||||
|
||||
// 🔽 [새로운 기능] 비디오를 완전히 중지하지 않고 소리만 나오도록 안보이게 함
|
||||
const hideVideo = useCallback(
|
||||
(options = {}) => {
|
||||
const { reason = 'hide' } = options;
|
||||
|
||||
try {
|
||||
// 현재 재생 중인 배너 파악
|
||||
const currentPlayingBanner = getCurrentPlayingBanner();
|
||||
|
||||
if (!currentPlayingBanner) {
|
||||
log('👁️🗨️ 재생 중인 비디오 없음 - skip');
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
log(`👁️🗨️ ${currentPlayingBanner} 숨김 (${reason}) - 소리 유지`);
|
||||
|
||||
// Redux 액션 dispatch - 비디오 숨김 (일시정지 + 모달 숨김)
|
||||
dispatch(hidePlayerVideo());
|
||||
|
||||
// 🔽 [중요] 전역 상태는 유지 (재생 상태는 그대로)
|
||||
// videoState.setCurrentPlaying(null); // <- 숨김 상태이므로 재생 상태 유지
|
||||
|
||||
return Promise.resolve(true);
|
||||
} catch (error) {
|
||||
log(`hideVideo 오류: ${error.message}, options: ${JSON.stringify(options)}`);
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
},
|
||||
[getCurrentPlayingBanner, dispatch, enableLogging, logPrefix]
|
||||
);
|
||||
|
||||
// 🔽 [새로운] 비디오 재시작 - 마지막으로 재생된 배너로 재시작 (기본 banner0)
|
||||
const restartVideo = useCallback(
|
||||
(bannerId = null, options = {}) => {
|
||||
const { reason = 'restart' } = options;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
try {
|
||||
// 🔥 핵심: bannerId가 주어지면 그걸 사용, 없으면 마지막 재생된 배너, 없으면 banner0
|
||||
const targetBannerId = bannerId || getLastPlayedBanner() || 'banner0';
|
||||
|
||||
console.log(`[DEBUG-restartVideo] ♾️ ${targetBannerId} 재시작 (${reason})`);
|
||||
log(`♾️ ${targetBannerId} 재시작 (${reason})`);
|
||||
|
||||
// 대상 배너를 다시 재생 (force 옵션으로 중복 방지 해제)
|
||||
playVideo(targetBannerId, { force: true, reason })
|
||||
.then(resolve)
|
||||
.catch(() => resolve(false));
|
||||
} catch (error) {
|
||||
log(
|
||||
`restartVideo 오류: ${error.message}, bannerId: ${bannerId}, options: ${JSON.stringify(options)}`
|
||||
);
|
||||
resolve(false);
|
||||
}
|
||||
});
|
||||
},
|
||||
[getLastPlayedBanner, playVideo, enableLogging, logPrefix]
|
||||
);
|
||||
|
||||
// 🔽 [편의 함수] 포커스 정책 기반 재생 제어
|
||||
const applyVideoPolicy = useCallback(
|
||||
(policy) => {
|
||||
try {
|
||||
if (!policy || typeof policy !== 'object') {
|
||||
log(`applyVideoPolicy: 잘못된 정책 - ${JSON.stringify(policy)}`);
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
|
||||
const { videoTarget, transition, confidence = 1.0 } = policy;
|
||||
|
||||
if (videoTarget) {
|
||||
// 동영상 재생
|
||||
return playVideo(videoTarget, {
|
||||
delay: confidence < 0.8 ? 200 : 100, // 신뢰도가 낮으면 지연
|
||||
reason: transition || 'policy',
|
||||
});
|
||||
} else {
|
||||
// 동영상 중지
|
||||
return stopVideo(null, {
|
||||
reason: transition || 'policy',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
log(`applyVideoPolicy 오류: ${error.message}, policy: ${JSON.stringify(policy)}`);
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
},
|
||||
[playVideo, stopVideo, enableLogging, logPrefix]
|
||||
);
|
||||
|
||||
// 🔽 정리 작업
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
// 컴포넌트 언마운트 시 타이머 정리
|
||||
if (playDelayTimerRef.current) {
|
||||
clearTimeout(playDelayTimerRef.current);
|
||||
}
|
||||
if (retryTimerRef.current) {
|
||||
clearTimeout(retryTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 🔽 현재 상태 계산
|
||||
const isPlaying = currentOwnerId && currentOwnerId.endsWith('_player');
|
||||
const currentBanner = currentOwnerId ? currentOwnerId.replace('_player', '') : null;
|
||||
|
||||
// 🔽 디버그 정보 (단순화)
|
||||
const getDebugInfo = useCallback(
|
||||
() => ({
|
||||
currentOwnerId,
|
||||
currentBanner,
|
||||
isPlaying,
|
||||
errorCount,
|
||||
videoState: videoState.getDebugInfo(),
|
||||
bannerAvailability: {
|
||||
banner0: isBannerAvailable('banner0'),
|
||||
banner1: isBannerAvailable('banner1'),
|
||||
},
|
||||
}),
|
||||
[currentOwnerId, currentBanner, isPlaying, errorCount, isBannerAvailable]
|
||||
);
|
||||
|
||||
return {
|
||||
// 🎯 핵심 함수
|
||||
playVideo,
|
||||
stopVideo,
|
||||
hideVideo, // 🔽 [새로운] 비디오 숨김 (소리 유지)
|
||||
restartVideo, // 🔽 [새로운] 파라미터 없이 재시작
|
||||
applyVideoPolicy,
|
||||
|
||||
// 📊 상태 정보
|
||||
getCurrentPlayingBanner,
|
||||
getLastPlayedBanner,
|
||||
isPlaying,
|
||||
currentBanner,
|
||||
bannerVisibility,
|
||||
|
||||
// 🔍 유틸리티
|
||||
isBannerAvailable,
|
||||
getDebugInfo,
|
||||
|
||||
// 📈 통계
|
||||
errorCount,
|
||||
};
|
||||
};
|
||||
|
||||
export default useVideoPlay;
|
||||
591
com.twin.app.shoptime/src/hooks/useVideoPlay/useVideoPlay.old.js
Normal file
591
com.twin.app.shoptime/src/hooks/useVideoPlay/useVideoPlay.old.js
Normal file
@@ -0,0 +1,591 @@
|
||||
// src/hooks/useVideoPlay/useVideoPlay.js
|
||||
|
||||
import { useCallback, useRef, useState, useEffect, useMemo } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { startBannerVideo, stopBannerVideo, stopAndHideVideo } from '../../actions/playActions';
|
||||
import fp from '../../utils/fp.js';
|
||||
|
||||
// 🔽 [전역 상태] 전역 비디오 상태 관리 - useFocusHistory와 동일한 구조
|
||||
let globalVideoState = null;
|
||||
|
||||
// 🔽 [전역 비디오 상태] 비디오 상태 관리 객체 생성
|
||||
const createVideoStateManager = () => {
|
||||
let currentPlayingBannerId = null; // 현재 재생 중인 배너 ID
|
||||
let lastPlayedBannerId = null; // 마지막으로 재생된 배너 ID (멈춰도 유지)
|
||||
let playHistory = []; // 최근 재생 히스토리 (10개)
|
||||
let lastUpdateTime = Date.now();
|
||||
|
||||
const setCurrentPlaying = (bannerId) => {
|
||||
const previousBannerId = currentPlayingBannerId;
|
||||
currentPlayingBannerId = bannerId;
|
||||
lastUpdateTime = Date.now();
|
||||
|
||||
// 🔥 핵심: 재생 시에만 lastPlayedBannerId 업데이트 (멈춰도 유지)
|
||||
if (bannerId) {
|
||||
lastPlayedBannerId = bannerId;
|
||||
}
|
||||
|
||||
// 히스토리 업데이트
|
||||
if (bannerId) {
|
||||
playHistory = [
|
||||
{ bannerId, timestamp: lastUpdateTime, action: 'play' },
|
||||
...playHistory.slice(0, 9), // 최대 10개
|
||||
];
|
||||
} else if (previousBannerId) {
|
||||
playHistory = [
|
||||
{ bannerId: previousBannerId, timestamp: lastUpdateTime, action: 'stop' },
|
||||
...playHistory.slice(0, 9),
|
||||
];
|
||||
}
|
||||
|
||||
return { previousBannerId, currentBannerId: bannerId };
|
||||
};
|
||||
|
||||
const getCurrentPlaying = () => currentPlayingBannerId;
|
||||
|
||||
const getLastPlayedBanner = () => lastPlayedBannerId; // 🔥 새로운 함수
|
||||
|
||||
const getPlayHistory = () => [...playHistory]; // 복사본 반환
|
||||
|
||||
const getState = () => ({
|
||||
currentPlayingBannerId,
|
||||
lastPlayedBannerId,
|
||||
playHistory: getPlayHistory(),
|
||||
lastUpdateTime,
|
||||
isPlaying: !!currentPlayingBannerId,
|
||||
});
|
||||
|
||||
const clear = () => {
|
||||
currentPlayingBannerId = null;
|
||||
lastPlayedBannerId = null;
|
||||
playHistory = [];
|
||||
lastUpdateTime = Date.now();
|
||||
};
|
||||
|
||||
return {
|
||||
setCurrentPlaying,
|
||||
getCurrentPlaying,
|
||||
getLastPlayedBanner, // 🔥 새로운 함수 추가
|
||||
getPlayHistory,
|
||||
getState,
|
||||
clear,
|
||||
};
|
||||
};
|
||||
|
||||
// 🔽 [전역 비디오 상태 키] useFocusHistory와 동일한 구조
|
||||
const GLOBAL_VIDEO_STATE_KEY = '__SHOPTIME_GLOBAL_VIDEO_STATE__';
|
||||
|
||||
/**
|
||||
* 안전한 전역 객체 접근 함수 (Chromium 68 호환)
|
||||
* @returns {object} 전역 객체 (폴백 포함)
|
||||
*/
|
||||
const getGlobalObject = () => {
|
||||
try {
|
||||
// 🔽 [Chromium 68 호환] window 우선 (webOS TV 환경)
|
||||
if (typeof window !== 'undefined') return window;
|
||||
|
||||
// Node.js 환경
|
||||
if (typeof global !== 'undefined') return global;
|
||||
|
||||
// Web Worker 환경
|
||||
// eslint-disable-next-line no-undef
|
||||
if (typeof self !== 'undefined') return self;
|
||||
|
||||
// 🔽 [제거] globalThis는 Chromium 68에서 지원하지 않음
|
||||
// if (typeof globalThis !== 'undefined') return globalThis;
|
||||
|
||||
// 최후의 수단 - 빈 객체
|
||||
console.warn('[useVideoPlay] 전역 객체 접근 불가, 빈 객체 사용');
|
||||
return {};
|
||||
} catch (error) {
|
||||
console.error('[useVideoPlay] 전역 객체 접근 오류:', error);
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 전역 비디오 상태 생성 및 등록
|
||||
* @returns {object} 생성된 비디오 상태
|
||||
*/
|
||||
const createAndRegisterVideoState = () => {
|
||||
try {
|
||||
const newVideoState = createVideoStateManager();
|
||||
const globalObj = getGlobalObject();
|
||||
|
||||
// 전역 객체에 안전하게 등록
|
||||
if (globalObj && typeof globalObj === 'object') {
|
||||
globalObj[GLOBAL_VIDEO_STATE_KEY] = newVideoState;
|
||||
|
||||
// 개발 환경에서 디버깅 편의성 제공
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
globalObj.globalVideoState = newVideoState;
|
||||
}
|
||||
}
|
||||
|
||||
return newVideoState;
|
||||
} catch (error) {
|
||||
console.error('[useVideoPlay] 전역 비디오 상태 등록 실패:', error);
|
||||
return createVideoStateManager(); // 최소한 로컬 상태라도 제공
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 전역 비디오 상태 복원 시도
|
||||
* @returns {object|null} 복원된 상태 또는 null
|
||||
*/
|
||||
const attemptVideoStateRestore = () => {
|
||||
try {
|
||||
const globalObj = getGlobalObject();
|
||||
const existingState = globalObj[GLOBAL_VIDEO_STATE_KEY];
|
||||
|
||||
if (existingState && typeof existingState === 'object') {
|
||||
// 기본적인 메소드 유효성 검사
|
||||
if (
|
||||
typeof existingState.getCurrentPlaying === 'function' &&
|
||||
typeof existingState.setCurrentPlaying === 'function'
|
||||
) {
|
||||
return existingState;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('[useVideoPlay] 비디오 상태 복원 실패:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 전역 비디오 상태 가져오기 또는 생성
|
||||
* @returns {object} 비디오 상태 관리자
|
||||
*/
|
||||
const getOrCreateGlobalVideoState = () => {
|
||||
try {
|
||||
// 1단계: 이미 생성된 전역 상태 확인
|
||||
if (globalVideoState) {
|
||||
return globalVideoState;
|
||||
}
|
||||
|
||||
// 2단계: 전역 객체에서 기존 상태 복원 시도
|
||||
const restoredState = attemptVideoStateRestore();
|
||||
if (restoredState) {
|
||||
globalVideoState = restoredState;
|
||||
return globalVideoState;
|
||||
}
|
||||
|
||||
// 3단계: 새 상태 생성 및 등록
|
||||
globalVideoState = createAndRegisterVideoState();
|
||||
return globalVideoState;
|
||||
} catch (error) {
|
||||
console.error('[useVideoPlay] 전역 비디오 상태 초기화 실패:', error);
|
||||
|
||||
// 최후의 수단: 최소한의 로컬 상태라도 제공
|
||||
try {
|
||||
if (!globalVideoState) {
|
||||
globalVideoState = createVideoStateManager();
|
||||
}
|
||||
return globalVideoState;
|
||||
} catch (fallbackError) {
|
||||
console.error('[useVideoPlay] 폴백 상태 생성도 실패:', fallbackError);
|
||||
// 더미 상태 반환 (앱 크래시 방지)
|
||||
return {
|
||||
setCurrentPlaying: () => ({ previousBannerId: null, currentBannerId: null }),
|
||||
getCurrentPlaying: () => null,
|
||||
getLastPlayedBanner: () => null, // 🔥 더미 함수 추가
|
||||
getPlayHistory: () => [],
|
||||
getState: () => ({
|
||||
currentPlayingBannerId: null,
|
||||
lastPlayedBannerId: null,
|
||||
playHistory: [],
|
||||
lastUpdateTime: 0,
|
||||
isPlaying: false,
|
||||
}),
|
||||
clear: () => {},
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* useVideoPlay Hook - 안전한 동영상 재생 제어
|
||||
*
|
||||
* 주요 기능:
|
||||
* - 동영상 재생/중지 안전 제어
|
||||
* - 중복 재생 방지
|
||||
* - 재생 상태 추적
|
||||
* - 오류 처리 및 복구
|
||||
* - useFocusHistory와 연동 지원
|
||||
*/
|
||||
|
||||
/**
|
||||
* 동영상 재생 제어 훅
|
||||
* @param {object} options - 옵션 설정
|
||||
* @param {boolean} options.enableLogging - 로깅 활성화 여부
|
||||
* @param {string} options.logPrefix - 로그 접두사
|
||||
* @param {boolean} options.autoErrorRecovery - 자동 오류 복구 여부
|
||||
* @returns {object} 동영상 재생 제어 인터페이스
|
||||
*/
|
||||
export const useVideoPlay = (options = {}) => {
|
||||
const {
|
||||
enableLogging = process.env.NODE_ENV === 'development',
|
||||
logPrefix = '[useVideoPlay]',
|
||||
autoErrorRecovery = true,
|
||||
} = options;
|
||||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
// 🔽 Redux 상태 구독
|
||||
const currentOwnerId = useSelector((state) => state.home.playerControl?.ownerId);
|
||||
const bannerDataList = useSelector((state) => state.home.bannerData?.bannerInfos);
|
||||
const bannerVisibility = useSelector((state) => state.home.bannerVisibility);
|
||||
|
||||
// 🔽 [추가] currentOwnerId -> bannerId 변환 훅
|
||||
const getCurrentPlayingBanner = useCallback(() => {
|
||||
const globalObj = getGlobalObject();
|
||||
const videoState = globalObj[GLOBAL_VIDEO_STATE_KEY];
|
||||
return videoState ? videoState.getCurrentPlaying() : null;
|
||||
}, []);
|
||||
|
||||
// 🔽 [새로운] 마지막으로 재생된 배너 가져오기
|
||||
const getLastPlayedBanner = useCallback(() => {
|
||||
const globalObj = getGlobalObject();
|
||||
const videoState = globalObj[GLOBAL_VIDEO_STATE_KEY];
|
||||
return videoState ? videoState.getLastPlayedBanner() : null;
|
||||
}, []);
|
||||
|
||||
// 🔽 로컬 상태 관리
|
||||
const [lastPlayedBanner, setLastPlayedBanner] = useState(null);
|
||||
const [playHistory, setPlayHistory] = useState([]); // 최근 재생 히스토리
|
||||
const [errorCount, setErrorCount] = useState(0);
|
||||
|
||||
// 🔽 타이머 및 참조 관리
|
||||
const playDelayTimerRef = useRef(null);
|
||||
const retryTimerRef = useRef(null);
|
||||
|
||||
// 🔽 [유틸리티] 배너 가용성 검사 (0부터 시작 통일)
|
||||
const isBannerAvailable = useCallback(
|
||||
(bannerId) => {
|
||||
try {
|
||||
if (!bannerDataList || bannerDataList.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// banner0/banner1 -> bannerDataList[0], banner2/banner3 -> bannerDataList[1]
|
||||
const bannerIndex = ['banner0', 'banner1'].includes(bannerId) ? 0 : 1;
|
||||
const bannerData = bannerDataList[bannerIndex];
|
||||
|
||||
if (!bannerData) return false;
|
||||
|
||||
if (['banner0', 'banner1'].includes(bannerId)) {
|
||||
// banner0/banner1: 첫 번째 배너의 첫 번째 상세 정보
|
||||
const detailInfo = fp.get('bannerDetailInfos[0]', bannerData);
|
||||
return !!fp.get('showUrl', detailInfo);
|
||||
} else if (['banner2', 'banner3'].includes(bannerId)) {
|
||||
// banner2/banner3: randomIndex에 따른 상세 정보
|
||||
const randomIndex = fp.get('randomIndex', bannerData) || 0;
|
||||
const detailInfo = fp.get(`bannerDetailInfos[${randomIndex}]`, bannerData);
|
||||
return !!fp.get('showUrl', detailInfo);
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (error) {
|
||||
if (enableLogging) {
|
||||
console.error(`${logPrefix} 배너 가용성 검사 오류:`, error, { bannerId });
|
||||
}
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[bannerDataList, enableLogging, logPrefix]
|
||||
);
|
||||
|
||||
// 🔽 [핵심] 안전한 동영상 재생
|
||||
const playVideo = useCallback(
|
||||
(bannerId, options = {}) => {
|
||||
const { delay = 0, force = false, reason = 'manual' } = options;
|
||||
|
||||
try {
|
||||
// 입력값 검증
|
||||
if (!bannerId || typeof bannerId !== 'string') {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
|
||||
// 지원되는 배너 확인 (0부터 시작 통일)
|
||||
if (!['banner0', 'banner1'].includes(bannerId)) {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
|
||||
// 배너 가용성 확인
|
||||
if (!isBannerAvailable(bannerId)) {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
|
||||
// 🔽 [개선] 중복 재생 방지 - getCurrentPlayingBanner() 사용
|
||||
const currentPlayingBanner = getCurrentPlayingBanner();
|
||||
|
||||
// 🔽 [활성화] 중복 재생 방지 - 같은 bannerId이면 skip
|
||||
if (!force && currentPlayingBanner === bannerId) {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
// 기존 타이머 정리
|
||||
if (playDelayTimerRef.current) {
|
||||
clearTimeout(playDelayTimerRef.current);
|
||||
}
|
||||
|
||||
const executePlay = () => {
|
||||
try {
|
||||
if (enableLogging) {
|
||||
console.log(`${logPrefix} 🎬 ${bannerId} 재생 시작 (${reason})`);
|
||||
}
|
||||
|
||||
// Redux 액션 dispatch - bannerId를 modalContainerId로 사용
|
||||
dispatch(startBannerVideo(bannerId, { modalContainerId: bannerId }));
|
||||
|
||||
// 🔽 [전역 상태 업데이트] 현재 재생 중인 bannerId 설정
|
||||
const globalObj = getGlobalObject();
|
||||
const videoState = globalObj[GLOBAL_VIDEO_STATE_KEY] || createAndRegisterVideoState();
|
||||
videoState.setCurrentPlaying(bannerId);
|
||||
|
||||
// 성공 상태 업데이트
|
||||
setLastPlayedBanner(bannerId);
|
||||
setPlayHistory((prev) => [
|
||||
{ bannerId, timestamp: Date.now(), reason },
|
||||
...prev.slice(0, 9), // 최근 10개만 유지
|
||||
]);
|
||||
setErrorCount(0); // 성공 시 오류 카운트 초기화
|
||||
|
||||
resolve(true);
|
||||
} catch (error) {
|
||||
if (enableLogging) {
|
||||
console.error(`${logPrefix} playVideo 실행 오류:`, error, { bannerId });
|
||||
}
|
||||
|
||||
// 오류 카운트 증가
|
||||
setErrorCount((prev) => prev + 1);
|
||||
|
||||
// 자동 복구 시도
|
||||
if (autoErrorRecovery && errorCount < 3) {
|
||||
if (enableLogging) {
|
||||
console.log(`${logPrefix} 자동 복구 시도 ${errorCount + 1}/3`);
|
||||
}
|
||||
|
||||
retryTimerRef.current = setTimeout(
|
||||
() => {
|
||||
playVideo(bannerId, { ...options, force: true, reason: 'auto-retry' });
|
||||
},
|
||||
1000 * Math.pow(2, errorCount)
|
||||
); // 지수 백오프
|
||||
}
|
||||
|
||||
resolve(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 지연 실행 또는 즉시 실행
|
||||
if (delay > 0) {
|
||||
playDelayTimerRef.current = setTimeout(executePlay, delay);
|
||||
} else {
|
||||
executePlay();
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
if (enableLogging) {
|
||||
console.error(`${logPrefix} playVideo 전체 오류:`, error, { bannerId, options });
|
||||
}
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
},
|
||||
[
|
||||
(bannerId) => isBannerAvailable(bannerId),
|
||||
currentOwnerId,
|
||||
dispatch,
|
||||
enableLogging,
|
||||
logPrefix,
|
||||
autoErrorRecovery,
|
||||
errorCount,
|
||||
]
|
||||
);
|
||||
|
||||
// 🔽 [핵심] 안전한 동영상 중지 - 파라미터 없이 사용 가능
|
||||
const stopVideo = useCallback(
|
||||
(options = {}) => {
|
||||
const { reason = 'manual' } = options;
|
||||
|
||||
try {
|
||||
// 현재 재생 중인 배너 파악
|
||||
const currentPlayingBanner = getCurrentPlayingBanner();
|
||||
|
||||
if (!currentPlayingBanner) {
|
||||
if (enableLogging) {
|
||||
console.log(`${logPrefix} 🛑 재생 중인 비디오 없음 - skip`);
|
||||
}
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
if (enableLogging) {
|
||||
console.log(`${logPrefix} 🛑 ${currentPlayingBanner} 중지 (${reason})`);
|
||||
}
|
||||
|
||||
// Redux 액션 dispatch - 모든 비디오 중지 및 숨김
|
||||
dispatch(stopAndHideVideo());
|
||||
|
||||
// 🔽 [전역 상태 업데이트] 현재 재생 중인 bannerId 클리어
|
||||
const globalObj = getGlobalObject();
|
||||
const videoState = globalObj[GLOBAL_VIDEO_STATE_KEY] || createAndRegisterVideoState();
|
||||
videoState.setCurrentPlaying(null);
|
||||
|
||||
// 상태 업데이트
|
||||
setLastPlayedBanner(null);
|
||||
|
||||
return Promise.resolve(true);
|
||||
} catch (error) {
|
||||
if (enableLogging) {
|
||||
console.error(`${logPrefix} stopVideo 오류:`, error, { options });
|
||||
}
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
},
|
||||
[getCurrentPlayingBanner, dispatch, enableLogging, logPrefix]
|
||||
);
|
||||
|
||||
// 🔽 [새로운] 비디오 재시작 - 마지막으로 재생된 배너로 재시작
|
||||
const restartVideo = useCallback(
|
||||
(options = {}) => {
|
||||
const { reason = 'restart' } = options;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
try {
|
||||
// 🔥 핵심: 마지막으로 재생된 배너 사용 (멈춰도 유지되는 값)
|
||||
const lastPlayedBanner = getLastPlayedBanner();
|
||||
|
||||
if (!lastPlayedBanner) {
|
||||
if (enableLogging) {
|
||||
console.log(`${logPrefix} ♾️ 재시작할 마지막 재생 배너 없음 - skip`);
|
||||
}
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (enableLogging) {
|
||||
console.log(`${logPrefix} ♾️ ${lastPlayedBanner} 재시작 (${reason})`);
|
||||
}
|
||||
|
||||
// 마지막 재생 배너를 다시 재생 (force 옵션으로 중복 방지 해제)
|
||||
playVideo(lastPlayedBanner, { force: true, reason })
|
||||
.then(resolve)
|
||||
.catch(() => resolve(false));
|
||||
} catch (error) {
|
||||
if (enableLogging) {
|
||||
console.error(`${logPrefix} restartVideo 오류:`, error, { options });
|
||||
}
|
||||
resolve(false);
|
||||
}
|
||||
});
|
||||
},
|
||||
[getLastPlayedBanner, playVideo, enableLogging, logPrefix]
|
||||
);
|
||||
|
||||
// 🔽 [편의 함수] 포커스 정책 기반 재생 제어
|
||||
const applyVideoPolicy = useCallback(
|
||||
(policy) => {
|
||||
try {
|
||||
if (!policy || typeof policy !== 'object') {
|
||||
if (enableLogging) {
|
||||
console.warn(`${logPrefix} applyVideoPolicy: 잘못된 정책`, policy);
|
||||
}
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
|
||||
const { videoTarget, transition, confidence = 1.0 } = policy;
|
||||
|
||||
if (videoTarget) {
|
||||
// 동영상 재생
|
||||
return playVideo(videoTarget, {
|
||||
delay: confidence < 0.8 ? 200 : 100, // 신뢰도가 낮으면 지연
|
||||
reason: transition || 'policy',
|
||||
});
|
||||
} else {
|
||||
// 동영상 중지
|
||||
return stopVideo(null, {
|
||||
reason: transition || 'policy',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (enableLogging) {
|
||||
console.error(`${logPrefix} applyVideoPolicy 오류:`, error, { policy });
|
||||
}
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
},
|
||||
[playVideo, stopVideo, enableLogging, logPrefix]
|
||||
);
|
||||
|
||||
// 🔽 정리 작업
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
// 컴포넌트 언마운트 시 타이머 정리
|
||||
if (playDelayTimerRef.current) {
|
||||
clearTimeout(playDelayTimerRef.current);
|
||||
}
|
||||
if (retryTimerRef.current) {
|
||||
clearTimeout(retryTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 🔽 현재 상태 계산
|
||||
const isPlaying = currentOwnerId && currentOwnerId.endsWith('_player');
|
||||
const currentBanner = currentOwnerId ? currentOwnerId.replace('_player', '') : null;
|
||||
|
||||
// 🔽 디버그 정보
|
||||
const getDebugInfo = useCallback(
|
||||
() => ({
|
||||
currentOwnerId,
|
||||
currentBanner,
|
||||
isPlaying,
|
||||
lastPlayedBanner,
|
||||
playHistory: playHistory.slice(0, 5), // 최근 5개
|
||||
errorCount,
|
||||
bannerAvailability: {
|
||||
banner1: isBannerAvailable('banner1'),
|
||||
banner2: isBannerAvailable('banner2'),
|
||||
},
|
||||
}),
|
||||
[
|
||||
currentOwnerId,
|
||||
currentBanner,
|
||||
isPlaying,
|
||||
lastPlayedBanner,
|
||||
playHistory,
|
||||
errorCount,
|
||||
isBannerAvailable,
|
||||
]
|
||||
);
|
||||
|
||||
return {
|
||||
// 🎯 핵심 함수
|
||||
playVideo,
|
||||
stopVideo,
|
||||
restartVideo, // 🔽 [새로운] 파라미터 없이 재시작
|
||||
applyVideoPolicy,
|
||||
|
||||
// 📊 상태 정보
|
||||
getCurrentPlayingBanner, // 🔽 [새로운] 현재 재생 중인 배너 ID 반환
|
||||
getLastPlayedBanner, // 🔽 [새로운] 마지막으로 재생된 배너 ID 반환 (멈춰도 유지)
|
||||
isPlaying,
|
||||
currentBanner,
|
||||
lastPlayedBanner,
|
||||
bannerVisibility,
|
||||
|
||||
// 🔍 유틸리티
|
||||
isBannerAvailable,
|
||||
getDebugInfo,
|
||||
|
||||
// 📈 통계
|
||||
playHistory: playHistory.slice(0, 5),
|
||||
errorCount,
|
||||
};
|
||||
};
|
||||
|
||||
export default useVideoPlay;
|
||||
@@ -0,0 +1,162 @@
|
||||
// src/hooks/useVideoPlay/useVideoPlay.js - Opus 개선 버전
|
||||
|
||||
import { useCallback, useRef, useEffect } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { startBannerVideo, stopAndHideVideo } from '../../actions/playActions';
|
||||
|
||||
/**
|
||||
* useVideoPlay Hook - Opus가 개선한 완전한 동영상 재생 제어
|
||||
*
|
||||
* 핵심 개선사항:
|
||||
* - 초기 Redux 상태와 ref 동기화
|
||||
* - 비디오 전환 시 명확한 이전 비디오 정리
|
||||
* - 엣지 케이스 처리 강화
|
||||
*/
|
||||
export const useVideoPlay = (options = {}) => {
|
||||
const { enableLogging = false, logPrefix = '[useVideoPlay]' } = options;
|
||||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
// Redux에서 현재 재생 상태 확인
|
||||
const currentOwnerId = useSelector((state) => state.home.playerControl?.ownerId);
|
||||
|
||||
// 🔥 [핵심1] 현재 재생 중인 배너를 useRef로 추적
|
||||
const currentPlayingBannerRef = useRef(null);
|
||||
|
||||
// 🔥 [핵심2] 마지막 재생 배너를 useRef로 유지
|
||||
const lastPlayedBannerRef = useRef(null);
|
||||
|
||||
// 🔥 [Opus 개선] 초기 마운트 시 Redux 상태와 ref 동기화
|
||||
useEffect(() => {
|
||||
if (currentOwnerId && currentOwnerId.endsWith('_player')) {
|
||||
const bannerId = currentOwnerId.replace('_player', '');
|
||||
if (['banner0', 'banner1'].includes(bannerId)) {
|
||||
currentPlayingBannerRef.current = bannerId;
|
||||
lastPlayedBannerRef.current = bannerId;
|
||||
if (enableLogging) {
|
||||
console.log(`${logPrefix} 🔄 Redux 상태와 동기화: ${bannerId}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, []); // 초기 마운트 시에만 실행
|
||||
|
||||
// 🔽 [핵심] 동영상 재생 - force 옵션 지원, 중복 재생 방지
|
||||
const playVideo = useCallback(
|
||||
(bannerId, playOptions = {}) => {
|
||||
const { force = false, reason = 'manual' } = playOptions;
|
||||
|
||||
// 입력 검증
|
||||
if (!bannerId || typeof bannerId !== 'string') {
|
||||
if (enableLogging) {
|
||||
console.warn(`${logPrefix} ⚠️ 잘못된 bannerId:`, bannerId);
|
||||
}
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
|
||||
// 지원되는 배너만 허용
|
||||
if (!['banner0', 'banner1'].includes(bannerId)) {
|
||||
if (enableLogging) {
|
||||
console.warn(`${logPrefix} ⚠️ 지원하지 않는 배너:`, bannerId);
|
||||
}
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
|
||||
// 🔥 [핵심] 중복 재생 방지 - force가 false이고 같은 배너이면 skip
|
||||
if (!force && currentPlayingBannerRef.current === bannerId) {
|
||||
if (enableLogging) {
|
||||
console.log(`${logPrefix} 🔄 ${bannerId} 이미 재생 중 - skip`);
|
||||
}
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
// 🔥 [Opus 개선] 다른 비디오로 전환 시 이전 비디오 명확히 기록
|
||||
const previousBanner = currentPlayingBannerRef.current;
|
||||
if (previousBanner && previousBanner !== bannerId) {
|
||||
if (enableLogging) {
|
||||
console.log(`${logPrefix} 🔄 전환: ${previousBanner} → ${bannerId}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (enableLogging) {
|
||||
console.log(`${logPrefix} 🎬 ${bannerId} 재생 시작 (force: ${force}, reason: ${reason})`);
|
||||
}
|
||||
|
||||
// 🔥 [핵심] 현재 재생 중인 배너와 마지막 재생 배너 모두 기록
|
||||
currentPlayingBannerRef.current = bannerId;
|
||||
lastPlayedBannerRef.current = bannerId;
|
||||
|
||||
// Redux 액션 dispatch - 내부적으로 이전 비디오 정리를 한다고 가정
|
||||
dispatch(startBannerVideo(bannerId, { modalContainerId: bannerId }));
|
||||
|
||||
return Promise.resolve(true);
|
||||
},
|
||||
[dispatch, enableLogging, logPrefix]
|
||||
);
|
||||
|
||||
// 🔽 [핵심] 동영상 중지 - 현재 재생은 클리어, 마지막 재생은 유지
|
||||
const stopVideo = useCallback(() => {
|
||||
const currentBanner = currentPlayingBannerRef.current;
|
||||
|
||||
if (enableLogging) {
|
||||
console.log(
|
||||
`${logPrefix} 🛑 중지 (현재: ${currentBanner}, 마지막 재생: ${lastPlayedBannerRef.current} 유지)`
|
||||
);
|
||||
}
|
||||
|
||||
// 🔥 [핵심] 현재 재생 중인 배너만 클리어, 마지막 재생은 유지
|
||||
currentPlayingBannerRef.current = null;
|
||||
// lastPlayedBannerRef.current는 유지! (재시작을 위해)
|
||||
|
||||
// Redux 액션 dispatch
|
||||
dispatch(stopAndHideVideo());
|
||||
|
||||
return Promise.resolve(true);
|
||||
}, [dispatch, enableLogging, logPrefix]);
|
||||
|
||||
// 🔽 [핵심] 재시작 - 마지막 재생 배너로 force: true 재생
|
||||
const restartVideo = useCallback(() => {
|
||||
const lastBannerId = lastPlayedBannerRef.current;
|
||||
const currentBanner = currentPlayingBannerRef.current;
|
||||
|
||||
// 🔥 [Opus 개선] 이미 재생 중이면 skip
|
||||
if (currentBanner === lastBannerId) {
|
||||
if (enableLogging) {
|
||||
console.log(`${logPrefix} ♾️ ${lastBannerId} 이미 재생 중 - skip`);
|
||||
}
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
if (!lastBannerId) {
|
||||
if (enableLogging) {
|
||||
console.log(`${logPrefix} ♾️ 재시작할 배너 없음 - skip`);
|
||||
}
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
|
||||
if (enableLogging) {
|
||||
console.log(`${logPrefix} ♾️ ${lastBannerId} 재시작 (force: true)`);
|
||||
}
|
||||
|
||||
// 🔥 [핵심] force: true로 중복 재생 방지 해제하여 재시작
|
||||
return playVideo(lastBannerId, { force: true, reason: 'restart' });
|
||||
}, [playVideo, enableLogging, logPrefix]);
|
||||
|
||||
// 🔽 [Opus 추가] 디버깅용 상태 확인 함수
|
||||
const getDebugInfo = useCallback(
|
||||
() => ({
|
||||
currentPlaying: currentPlayingBannerRef.current,
|
||||
lastPlayed: lastPlayedBannerRef.current,
|
||||
reduxOwnerId: currentOwnerId,
|
||||
}),
|
||||
[currentOwnerId]
|
||||
);
|
||||
|
||||
return {
|
||||
playVideo,
|
||||
stopVideo,
|
||||
restartVideo,
|
||||
getDebugInfo, // 디버깅용 (선택사항)
|
||||
};
|
||||
};
|
||||
|
||||
export default useVideoPlay;
|
||||
@@ -0,0 +1,84 @@
|
||||
// src/hooks/useVideoPlay/useVideoPlay.simple.js
|
||||
|
||||
import { useCallback } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { startBannerVideo, stopAndHideVideo } from '../../actions/playActions';
|
||||
|
||||
/**
|
||||
* useVideoPlay Hook - 간소화된 동영상 재생 제어
|
||||
*
|
||||
* 핵심 기능만 제공:
|
||||
* - playVideo: 동영상 재생
|
||||
* - stopVideo: 동영상 중지
|
||||
* - restartVideo: 마지막 재생 배너로 재시작
|
||||
*/
|
||||
export const useVideoPlay = (options = {}) => {
|
||||
const { enableLogging = false, logPrefix = '[useVideoPlay]' } = options;
|
||||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
// Redux에서 마지막 재생 배너 추출
|
||||
const currentOwnerId = useSelector((state) => state.home.playerControl?.ownerId);
|
||||
|
||||
// 🔽 [핵심] 동영상 재생 - bannerId를 받아 Redux 액션 호출
|
||||
const playVideo = useCallback(
|
||||
(bannerId) => {
|
||||
if (!bannerId || typeof bannerId !== 'string') {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
|
||||
// 지원되는 배너만 허용 (banner0, banner1)
|
||||
if (!['banner0', 'banner1'].includes(bannerId)) {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
|
||||
if (enableLogging) {
|
||||
console.log(`${logPrefix} 🎬 ${bannerId} 재생`);
|
||||
}
|
||||
|
||||
// Redux 액션 dispatch
|
||||
dispatch(startBannerVideo(bannerId, { modalContainerId: bannerId }));
|
||||
return Promise.resolve(true);
|
||||
},
|
||||
[dispatch, enableLogging, logPrefix]
|
||||
);
|
||||
|
||||
// 🔽 [핵심] 동영상 중지 - Redux 액션 호출
|
||||
const stopVideo = useCallback(() => {
|
||||
if (enableLogging) {
|
||||
console.log(`${logPrefix} 🛑 중지`);
|
||||
}
|
||||
|
||||
// Redux 액션 dispatch
|
||||
dispatch(stopAndHideVideo());
|
||||
return Promise.resolve(true);
|
||||
}, [dispatch, enableLogging, logPrefix]);
|
||||
|
||||
// 🔽 [핵심] 재시작 - Redux에서 마지막 재생 배너 추출 후 재생
|
||||
const restartVideo = useCallback(() => {
|
||||
// Redux에서 마지막 재생 배너 추출
|
||||
const lastBannerId = currentOwnerId ? currentOwnerId.replace('_player', '') : null;
|
||||
|
||||
if (!lastBannerId) {
|
||||
if (enableLogging) {
|
||||
console.log(`${logPrefix} ♾️ 재시작할 배너 없음 - skip`);
|
||||
}
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
|
||||
if (enableLogging) {
|
||||
console.log(`${logPrefix} ♾️ ${lastBannerId} 재시작`);
|
||||
}
|
||||
|
||||
// 마지막 재생 배너를 다시 재생
|
||||
return playVideo(lastBannerId);
|
||||
}, [currentOwnerId, playVideo, enableLogging, logPrefix]);
|
||||
|
||||
return {
|
||||
playVideo,
|
||||
stopVideo,
|
||||
restartVideo,
|
||||
};
|
||||
};
|
||||
|
||||
export default useVideoPlay;
|
||||
35
com.twin.app.shoptime/src/hooks/useVideoPlay/videoState.js
Normal file
35
com.twin.app.shoptime/src/hooks/useVideoPlay/videoState.js
Normal file
@@ -0,0 +1,35 @@
|
||||
// src/hooks/useVideoPlay/videoState.js
|
||||
|
||||
// 🔽 [전역 비디오 상태] 단순한 전역변수 관리
|
||||
let currentPlayingBannerId = null;
|
||||
let lastPlayedBannerId = null;
|
||||
|
||||
export const videoState = {
|
||||
// 현재 재생 중인 배너 설정
|
||||
setCurrentPlaying: (bannerId) => {
|
||||
currentPlayingBannerId = bannerId;
|
||||
// 재생 시에만 lastPlayedBannerId 업데이트 (멈춰도 유지)
|
||||
if (bannerId) {
|
||||
lastPlayedBannerId = bannerId;
|
||||
}
|
||||
},
|
||||
|
||||
// 현재 재생 중인 배너 가져오기
|
||||
getCurrentPlaying: () => currentPlayingBannerId,
|
||||
|
||||
// 마지막으로 재생된 배너 가져오기 (멈춰도 유지)
|
||||
getLastPlayedBanner: () => lastPlayedBannerId,
|
||||
|
||||
// 상태 초기화
|
||||
clear: () => {
|
||||
currentPlayingBannerId = null;
|
||||
lastPlayedBannerId = null;
|
||||
},
|
||||
|
||||
// 디버그 정보
|
||||
getDebugInfo: () => ({
|
||||
currentPlayingBannerId,
|
||||
lastPlayedBannerId,
|
||||
isPlaying: !!currentPlayingBannerId,
|
||||
}),
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
// src/hooks/useVideoTransition/index.js
|
||||
|
||||
export { useVideoTransition, default } from './useVideoTransition.js';
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useRef } from 'react';
|
||||
import fp from '../../utils/fp.js';
|
||||
import { useVideoPlay } from '../useVideoPlay/useVideoPlay';
|
||||
|
||||
const useVideoMove = (options = {}) => {
|
||||
const { enableLogging = false, logPrefix = '[useVideoMove]' } = options;
|
||||
|
||||
const { playVideo, restartVideo } = useVideoPlay(options);
|
||||
|
||||
const log = (message) => enableLogging && console.log(`${logPrefix} ${message}`);
|
||||
|
||||
const timerRef = useRef(null);
|
||||
|
||||
const playByTransition = (queue = []) => {
|
||||
log(`playByTransition 시작: queue = ${JSON.stringify(queue)}`);
|
||||
const q = fp.defaultTo([])(queue);
|
||||
|
||||
if (q.length === 0) {
|
||||
log('빈 큐: Promise.resolve 반환');
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
if (q[0] === 'banner0' || q[0] === 'banner1') {
|
||||
timerRef.current = setTimeout(() => {
|
||||
if (window.restoreVideoSize) {
|
||||
window.restoreVideoSize();
|
||||
}
|
||||
}, 1000);
|
||||
log(`banner0/1 케이스: playVideo(${q[0]}) 호출`);
|
||||
return playVideo(q[0]);
|
||||
} else if (q[0] === 'banner2' || q[0] === 'banner3') {
|
||||
if (q[1] === 'banner0' || q[1] === 'banner1') {
|
||||
log(`banner2/3 → banner0/1: 동영상 유지 (재생하지 않음)`);
|
||||
return Promise.resolve(); // 동영상 유지
|
||||
} else if (q[1] === 'icons') {
|
||||
log(`icons → banner2/3: restartVideo 호출 (${q[0]})`);
|
||||
timerRef.current = setTimeout(() => {
|
||||
if (window.restoreVideoSize) {
|
||||
window.restoreVideoSize();
|
||||
}
|
||||
}, 1000);
|
||||
return restartVideo();
|
||||
}
|
||||
} else if (q[0] === 'icons') {
|
||||
log('icons 케이스: 비디오 숨김 (소리 유지)');
|
||||
|
||||
if (window.shrinkVideoTo1px) {
|
||||
window.shrinkVideoTo1px();
|
||||
}
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
log('기본 케이스: Promise.resolve 반환');
|
||||
return Promise.resolve();
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
log('cleanup: 타이머 정리');
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
return { playByTransition, cleanup };
|
||||
};
|
||||
|
||||
export { useVideoMove };
|
||||
@@ -0,0 +1,41 @@
|
||||
//
|
||||
|
||||
import { useVideoPlay } from '../useVideoPlay/useVideoPlay';
|
||||
const useVideoMove = () => {
|
||||
const { playVideo, stopVideo, restartVideo } = useVideoPlay();
|
||||
const defer = (t) => Promise.resolve().then(t);
|
||||
let tRef;
|
||||
const playByTransition = (q) => {
|
||||
const [p0, p1] = q || [];
|
||||
if (!p0) return Promise.resolve();
|
||||
switch (p0) {
|
||||
case 'banner0':
|
||||
case 'banner1':
|
||||
return defer(() => playVideo(p0));
|
||||
case 'banner2':
|
||||
case 'banner3':
|
||||
if (p1 === 'banner0' || p1 === 'banner1') return defer(() => playVideo(p1));
|
||||
if (p1 === 'icons')
|
||||
return new Promise((r) => {
|
||||
clearTimeout(tRef);
|
||||
tRef = setTimeout(
|
||||
() =>
|
||||
restartVideo()
|
||||
.then(r)
|
||||
.catch(() => r(false)),
|
||||
50
|
||||
);
|
||||
});
|
||||
break;
|
||||
case 'icons':
|
||||
return defer(() => stopVideo());
|
||||
}
|
||||
return Promise.resolve();
|
||||
};
|
||||
const cleanup = () => {
|
||||
clearTimeout(tRef);
|
||||
tRef = null;
|
||||
};
|
||||
return { playByTransition, cleanup };
|
||||
};
|
||||
export { useVideoMove };
|
||||
@@ -0,0 +1,75 @@
|
||||
import fp from '../../utils/fp.js';
|
||||
import { useVideoPlay } from '../useVideoPlay/useVideoPlay';
|
||||
|
||||
const useVideoMove = () => {
|
||||
const { playVideo, stopVideo, restartVideo } = useVideoPlay();
|
||||
const defer = (thunk) => Promise.resolve().then(thunk);
|
||||
const timerRef = { current: null };
|
||||
|
||||
const playByTransition = (queue = []) => {
|
||||
const q = fp.defaultTo([])(queue);
|
||||
|
||||
if (q.length === 0) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
const current = q[0]; // 현재 포커스 위치
|
||||
const previous = q[1]; // 이전 포커스 위치
|
||||
|
||||
// 🔥 [핵심] banner0/banner1로 이동
|
||||
if (current === 'banner0' || current === 'banner1') {
|
||||
// icons에서 왔으면 restartVideo (이전 재생 복원)
|
||||
if (previous === 'icons') {
|
||||
console.log('[restartVideo] icons → banner0/1 전환 감지');
|
||||
return new Promise((resolve) => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
timerRef.current = setTimeout(() => {
|
||||
restartVideo()
|
||||
.then(resolve)
|
||||
.catch(() => resolve(false));
|
||||
}, 50);
|
||||
});
|
||||
}
|
||||
// 다른 곳에서 왔으면 해당 배너 재생
|
||||
return defer(() => playVideo(current));
|
||||
}
|
||||
|
||||
// 🔥 [핵심] banner2/banner3로 이동
|
||||
else if (current === 'banner2' || current === 'banner3') {
|
||||
// banner0/1에서 왔으면 그 비디오 유지
|
||||
if (previous === 'banner0' || previous === 'banner1') {
|
||||
return defer(() => playVideo(previous));
|
||||
}
|
||||
// icons에서 왔으면 restartVideo (이전 재생 복원)
|
||||
else if (previous === 'icons') {
|
||||
console.log('[restartVideo] icons → banner2/3 전환 감지');
|
||||
return new Promise((resolve) => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
timerRef.current = setTimeout(() => {
|
||||
restartVideo()
|
||||
.then(resolve)
|
||||
.catch(() => resolve(false));
|
||||
}, 50);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 🔥 [핵심] icons로 이동 - 비디오 중지
|
||||
else if (current === 'icons') {
|
||||
return defer(() => stopVideo());
|
||||
}
|
||||
|
||||
return Promise.resolve();
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
return { playByTransition, cleanup };
|
||||
};
|
||||
|
||||
export { useVideoMove };
|
||||
@@ -0,0 +1,63 @@
|
||||
import fp from '../../utils/fp.js';
|
||||
import { useVideoPlay } from '../useVideoPlay/useVideoPlay';
|
||||
|
||||
const useVideoMove = () => {
|
||||
const { playVideo, stopVideo, restartVideo } = useVideoPlay();
|
||||
const defer = (thunk) => Promise.resolve().then(thunk);
|
||||
const timerRef = { current: null };
|
||||
|
||||
const playByTransition = (queue = []) => {
|
||||
const q = fp.defaultTo([])(queue);
|
||||
|
||||
if (q.length === 0) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
// 현재 포커스 위치 (q[0])와 이전 위치 (q[1]) 확인
|
||||
const current = q[0];
|
||||
const previous = q[1];
|
||||
|
||||
// banner0/banner1: 항상 해당 비디오 재생
|
||||
if (current === 'banner0' || current === 'banner1') {
|
||||
return defer(() => playVideo(current));
|
||||
}
|
||||
|
||||
// banner2/banner3: 이전 위치에 따라 다르게 동작
|
||||
else if (current === 'banner2' || current === 'banner3') {
|
||||
// banner0/1에서 왔으면 그 비디오 유지
|
||||
if (previous === 'banner0' || previous === 'banner1') {
|
||||
return defer(() => playVideo(previous));
|
||||
}
|
||||
// 🔥 [핵심 수정] icons에서 왔으면 마지막 재생 비디오 재시작
|
||||
else if (previous === 'icons') {
|
||||
console.log('[restartVideo] icons → banner2/3 전환 감지');
|
||||
return new Promise((resolve) => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
timerRef.current = setTimeout(() => {
|
||||
restartVideo()
|
||||
.then(resolve)
|
||||
.catch(() => resolve(false));
|
||||
}, 50);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// icons: 비디오 중지
|
||||
else if (current === 'icons') {
|
||||
return defer(() => stopVideo());
|
||||
}
|
||||
|
||||
return Promise.resolve();
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
return { playByTransition, cleanup };
|
||||
};
|
||||
|
||||
export { useVideoMove };
|
||||
@@ -0,0 +1,101 @@
|
||||
import { useRef } from 'react';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import fp from '../../utils/fp.js';
|
||||
import { useVideoPlay } from '../useVideoPlay/useVideoPlay';
|
||||
import { VIDEO_STATES, setVideoBanner, setVideoMinimized } from '../../actions/videoPlayActions';
|
||||
|
||||
const useVideoMove = (options = {}) => {
|
||||
const { enableLogging = false, logPrefix = '[useVideoMove]' } = options;
|
||||
|
||||
const { playVideo, restartVideo } = useVideoPlay(options);
|
||||
const dispatch = useDispatch();
|
||||
|
||||
// Redux에서 비디오 플레이 상태 구독
|
||||
const videoPlayState = useSelector((state) => fp.get('videoPlay.state', state));
|
||||
|
||||
const log = (message) => enableLogging && console.log(`${logPrefix} ${message}`);
|
||||
|
||||
const timerRef = useRef(null);
|
||||
|
||||
const playByTransition = (queue = []) => {
|
||||
log(`playByTransition 시작: queue = ${JSON.stringify(queue)}`);
|
||||
const q = fp.defaultTo([])(queue);
|
||||
|
||||
if (q.length === 0) {
|
||||
log('빈 큐: Promise.resolve 반환');
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
if (q[0] === 'banner0' || q[0] === 'banner1') {
|
||||
// DOM 렌더링 완료 후 크기 복원 (1초 대기)
|
||||
timerRef.current = setTimeout(() => {
|
||||
if (window.mediaPlayer && window.mediaPlayer._controller) {
|
||||
// ✅ FULLSCREEN 상태일 때는 restoreSize 스킵
|
||||
if (videoPlayState === VIDEO_STATES.FULLSCREEN) {
|
||||
log(`banner0/1 케이스: FULLSCREEN 상태 - restoreSize 스킵`);
|
||||
return;
|
||||
}
|
||||
// ✅ 전체화면 강제 모드일 때는 restoreSize 스킵 (기존 로직 유지)
|
||||
if (window.mediaPlayer._controller.isFullscreenOverride) {
|
||||
log(`banner0/1 케이스: 전체화면 강제 모드 - restoreSize 스킵`);
|
||||
return;
|
||||
}
|
||||
window.mediaPlayer._controller.restoreSize();
|
||||
log(`banner0/1 케이스: restoreSize 실행 (1초 후)`);
|
||||
}
|
||||
}, 1000);
|
||||
log(`banner0/1 케이스: playVideo(${q[0]}) 호출`);
|
||||
// 비디오 상태를 BANNER로 업데이트
|
||||
dispatch(setVideoBanner({ bannerId: q[0] }));
|
||||
return playVideo(q[0]);
|
||||
} else if (q[0] === 'banner2' || q[0] === 'banner3') {
|
||||
if (q[1] === 'banner0' || q[1] === 'banner1') {
|
||||
log(`banner2/3 → banner0/1: 동영상 유지 (재생하지 않음)`);
|
||||
// 비디오 상태를 BANNER로 업데이트
|
||||
dispatch(setVideoBanner({ bannerId: q[0] }));
|
||||
return Promise.resolve(); // 동영상 유지
|
||||
} else if (q[1] === 'icons') {
|
||||
log(`icons → banner2/3: restartVideo 호출 (${q[0]})`);
|
||||
// DOM 렌더링 완료 후 크기 복원 (1초 대기)
|
||||
timerRef.current = setTimeout(() => {
|
||||
if (window.mediaPlayer && window.mediaPlayer._controller) {
|
||||
// ✅ FULLSCREEN 상태일 때는 restoreSize 스킵
|
||||
if (videoPlayState === VIDEO_STATES.FULLSCREEN) {
|
||||
log(`banner2/3 케이스: FULLSCREEN 상태 - restoreSize 스킵`);
|
||||
return;
|
||||
}
|
||||
window.mediaPlayer._controller.restoreSize();
|
||||
log(`banner2/3 케이스: restoreSize 실행 (1초 후)`);
|
||||
}
|
||||
}, 1000);
|
||||
// 비디오 상태를 BANNER로 업데이트
|
||||
dispatch(setVideoBanner({ bannerId: q[0] }));
|
||||
return restartVideo();
|
||||
}
|
||||
} else if (q[0] === 'icons') {
|
||||
log('icons 케이스: 비디오 숨김 (소리 유지)');
|
||||
|
||||
if (window.mediaPlayer && window.mediaPlayer._controller) {
|
||||
window.mediaPlayer._controller.shrinkTo1px();
|
||||
}
|
||||
// 비디오 상태를 MINIMIZED로 업데이트
|
||||
dispatch(setVideoMinimized({ reason: 'icons_focus' }));
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
log('기본 케이스: Promise.resolve 반환');
|
||||
return Promise.resolve();
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
log('cleanup: 타이머 정리');
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
return { playByTransition, cleanup };
|
||||
};
|
||||
|
||||
export { useVideoMove };
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useRef } from 'react';
|
||||
import fp from '../../utils/fp.js';
|
||||
import { useVideoPlay } from '../useVideoPlay/useVideoPlay';
|
||||
|
||||
const useVideoMove = (options = {}) => {
|
||||
const { enableLogging = false, logPrefix = '[useVideoMove]' } = options;
|
||||
|
||||
const { playVideo, restartVideo } = useVideoPlay(options);
|
||||
|
||||
const log = (message) => enableLogging && console.log(`${logPrefix} ${message}`);
|
||||
|
||||
const timerRef = useRef(null);
|
||||
|
||||
const playByTransition = (queue = []) => {
|
||||
log(`playByTransition 시작: queue = ${JSON.stringify(queue)}`);
|
||||
const q = fp.defaultTo([])(queue);
|
||||
|
||||
if (q.length === 0) {
|
||||
log('빈 큐: Promise.resolve 반환');
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
if (q[0] === 'banner0' || q[0] === 'banner1') {
|
||||
timerRef.current = setTimeout(() => {
|
||||
if (window.restoreVideoSize) {
|
||||
window.restoreVideoSize();
|
||||
}
|
||||
}, 1000);
|
||||
log(`banner0/1 케이스: playVideo(${q[0]}) 호출`);
|
||||
return playVideo(q[0]);
|
||||
} else if (q[0] === 'banner2' || q[0] === 'banner3') {
|
||||
if (q[1] === 'banner0' || q[1] === 'banner1') {
|
||||
log(`banner2/3 → banner0/1: 동영상 유지 (재생하지 않음)`);
|
||||
return Promise.resolve(); // 동영상 유지
|
||||
} else if (q[1] === 'icons') {
|
||||
log(`icons → banner2/3: restartVideo 호출 (${q[0]})`);
|
||||
timerRef.current = setTimeout(() => {
|
||||
if (window.restoreVideoSize) {
|
||||
window.restoreVideoSize();
|
||||
}
|
||||
}, 1000);
|
||||
return restartVideo();
|
||||
}
|
||||
} else if (q[0] === 'icons') {
|
||||
log('icons 케이스: 비디오 숨김 (소리 유지)');
|
||||
|
||||
if (window.shrinkVideoTo1px) {
|
||||
window.shrinkVideoTo1px();
|
||||
}
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
log('기본 케이스: Promise.resolve 반환');
|
||||
return Promise.resolve();
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
log('cleanup: 타이머 정리');
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
return { playByTransition, cleanup };
|
||||
};
|
||||
|
||||
export { useVideoMove };
|
||||
@@ -0,0 +1,39 @@
|
||||
import fp from '../../utils/fp.js';
|
||||
import { useVideoPlay } from '../useVideoPlay/useVideoPlay';
|
||||
|
||||
const useVideoMove = () => {
|
||||
const { playVideo, stopVideo, restartVideo } = useVideoPlay();
|
||||
|
||||
const playByTransition = (queue = []) => {
|
||||
const q = fp.defaultTo([])(queue);
|
||||
|
||||
if (q.length === 0) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
if (q[0] === 'banner0' || q[0] === 'banner1') {
|
||||
// icons에서 왔으면 restartVideo
|
||||
if (q[1] === 'icons') {
|
||||
console.log('[restartVideo] icons → banner0/1');
|
||||
return restartVideo();
|
||||
}
|
||||
// defer 제거하고 직접 호출
|
||||
return playVideo(q[0]);
|
||||
} else if (q[0] === 'banner2' || q[0] === 'banner3') {
|
||||
if (q[1] === 'banner0' || q[1] === 'banner1') {
|
||||
return playVideo(q[1]); // defer 제거
|
||||
} else if (q[1] === 'icons') {
|
||||
console.log('[restartVideo] icons → banner2/3');
|
||||
return restartVideo();
|
||||
}
|
||||
} else if (q[0] === 'icons') {
|
||||
return stopVideo(); // defer 제거
|
||||
}
|
||||
|
||||
return Promise.resolve();
|
||||
};
|
||||
|
||||
return { playByTransition };
|
||||
};
|
||||
|
||||
export { useVideoMove };
|
||||
@@ -0,0 +1,569 @@
|
||||
// src/hooks/useVideoTransition/useVideoTransition.js
|
||||
|
||||
import { useEffect, useRef, useCallback, useMemo } from 'react';
|
||||
import fp from 'src/utils/fp.js';
|
||||
|
||||
/**
|
||||
* useVideoTransition - Manage banner/video transitions with simple rules.
|
||||
*
|
||||
* @param {Object} videoPlayer - Video player instance { playVideo, stopVideo, restartVideo }
|
||||
* @param {string[]} queue - Transition queue [current, previous, beforePrevious] (0~2 elements)
|
||||
* @param {Object} options - Extra options
|
||||
*
|
||||
* @example
|
||||
* // simplest usage
|
||||
* useVideoTransition(videoPlay, [currentBanner]);
|
||||
*
|
||||
* // with previous state
|
||||
* useVideoTransition(videoPlay, [currentBanner, previousBanner]);
|
||||
*
|
||||
* // with custom rules & debug
|
||||
* useVideoTransition(videoPlay, queue, {
|
||||
* customRules: [{ name:'x', test: q => true, action: q => ({ type:'PLAY', params:{ banner:q[0] }})}],
|
||||
* debug: true
|
||||
* });
|
||||
*/
|
||||
const useVideoTransition = (videoPlayer, queue = [], options = {}) => {
|
||||
const { customRules = [], debug = false, autoExecute = true, onTransition = null } = options;
|
||||
|
||||
// ============================================
|
||||
// 1) Refs (mutable state not causing re-renders)
|
||||
// ============================================
|
||||
const previousQueueRef = useRef([]);
|
||||
const transitionHistoryRef = useRef([]);
|
||||
|
||||
// ============================================
|
||||
// 2) Banner Types (immutable constants)
|
||||
// ============================================
|
||||
const BannerTypes = useMemo(
|
||||
() =>
|
||||
fp.deepFreeze({
|
||||
MAIN: ['banner0', 'banner1'],
|
||||
SUB: ['banner2', 'banner3'],
|
||||
ICONS: ['icons'],
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
// ============================================
|
||||
// 3) FP Helpers (pure functions)
|
||||
// ============================================
|
||||
const helpers = useMemo(
|
||||
() =>
|
||||
fp.deepFreeze({
|
||||
isMainBanner: fp.pipe(fp.defaultTo(''), (banner) => fp.includes(banner)(BannerTypes.MAIN)),
|
||||
isSubBanner: fp.pipe(fp.defaultTo(''), (banner) => fp.includes(banner)(BannerTypes.SUB)),
|
||||
isIcons: fp.pipe(fp.defaultTo(''), fp.equals('icons')),
|
||||
isSingleState: fp.pipe(fp.defaultTo([]), (q) => fp.safeGet('0', q) && !fp.safeGet('1', q)),
|
||||
isSameState: fp.pipe(fp.defaultTo([]), (q) => fp.safeGet('0', q) === fp.safeGet('1', q)),
|
||||
hasTransition: fp.pipe(
|
||||
fp.defaultTo([]),
|
||||
(q) =>
|
||||
fp.safeGet('0', q) && fp.safeGet('1', q) && fp.safeGet('0', q) !== fp.safeGet('1', q)
|
||||
),
|
||||
}),
|
||||
[BannerTypes]
|
||||
);
|
||||
|
||||
const areQueuesEqual = useCallback(
|
||||
fp.curry((a, b) =>
|
||||
fp.pipe(fp.defaultTo([]), (arrA) =>
|
||||
fp.pipe(fp.defaultTo([]), (arrB) => fp.isEqual(arrA)(arrB))(b)
|
||||
)(a)
|
||||
),
|
||||
[]
|
||||
);
|
||||
|
||||
// ============================================
|
||||
// 4) FP Actions (pure action factories)
|
||||
// ============================================
|
||||
const createActions = useMemo(
|
||||
() =>
|
||||
fp.curry((videoPlayer, debug, helpers) =>
|
||||
fp.deepFreeze({
|
||||
SWITCH: fp.curry((params) =>
|
||||
fp.pipe(
|
||||
fp.tap(() =>
|
||||
fp.when(debug, () =>
|
||||
console.log(
|
||||
`🔄 배너 전환: ${fp.safeGet('from', params)} → ${fp.safeGet('to', params)}`
|
||||
)
|
||||
)
|
||||
),
|
||||
() => fp.safeGet('stopVideo', videoPlayer),
|
||||
fp.when(fp.isNotNil, (stopVideo) => stopVideo()),
|
||||
() => fp.safeGet('playVideo', videoPlayer),
|
||||
fp.when(fp.isNotNil, (playVideo) => playVideo(fp.safeGet('to', params)))
|
||||
)()
|
||||
),
|
||||
|
||||
PLAY: fp.curry((params) =>
|
||||
fp.pipe(
|
||||
fp.tap(() =>
|
||||
fp.when(debug, () => console.log(`🎬 동영상 재생: ${fp.safeGet('banner', params)}`))
|
||||
),
|
||||
() => fp.safeGet('playVideo', videoPlayer),
|
||||
fp.when(fp.isNotNil, (playVideo) => playVideo(fp.safeGet('banner', params)))
|
||||
)()
|
||||
),
|
||||
|
||||
STOP_AND_PLAY: fp.curry((params) =>
|
||||
fp.pipe(
|
||||
fp.tap(() =>
|
||||
fp.when(debug, () =>
|
||||
console.log(
|
||||
`📱 중지 후 재생: ${fp.safeGet('from', params)} → ${fp.safeGet('to', params)}`
|
||||
)
|
||||
)
|
||||
),
|
||||
() => fp.safeGet('stopVideo', videoPlayer),
|
||||
fp.when(fp.isNotNil, (stopVideo) => stopVideo()),
|
||||
() => fp.safeGet('playVideo', videoPlayer),
|
||||
fp.when(fp.isNotNil, (playVideo) => playVideo(fp.safeGet('to', params)))
|
||||
)()
|
||||
),
|
||||
|
||||
RESTART: fp.curry(() =>
|
||||
fp.pipe(
|
||||
fp.tap(() => fp.when(debug, () => console.log(`♻️ 동영상 재시작`))),
|
||||
() => fp.safeGet('restartVideo', videoPlayer),
|
||||
fp.when(fp.isNotNil, (restartVideo) => restartVideo())
|
||||
)()
|
||||
),
|
||||
|
||||
CONTINUE: fp.curry((params) =>
|
||||
fp.pipe(
|
||||
fp.tap(() =>
|
||||
fp.when(debug, () =>
|
||||
console.log(
|
||||
`▶️ 계속 재생: ${fp.safeGet('from', params)} → ${fp.safeGet('to', params)}`
|
||||
)
|
||||
)
|
||||
)
|
||||
)()
|
||||
),
|
||||
|
||||
INITIAL: fp.curry((params) =>
|
||||
fp.pipe(
|
||||
fp.tap(() =>
|
||||
fp.when(debug, () => console.log(`🎯 초기 포커스: ${fp.safeGet('banner', params)}`))
|
||||
),
|
||||
() => fp.safeGet('banner', params),
|
||||
(banner) => (helpers.isMainBanner(banner) ? banner : 'banner0'),
|
||||
(target) => fp.safeGet('playVideo', videoPlayer),
|
||||
fp.when(fp.isNotNil, (playVideo) => playVideo(target))
|
||||
)()
|
||||
),
|
||||
|
||||
REFOCUS: fp.curry((params) =>
|
||||
fp.pipe(
|
||||
fp.tap(() =>
|
||||
fp.when(debug, () => console.log(`🔄 재포커스: ${fp.safeGet('banner', params)}`))
|
||||
),
|
||||
() => fp.safeGet('banner', params),
|
||||
fp.when(helpers.isMainBanner, (banner) => {
|
||||
const playVideo = fp.safeGet('playVideo', videoPlayer);
|
||||
return fp.when(fp.isNotNil, () => playVideo(banner))(playVideo);
|
||||
})
|
||||
)()
|
||||
),
|
||||
|
||||
NOOP: fp.curry(() =>
|
||||
fp.pipe(fp.tap(() => fp.when(debug, () => console.log(`❓ 처리되지 않은 케이스`))))()
|
||||
),
|
||||
})
|
||||
),
|
||||
[]
|
||||
);
|
||||
|
||||
const actions = useMemo(
|
||||
() => createActions(videoPlayer)(debug)(helpers),
|
||||
[createActions, videoPlayer, debug, helpers]
|
||||
);
|
||||
|
||||
// ============================================
|
||||
// 5) FP Transition Rules (immutable rule factories)
|
||||
// ============================================
|
||||
const createDefaultRules = useMemo(
|
||||
() => (helpers) =>
|
||||
fp.deepFreeze([
|
||||
// main ↔ main switch
|
||||
fp.deepFreeze({
|
||||
name: 'mainBannerSwitch',
|
||||
test: fp.pipe(
|
||||
fp.defaultTo([]),
|
||||
(q) =>
|
||||
helpers.isMainBanner(fp.safeGet('0', q)) &&
|
||||
helpers.isMainBanner(fp.safeGet('1', q)) &&
|
||||
fp.safeGet('0', q) !== fp.safeGet('1', q)
|
||||
),
|
||||
action: fp.pipe(fp.defaultTo([]), (q) =>
|
||||
fp.deepFreeze({
|
||||
type: 'SWITCH',
|
||||
params: fp.deepFreeze({ from: fp.safeGet('1', q), to: fp.safeGet('0', q) }),
|
||||
})
|
||||
),
|
||||
}),
|
||||
|
||||
// banner2 → banner0: play banner0
|
||||
fp.deepFreeze({
|
||||
name: 'banner2ToBanner0',
|
||||
test: fp.pipe(
|
||||
fp.defaultTo([]),
|
||||
(q) => fp.safeGet('0', q) === 'banner0' && fp.safeGet('1', q) === 'banner2'
|
||||
),
|
||||
action: fp.pipe(fp.defaultTo([]), (q) =>
|
||||
fp.deepFreeze({ type: 'PLAY', params: fp.deepFreeze({ banner: fp.safeGet('0', q) }) })
|
||||
),
|
||||
}),
|
||||
|
||||
// banner2 → banner1: play banner1
|
||||
fp.deepFreeze({
|
||||
name: 'banner2ToBanner1',
|
||||
test: fp.pipe(
|
||||
fp.defaultTo([]),
|
||||
(q) => fp.safeGet('0', q) === 'banner1' && fp.safeGet('1', q) === 'banner2'
|
||||
),
|
||||
action: fp.pipe(fp.defaultTo([]), (q) =>
|
||||
fp.deepFreeze({ type: 'PLAY', params: fp.deepFreeze({ banner: fp.safeGet('0', q) }) })
|
||||
),
|
||||
}),
|
||||
|
||||
// icons → main: stop icons then play main
|
||||
fp.deepFreeze({
|
||||
name: 'iconsToMainBanner',
|
||||
test: fp.pipe(
|
||||
fp.defaultTo([]),
|
||||
(q) => helpers.isMainBanner(fp.safeGet('0', q)) && helpers.isIcons(fp.safeGet('1', q))
|
||||
),
|
||||
action: fp.pipe(fp.defaultTo([]), (q) =>
|
||||
fp.deepFreeze({
|
||||
type: 'STOP_AND_PLAY',
|
||||
params: fp.deepFreeze({ from: fp.safeGet('1', q), to: fp.safeGet('0', q) }),
|
||||
})
|
||||
),
|
||||
}),
|
||||
|
||||
// icons → banner3: restart
|
||||
fp.deepFreeze({
|
||||
name: 'iconsToBanner3',
|
||||
test: fp.pipe(
|
||||
fp.defaultTo([]),
|
||||
(q) => fp.safeGet('0', q) === 'banner3' && fp.safeGet('1', q) === 'icons'
|
||||
),
|
||||
action: () => fp.deepFreeze({ type: 'RESTART', params: fp.deepFreeze({}) }),
|
||||
}),
|
||||
|
||||
// main → sub: continue
|
||||
fp.deepFreeze({
|
||||
name: 'mainToSubBanner',
|
||||
test: fp.pipe(
|
||||
fp.defaultTo([]),
|
||||
(q) =>
|
||||
helpers.isSubBanner(fp.safeGet('0', q)) && helpers.isMainBanner(fp.safeGet('1', q))
|
||||
),
|
||||
action: fp.pipe(fp.defaultTo([]), (q) =>
|
||||
fp.deepFreeze({
|
||||
type: 'CONTINUE',
|
||||
params: fp.deepFreeze({ from: fp.safeGet('1', q), to: fp.safeGet('0', q) }),
|
||||
})
|
||||
),
|
||||
}),
|
||||
|
||||
// same banner: refocus
|
||||
fp.deepFreeze({
|
||||
name: 'sameBannerRefocus',
|
||||
test: (q) => helpers.isSameState(q),
|
||||
action: fp.pipe(fp.defaultTo([]), (q) =>
|
||||
fp.deepFreeze({
|
||||
type: 'REFOCUS',
|
||||
params: fp.deepFreeze({ banner: fp.safeGet('0', q) }),
|
||||
})
|
||||
),
|
||||
}),
|
||||
|
||||
// initial single focus
|
||||
fp.deepFreeze({
|
||||
name: 'initialFocus',
|
||||
test: (q) => helpers.isSingleState(q),
|
||||
action: fp.pipe(fp.defaultTo([]), (q) =>
|
||||
fp.deepFreeze({
|
||||
type: 'INITIAL',
|
||||
params: fp.deepFreeze({ banner: fp.safeGet('0', q) }),
|
||||
})
|
||||
),
|
||||
}),
|
||||
]),
|
||||
[]
|
||||
);
|
||||
|
||||
const transitionRules = useMemo(
|
||||
() =>
|
||||
fp.pipe(createDefaultRules(helpers), (defaultRules) =>
|
||||
fp.concat(defaultRules)(fp.defaultTo([])(customRules))
|
||||
),
|
||||
[createDefaultRules, helpers, customRules]
|
||||
);
|
||||
|
||||
// ============================================
|
||||
// 6) FP Transition Processor (pure processing pipeline)
|
||||
// ============================================
|
||||
const createTransitionRecord = useMemo(
|
||||
() =>
|
||||
fp.curry((rule, currentQueue, actionType) =>
|
||||
fp.deepFreeze({
|
||||
timestamp: Date.now(),
|
||||
rule: fp.safeGet('name', rule),
|
||||
queue: fp.defaultTo([])(currentQueue),
|
||||
action: actionType,
|
||||
})
|
||||
),
|
||||
[]
|
||||
);
|
||||
|
||||
const processTransition = useCallback(
|
||||
fp.pipe(fp.defaultTo([]), (currentQueue) =>
|
||||
fp.when(
|
||||
(queue) => fp.isNotEmpty(queue),
|
||||
(queue) =>
|
||||
fp.pipe(
|
||||
() => transitionRules,
|
||||
fp.find((rule) => fp.safeGet('test', rule)(queue)),
|
||||
fp.when(fp.isNotNil, (matchedRule) =>
|
||||
fp.pipe(
|
||||
() => fp.safeGet('action', matchedRule)(queue),
|
||||
(actionResult) =>
|
||||
fp.pipe(
|
||||
() => fp.safeGet('type', actionResult),
|
||||
(actionType) =>
|
||||
fp.pipe(
|
||||
() => fp.safeGet(actionType, actions),
|
||||
fp.when(fp.isNotNil, (actionFn) =>
|
||||
fp.pipe(
|
||||
() => actionFn(fp.safeGet('params', actionResult)),
|
||||
() => createTransitionRecord(matchedRule)(queue)(actionType),
|
||||
fp.tap((record) => {
|
||||
transitionHistoryRef.current = fp.append(record)(
|
||||
transitionHistoryRef.current
|
||||
);
|
||||
fp.when(fp.isNotNil, (callback) => callback(record))(onTransition);
|
||||
})
|
||||
)
|
||||
),
|
||||
fp.defaultTo(
|
||||
fp.pipe(
|
||||
() => actions.NOOP(),
|
||||
() => null
|
||||
)
|
||||
)
|
||||
)()
|
||||
)()
|
||||
)
|
||||
),
|
||||
fp.defaultTo(
|
||||
fp.pipe(
|
||||
() => actions.NOOP(),
|
||||
() => null
|
||||
)
|
||||
)
|
||||
)()
|
||||
)(currentQueue)
|
||||
),
|
||||
[transitionRules, actions, onTransition, createTransitionRecord]
|
||||
);
|
||||
|
||||
// ============================================
|
||||
// 7) FP Auto execution effect
|
||||
// ============================================
|
||||
useEffect(
|
||||
() =>
|
||||
fp.pipe(
|
||||
() => autoExecute,
|
||||
fp.when(fp.isTruthy, () =>
|
||||
fp.pipe(
|
||||
() => areQueuesEqual(queue)(previousQueueRef.current),
|
||||
fp.not,
|
||||
fp.when(fp.isTruthy, () =>
|
||||
fp.pipe(
|
||||
() => fp.isNotEmpty(queue),
|
||||
fp.when(fp.isTruthy, () => {
|
||||
processTransition(queue);
|
||||
previousQueueRef.current = fp.defaultTo([])(queue);
|
||||
})
|
||||
)()
|
||||
)
|
||||
)()
|
||||
)
|
||||
)(),
|
||||
[queue, autoExecute, processTransition, areQueuesEqual]
|
||||
);
|
||||
|
||||
// ============================================
|
||||
// 8) FP Manual API (pure function composition)
|
||||
// ============================================
|
||||
const manualTransition = useCallback(
|
||||
fp.pipe((customQueue) => fp.defaultTo(queue)(customQueue), processTransition),
|
||||
[queue, processTransition]
|
||||
);
|
||||
|
||||
const getHistory = useCallback(
|
||||
() =>
|
||||
fp.pipe(
|
||||
() => transitionHistoryRef.current,
|
||||
fp.defaultTo([]),
|
||||
(history) => [...history]
|
||||
)(),
|
||||
[]
|
||||
);
|
||||
|
||||
const clearHistory = useCallback(
|
||||
() =>
|
||||
fp.pipe(() => {
|
||||
transitionHistoryRef.current = [];
|
||||
})(),
|
||||
[]
|
||||
);
|
||||
|
||||
const getLastTransition = useCallback(
|
||||
() =>
|
||||
fp.pipe(() => transitionHistoryRef.current, fp.defaultTo([]), fp.last, fp.defaultTo(null))(),
|
||||
[]
|
||||
);
|
||||
|
||||
// ============================================
|
||||
// 9) FP Debug info (conditional immutable object)
|
||||
// ============================================
|
||||
const debugInfo = useMemo(
|
||||
() =>
|
||||
fp.pipe(
|
||||
() => debug,
|
||||
fp.when(fp.isTruthy, () =>
|
||||
fp.deepFreeze({
|
||||
currentQueue: fp.defaultTo([])(queue),
|
||||
previousQueue: fp.defaultTo([])(previousQueueRef.current),
|
||||
historyLength: fp.pipe(() => transitionHistoryRef.current, fp.defaultTo([]), fp.size)(),
|
||||
lastTransition: getLastTransition(),
|
||||
availableRules: fp.pipe(
|
||||
() => transitionRules,
|
||||
fp.map(fp.safeGet('name')),
|
||||
fp.defaultTo([])
|
||||
)(),
|
||||
})
|
||||
),
|
||||
fp.defaultTo(null)
|
||||
)(),
|
||||
[debug, queue, getLastTransition, transitionRules]
|
||||
);
|
||||
|
||||
// ============================================
|
||||
// 10) FP Return shape (immutable API object)
|
||||
// ============================================
|
||||
return useMemo(
|
||||
() =>
|
||||
fp.deepFreeze({
|
||||
// manual control
|
||||
transition: manualTransition,
|
||||
|
||||
// history (immutable API)
|
||||
history: fp.deepFreeze({
|
||||
get: getHistory,
|
||||
clear: clearHistory,
|
||||
last: getLastTransition,
|
||||
}),
|
||||
|
||||
// debug
|
||||
debug: debugInfo,
|
||||
|
||||
// current states (safe access)
|
||||
currentState: fp.safeGet('0', fp.defaultTo([])(queue)),
|
||||
previousState: fp.safeGet('1', fp.defaultTo([])(queue)),
|
||||
|
||||
// utils (FP helper functions)
|
||||
utils: fp.deepFreeze({
|
||||
isMainBanner: helpers.isMainBanner,
|
||||
isSubBanner: helpers.isSubBanner,
|
||||
isIcons: helpers.isIcons,
|
||||
}),
|
||||
}),
|
||||
[manualTransition, getHistory, clearHistory, getLastTransition, debugInfo, queue, helpers]
|
||||
);
|
||||
};
|
||||
|
||||
export default useVideoTransition;
|
||||
|
||||
/* =========================
|
||||
Usage Examples (JS)
|
||||
=========================
|
||||
|
||||
1) Simple
|
||||
function VideoComponent({ currentBanner }) {
|
||||
const videoPlay = useVideoPlayer();
|
||||
useVideoTransition(videoPlay, [currentBanner]);
|
||||
return <div>...</div>;
|
||||
}
|
||||
|
||||
2) With previous state
|
||||
function VideoComponent({ currentBanner, previousBanner }) {
|
||||
const videoPlay = useVideoPlayer();
|
||||
useVideoTransition(videoPlay, [currentBanner, previousBanner]);
|
||||
return <div>...</div>;
|
||||
}
|
||||
|
||||
3) Manual control
|
||||
function VideoComponent() {
|
||||
const videoPlay = useVideoPlayer();
|
||||
const [queue, setQueue] = useState(['banner0']);
|
||||
|
||||
const { transition, history } = useVideoTransition(
|
||||
videoPlay,
|
||||
queue,
|
||||
{ autoExecute: false }
|
||||
);
|
||||
|
||||
const handleBannerClick = (newBanner) => {
|
||||
const newQueue = [newBanner, queue[0]];
|
||||
setQueue(newQueue);
|
||||
transition(newQueue);
|
||||
};
|
||||
|
||||
return <div>...</div>;
|
||||
}
|
||||
|
||||
4) Debug + callback
|
||||
function VideoComponent({ currentBanner, previousBanner }) {
|
||||
const videoPlay = useVideoPlayer();
|
||||
|
||||
const { debug, history } = useVideoTransition(
|
||||
videoPlay,
|
||||
[currentBanner, previousBanner],
|
||||
{
|
||||
debug: true,
|
||||
onTransition: (record) => {
|
||||
console.log('Transition occurred:', record);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (debug) console.table(debug);
|
||||
}, [debug]);
|
||||
|
||||
return <div>...</div>;
|
||||
}
|
||||
|
||||
5) Custom rule
|
||||
function VideoComponent({ queue }) {
|
||||
const videoPlay = useVideoPlayer();
|
||||
|
||||
const customRules = [
|
||||
{
|
||||
name: 'specialTransition',
|
||||
test: (q) => q[0] === 'special' && q[1] === 'banner0',
|
||||
action: () => ({ type: 'PLAY', params: { banner: 'special' } })
|
||||
}
|
||||
];
|
||||
|
||||
useVideoTransition(videoPlay, queue, { customRules });
|
||||
return <div>...</div>;
|
||||
}
|
||||
*/
|
||||
68
com.twin.app.shoptime/src/reducers/videoPlayReducer.js
Normal file
68
com.twin.app.shoptime/src/reducers/videoPlayReducer.js
Normal file
@@ -0,0 +1,68 @@
|
||||
import { VIDEO_PLAY_ACTIONS, VIDEO_STATES } from '../actions/videoPlayActions';
|
||||
import { curry, get, set } from '../utils/fp';
|
||||
|
||||
const initialState = {
|
||||
state: VIDEO_STATES.STOPPED, // 'stopped', 'banner', 'fullscreen', 'minimized'
|
||||
videoInfo: {}, // 비디오 관련 정보 (showUrl, thumbnail, modalContainerId 등)
|
||||
timestamp: null, // 마지막 상태 변경 시간
|
||||
};
|
||||
|
||||
// FP handlers (curried) with immutable updates only
|
||||
const handleUpdateVideoState = curry((state, action) => {
|
||||
const payload = get('payload', action);
|
||||
return set(
|
||||
'state',
|
||||
get('state', payload),
|
||||
set('videoInfo', get('videoInfo', payload), set('timestamp', get('timestamp', payload), state))
|
||||
);
|
||||
});
|
||||
|
||||
const handleSetVideoStopped = curry((state, action) => {
|
||||
const payload = get('payload', action);
|
||||
return set(
|
||||
'state',
|
||||
VIDEO_STATES.STOPPED,
|
||||
set('videoInfo', {}, set('timestamp', get('timestamp', payload), state))
|
||||
);
|
||||
});
|
||||
|
||||
const handleSetVideoBanner = curry((state, action) => {
|
||||
const payload = get('payload', action);
|
||||
return set(
|
||||
'state',
|
||||
VIDEO_STATES.BANNER,
|
||||
set('videoInfo', get('videoInfo', payload), set('timestamp', get('timestamp', payload), state))
|
||||
);
|
||||
});
|
||||
|
||||
const handleSetVideoFullscreen = curry((state, action) => {
|
||||
const payload = get('payload', action);
|
||||
return set(
|
||||
'state',
|
||||
VIDEO_STATES.FULLSCREEN,
|
||||
set('videoInfo', get('videoInfo', payload), set('timestamp', get('timestamp', payload), state))
|
||||
);
|
||||
});
|
||||
|
||||
const handleSetVideoMinimized = curry((state, action) => {
|
||||
const payload = get('payload', action);
|
||||
return set(
|
||||
'state',
|
||||
VIDEO_STATES.MINIMIZED,
|
||||
set('videoInfo', get('videoInfo', payload), set('timestamp', get('timestamp', payload), state))
|
||||
);
|
||||
});
|
||||
|
||||
const handlers = {
|
||||
[VIDEO_PLAY_ACTIONS.UPDATE_VIDEO_STATE]: handleUpdateVideoState,
|
||||
[VIDEO_PLAY_ACTIONS.SET_VIDEO_STOPPED]: handleSetVideoStopped,
|
||||
[VIDEO_PLAY_ACTIONS.SET_VIDEO_BANNER]: handleSetVideoBanner,
|
||||
[VIDEO_PLAY_ACTIONS.SET_VIDEO_FULLSCREEN]: handleSetVideoFullscreen,
|
||||
[VIDEO_PLAY_ACTIONS.SET_VIDEO_MINIMIZED]: handleSetVideoMinimized,
|
||||
};
|
||||
|
||||
export default function videoPlayReducer(state = initialState, action = {}) {
|
||||
const type = get('type', action);
|
||||
const handler = handlers[type];
|
||||
return handler ? handler(state, action) : state;
|
||||
}
|
||||
@@ -1,8 +1,4 @@
|
||||
import {
|
||||
applyMiddleware,
|
||||
combineReducers,
|
||||
createStore,
|
||||
} from 'redux';
|
||||
import { applyMiddleware, combineReducers, createStore } from 'redux';
|
||||
import thunk from 'redux-thunk';
|
||||
|
||||
import { appDataReducer } from '../reducers/appDataReducer';
|
||||
@@ -32,6 +28,7 @@ import { productReducer } from '../reducers/productReducer';
|
||||
import { searchReducer } from '../reducers/searchReducer';
|
||||
import { shippingReducer } from '../reducers/shippingReducer';
|
||||
import { toastReducer } from '../reducers/toastReducer';
|
||||
import { videoPlayReducer } from '../reducers/videoPlayReducer';
|
||||
|
||||
const rootReducer = combineReducers({
|
||||
panels: panelsReducer,
|
||||
@@ -60,6 +57,7 @@ const rootReducer = combineReducers({
|
||||
emp: empReducer,
|
||||
foryou: foryouReducer,
|
||||
toast: toastReducer,
|
||||
videoPlay: videoPlayReducer,
|
||||
});
|
||||
|
||||
export const store = createStore(rootReducer, applyMiddleware(thunk));
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// src/utils/domUtils.js
|
||||
// DOM 요소 관련 유틸리티 함수들 (크로미움68 호환)
|
||||
/* eslint-env browser */
|
||||
|
||||
/**
|
||||
* 특정 spotlight-id를 가진 요소를 재시도 메커니즘과 함께 찾습니다.
|
||||
@@ -67,9 +68,10 @@ export const getElementPosition = (element) => {
|
||||
* @param {number} interval - 재시도 간격 (ms)
|
||||
* @returns {Promise<Object|null>} 위치 정보 객체 또는 null
|
||||
*/
|
||||
export const getElementPositionById = async (spotlightId, maxRetries = 10, interval = 100) => {
|
||||
const element = await findElementWithRetry(spotlightId, maxRetries, interval);
|
||||
return getElementPosition(element);
|
||||
export const getElementPositionById = (spotlightId, maxRetries = 10, interval = 100) => {
|
||||
return findElementWithRetry(spotlightId, maxRetries, interval).then((element) =>
|
||||
getElementPosition(element)
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -84,12 +86,12 @@ export const createModalStyle = (position, scale = 1) => {
|
||||
}
|
||||
|
||||
return {
|
||||
width: position.width + "px",
|
||||
height: position.height + "px",
|
||||
top: position.top + "px",
|
||||
left: position.left + "px",
|
||||
position: "fixed",
|
||||
overflow: "visible",
|
||||
width: position.width + 'px',
|
||||
height: position.height + 'px',
|
||||
top: position.top + 'px',
|
||||
left: position.left + 'px',
|
||||
position: 'fixed',
|
||||
overflow: 'visible',
|
||||
transform: scale !== 1 ? `scale(${scale})` : undefined,
|
||||
};
|
||||
};
|
||||
@@ -115,11 +117,14 @@ export const getDefaultBannerPosition = (bannerId) => {
|
||||
* @param {Array<string>} bannerIds - 수집할 배너 ID 배열
|
||||
* @returns {Promise<Object>} 배너별 위치 정보 맵
|
||||
*/
|
||||
export const collectBannerPositions = async (bannerIds = ['banner0', 'banner1', 'banner2', 'banner3']) => {
|
||||
export const collectBannerPositions = (
|
||||
bannerIds = ['banner0', 'banner1', 'banner2', 'banner3']
|
||||
) => {
|
||||
const positions = {};
|
||||
|
||||
for (const bannerId of bannerIds) {
|
||||
const position = await getElementPositionById(bannerId, 5, 50); // 빠른 수집
|
||||
// Promise.all을 사용하여 모든 배너 위치를 병렬로 수집
|
||||
const positionPromises = bannerIds.map((bannerId) => {
|
||||
return getElementPositionById(bannerId, 5, 50).then((position) => {
|
||||
if (position) {
|
||||
positions[bannerId] = position;
|
||||
} else {
|
||||
@@ -127,9 +132,11 @@ export const collectBannerPositions = async (bannerIds = ['banner0', 'banner1',
|
||||
positions[bannerId] = getDefaultBannerPosition(bannerId);
|
||||
console.warn(`[domUtils] Using fallback position for ${bannerId}`);
|
||||
}
|
||||
}
|
||||
return { bannerId, position: positions[bannerId] };
|
||||
});
|
||||
});
|
||||
|
||||
return positions;
|
||||
return Promise.all(positionPromises).then(() => positions);
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -156,3 +163,83 @@ export const safeGet = (obj, path, defaultValue = null) => {
|
||||
return defaultValue;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* TV 환경에 최적화된 간단한 DOM 요소 대기 함수
|
||||
* @param {string} selector - CSS 선택자
|
||||
* @param {number} timeout - 최대 대기 시간 (기본 1500ms)
|
||||
* @returns {Promise<Element|null>}
|
||||
*/
|
||||
export const waitForDOMElementReady = (selector, timeout = 1500) => {
|
||||
return new Promise((resolve) => {
|
||||
const maxRetries = Math.floor(timeout / 100); // 100ms 간격으로 체크
|
||||
let retryCount = 0;
|
||||
|
||||
const checkElement = () => {
|
||||
const element = document.querySelector(selector);
|
||||
|
||||
if (element) {
|
||||
// 단순한 크기 확인만 수행
|
||||
const rect = element.getBoundingClientRect();
|
||||
if (rect && rect.width > 0 && rect.height > 0) {
|
||||
resolve(element);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
retryCount++;
|
||||
if (retryCount < maxRetries) {
|
||||
setTimeout(checkElement, 100);
|
||||
} else {
|
||||
resolve(null);
|
||||
}
|
||||
};
|
||||
|
||||
checkElement();
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* TV 환경에 최적화된 간단한 요소 안정화 대기
|
||||
* @param {string} selector - CSS 선택자
|
||||
* @param {number} stableTime - 안정화 시간 (기본 150ms)
|
||||
* @returns {Promise<Element|null>}
|
||||
*/
|
||||
export const waitForElementStabilization = (selector, stableTime = 150) => {
|
||||
return new Promise((resolve) => {
|
||||
// 단순히 stableTime만큼 대기 후 요소 반환
|
||||
setTimeout(() => {
|
||||
const element = document.querySelector(selector);
|
||||
resolve(element);
|
||||
}, stableTime);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 디바운스 함수 (lodash 없이 순수 구현)
|
||||
*
|
||||
* @param {Function} func - 디바운스할 함수
|
||||
* @param {number} wait - 대기 시간 (ms)
|
||||
* @param {Object} options - immediate 옵션
|
||||
* @returns {Function}
|
||||
*/
|
||||
export const debounce = (func, wait, options = {}) => {
|
||||
const immediate = !!options.immediate;
|
||||
let timeout;
|
||||
|
||||
return function executedFunction() {
|
||||
const context = this;
|
||||
const args = arguments;
|
||||
|
||||
const later = function () {
|
||||
timeout = null;
|
||||
if (!immediate) func.apply(context, args);
|
||||
};
|
||||
|
||||
const callNow = immediate && !timeout;
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(later, wait);
|
||||
|
||||
if (callNow) func.apply(context, args);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -40,6 +40,9 @@ import Random from './RandomUnit';
|
||||
import RandomUnitNew from './RandomUnit';
|
||||
import Rolling from './RollingUnit';
|
||||
import SimpleVideoContainer from './SimpleVideoContainer';
|
||||
import { useFocusHistory } from '../../../hooks/useFocusHistory/useFocusHistory';
|
||||
import { useVideoPlay } from '../../../hooks/useVideoPlay/useVideoPlay';
|
||||
import { useVideoMove } from '../../../hooks/useVideoTransition/useVideoMove';
|
||||
|
||||
const SpottableComponent = Spottable('div');
|
||||
const Container = SpotlightContainerDecorator({ enterTo: 'last-focused' }, 'div');
|
||||
@@ -52,6 +55,24 @@ export default function HomeBanner({ firstSpot, spotlightId, handleItemFocus, ha
|
||||
const bannerDataList = useSelector((state) => state.home.bannerData?.bannerInfos);
|
||||
|
||||
const popupVisible = useSelector((state) => state.common.popup.popupVisible);
|
||||
// 🔽 useFocusHistory - 경량화된 범용 포커스 히스토리
|
||||
const focusHistory = useFocusHistory({
|
||||
enableLogging: true,
|
||||
useGlobalState: true,
|
||||
logPrefix: '[HomeBanner-Focus]',
|
||||
});
|
||||
|
||||
// 🔽 useVideoPlay - 동영상 재생 제어
|
||||
const videoPlay = useVideoPlay({
|
||||
enableLogging: true,
|
||||
logPrefix: '[HomeBanner-VideoPlay]',
|
||||
});
|
||||
|
||||
// 🔽 useVideoMove - 포커스 전환 기반 동영상 제어
|
||||
const { playByTransition, cleanup } = useVideoMove({
|
||||
enableLogging: true,
|
||||
logPrefix: '[HomeBanner-VideoMove]',
|
||||
});
|
||||
|
||||
const selectTemplate = useMemo(() => {
|
||||
return homeTopDisplayInfo.shptmTmplCd;
|
||||
|
||||
@@ -1,19 +1,13 @@
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import classNames from "classnames";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import classNames from 'classnames';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import Spotlight from "@enact/spotlight";
|
||||
import Spotlight from '@enact/spotlight';
|
||||
import {
|
||||
getContainerNavigableElements,
|
||||
setContainerLastFocusedElement,
|
||||
} from "@enact/spotlight/src/container";
|
||||
} from '@enact/spotlight/src/container';
|
||||
|
||||
import {
|
||||
changeAppStatus,
|
||||
@@ -21,50 +15,53 @@ import {
|
||||
setExitApp,
|
||||
setHidePopup,
|
||||
setShowPopup,
|
||||
} from "../../actions/commonActions";
|
||||
import { getWelcomeEventInfo } from "../../actions/eventActions";
|
||||
} from '../../actions/commonActions';
|
||||
import { getWelcomeEventInfo } from '../../actions/eventActions';
|
||||
import {
|
||||
checkEnterThroughGNB,
|
||||
getHomeLayout,
|
||||
getHomeMainContents,
|
||||
updateHomeInfo,
|
||||
} from "../../actions/homeActions";
|
||||
import { sendLogGNB, sendLogTotalRecommend } from "../../actions/logActions";
|
||||
import { getSubCategory, getTop20Show } from "../../actions/mainActions";
|
||||
import { getHomeOnSaleInfo } from "../../actions/onSaleActions";
|
||||
import { finishVideoPreview } from "../../actions/playActions";
|
||||
import { getBestSeller } from "../../actions/productActions";
|
||||
import TBody from "../../components/TBody/TBody";
|
||||
import TButton, { TYPES } from "../../components/TButton/TButton";
|
||||
import TPanel from "../../components/TPanel/TPanel";
|
||||
import TPopUp from "../../components/TPopUp/TPopUp";
|
||||
import TVerticalPagenator from "../../components/TVerticalPagenator/TVerticalPagenator";
|
||||
import useDebugKey from "../../hooks/useDebugKey";
|
||||
import usePrevious from "../../hooks/usePrevious";
|
||||
} from '../../actions/homeActions';
|
||||
import { sendLogGNB, sendLogTotalRecommend } from '../../actions/logActions';
|
||||
import { getSubCategory, getTop20Show } from '../../actions/mainActions';
|
||||
import { getHomeOnSaleInfo } from '../../actions/onSaleActions';
|
||||
import { finishVideoPreview } from '../../actions/playActions';
|
||||
import { getBestSeller } from '../../actions/productActions';
|
||||
import TBody from '../../components/TBody/TBody';
|
||||
import TButton, { TYPES } from '../../components/TButton/TButton';
|
||||
import TPanel from '../../components/TPanel/TPanel';
|
||||
import TPopUp from '../../components/TPopUp/TPopUp';
|
||||
import TVerticalPagenator from '../../components/TVerticalPagenator/TVerticalPagenator';
|
||||
import useDebugKey from '../../hooks/useDebugKey';
|
||||
import usePrevious from '../../hooks/usePrevious';
|
||||
import { useFocusHistory } from '../../hooks/useFocusHistory/useFocusHistory';
|
||||
import { useVideoPlay } from '../../hooks/useVideoPlay/useVideoPlay';
|
||||
import { useVideoMove } from '../../hooks/useVideoTransition/useVideoMove';
|
||||
import {
|
||||
ACTIVE_POPUP,
|
||||
LOG_CONTEXT_NAME,
|
||||
LOG_MENU,
|
||||
LOG_MESSAGE_ID,
|
||||
panel_names,
|
||||
} from "../../utils/Config";
|
||||
import { $L } from "../../utils/helperMethods";
|
||||
import { SpotlightIds } from "../../utils/SpotlightIds";
|
||||
import BestSeller from "../HomePanel/BestSeller/BestSeller";
|
||||
import HomeBanner from "../HomePanel/HomeBanner/HomeBanner";
|
||||
import HomeOnSale from "../HomePanel/HomeOnSale/HomeOnSale";
|
||||
import css from "../HomePanel/HomePanel.module.less";
|
||||
import PopularShow from "../HomePanel/PopularShow/PopularShow";
|
||||
import SubCategory from "../HomePanel/SubCategory/SubCategory";
|
||||
import EventPopUpBanner from "./EventPopUpBanner/EventPopUpBanner";
|
||||
import { applyMiddleware } from "redux";
|
||||
} from '../../utils/Config';
|
||||
import { $L } from '../../utils/helperMethods';
|
||||
import { SpotlightIds } from '../../utils/SpotlightIds';
|
||||
import BestSeller from '../HomePanel/BestSeller/BestSeller';
|
||||
import HomeBanner from '../HomePanel/HomeBanner/HomeBanner';
|
||||
import HomeOnSale from '../HomePanel/HomeOnSale/HomeOnSale';
|
||||
import css from '../HomePanel/HomePanel.module.less';
|
||||
import PopularShow from '../HomePanel/PopularShow/PopularShow';
|
||||
import SubCategory from '../HomePanel/SubCategory/SubCategory';
|
||||
import EventPopUpBanner from './EventPopUpBanner/EventPopUpBanner';
|
||||
import { applyMiddleware } from 'redux';
|
||||
|
||||
export const TEMPLATE_CODE_CONF = {
|
||||
TOP: "DSP00101",
|
||||
CATEGORY_ITEM: "DSP00102",
|
||||
ON_SALE: "DSP00103",
|
||||
POPULAR_SHOW: "DSP00104",
|
||||
BEST_SELLER: "DSP00105",
|
||||
TOP: 'DSP00101',
|
||||
CATEGORY_ITEM: 'DSP00102',
|
||||
ON_SALE: 'DSP00103',
|
||||
POPULAR_SHOW: 'DSP00104',
|
||||
BEST_SELLER: 'DSP00105',
|
||||
};
|
||||
|
||||
const HomePanel = ({ isOnTop }) => {
|
||||
@@ -72,69 +69,63 @@ const HomePanel = ({ isOnTop }) => {
|
||||
|
||||
useDebugKey({ isLandingPage: true });
|
||||
|
||||
// 🔽 HomeBanner 외부 7개 아이콘들의 focusHistory 추적
|
||||
const focusHistory = useFocusHistory({
|
||||
enableLogging: true,
|
||||
useGlobalState: true,
|
||||
logPrefix: '[HomePanel-Focus]',
|
||||
});
|
||||
|
||||
// 🔽 useVideoPlay - 동영상 재생 제어
|
||||
const videoPlay = useVideoPlay({
|
||||
enableLogging: true,
|
||||
logPrefix: '[HomePanel-VideoPlay]',
|
||||
});
|
||||
|
||||
// 🔽 useVideoMove - 포커스 전환 기반 동영상 제어
|
||||
const { playByTransition, cleanup } = useVideoMove({
|
||||
enableLogging: true,
|
||||
logPrefix: '[HomePanel-VideoMove]',
|
||||
});
|
||||
const isGnbOpened = useSelector((state) => state.common.isGnbOpened);
|
||||
const homeLayoutInfo = useSelector((state) => state.home.layoutData);
|
||||
const panelInfo = useSelector(
|
||||
(state) => state.home.homeInfo?.panelInfo ?? {}
|
||||
);
|
||||
const panelInfo = useSelector((state) => state.home.homeInfo?.panelInfo ?? {});
|
||||
const panels = useSelector((state) => state.panels.panels);
|
||||
const webOSVersion = useSelector(
|
||||
(state) => state.common.appStatus?.webOSVersion
|
||||
);
|
||||
const webOSVersion = useSelector((state) => state.common.appStatus?.webOSVersion);
|
||||
const enterThroughGNB = useSelector((state) => state.home.enterThroughGNB);
|
||||
const defaultFocus = useSelector((state) => state.home.defaultFocus);
|
||||
|
||||
const categoryInfos = useSelector(
|
||||
(state) => state.onSale.homeOnSaleData?.data?.categoryInfos
|
||||
);
|
||||
const categoryInfos = useSelector((state) => state.onSale.homeOnSaleData?.data?.categoryInfos);
|
||||
|
||||
const categoryItemInfos = useSelector(
|
||||
(state) => state.main.subCategoryData?.categoryItemInfos
|
||||
);
|
||||
const categoryItemInfos = useSelector((state) => state.main.subCategoryData?.categoryItemInfos);
|
||||
|
||||
const { popupVisible, activePopup } = useSelector(
|
||||
(state) => state.common.popup
|
||||
);
|
||||
const { popupVisible, activePopup } = useSelector((state) => state.common.popup);
|
||||
|
||||
const eventPopInfosData = useSelector(
|
||||
(state) => state.event.eventData.eventPopInfo
|
||||
);
|
||||
const eventPopInfosData = useSelector((state) => state.event.eventData.eventPopInfo);
|
||||
const eventData = useSelector((state) => state.event.eventData);
|
||||
const eventClickSuccess = useSelector(
|
||||
(state) => state.event.eventClickSuccess
|
||||
);
|
||||
const homeOnSaleInfos = useSelector(
|
||||
(state) => state.onSale.homeOnSaleData?.data.homeOnSaleInfos
|
||||
);
|
||||
const bestSellerDatas = useSelector(
|
||||
(state) => state.product.bestSellerData?.bestSeller
|
||||
);
|
||||
const eventClickSuccess = useSelector((state) => state.event.eventClickSuccess);
|
||||
const homeOnSaleInfos = useSelector((state) => state.onSale.homeOnSaleData?.data.homeOnSaleInfos);
|
||||
const bestSellerDatas = useSelector((state) => state.product.bestSellerData?.bestSeller);
|
||||
const topInfos = useSelector((state) => state.main.top20ShowData.topInfos);
|
||||
const isDeepLink = useSelector(
|
||||
(state) => state.common.deepLinkInfo.isDeepLink
|
||||
);
|
||||
const isDeepLink = useSelector((state) => state.common.deepLinkInfo.isDeepLink);
|
||||
|
||||
const [btnDisabled, setBtnDisabled] = useState(true);
|
||||
const [arrowBottom, setArrowBottom] = useState(true);
|
||||
const [firstSpot, setFirstSpot] = useState(false);
|
||||
const [eventPopOpen, setEventPopOpen] = useState(false);
|
||||
const [nowShelf, setNowShelf] = useState(panelInfo.nowShelf);
|
||||
const [firstLgCatCd, setFirstLgCatCd] = useState(
|
||||
panelInfo.currentCatCd ?? null
|
||||
);
|
||||
const [firstLgCatCd, setFirstLgCatCd] = useState(panelInfo.currentCatCd ?? null);
|
||||
const [cateCd, setCateCd] = useState(panelInfo.currentCatCd ?? null);
|
||||
const [cateNm, setCateNm] = useState(panelInfo.currentCateName ?? null);
|
||||
const { entryMenu, nowMenu } = useSelector((state) => state.common.menu);
|
||||
const [focusedContainerId, setFocusedContainerId] = useState(
|
||||
panelInfo.focusedContainerId
|
||||
);
|
||||
const [focusedContainerId, setFocusedContainerId] = useState(panelInfo.focusedContainerId);
|
||||
|
||||
const isInitialRender = useRef(true);
|
||||
const verticalPagenatorRef = useRef(null);
|
||||
const currentSentMenuRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (nowMenu === "Home/Top") {
|
||||
if (nowMenu === 'Home/Top') {
|
||||
dispatch(
|
||||
sendLogTotalRecommend({
|
||||
messageId: LOG_MESSAGE_ID.HOME,
|
||||
@@ -146,9 +137,7 @@ const HomePanel = ({ isOnTop }) => {
|
||||
|
||||
const sortedHomeLayoutInfo = useMemo(() => {
|
||||
if (homeLayoutInfo && homeLayoutInfo.homeLayoutInfo) {
|
||||
const sorted = [...homeLayoutInfo.homeLayoutInfo].sort(
|
||||
(x, y) => x.expsOrd - y.expsOrd
|
||||
);
|
||||
const sorted = [...homeLayoutInfo.homeLayoutInfo].sort((x, y) => x.expsOrd - y.expsOrd);
|
||||
return sorted;
|
||||
}
|
||||
return [];
|
||||
@@ -216,9 +205,8 @@ const HomePanel = ({ isOnTop }) => {
|
||||
if (sortedHomeLayoutInfo?.[0]) {
|
||||
const containerId = sortedHomeLayoutInfo[0].shptmApphmDspyOptCd;
|
||||
const navigableEls = getContainerNavigableElements(containerId);
|
||||
const navigableIds = navigableEls.filter((el) => typeof el === "string");
|
||||
const target =
|
||||
containerId === TEMPLATE_CODE_CONF.TOP ? "banner0" : containerId;
|
||||
const navigableIds = navigableEls.filter((el) => typeof el === 'string');
|
||||
const target = containerId === TEMPLATE_CODE_CONF.TOP ? 'banner0' : containerId;
|
||||
|
||||
if (navigableIds.length > 0) {
|
||||
setContainerLastFocusedElement(null, navigableIds);
|
||||
@@ -291,9 +279,7 @@ const HomePanel = ({ isOnTop }) => {
|
||||
<HomeBanner
|
||||
key={el.shptmApphmDspyOptCd}
|
||||
spotlightId={el.shptmApphmDspyOptCd}
|
||||
firstSpot={
|
||||
!panelInfo.focusedContainerId && !panelInfo.currentSpot
|
||||
}
|
||||
firstSpot={!panelInfo.focusedContainerId && !panelInfo.currentSpot}
|
||||
className={css.homeBannerWrap}
|
||||
handleShelfFocus={handleItemFocus(
|
||||
el.shptmApphmDspyOptCd,
|
||||
@@ -385,15 +371,13 @@ const HomePanel = ({ isOnTop }) => {
|
||||
}
|
||||
}
|
||||
})}
|
||||
{loadingComplete &&
|
||||
sortedHomeLayoutInfo &&
|
||||
sortedHomeLayoutInfo.length > 0 && (
|
||||
{loadingComplete && sortedHomeLayoutInfo && sortedHomeLayoutInfo.length > 0 && (
|
||||
<TButton
|
||||
className={css.tButton}
|
||||
onClick={handleTopButtonClick}
|
||||
size={null}
|
||||
type={TYPES.topButton}
|
||||
spotlightId={"home-top-btn"}
|
||||
spotlightId={'home-top-btn'}
|
||||
spotlightDisabled={btnDisabled}
|
||||
data-wheel-point={true}
|
||||
aria-label="Move to Top, Button"
|
||||
@@ -418,7 +402,7 @@ const HomePanel = ({ isOnTop }) => {
|
||||
]);
|
||||
|
||||
const _onScrollStatusChanged = useCallback((status) => {
|
||||
if (status === "end") {
|
||||
if (status === 'end') {
|
||||
setArrowBottom(false);
|
||||
} else {
|
||||
setArrowBottom(true);
|
||||
@@ -452,7 +436,7 @@ const HomePanel = ({ isOnTop }) => {
|
||||
dispatch(finishVideoPreview());
|
||||
}
|
||||
if (panelInfo.currentCatCd) {
|
||||
Spotlight.focus("spotlightId-" + panelInfo.currentCatCd);
|
||||
Spotlight.focus('spotlightId-' + panelInfo.currentCatCd);
|
||||
}
|
||||
if (panelInfo.currentSpot) {
|
||||
Spotlight.focus(panelInfo.currentSpot);
|
||||
@@ -465,15 +449,7 @@ const HomePanel = ({ isOnTop }) => {
|
||||
}, 0);
|
||||
}
|
||||
},
|
||||
[
|
||||
panelInfo,
|
||||
firstSpot,
|
||||
enterThroughGNB,
|
||||
defaultFocus,
|
||||
cbChangePageRef,
|
||||
dispatch,
|
||||
isOnTop,
|
||||
]
|
||||
[panelInfo, firstSpot, enterThroughGNB, defaultFocus, cbChangePageRef, dispatch, isOnTop]
|
||||
);
|
||||
|
||||
const bestSellerLoaded = useCallback(() => {
|
||||
@@ -485,23 +461,21 @@ const HomePanel = ({ isOnTop }) => {
|
||||
isInitialRender.current = false;
|
||||
|
||||
if (isDeepLink || (!panels.length && !panelInfo.focusedContainerId)) {
|
||||
dispatch(
|
||||
changeAppStatus({ showLoadingPanel: { show: true, type: "wait" } })
|
||||
);
|
||||
dispatch(changeAppStatus({ showLoadingPanel: { show: true, type: 'wait' } }));
|
||||
dispatch(getHomeMainContents());
|
||||
dispatch(getHomeLayout());
|
||||
dispatch(
|
||||
getHomeOnSaleInfo({
|
||||
homeSaleInfosIncFlag: "Y",
|
||||
categoryIncFlag: "Y",
|
||||
saleInfosIncFlag: "N",
|
||||
homeSaleInfosIncFlag: 'Y',
|
||||
categoryIncFlag: 'Y',
|
||||
saleInfosIncFlag: 'N',
|
||||
})
|
||||
);
|
||||
dispatch(getTop20Show());
|
||||
dispatch(getBestSeller(bestSellerLoaded));
|
||||
|
||||
if (isDeepLink) {
|
||||
dispatch(setDeepLink({ contentTarget: "", isDeepLink: false }));
|
||||
dispatch(setDeepLink({ contentTarget: '', isDeepLink: false }));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -525,8 +499,8 @@ const HomePanel = ({ isOnTop }) => {
|
||||
{
|
||||
lgCatCd: firstLgCatCd, //LG Electronics Base
|
||||
pageSize: 10,
|
||||
tabType: "CAT00102",
|
||||
filterType: "CAT00202",
|
||||
tabType: 'CAT00102',
|
||||
filterType: 'CAT00202',
|
||||
},
|
||||
1
|
||||
)
|
||||
@@ -537,7 +511,7 @@ const HomePanel = ({ isOnTop }) => {
|
||||
const checkBillngEvent = useCallback(
|
||||
(eventTpCd) => {
|
||||
if (webOSVersion && Number(webOSVersion) >= 4) {
|
||||
if (eventTpCd === "EVT00108" || eventTpCd === "EVT00107") {
|
||||
if (eventTpCd === 'EVT00108' || eventTpCd === 'EVT00107') {
|
||||
if (webOSVersion && Number(webOSVersion) >= 6) {
|
||||
setEventPopOpen(true);
|
||||
} else setEventPopOpen(false);
|
||||
@@ -577,14 +551,12 @@ const HomePanel = ({ isOnTop }) => {
|
||||
let targetSpotlightCatcd = null;
|
||||
let targetSpotlightCateNm = null;
|
||||
if (c) {
|
||||
targetSpotlightId = c.getAttribute("data-spotlight-id");
|
||||
targetSpotlightCatcd = c.getAttribute("data-catcd-num");
|
||||
targetSpotlightCateNm = c.getAttribute("data-catcd-nm");
|
||||
targetSpotlightId = c.getAttribute('data-spotlight-id');
|
||||
targetSpotlightCatcd = c.getAttribute('data-catcd-num');
|
||||
targetSpotlightCateNm = c.getAttribute('data-catcd-nm');
|
||||
}
|
||||
|
||||
const tBody = document.querySelector(
|
||||
`[data-spotlight-id="${SpotlightIds.HOME_TBODY}"]`
|
||||
);
|
||||
const tBody = document.querySelector(`[data-spotlight-id="${SpotlightIds.HOME_TBODY}"]`);
|
||||
const currentSpot = c && tBody.contains(c) ? targetSpotlightId : null;
|
||||
|
||||
dispatch(checkEnterThroughGNB(false));
|
||||
@@ -624,7 +596,7 @@ const HomePanel = ({ isOnTop }) => {
|
||||
<TVerticalPagenator
|
||||
ref={verticalPagenatorRef}
|
||||
className={css.tVerticalPagenator}
|
||||
spotlightId={"home_verticalPagenator"}
|
||||
spotlightId={'home_verticalPagenator'}
|
||||
defaultContainerId={panelInfo.focusedContainerId}
|
||||
disabled={!isOnTop}
|
||||
onScrollStatusChanged={_onScrollStatusChanged}
|
||||
@@ -639,10 +611,7 @@ const HomePanel = ({ isOnTop }) => {
|
||||
)}
|
||||
|
||||
{arrowBottom && (
|
||||
<p
|
||||
className={classNames(css.arrow, css.arrowBottom)}
|
||||
onClick={handleArrowClick}
|
||||
/>
|
||||
<p className={classNames(css.arrow, css.arrowBottom)} onClick={handleArrowClick} />
|
||||
)}
|
||||
|
||||
{activePopup === ACTIVE_POPUP.exitPopup && (
|
||||
@@ -652,15 +621,16 @@ const HomePanel = ({ isOnTop }) => {
|
||||
onExit={onExit}
|
||||
onClose={onClose}
|
||||
hasButton
|
||||
button1Text={$L("Exit")}
|
||||
button2Text={$L("Cancel")}
|
||||
button1Text={$L('Exit')}
|
||||
button2Text={$L('Cancel')}
|
||||
hasText
|
||||
title={$L("Exit Shop Time")}
|
||||
text={$L("Are you sure you want to exit Shop Time?")}
|
||||
title={$L('Exit Shop Time')}
|
||||
text={$L('Are you sure you want to exit Shop Time?')}
|
||||
/>
|
||||
)}
|
||||
{(activePopup === ACTIVE_POPUP.eventPopup ||
|
||||
activePopup === ACTIVE_POPUP.smsPopup) && <EventPopUpBanner />}
|
||||
{(activePopup === ACTIVE_POPUP.eventPopup || activePopup === ACTIVE_POPUP.smsPopup) && (
|
||||
<EventPopUpBanner />
|
||||
)}
|
||||
</TPanel>
|
||||
</>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user