🕐 커밋 시간: 2025. 11. 24. 09:08:54 📊 변경 통계: • 총 파일: 10개 • 추가: +93줄 • 삭제: -97줄 📝 수정된 파일: ~ com.twin.app.shoptime/src/App/App.js ~ com.twin.app.shoptime/src/components/MediaItem/MediaItem.module.less ~ com.twin.app.shoptime/src/components/MobileSend/PhoneInputSection.module.less ~ com.twin.app.shoptime/src/components/TPopUp/TNewPopUp.module.less ~ com.twin.app.shoptime/src/hooks/useVideoPlay/useVideoPlay.js ~ com.twin.app.shoptime/src/views/DetailPanel/ProductOverview/ProductOverview.module.less ~ com.twin.app.shoptime/src/views/MediaPanel/MediaPanel.jsx ~ com.twin.app.shoptime/src/views/MediaPanel/MediaPanel.v3.jsx ~ com.twin.app.shoptime/src/views/MyPagePanel/MyPageSub/TermsOfService/TermsOfOptional.module copy.less ~ com.twin.app.shoptime/src/views/PlayerPanel/PlayerPanel.jsx 🔧 함수 변경 내용: 📄 com.twin.app.shoptime/src/App/App.js (javascript): 🔄 Modified: resolveSpotlightIdFromEvent() ❌ Deleted: handleFocusLog(), handleBlurLog() 📄 com.twin.app.shoptime/src/views/MediaPanel/MediaPanel.v3.jsx (javascript): 🔄 Modified: normalizeModalStyle() 🔧 주요 변경 내용: • 핵심 비즈니스 로직 개선 • UI 컴포넌트 아키텍처 개선 • API 서비스 레이어 개선 Performance: 코드 최적화로 성능 개선 기대
408 lines
13 KiB
JavaScript
408 lines
13 KiB
JavaScript
// src/hooks/useVideoPlay/useVideoPlay.js
|
|
|
|
import { useCallback, useRef, useState, useEffect, useMemo } from 'react';
|
|
import { useDispatch, useSelector } from 'react-redux';
|
|
import {
|
|
startVideoPlayerNew,
|
|
stopBannerVideo,
|
|
stopAndHideVideo,
|
|
hidePlayerVideo,
|
|
PLAYBACK_STATUS,
|
|
} 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 reduxVideoPlayState = useSelector((state) => state.play.videoPlayState);
|
|
|
|
// 🔽 [단순화] 현재 재생 중인 배너 가져오기
|
|
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);
|
|
|
|
// 🔽 Redux 기반 실제 재생 상태
|
|
const isVideoPlaying =
|
|
reduxVideoPlayState?.isPlaying === true &&
|
|
reduxVideoPlayState?.playback === PLAYBACK_STATUS.PLAYING;
|
|
|
|
// 🔽 전역 videoState가 실제 재생 상태와 불일치할 때 정리
|
|
useEffect(() => {
|
|
if (!isVideoPlaying && videoState.getCurrentPlaying()) {
|
|
videoState.setCurrentPlaying(null);
|
|
}
|
|
}, [isVideoPlaying]);
|
|
|
|
// 🔽 [유틸리티] 배너 가용성 검사 (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(
|
|
startVideoPlayerNew({
|
|
bannerId,
|
|
modal: true,
|
|
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 bannerAvailability = useMemo(
|
|
() => ({
|
|
banner0: isBannerAvailable('banner0'),
|
|
banner1: isBannerAvailable('banner1'),
|
|
}),
|
|
[isBannerAvailable]
|
|
);
|
|
|
|
// 🔽 디버그 정보 (단순화)
|
|
const getDebugInfo = useCallback(
|
|
() => ({
|
|
currentOwnerId,
|
|
currentBanner,
|
|
isPlaying,
|
|
errorCount,
|
|
videoState: videoState.getDebugInfo(),
|
|
bannerAvailability,
|
|
}),
|
|
[currentOwnerId, currentBanner, isPlaying, errorCount, bannerAvailability]
|
|
);
|
|
|
|
return {
|
|
// 🎯 핵심 함수
|
|
playVideo,
|
|
stopVideo,
|
|
hideVideo, // 🔽 [새로운] 비디오 숨김 (소리 유지)
|
|
restartVideo, // 🔽 [새로운] 파라미터 없이 재시작
|
|
applyVideoPolicy,
|
|
|
|
// 📊 상태 정보
|
|
getCurrentPlayingBanner,
|
|
getLastPlayedBanner,
|
|
isPlaying,
|
|
isVideoPlaying,
|
|
currentBanner,
|
|
bannerVisibility,
|
|
videoPlayState: reduxVideoPlayState,
|
|
bannerAvailability, // ✅ [최적화] 메모이제이션된 배너 가용성
|
|
|
|
// 🔍 유틸리티
|
|
isBannerAvailable,
|
|
getDebugInfo,
|
|
|
|
// 📈 통계
|
|
errorCount,
|
|
};
|
|
};
|
|
|
|
export default useVideoPlay;
|