[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:
2025-10-06 15:22:53 +09:00
parent d0b8a2cba9
commit 1696bad8ff
27 changed files with 3729 additions and 183 deletions

View 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(),
},
}));

View File

@@ -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);

View File

@@ -0,0 +1,3 @@
// src/hooks/useFocusHistory/index.js
export { useFocusHistory, default } from './useFocusHistory.js';

View File

@@ -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;

View File

@@ -0,0 +1,3 @@
// src/hooks/useVideoPlay/index.js
export { useVideoPlay, default } from './useVideoPlay';

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View 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;

View 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;

View File

@@ -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;

View File

@@ -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;

View 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,
}),
};

View File

@@ -0,0 +1,3 @@
// src/hooks/useVideoTransition/index.js
export { useVideoTransition, default } from './useVideoTransition.js';

View File

@@ -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 };

View File

@@ -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 };

View File

@@ -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 };

View File

@@ -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 };

View File

@@ -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 };

View File

@@ -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 };

View File

@@ -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 };

View File

@@ -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>;
}
*/

View 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;
}

View File

@@ -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));

View File

@@ -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);
};
};

View File

@@ -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;

View File

@@ -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>
</>
);