[251026] feat: playerState
🕐 커밋 시간: 2025. 10. 26. 17:47:15 📊 변경 통계: • 총 파일: 7개 📁 추가된 파일: + com.twin.app.shoptime/src/hooks/usePlayerState.js + com.twin.app.shoptime/src/hooks/usePlayerStateChanges.js + com.twin.app.shoptime/src/hooks/useVideoActions.js + com.twin.app.shoptime/src/types/PlayerState.js + com.twin.app.shoptime/src/utils/playerState/playerStateHelpers.js + com.twin.app.shoptime/src/utils/playerState/playerStateSelectors.js + com.twin.app.shoptime/src/utils/playerState/playerStateValidator.js 🔧 주요 변경 내용: • 핵심 비즈니스 로직 개선 • 타입 시스템 안정성 강화 • 공통 유틸리티 함수 최적화 • 모듈 구조 개선
This commit is contained in:
336
com.twin.app.shoptime/src/hooks/usePlayerState.js
Normal file
336
com.twin.app.shoptime/src/hooks/usePlayerState.js
Normal file
@@ -0,0 +1,336 @@
|
||||
/**
|
||||
* @fileoverview React Hooks for PlayerState
|
||||
* playerState 조회 및 상태 변경을 위한 custom hooks
|
||||
* @author ShopTime Dev Team
|
||||
*/
|
||||
|
||||
import { useSelector } from 'react-redux';
|
||||
import {
|
||||
selectPlayerState,
|
||||
selectCurrentlyPlayingBannerId,
|
||||
selectPlayState,
|
||||
selectVideoSize,
|
||||
selectIsPaused,
|
||||
selectIsPlaying,
|
||||
selectCurrentTime,
|
||||
selectDuration,
|
||||
selectModal,
|
||||
selectPatnrId,
|
||||
selectShowId,
|
||||
selectIsBannerPlaying,
|
||||
selectIsVideoLoading,
|
||||
selectIsAutoReplaying,
|
||||
selectVideoProgress,
|
||||
selectIsVideoEnded,
|
||||
selectPlayerError,
|
||||
selectAutoReplayEnabled,
|
||||
selectIsPlayerVisible,
|
||||
selectIsSameVideoPlaying,
|
||||
} from '../utils/playerState/playerStateSelectors';
|
||||
|
||||
/**
|
||||
* PlayerPanel의 현재 playerState 전체를 조회하는 hook
|
||||
* playerState가 변경될 때마다 컴포넌트가 리렌더링 됨
|
||||
*
|
||||
* @returns {Object | null} 현재 playerState 또는 null
|
||||
*
|
||||
* @example
|
||||
* const playerState = usePlayerState();
|
||||
* if (playerState) {
|
||||
* console.log(playerState.playState);
|
||||
* }
|
||||
*/
|
||||
export const usePlayerState = () => {
|
||||
return useSelector(selectPlayerState);
|
||||
};
|
||||
|
||||
/**
|
||||
* playerState의 특정 필드만 조회하는 hook (성능 최적화)
|
||||
* 불필요한 리렌더링을 방지하기 위해 필요한 필드만 선택
|
||||
*
|
||||
* @param {string} fieldName - playerState의 필드명
|
||||
*
|
||||
* @returns {*} 해당 필드의 값
|
||||
*
|
||||
* @example
|
||||
* const playState = usePlayerStateField('playState');
|
||||
* const videoSize = usePlayerStateField('videoSize');
|
||||
*/
|
||||
export const usePlayerStateField = (fieldName) => {
|
||||
return useSelector((state) => selectPlayerState(state)?.[fieldName]);
|
||||
};
|
||||
|
||||
/**
|
||||
* 현재 재생 중인 배너 ID 조회
|
||||
*
|
||||
* @returns {string | null}
|
||||
*
|
||||
* @example
|
||||
* const bannerId = useCurrentlyPlayingBannerId();
|
||||
* if (bannerId === 'banner0') {
|
||||
* console.log('banner0이 현재 재생 중');
|
||||
* }
|
||||
*/
|
||||
export const useCurrentlyPlayingBannerId = () => {
|
||||
return useSelector(selectCurrentlyPlayingBannerId);
|
||||
};
|
||||
|
||||
/**
|
||||
* 비디오 재생 상태 조회 ('playing' | 'paused' | 'stopped' | 'auto-replaying' | 'loading')
|
||||
*
|
||||
* @returns {string}
|
||||
*
|
||||
* @example
|
||||
* const playState = usePlayState();
|
||||
* if (playState === 'playing') {
|
||||
* console.log('재생 중');
|
||||
* }
|
||||
*/
|
||||
export const usePlayState = () => {
|
||||
return useSelector(selectPlayState);
|
||||
};
|
||||
|
||||
/**
|
||||
* 비디오 크기 조회 ('fullscreen' | 'banner' | 'minimized' | '1px')
|
||||
*
|
||||
* @returns {string}
|
||||
*
|
||||
* @example
|
||||
* const videoSize = useVideoSize();
|
||||
* if (videoSize === '1px') {
|
||||
* // 백그라운드 비디오 처리
|
||||
* }
|
||||
*/
|
||||
export const useVideoSize = () => {
|
||||
return useSelector(selectVideoSize);
|
||||
};
|
||||
|
||||
/**
|
||||
* 비디오 일시정지 상태 조회
|
||||
*
|
||||
* @returns {boolean}
|
||||
*
|
||||
* @example
|
||||
* const isPaused = useIsPaused();
|
||||
* if (isPaused) {
|
||||
* // 일시정지 상태 UI 표시
|
||||
* }
|
||||
*/
|
||||
export const useIsPaused = () => {
|
||||
return useSelector(selectIsPaused);
|
||||
};
|
||||
|
||||
/**
|
||||
* 비디오 재생 중 상태 조회
|
||||
*
|
||||
* @returns {boolean}
|
||||
*
|
||||
* @example
|
||||
* const isPlaying = useIsPlaying();
|
||||
* if (isPlaying) {
|
||||
* // 재생 중 UI 업데이트
|
||||
* }
|
||||
*/
|
||||
export const useIsPlaying = () => {
|
||||
return useSelector(selectIsPlaying);
|
||||
};
|
||||
|
||||
/**
|
||||
* 현재 재생 시간 조회 (초 단위)
|
||||
*
|
||||
* @returns {number}
|
||||
*
|
||||
* @example
|
||||
* const currentTime = useCurrentTime();
|
||||
* console.log(`재생 시간: ${currentTime}초`);
|
||||
*/
|
||||
export const useCurrentTime = () => {
|
||||
return useSelector(selectCurrentTime);
|
||||
};
|
||||
|
||||
/**
|
||||
* 비디오 전체 길이 조회 (초 단위)
|
||||
*
|
||||
* @returns {number}
|
||||
*
|
||||
* @example
|
||||
* const duration = useDuration();
|
||||
* console.log(`비디오 길이: ${duration}초`);
|
||||
*/
|
||||
export const useDuration = () => {
|
||||
return useSelector(selectDuration);
|
||||
};
|
||||
|
||||
/**
|
||||
* 모달/전체화면 모드 조회
|
||||
*
|
||||
* @returns {boolean} true = 모달, false = 전체화면
|
||||
*
|
||||
* @example
|
||||
* const modal = useModal();
|
||||
* if (modal) {
|
||||
* // 모달 크기로 렌더링
|
||||
* }
|
||||
*/
|
||||
export const useModal = () => {
|
||||
return useSelector(selectModal);
|
||||
};
|
||||
|
||||
/**
|
||||
* Partner ID 조회
|
||||
*
|
||||
* @returns {number | null}
|
||||
*
|
||||
* @example
|
||||
* const patnrId = usePatnrId();
|
||||
*/
|
||||
export const usePatnrId = () => {
|
||||
return useSelector(selectPatnrId);
|
||||
};
|
||||
|
||||
/**
|
||||
* Show ID 조회
|
||||
*
|
||||
* @returns {number | null}
|
||||
*
|
||||
* @example
|
||||
* const showId = useShowId();
|
||||
*/
|
||||
export const useShowId = () => {
|
||||
return useSelector(selectShowId);
|
||||
};
|
||||
|
||||
/**
|
||||
* 특정 배너가 현재 재생 중인지 확인 (성능 최적화)
|
||||
* 특정 배너에만 관심 있을 때 사용
|
||||
*
|
||||
* @param {string} bannerId - 확인할 배너 ID
|
||||
*
|
||||
* @returns {boolean}
|
||||
*
|
||||
* @example
|
||||
* const isPlaying = useIsBannerPlaying('banner0');
|
||||
* if (isPlaying) {
|
||||
* // banner0 대한 UI 업데이트
|
||||
* }
|
||||
*/
|
||||
export const useIsBannerPlaying = (bannerId) => {
|
||||
return useSelector(selectIsBannerPlaying(bannerId));
|
||||
};
|
||||
|
||||
/**
|
||||
* 비디오가 로딩 중인지 확인
|
||||
*
|
||||
* @returns {boolean}
|
||||
*
|
||||
* @example
|
||||
* const isLoading = useIsVideoLoading();
|
||||
* if (isLoading) {
|
||||
* // 로딩 스피너 표시
|
||||
* }
|
||||
*/
|
||||
export const useIsVideoLoading = () => {
|
||||
return useSelector(selectIsVideoLoading);
|
||||
};
|
||||
|
||||
/**
|
||||
* 비디오가 자동 반복 중인지 확인
|
||||
*
|
||||
* @returns {boolean}
|
||||
*
|
||||
* @example
|
||||
* const isAutoReplaying = useIsAutoReplaying();
|
||||
*/
|
||||
export const useIsAutoReplaying = () => {
|
||||
return useSelector(selectIsAutoReplaying);
|
||||
};
|
||||
|
||||
/**
|
||||
* 비디오 진행률 조회 (0 ~ 1)
|
||||
*
|
||||
* @returns {number}
|
||||
*
|
||||
* @example
|
||||
* const progress = useVideoProgress();
|
||||
* console.log(`진행률: ${(progress * 100).toFixed(2)}%`);
|
||||
*/
|
||||
export const useVideoProgress = () => {
|
||||
return useSelector(selectVideoProgress);
|
||||
};
|
||||
|
||||
/**
|
||||
* 비디오 완료 여부 확인
|
||||
*
|
||||
* @returns {boolean}
|
||||
*
|
||||
* @example
|
||||
* const isEnded = useIsVideoEnded();
|
||||
* if (isEnded) {
|
||||
* // 다음 비디오 재생
|
||||
* }
|
||||
*/
|
||||
export const useIsVideoEnded = () => {
|
||||
return useSelector(selectIsVideoEnded);
|
||||
};
|
||||
|
||||
/**
|
||||
* 에러 메시지 조회
|
||||
*
|
||||
* @returns {string | null}
|
||||
*
|
||||
* @example
|
||||
* const error = usePlayerError();
|
||||
* if (error) {
|
||||
* console.error(`비디오 재생 에러: ${error}`);
|
||||
* }
|
||||
*/
|
||||
export const usePlayerError = () => {
|
||||
return useSelector(selectPlayerError);
|
||||
};
|
||||
|
||||
/**
|
||||
* 자동 반복 활성화 여부 조회
|
||||
*
|
||||
* @returns {boolean}
|
||||
*
|
||||
* @example
|
||||
* const autoReplayEnabled = useAutoReplayEnabled();
|
||||
*/
|
||||
export const useAutoReplayEnabled = () => {
|
||||
return useSelector(selectAutoReplayEnabled);
|
||||
};
|
||||
|
||||
/**
|
||||
* 플레이어 표시 여부 조회
|
||||
*
|
||||
* @returns {boolean}
|
||||
*
|
||||
* @example
|
||||
* const isVisible = useIsPlayerVisible();
|
||||
* return isVisible && <VideoPlayer />;
|
||||
*/
|
||||
export const useIsPlayerVisible = () => {
|
||||
return useSelector(selectIsPlayerVisible);
|
||||
};
|
||||
|
||||
/**
|
||||
* 특정 비디오가 현재 재생 중인지 확인
|
||||
* bannerId, patnrId, showId 모두 일치해야 true
|
||||
*
|
||||
* @param {string} bannerId - 배너 ID
|
||||
* @param {number} patnrId - Partner ID
|
||||
* @param {number} showId - Show ID
|
||||
*
|
||||
* @returns {boolean}
|
||||
*
|
||||
* @example
|
||||
* const isSame = useIsSameVideoPlaying('banner0', 123, 456);
|
||||
* if (isSame) {
|
||||
* console.log('Same video is playing');
|
||||
* }
|
||||
*/
|
||||
export const useIsSameVideoPlaying = (bannerId, patnrId, showId) => {
|
||||
return useSelector(selectIsSameVideoPlaying(bannerId, patnrId, showId));
|
||||
};
|
||||
|
||||
export default usePlayerState;
|
||||
291
com.twin.app.shoptime/src/hooks/usePlayerStateChanges.js
Normal file
291
com.twin.app.shoptime/src/hooks/usePlayerStateChanges.js
Normal file
@@ -0,0 +1,291 @@
|
||||
/**
|
||||
* @fileoverview Change Detection Hooks for PlayerState
|
||||
* playerState의 변경을 감지하고 콜백을 실행하는 hooks
|
||||
* @author ShopTime Dev Team
|
||||
*/
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { usePlayerState } from './usePlayerState';
|
||||
import { hasStateChanged, getStateChanges } from '../utils/playerState/playerStateHelpers';
|
||||
|
||||
/**
|
||||
* playerState의 특정 부분이 변경될 때를 감지하는 hook
|
||||
* 주로 side effects를 트리거할 때 사용
|
||||
*
|
||||
* @param {Function} callback - 변경 감지 시 호출할 함수 (newState, prevState) => void
|
||||
* @param {string[]} [deps=[]] - 감시할 playerState의 필드들
|
||||
* 빈 배열이면 모든 필드 변경 감지
|
||||
*
|
||||
* @example
|
||||
* usePlayerStateChanges((newState, prevState) => {
|
||||
* console.log(`상태 변경: ${prevState.playState} -> ${newState.playState}`);
|
||||
* }, ['playState', 'currentTime']);
|
||||
*/
|
||||
export const usePlayerStateChanges = (callback, deps = []) => {
|
||||
const playerState = usePlayerState();
|
||||
const prevStateRef = useRef(playerState);
|
||||
|
||||
useEffect(() => {
|
||||
if (!playerState) return;
|
||||
|
||||
// deps가 비어있으면 모든 필드 변경 감지, 아니면 지정된 필드만 감지
|
||||
const hasChanged = deps.length === 0 ||
|
||||
(prevStateRef.current && hasStateChanged(prevStateRef.current, playerState, deps));
|
||||
|
||||
if (hasChanged && prevStateRef.current) {
|
||||
callback(playerState, prevStateRef.current);
|
||||
}
|
||||
|
||||
prevStateRef.current = playerState;
|
||||
}, [playerState, deps, callback]);
|
||||
};
|
||||
|
||||
/**
|
||||
* 특정 playState 변경만 감지하는 hook
|
||||
* playState가 'playing' -> 'paused' 또는 다른 상태로 변경될 때 실행
|
||||
*
|
||||
* @param {Function} callback - 변경 시 호출할 함수 (newState, prevState) => void
|
||||
*
|
||||
* @example
|
||||
* useOnPlayStateChange((newState, prevState) => {
|
||||
* console.log(`Play state: ${prevState.playState} -> ${newState.playState}`);
|
||||
* if (newState.playState === 'playing') {
|
||||
* analytics.trackVideoPlay();
|
||||
* }
|
||||
* });
|
||||
*/
|
||||
export const useOnPlayStateChange = (callback) => {
|
||||
usePlayerStateChanges(callback, ['playState']);
|
||||
};
|
||||
|
||||
/**
|
||||
* 비디오 현재 시간 변경만 감지하는 hook
|
||||
* 비디오 진행 바 업데이트 등에 유용
|
||||
*
|
||||
* @param {Function} callback - 변경 시 호출할 함수 (newState, prevState) => void
|
||||
*
|
||||
* @example
|
||||
* useOnCurrentTimeChange((newState, prevState) => {
|
||||
* console.log(`Time: ${prevState.currentTime}s -> ${newState.currentTime}s`);
|
||||
* updateProgressBar(newState.currentTime);
|
||||
* });
|
||||
*/
|
||||
export const useOnCurrentTimeChange = (callback) => {
|
||||
usePlayerStateChanges(callback, ['currentTime']);
|
||||
};
|
||||
|
||||
/**
|
||||
* 비디오 전체 길이(duration) 변경만 감지하는 hook
|
||||
* 비디오 메타데이터 로드 시 실행됨
|
||||
*
|
||||
* @param {Function} callback - 변경 시 호출할 함수 (newState, prevState) => void
|
||||
*
|
||||
* @example
|
||||
* useOnDurationChange((newState) => {
|
||||
* console.log(`Duration: ${newState.duration}s`);
|
||||
* updateProgressBarMax(newState.duration);
|
||||
* });
|
||||
*/
|
||||
export const useOnDurationChange = (callback) => {
|
||||
usePlayerStateChanges(callback, ['duration']);
|
||||
};
|
||||
|
||||
/**
|
||||
* 재생 중인 배너 변경만 감지하는 hook
|
||||
* 다른 배너로 비디오가 전환될 때 실행
|
||||
*
|
||||
* @param {Function} callback - 변경 시 호출할 함수 (newState, prevState) => void
|
||||
*
|
||||
* @example
|
||||
* useOnBannerChange((newState, prevState) => {
|
||||
* console.log(`배너: ${prevState.currentlyPlayingBannerId} -> ${newState.currentlyPlayingBannerId}`);
|
||||
* onBannerSwitched(newState.currentlyPlayingBannerId);
|
||||
* });
|
||||
*/
|
||||
export const useOnBannerChange = (callback) => {
|
||||
usePlayerStateChanges(callback, ['currentlyPlayingBannerId']);
|
||||
};
|
||||
|
||||
/**
|
||||
* 비디오 크기 변경만 감지하는 hook
|
||||
*
|
||||
* @param {Function} callback - 변경 시 호출할 함수 (newState, prevState) => void
|
||||
*
|
||||
* @example
|
||||
* useOnVideoSizeChange((newState, prevState) => {
|
||||
* console.log(`크기: ${prevState.videoSize} -> ${newState.videoSize}`);
|
||||
* layout.updatePlayerSize(newState.videoSize);
|
||||
* });
|
||||
*/
|
||||
export const useOnVideoSizeChange = (callback) => {
|
||||
usePlayerStateChanges(callback, ['videoSize']);
|
||||
};
|
||||
|
||||
/**
|
||||
* 모달/전체화면 모드 변경만 감지하는 hook
|
||||
*
|
||||
* @param {Function} callback - 변경 시 호출할 함수 (newState, prevState) => void
|
||||
*
|
||||
* @example
|
||||
* useOnModalChange((newState, prevState) => {
|
||||
* if (newState.modal === false) {
|
||||
* console.log('전체화면으로 진입');
|
||||
* } else {
|
||||
* console.log('모달 모드로 복귀');
|
||||
* }
|
||||
* });
|
||||
*/
|
||||
export const useOnModalChange = (callback) => {
|
||||
usePlayerStateChanges(callback, ['modal']);
|
||||
};
|
||||
|
||||
/**
|
||||
* 에러 발생 감지 hook
|
||||
* playerState.error가 null이 아닐 때 실행
|
||||
*
|
||||
* @param {Function} callback - 에러 발생 시 호출할 함수 (newState) => void
|
||||
*
|
||||
* @example
|
||||
* useOnPlayerError((newState) => {
|
||||
* showErrorNotification(newState.error);
|
||||
* analytics.trackVideoError(newState.error);
|
||||
* });
|
||||
*/
|
||||
export const useOnPlayerError = (callback) => {
|
||||
const playerState = usePlayerState();
|
||||
const prevErrorRef = useRef(playerState?.error);
|
||||
|
||||
useEffect(() => {
|
||||
if (!playerState) return;
|
||||
|
||||
const hasError = playerState.error && playerState.error !== prevErrorRef.current;
|
||||
|
||||
if (hasError) {
|
||||
callback(playerState);
|
||||
}
|
||||
|
||||
prevErrorRef.current = playerState.error;
|
||||
}, [playerState, callback]);
|
||||
};
|
||||
|
||||
/**
|
||||
* 비디오 재생 완료 감지 hook
|
||||
* currentTime이 duration에 도달하면 실행
|
||||
*
|
||||
* @param {Function} callback - 완료 시 호출할 함수 (newState) => void
|
||||
*
|
||||
* @example
|
||||
* useOnVideoEnd((newState) => {
|
||||
* console.log('비디오 재생 완료');
|
||||
* playNextVideo();
|
||||
* });
|
||||
*/
|
||||
export const useOnVideoEnd = (callback) => {
|
||||
const playerState = usePlayerState();
|
||||
const hasEndedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!playerState) {
|
||||
hasEndedRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const isEnded = playerState.duration > 0 &&
|
||||
playerState.currentTime >= playerState.duration - 1;
|
||||
|
||||
if (isEnded && !hasEndedRef.current) {
|
||||
hasEndedRef.current = true;
|
||||
callback(playerState);
|
||||
} else if (!isEnded) {
|
||||
hasEndedRef.current = false;
|
||||
}
|
||||
}, [playerState, callback]);
|
||||
};
|
||||
|
||||
/**
|
||||
* 여러 필드의 변경을 감지하고 상세 정보를 제공하는 hook
|
||||
* 변경된 모든 필드와 그 값을 객체로 전달
|
||||
*
|
||||
* @param {Function} callback - 변경 감지 시 호출할 함수 (changes, newState) => void
|
||||
* @param {string[]} [deps=[]] - 감시할 playerState의 필드들
|
||||
*
|
||||
* @returns {Object} 현재 상태의 변경사항 객체
|
||||
*
|
||||
* @example
|
||||
* const changes = usePlayerStateChangesDetailed((changes, newState) => {
|
||||
* console.log('변경된 필드:', Object.keys(changes));
|
||||
* console.log('변경 상세:', changes);
|
||||
* }, ['playState', 'currentTime']);
|
||||
*
|
||||
* if (changes.playState) {
|
||||
* console.log(`playState: ${changes.playState.prev} -> ${changes.playState.next}`);
|
||||
* }
|
||||
*/
|
||||
export const usePlayerStateChangesDetailed = (callback, deps = []) => {
|
||||
const playerState = usePlayerState();
|
||||
const prevStateRef = useRef(playerState);
|
||||
const changesRef = useRef({});
|
||||
|
||||
useEffect(() => {
|
||||
if (!playerState) return;
|
||||
|
||||
if (prevStateRef.current) {
|
||||
const fieldsToCheck = deps.length === 0 ? Object.keys(playerState) : deps;
|
||||
|
||||
const changes = {};
|
||||
let hasAnyChange = false;
|
||||
|
||||
fieldsToCheck.forEach((field) => {
|
||||
if (prevStateRef.current[field] !== playerState[field]) {
|
||||
changes[field] = {
|
||||
prev: prevStateRef.current[field],
|
||||
next: playerState[field],
|
||||
};
|
||||
hasAnyChange = true;
|
||||
}
|
||||
});
|
||||
|
||||
if (hasAnyChange) {
|
||||
callback(changes, playerState);
|
||||
changesRef.current = changes;
|
||||
}
|
||||
}
|
||||
|
||||
prevStateRef.current = playerState;
|
||||
}, [playerState, deps, callback]);
|
||||
|
||||
return changesRef.current;
|
||||
};
|
||||
|
||||
/**
|
||||
* 재생 상태 변화를 로깅하는 hook (개발/디버깅용)
|
||||
*
|
||||
* @param {string} [componentName='Component'] - 컴포넌트 이름 (로그에 표시)
|
||||
*
|
||||
* @example
|
||||
* usePlayerStateLogger('HomeBanner');
|
||||
* // 콘솔에 모든 playerState 변경 로그 출력
|
||||
*/
|
||||
export const usePlayerStateLogger = (componentName = 'Component') => {
|
||||
const playerState = usePlayerState();
|
||||
const prevStateRef = useRef(playerState);
|
||||
|
||||
useEffect(() => {
|
||||
if (!playerState) return;
|
||||
|
||||
if (prevStateRef.current) {
|
||||
const changes = getStateChanges(prevStateRef.current, playerState);
|
||||
|
||||
if (Object.keys(changes).length > 0) {
|
||||
console.log(
|
||||
`[${componentName}] PlayerState Changed:`,
|
||||
changes
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
prevStateRef.current = playerState;
|
||||
}, [playerState, componentName]);
|
||||
};
|
||||
|
||||
export default usePlayerStateChanges;
|
||||
278
com.twin.app.shoptime/src/hooks/useVideoActions.js
Normal file
278
com.twin.app.shoptime/src/hooks/useVideoActions.js
Normal file
@@ -0,0 +1,278 @@
|
||||
/**
|
||||
* @fileoverview Video Action Dispatch Hooks
|
||||
* 비디오 재생/제어 액션을 dispatch하는 hooks
|
||||
* @author ShopTime Dev Team
|
||||
*/
|
||||
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useCallback } from 'react';
|
||||
import {
|
||||
startVideoPlayer,
|
||||
finishVideoPreview,
|
||||
pauseVideo,
|
||||
resumeVideo,
|
||||
changeVideoSize,
|
||||
stopVideo,
|
||||
autoReplayVideo,
|
||||
updatePlayerState,
|
||||
goToFullScreen,
|
||||
finishModalVideoForce,
|
||||
pauseModalVideo,
|
||||
resumeModalVideo,
|
||||
pauseFullscreenVideo,
|
||||
resumeFullscreenVideo,
|
||||
} from '../actions/playActions';
|
||||
import { selectPlayerState } from '../utils/playerState/playerStateSelectors';
|
||||
|
||||
/**
|
||||
* 비디오 재생 제어 액션들을 제공하는 hook
|
||||
* 컴포넌트에서 직접 dispatch를 사용하지 않고 이 hook을 사용
|
||||
*
|
||||
* @returns {Object} 비디오 제어 함수들
|
||||
*
|
||||
* @example
|
||||
* const { play, pause, stop } = useVideoActions();
|
||||
*
|
||||
* const handlePlayClick = () => {
|
||||
* play({ bannerId: 'banner0', patnrId: 123, showId: 456 });
|
||||
* };
|
||||
*/
|
||||
export const useVideoActions = () => {
|
||||
const dispatch = useDispatch();
|
||||
const playerState = useSelector(selectPlayerState);
|
||||
|
||||
/**
|
||||
* 비디오 재생 시작
|
||||
* 이미 같은 비디오가 재생 중이면 아무것도 하지 않음
|
||||
*
|
||||
* @param {Object} options - 비디오 정보
|
||||
* @param {string} options.bannerId - 배너 ID
|
||||
* @param {number} options.patnrId - Partner ID
|
||||
* @param {number} options.showId - Show ID
|
||||
* @param {boolean} [options.modal=false] - 모달 여부
|
||||
* @param {string} [options.videoSize='banner'] - 비디오 크기
|
||||
* @param {...any} options.rest - 추가 정보
|
||||
*/
|
||||
const play = useCallback(
|
||||
({
|
||||
bannerId,
|
||||
patnrId,
|
||||
showId,
|
||||
modal = false,
|
||||
videoSize = 'banner',
|
||||
...rest
|
||||
}) => {
|
||||
dispatch(
|
||||
startVideoPlayer({
|
||||
bannerId,
|
||||
patnrId,
|
||||
showId,
|
||||
modal,
|
||||
videoSize,
|
||||
...rest,
|
||||
})
|
||||
);
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
/**
|
||||
* 비디오 재생 중지 (패널 닫기)
|
||||
* 모달 비디오 미리보기 종료
|
||||
*/
|
||||
const stop = useCallback(() => {
|
||||
dispatch(finishVideoPreview());
|
||||
}, [dispatch]);
|
||||
|
||||
/**
|
||||
* 비디오 일시정지 (패널 유지)
|
||||
* 현재 재생 상태에 따라 모달 또는 전체화면 비디오를 일시정지
|
||||
*/
|
||||
const pause = useCallback(() => {
|
||||
if (!playerState) return;
|
||||
|
||||
if (playerState.modal) {
|
||||
dispatch(pauseModalVideo());
|
||||
} else {
|
||||
dispatch(pauseFullscreenVideo());
|
||||
}
|
||||
}, [dispatch, playerState]);
|
||||
|
||||
/**
|
||||
* 비디오 재개 (일시정지 해제)
|
||||
* 현재 재생 상태에 따라 모달 또는 전체화면 비디오를 재개
|
||||
*/
|
||||
const resume = useCallback(() => {
|
||||
if (!playerState) return;
|
||||
|
||||
if (playerState.modal) {
|
||||
dispatch(resumeModalVideo());
|
||||
} else {
|
||||
dispatch(resumeFullscreenVideo());
|
||||
}
|
||||
}, [dispatch, playerState]);
|
||||
|
||||
/**
|
||||
* 비디오 크기 변경
|
||||
* banner -> minimized -> 1px -> banner 순서로 순환
|
||||
*
|
||||
* @param {string} [newSize] - 변경할 크기 ('fullscreen' | 'banner' | 'minimized' | '1px')
|
||||
* 미지정 시 현재 크기의 다음 크기로 자동 변경
|
||||
*/
|
||||
const changeSize = useCallback(
|
||||
(newSize) => {
|
||||
if (newSize) {
|
||||
dispatch(changeVideoSize(newSize));
|
||||
} else if (playerState) {
|
||||
// 다음 크기로 자동 전환
|
||||
const sizeOrder = ['banner', 'minimized', '1px'];
|
||||
const currentIndex = sizeOrder.indexOf(playerState.videoSize);
|
||||
const nextIndex = (currentIndex + 1) % sizeOrder.length;
|
||||
dispatch(changeVideoSize(sizeOrder[nextIndex]));
|
||||
}
|
||||
},
|
||||
[dispatch, playerState]
|
||||
);
|
||||
|
||||
/**
|
||||
* 비디오 완전 중지 (상태만 변경, 패널 유지)
|
||||
*/
|
||||
const stopPlayback = useCallback(() => {
|
||||
dispatch(stopVideo());
|
||||
}, [dispatch]);
|
||||
|
||||
/**
|
||||
* 자동 반복 재생 시작
|
||||
*/
|
||||
const autoReplay = useCallback(() => {
|
||||
dispatch(autoReplayVideo());
|
||||
}, [dispatch]);
|
||||
|
||||
/**
|
||||
* 모달 모드에서 전체화면으로 전환
|
||||
*/
|
||||
const enterFullscreen = useCallback(() => {
|
||||
dispatch(goToFullScreen());
|
||||
}, [dispatch]);
|
||||
|
||||
/**
|
||||
* 모달 비디오 강제 종료
|
||||
* 패널 스택 어디에 있든 모달 PlayerPanel을 제거
|
||||
*/
|
||||
const forceStop = useCallback(() => {
|
||||
dispatch(finishModalVideoForce());
|
||||
}, [dispatch]);
|
||||
|
||||
/**
|
||||
* playerState의 특정 필드 업데이트
|
||||
* 고급 사용용 - 일반적으로 위의 함수들을 사용할 것
|
||||
*
|
||||
* @param {Object} updates - 업데이트할 필드들
|
||||
*
|
||||
* @example
|
||||
* updateState({ currentTime: 30, duration: 120 });
|
||||
*/
|
||||
const updateState = useCallback(
|
||||
(updates) => {
|
||||
dispatch(updatePlayerState(updates));
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
return {
|
||||
play,
|
||||
stop,
|
||||
pause,
|
||||
resume,
|
||||
changeSize,
|
||||
stopPlayback,
|
||||
autoReplay,
|
||||
enterFullscreen,
|
||||
forceStop,
|
||||
updateState,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 비디오 재생/일시정지 토글 hook
|
||||
*
|
||||
* @returns {Object} { toggle, isPlaying }
|
||||
*
|
||||
* @example
|
||||
* const { toggle, isPlaying } = usePlayToggle();
|
||||
* <button onClick={toggle}>
|
||||
* {isPlaying ? '일시정지' : '재생'}
|
||||
* </button>
|
||||
*/
|
||||
export const usePlayToggle = () => {
|
||||
const dispatch = useDispatch();
|
||||
const playerState = useSelector(selectPlayerState);
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
if (!playerState) return;
|
||||
|
||||
if (playerState.isPlaying) {
|
||||
dispatch(pauseVideo());
|
||||
} else {
|
||||
dispatch(resumeVideo());
|
||||
}
|
||||
}, [dispatch, playerState]);
|
||||
|
||||
return {
|
||||
toggle,
|
||||
isPlaying: playerState?.isPlaying || false,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 비디오 크기 변경만 담당하는 hook
|
||||
*
|
||||
* @returns {Object} { changeSize, currentSize }
|
||||
*
|
||||
* @example
|
||||
* const { changeSize, currentSize } = useVideoSizeControl();
|
||||
*/
|
||||
export const useVideoSizeControl = () => {
|
||||
const dispatch = useDispatch();
|
||||
const playerState = useSelector(selectPlayerState);
|
||||
|
||||
const changeSize = useCallback(
|
||||
(newSize) => {
|
||||
dispatch(changeVideoSize(newSize));
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
return {
|
||||
changeSize,
|
||||
currentSize: playerState?.videoSize || 'banner',
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 모달 <-> 전체화면 전환 hook
|
||||
*
|
||||
* @returns {Object} { toggleFullscreen, isFullscreen }
|
||||
*
|
||||
* @example
|
||||
* const { toggleFullscreen, isFullscreen } = useFullscreenToggle();
|
||||
*/
|
||||
export const useFullscreenToggle = () => {
|
||||
const dispatch = useDispatch();
|
||||
const playerState = useSelector(selectPlayerState);
|
||||
|
||||
const toggleFullscreen = useCallback(() => {
|
||||
if (!playerState) return;
|
||||
|
||||
if (playerState.modal) {
|
||||
dispatch(goToFullScreen());
|
||||
}
|
||||
}, [dispatch, playerState]);
|
||||
|
||||
return {
|
||||
toggleFullscreen,
|
||||
isFullscreen: playerState?.modal === false,
|
||||
};
|
||||
};
|
||||
|
||||
export default useVideoActions;
|
||||
173
com.twin.app.shoptime/src/types/PlayerState.js
Normal file
173
com.twin.app.shoptime/src/types/PlayerState.js
Normal file
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* @fileoverview PlayerPanel의 playerState 타입 정의 (JSDoc)
|
||||
* @author ShopTime Dev Team
|
||||
*/
|
||||
|
||||
/**
|
||||
* 비디오 크기/재생 모드 타입
|
||||
* @typedef {'fullscreen' | 'banner' | 'minimized' | '1px'} VideoSize
|
||||
*/
|
||||
|
||||
/**
|
||||
* 비디오 재생 상태 타입
|
||||
* @typedef {'playing' | 'paused' | 'stopped' | 'auto-replaying' | 'loading'} PlayState
|
||||
*/
|
||||
|
||||
/**
|
||||
* PlayerPanel의 playerState 객체 구조
|
||||
* Singleton PlayerPanel에서 비디오의 전체 상태를 관리하는 중앙화된 상태 객체
|
||||
*
|
||||
* @typedef {Object} PlayerState
|
||||
*
|
||||
* ═══════════════════════════════════════════════════════════
|
||||
* 1️⃣ 비디오 식별 정보 (변경 거의 없음)
|
||||
* ═══════════════════════════════════════════════════════════
|
||||
*
|
||||
* @property {string | null} currentlyPlayingBannerId
|
||||
* - 현재 재생 중인 배너의 ID
|
||||
* - 예: 'banner0', 'banner1' 등
|
||||
* - null이면 재생 중인 배너 없음
|
||||
*
|
||||
* @property {number | null} patnrId
|
||||
* - Partner ID (비디오 메타데이터)
|
||||
* - API에서 제공하는 고유 ID
|
||||
*
|
||||
* @property {number | null} showId
|
||||
* - Show ID (비디오 메타데이터)
|
||||
* - API에서 제공하는 고유 ID
|
||||
*
|
||||
* @property {string | null} [videoUrl]
|
||||
* - 비디오 URL (선택사항)
|
||||
* - 실제 비디오 파일 경로
|
||||
*
|
||||
* ═══════════════════════════════════════════════════════════
|
||||
* 2️⃣ 재생 모드 & 크기 (사용자 상호작용시 변경)
|
||||
* ═══════════════════════════════════════════════════════════
|
||||
*
|
||||
* @property {boolean} modal
|
||||
* - true = 모달 모드 (배너 크기 또는 최소화)
|
||||
* - false = 전체화면 모드
|
||||
*
|
||||
* @property {VideoSize} videoSize
|
||||
* - 'fullscreen': 전체 화면 재생
|
||||
* - 'banner': 배너 기본 크기
|
||||
* - 'minimized': 축소된 크기
|
||||
* - '1px': 1px 크기 (백그라운드 비디오)
|
||||
*
|
||||
* ═══════════════════════════════════════════════════════════
|
||||
* 3️⃣ 재생 상태 (실시간 변경)
|
||||
* ═══════════════════════════════════════════════════════════
|
||||
*
|
||||
* @property {PlayState} playState
|
||||
* - 'playing': 현재 재생 중
|
||||
* - 'paused': 일시정지 상태
|
||||
* - 'stopped': 정지 상태
|
||||
* - 'auto-replaying': 자동 반복 재생 중
|
||||
* - 'loading': 로딩 중
|
||||
*
|
||||
* @property {boolean} isPaused
|
||||
* - 일시정지 상태 플래그 (playState 기반 캐시)
|
||||
* - playState가 'paused'일 때 true
|
||||
*
|
||||
* @property {boolean} isPlaying
|
||||
* - 재생 중 플래그 (playState 기반 캐시)
|
||||
* - playState가 'playing' 또는 'auto-replaying'일 때 true
|
||||
*
|
||||
* ═══════════════════════════════════════════════════════════
|
||||
* 4️⃣ 비디오 메타데이터
|
||||
* ═══════════════════════════════════════════════════════════
|
||||
*
|
||||
* @property {number} duration
|
||||
* - 비디오 전체 길이 (초 단위)
|
||||
*
|
||||
* @property {number} [currentTime]
|
||||
* - 현재 재생 시간 (초 단위)
|
||||
* - 기본값: 0
|
||||
*
|
||||
* @property {number} [startTime]
|
||||
* - 비디오 시작 시간 (초 단위)
|
||||
* - 기본값: 0
|
||||
*
|
||||
* ═══════════════════════════════════════════════════════════
|
||||
* 5️⃣ 추가 상태
|
||||
* ═══════════════════════════════════════════════════════════
|
||||
*
|
||||
* @property {boolean} autoReplayEnabled
|
||||
* - 자동 반복 재생 활성화 여부
|
||||
*
|
||||
* @property {boolean} isVisible
|
||||
* - 비디오가 화면에 표시되는가
|
||||
*
|
||||
* @property {number | null} lastPlayedAt
|
||||
* - 마지막 재생 시간 (timestamp)
|
||||
*
|
||||
* @property {string | null} [error]
|
||||
* - 에러 메시지 (에러 발생 시)
|
||||
*/
|
||||
|
||||
/**
|
||||
* 초기 playerState 객체
|
||||
* 새로운 비디오 재생 시 이를 기반으로 mergePlayerState()를 사용해서 생성
|
||||
*
|
||||
* @type {PlayerState}
|
||||
*/
|
||||
export const initialPlayerState = {
|
||||
// 비디오 식별 정보
|
||||
currentlyPlayingBannerId: null,
|
||||
patnrId: null,
|
||||
showId: null,
|
||||
videoUrl: null,
|
||||
|
||||
// 재생 모드 & 크기
|
||||
modal: false,
|
||||
videoSize: 'banner',
|
||||
|
||||
// 재생 상태
|
||||
playState: 'stopped',
|
||||
isPaused: false,
|
||||
isPlaying: false,
|
||||
|
||||
// 비디오 메타데이터
|
||||
duration: 0,
|
||||
currentTime: 0,
|
||||
startTime: 0,
|
||||
|
||||
// 추가 상태
|
||||
autoReplayEnabled: false,
|
||||
isVisible: true,
|
||||
lastPlayedAt: null,
|
||||
error: null,
|
||||
};
|
||||
|
||||
/**
|
||||
* PlayerState 객체의 유효성을 확인하는 타입가드
|
||||
*
|
||||
* @param {*} obj - 확인할 객체
|
||||
* @returns {boolean} PlayerState 유효 여부
|
||||
*
|
||||
* @example
|
||||
* if (isPlayerState(playerState)) {
|
||||
* console.log('Valid playerState');
|
||||
* }
|
||||
*/
|
||||
export const isPlayerState = (obj) => {
|
||||
if (!obj || typeof obj !== 'object') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const requiredKeys = [
|
||||
'currentlyPlayingBannerId',
|
||||
'patnrId',
|
||||
'showId',
|
||||
'modal',
|
||||
'videoSize',
|
||||
'playState',
|
||||
'isPaused',
|
||||
'isPlaying',
|
||||
'duration',
|
||||
];
|
||||
|
||||
return requiredKeys.every((key) => key in obj);
|
||||
};
|
||||
|
||||
export default initialPlayerState;
|
||||
@@ -0,0 +1,363 @@
|
||||
/**
|
||||
* @fileoverview Helper functions for PlayerState management
|
||||
* playerState 생성, 수정, 비교 등의 유틸리티 함수들
|
||||
* @author ShopTime Dev Team
|
||||
*/
|
||||
|
||||
import { initialPlayerState } from '../../types/PlayerState';
|
||||
|
||||
/**
|
||||
* playerState 객체를 생성하는 helper
|
||||
* 필수 필드는 반드시 포함, 선택적 필드는 초기값과 병합
|
||||
*
|
||||
* @param {Object} params - playerState 생성 파라미터
|
||||
* @param {string} params.currentlyPlayingBannerId - 재생 중인 배너 ID
|
||||
* @param {number} params.patnrId - Partner ID
|
||||
* @param {number} params.showId - Show ID
|
||||
* @param {boolean} [params.modal=false] - 모달 여부
|
||||
* @param {string} [params.videoSize='banner'] - 비디오 크기
|
||||
* @param {string} [params.playState='stopped'] - 재생 상태
|
||||
* @param {Object} [params.rest] - 추가 필드들
|
||||
*
|
||||
* @returns {Object} 생성된 playerState 객체
|
||||
*
|
||||
* @example
|
||||
* const playerState = createPlayerState({
|
||||
* currentlyPlayingBannerId: 'banner0',
|
||||
* patnrId: 123,
|
||||
* showId: 456,
|
||||
* modal: true,
|
||||
* videoSize: 'banner',
|
||||
* playState: 'playing',
|
||||
* });
|
||||
*/
|
||||
export const createPlayerState = ({
|
||||
currentlyPlayingBannerId,
|
||||
patnrId,
|
||||
showId,
|
||||
modal = false,
|
||||
videoSize = 'banner',
|
||||
playState = 'stopped',
|
||||
...rest
|
||||
}) => {
|
||||
const newState = {
|
||||
...initialPlayerState,
|
||||
currentlyPlayingBannerId,
|
||||
patnrId,
|
||||
showId,
|
||||
modal,
|
||||
videoSize,
|
||||
playState,
|
||||
isPaused: playState === 'paused',
|
||||
isPlaying: playState === 'playing' || playState === 'auto-replaying',
|
||||
lastPlayedAt: Date.now(),
|
||||
...rest,
|
||||
};
|
||||
|
||||
return newState;
|
||||
};
|
||||
|
||||
/**
|
||||
* playerState를 업데이트할 때 일관성 유지
|
||||
* playState 변경 시 자동으로 isPaused, isPlaying도 업데이트
|
||||
*
|
||||
* @param {Object} currentState - 현재 playerState
|
||||
* @param {Object} updates - 업데이트할 필드들
|
||||
*
|
||||
* @returns {Object} 병합된 새로운 playerState
|
||||
*
|
||||
* @example
|
||||
* const updated = mergePlayerState(playerState, {
|
||||
* playState: 'paused',
|
||||
* currentTime: 30,
|
||||
* });
|
||||
*/
|
||||
export const mergePlayerState = (currentState, updates) => {
|
||||
if (!currentState) {
|
||||
return createPlayerState(updates);
|
||||
}
|
||||
|
||||
const merged = { ...currentState, ...updates };
|
||||
|
||||
// playState 기반으로 isPaused, isPlaying 자동 갱신
|
||||
if (updates.playState) {
|
||||
merged.isPaused = updates.playState === 'paused';
|
||||
merged.isPlaying =
|
||||
updates.playState === 'playing' || updates.playState === 'auto-replaying';
|
||||
}
|
||||
|
||||
return merged;
|
||||
};
|
||||
|
||||
/**
|
||||
* 비디오가 재생 가능한 상태인지 확인
|
||||
* 필수 정보가 모두 있고 에러가 없어야 함
|
||||
*
|
||||
* @param {Object} playerState - playerState 객체
|
||||
*
|
||||
* @returns {boolean}
|
||||
*
|
||||
* @example
|
||||
* if (canPlayVideo(playerState)) {
|
||||
* playVideo();
|
||||
* }
|
||||
*/
|
||||
export const canPlayVideo = (playerState) => {
|
||||
return (
|
||||
playerState &&
|
||||
playerState.currentlyPlayingBannerId &&
|
||||
playerState.patnrId &&
|
||||
playerState.showId &&
|
||||
!playerState.error
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 두 playerState가 같은 비디오인지 비교
|
||||
* patnrId, showId, currentlyPlayingBannerId가 모두 일치해야 true
|
||||
*
|
||||
* @param {Object} state1 - 첫 번째 playerState
|
||||
* @param {Object} state2 - 두 번째 playerState
|
||||
*
|
||||
* @returns {boolean}
|
||||
*
|
||||
* @example
|
||||
* if (isSameVideo(oldState, newState)) {
|
||||
* console.log('Same video');
|
||||
* }
|
||||
*/
|
||||
export const isSameVideo = (state1, state2) => {
|
||||
if (!state1 || !state2) return false;
|
||||
|
||||
return (
|
||||
state1.patnrId === state2.patnrId &&
|
||||
state1.showId === state2.showId &&
|
||||
state1.currentlyPlayingBannerId === state2.currentlyPlayingBannerId
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 새로운 비디오를 시작해야 하는지 판단
|
||||
* 이미 같은 비디오가 재생 중이면 false (시작 불필요)
|
||||
*
|
||||
* @param {Object} currentState - 현재 playerState
|
||||
* @param {string} newBannerId - 새 배너 ID
|
||||
* @param {number} patnrId - Partner ID
|
||||
* @param {number} showId - Show ID
|
||||
*
|
||||
* @returns {boolean}
|
||||
*
|
||||
* @example
|
||||
* if (shouldStartVideo(playerState, 'banner0', 123, 456)) {
|
||||
* dispatch(startVideoPlayer(...));
|
||||
* }
|
||||
*/
|
||||
export const shouldStartVideo = (currentState, newBannerId, patnrId, showId) => {
|
||||
if (!currentState) return true;
|
||||
|
||||
// 이미 같은 비디오가 재생 중이면 시작 불필요
|
||||
if (
|
||||
currentState.currentlyPlayingBannerId === newBannerId &&
|
||||
currentState.patnrId === patnrId &&
|
||||
currentState.showId === showId &&
|
||||
currentState.isPlaying
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* playerState를 panelInfo에 저장하기 위한 형식으로 변환 (직렬화)
|
||||
*
|
||||
* @param {Object} playerState - playerState 객체
|
||||
*
|
||||
* @returns {Object} 직렬화된 playerState
|
||||
*
|
||||
* @example
|
||||
* const serialized = serializePlayerState(playerState);
|
||||
* dispatch(updatePanel({ panelInfo: { playerState: serialized } }));
|
||||
*/
|
||||
export const serializePlayerState = (playerState) => {
|
||||
return {
|
||||
...playerState,
|
||||
lastPlayedAt: playerState.lastPlayedAt || Date.now(),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* panelInfo에서 playerState를 추출하고 검증
|
||||
*
|
||||
* @param {Object} panelInfo - panelInfo 객체
|
||||
*
|
||||
* @returns {Object | null} 유효한 playerState 또는 null
|
||||
*
|
||||
* @example
|
||||
* const playerState = deserializePlayerState(panelInfo);
|
||||
* if (playerState) {
|
||||
* // playerState 사용 가능
|
||||
* }
|
||||
*/
|
||||
export const deserializePlayerState = (panelInfo) => {
|
||||
const playerState = panelInfo?.playerState;
|
||||
|
||||
if (!playerState) return null;
|
||||
|
||||
// 필수 필드 검증
|
||||
if (!playerState.currentlyPlayingBannerId || !playerState.patnrId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return playerState;
|
||||
};
|
||||
|
||||
/**
|
||||
* 현재 재생 시간을 형식이 지정된 문자열로 변환
|
||||
* 예: "00:45:30" (시:분:초)
|
||||
*
|
||||
* @param {number} seconds - 초 단위 시간
|
||||
*
|
||||
* @returns {string} 형식이 지정된 시간 문자열
|
||||
*
|
||||
* @example
|
||||
* const timeStr = formatTime(playerState.currentTime);
|
||||
* console.log(timeStr); // "01:23:45"
|
||||
*/
|
||||
export const formatTime = (seconds) => {
|
||||
if (!Number.isFinite(seconds) || seconds < 0) {
|
||||
return '00:00:00';
|
||||
}
|
||||
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
|
||||
const pad = (num) => String(num).padStart(2, '0');
|
||||
|
||||
return `${pad(hours)}:${pad(minutes)}:${pad(secs)}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* 비디오 진행 시간을 백분율로 계산
|
||||
* duration이 0이면 0 반환
|
||||
*
|
||||
* @param {number} currentTime - 현재 재생 시간 (초)
|
||||
* @param {number} duration - 비디오 전체 길이 (초)
|
||||
*
|
||||
* @returns {number} 0 ~ 100 범위의 백분율
|
||||
*
|
||||
* @example
|
||||
* const percent = getVideoProgressPercent(30, 120); // 25
|
||||
*/
|
||||
export const getVideoProgressPercent = (currentTime, duration) => {
|
||||
if (!duration || duration === 0) return 0;
|
||||
|
||||
const percent = (currentTime / duration) * 100;
|
||||
return Math.min(Math.max(percent, 0), 100); // 0 ~ 100 범위로 제한
|
||||
};
|
||||
|
||||
/**
|
||||
* 비디오 크기 변경 가능 여부 확인
|
||||
* modal 상태에서만 크기 변경 가능
|
||||
*
|
||||
* @param {Object} playerState - playerState 객체
|
||||
*
|
||||
* @returns {boolean}
|
||||
*
|
||||
* @example
|
||||
* if (canChangeVideoSize(playerState)) {
|
||||
* // 크기 변경 버튼 표시
|
||||
* }
|
||||
*/
|
||||
export const canChangeVideoSize = (playerState) => {
|
||||
return playerState?.modal === true;
|
||||
};
|
||||
|
||||
/**
|
||||
* 현재 playerState에 기반해서 다음 비디오 크기 반환
|
||||
* 크기 변경 사이클: banner -> minimized -> 1px -> banner
|
||||
*
|
||||
* @param {string} currentSize - 현재 비디오 크기
|
||||
*
|
||||
* @returns {string} 다음 비디오 크기
|
||||
*
|
||||
* @example
|
||||
* const nextSize = getNextVideoSize(playerState.videoSize);
|
||||
* dispatch(changeVideoSize(nextSize));
|
||||
*/
|
||||
export const getNextVideoSize = (currentSize) => {
|
||||
const sizeOrder = ['banner', 'minimized', '1px'];
|
||||
const currentIndex = sizeOrder.indexOf(currentSize);
|
||||
const nextIndex = (currentIndex + 1) % sizeOrder.length;
|
||||
return sizeOrder[nextIndex];
|
||||
};
|
||||
|
||||
/**
|
||||
* playerState의 특정 필드가 변경되었는지 확인
|
||||
*
|
||||
* @param {Object} prevState - 이전 playerState
|
||||
* @param {Object} nextState - 새로운 playerState
|
||||
* @param {string | string[]} fields - 확인할 필드명(들)
|
||||
*
|
||||
* @returns {boolean}
|
||||
*
|
||||
* @example
|
||||
* if (hasStateChanged(prev, next, ['playState', 'currentTime'])) {
|
||||
* handleStateChange();
|
||||
* }
|
||||
*/
|
||||
export const hasStateChanged = (prevState, nextState, fields) => {
|
||||
if (!prevState || !nextState) return true;
|
||||
|
||||
const fieldsArray = Array.isArray(fields) ? fields : [fields];
|
||||
|
||||
return fieldsArray.some((field) => prevState[field] !== nextState[field]);
|
||||
};
|
||||
|
||||
/**
|
||||
* 두 playerState 간의 차이점 반환
|
||||
* 변경된 필드와 그 값을 객체로 반환
|
||||
*
|
||||
* @param {Object} prevState - 이전 playerState
|
||||
* @param {Object} nextState - 새로운 playerState
|
||||
*
|
||||
* @returns {Object} 변경된 필드들 {field: {prev, next}}
|
||||
*
|
||||
* @example
|
||||
* const changes = getStateChanges(prev, next);
|
||||
* console.log(changes);
|
||||
* // { playState: {prev: 'playing', next: 'paused'}, currentTime: {...} }
|
||||
*/
|
||||
export const getStateChanges = (prevState, nextState) => {
|
||||
const changes = {};
|
||||
|
||||
if (!prevState || !nextState) return changes;
|
||||
|
||||
Object.keys(nextState).forEach((key) => {
|
||||
if (prevState[key] !== nextState[key]) {
|
||||
changes[key] = {
|
||||
prev: prevState[key],
|
||||
next: nextState[key],
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
return changes;
|
||||
};
|
||||
|
||||
export default {
|
||||
createPlayerState,
|
||||
mergePlayerState,
|
||||
canPlayVideo,
|
||||
isSameVideo,
|
||||
shouldStartVideo,
|
||||
serializePlayerState,
|
||||
deserializePlayerState,
|
||||
formatTime,
|
||||
getVideoProgressPercent,
|
||||
canChangeVideoSize,
|
||||
getNextVideoSize,
|
||||
hasStateChanged,
|
||||
getStateChanges,
|
||||
};
|
||||
@@ -0,0 +1,341 @@
|
||||
/**
|
||||
* @fileoverview Redux Selectors for PlayerState
|
||||
* playerState에서 특정 필드나 계산된 값을 효율적으로 조회하는 selector들
|
||||
* @author ShopTime Dev Team
|
||||
*/
|
||||
|
||||
import { panel_names } from '../Config';
|
||||
|
||||
/**
|
||||
* 기본 selector: playerState 전체 조회
|
||||
* 모든 다른 selector의 기반이 되는 가장 기본적인 selector
|
||||
*
|
||||
* @param {Object} state - Redux state
|
||||
* @returns {Object | null} playerState 객체 또는 null
|
||||
*
|
||||
* @example
|
||||
* const playerState = useSelector(selectPlayerState);
|
||||
*/
|
||||
export const selectPlayerState = (state) => {
|
||||
const panels = state?.panels?.panels;
|
||||
if (!Array.isArray(panels)) return null;
|
||||
|
||||
const playerPanel = panels.find((p) => p.name === panel_names.PLAYER_PANEL);
|
||||
return playerPanel?.panelInfo?.playerState || null;
|
||||
};
|
||||
|
||||
/**
|
||||
* playerState의 특정 필드만 조회 (성능 최적화)
|
||||
* 리렌더링을 최소화하기 위해 필요한 필드만 선택
|
||||
*
|
||||
* @param {string} fieldName - 조회할 playerState의 필드명
|
||||
* @returns {Function} selector 함수
|
||||
*
|
||||
* @example
|
||||
* const playState = useSelector(selectPlayerStateField('playState'));
|
||||
*/
|
||||
export const selectPlayerStateField = (fieldName) => (state) => {
|
||||
const playerState = selectPlayerState(state);
|
||||
return playerState ? playerState[fieldName] : null;
|
||||
};
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// 개별 필드 selector들
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* 현재 재생 중인 배너 ID 조회
|
||||
*
|
||||
* @param {Object} state - Redux state
|
||||
* @returns {string | null}
|
||||
*
|
||||
* @example
|
||||
* const bannerId = useSelector(selectCurrentlyPlayingBannerId);
|
||||
*/
|
||||
export const selectCurrentlyPlayingBannerId = (state) => {
|
||||
const playerState = selectPlayerState(state);
|
||||
return playerState?.currentlyPlayingBannerId || null;
|
||||
};
|
||||
|
||||
/**
|
||||
* 현재 재생 상태 조회
|
||||
*
|
||||
* @param {Object} state - Redux state
|
||||
* @returns {string} playState ('playing' | 'paused' | 'stopped' | 'auto-replaying' | 'loading')
|
||||
*
|
||||
* @example
|
||||
* const playState = useSelector(selectPlayState);
|
||||
*/
|
||||
export const selectPlayState = (state) => {
|
||||
const playerState = selectPlayerState(state);
|
||||
return playerState?.playState || 'stopped';
|
||||
};
|
||||
|
||||
/**
|
||||
* 비디오 크기 조회
|
||||
*
|
||||
* @param {Object} state - Redux state
|
||||
* @returns {string} videoSize ('fullscreen' | 'banner' | 'minimized' | '1px')
|
||||
*
|
||||
* @example
|
||||
* const videoSize = useSelector(selectVideoSize);
|
||||
*/
|
||||
export const selectVideoSize = (state) => {
|
||||
const playerState = selectPlayerState(state);
|
||||
return playerState?.videoSize || 'banner';
|
||||
};
|
||||
|
||||
/**
|
||||
* 일시정지 상태 플래그 조회
|
||||
*
|
||||
* @param {Object} state - Redux state
|
||||
* @returns {boolean}
|
||||
*
|
||||
* @example
|
||||
* const isPaused = useSelector(selectIsPaused);
|
||||
*/
|
||||
export const selectIsPaused = (state) => {
|
||||
const playerState = selectPlayerState(state);
|
||||
return playerState?.isPaused || false;
|
||||
};
|
||||
|
||||
/**
|
||||
* 재생 중 상태 플래그 조회
|
||||
*
|
||||
* @param {Object} state - Redux state
|
||||
* @returns {boolean}
|
||||
*
|
||||
* @example
|
||||
* const isPlaying = useSelector(selectIsPlaying);
|
||||
*/
|
||||
export const selectIsPlaying = (state) => {
|
||||
const playerState = selectPlayerState(state);
|
||||
return playerState?.isPlaying || false;
|
||||
};
|
||||
|
||||
/**
|
||||
* 현재 재생 시간 조회
|
||||
*
|
||||
* @param {Object} state - Redux state
|
||||
* @returns {number}
|
||||
*
|
||||
* @example
|
||||
* const currentTime = useSelector(selectCurrentTime);
|
||||
*/
|
||||
export const selectCurrentTime = (state) => {
|
||||
const playerState = selectPlayerState(state);
|
||||
return playerState?.currentTime || 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* 비디오 전체 길이 조회
|
||||
*
|
||||
* @param {Object} state - Redux state
|
||||
* @returns {number}
|
||||
*
|
||||
* @example
|
||||
* const duration = useSelector(selectDuration);
|
||||
*/
|
||||
export const selectDuration = (state) => {
|
||||
const playerState = selectPlayerState(state);
|
||||
return playerState?.duration || 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* 모달/전체화면 모드 조회
|
||||
*
|
||||
* @param {Object} state - Redux state
|
||||
* @returns {boolean}
|
||||
*
|
||||
* @example
|
||||
* const modal = useSelector(selectModal);
|
||||
*/
|
||||
export const selectModal = (state) => {
|
||||
const playerState = selectPlayerState(state);
|
||||
return playerState?.modal || false;
|
||||
};
|
||||
|
||||
/**
|
||||
* Partner ID 조회
|
||||
*
|
||||
* @param {Object} state - Redux state
|
||||
* @returns {number | null}
|
||||
*
|
||||
* @example
|
||||
* const patnrId = useSelector(selectPatnrId);
|
||||
*/
|
||||
export const selectPatnrId = (state) => {
|
||||
const playerState = selectPlayerState(state);
|
||||
return playerState?.patnrId || null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Show ID 조회
|
||||
*
|
||||
* @param {Object} state - Redux state
|
||||
* @returns {number | null}
|
||||
*
|
||||
* @example
|
||||
* const showId = useSelector(selectShowId);
|
||||
*/
|
||||
export const selectShowId = (state) => {
|
||||
const playerState = selectPlayerState(state);
|
||||
return playerState?.showId || null;
|
||||
};
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// 계산된 selector들 (조건부 조회)
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* 특정 배너가 현재 재생 중인지 확인
|
||||
* 성능 최적화를 위해 특정 배너에만 관심 있을 때 사용
|
||||
*
|
||||
* @param {string} bannerId - 확인할 배너 ID
|
||||
* @returns {Function} selector 함수
|
||||
*
|
||||
* @example
|
||||
* const isBannerPlaying = useSelector(selectIsBannerPlaying('banner0'));
|
||||
* if (isBannerPlaying) {
|
||||
* // banner0이 현재 재생 중
|
||||
* }
|
||||
*/
|
||||
export const selectIsBannerPlaying = (bannerId) => (state) => {
|
||||
const playerState = selectPlayerState(state);
|
||||
return (
|
||||
playerState?.currentlyPlayingBannerId === bannerId &&
|
||||
playerState?.isPlaying === true
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 비디오가 로딩 중인지 확인
|
||||
*
|
||||
* @param {Object} state - Redux state
|
||||
* @returns {boolean}
|
||||
*
|
||||
* @example
|
||||
* const isLoading = useSelector(selectIsVideoLoading);
|
||||
*/
|
||||
export const selectIsVideoLoading = (state) => {
|
||||
return selectPlayState(state) === 'loading';
|
||||
};
|
||||
|
||||
/**
|
||||
* 비디오가 자동 반복 중인지 확인
|
||||
*
|
||||
* @param {Object} state - Redux state
|
||||
* @returns {boolean}
|
||||
*
|
||||
* @example
|
||||
* const isAutoReplaying = useSelector(selectIsAutoReplaying);
|
||||
*/
|
||||
export const selectIsAutoReplaying = (state) => {
|
||||
return selectPlayState(state) === 'auto-replaying';
|
||||
};
|
||||
|
||||
/**
|
||||
* 비디오 진행률 계산 (0 ~ 1)
|
||||
* duration이 0이면 0 반환 (에러 방지)
|
||||
*
|
||||
* @param {Object} state - Redux state
|
||||
* @returns {number} 0 ~ 1 범위의 진행률
|
||||
*
|
||||
* @example
|
||||
* const progress = useSelector(selectVideoProgress);
|
||||
* console.log(`${(progress * 100).toFixed(2)}%`);
|
||||
*/
|
||||
export const selectVideoProgress = (state) => {
|
||||
const playerState = selectPlayerState(state);
|
||||
if (!playerState?.duration || playerState.duration === 0) {
|
||||
return 0;
|
||||
}
|
||||
const progress = playerState.currentTime / playerState.duration;
|
||||
return Math.min(Math.max(progress, 0), 1); // 0 ~ 1 범위로 제한
|
||||
};
|
||||
|
||||
/**
|
||||
* 비디오가 완료되었는지 확인 (마지막 1초 이내)
|
||||
*
|
||||
* @param {Object} state - Redux state
|
||||
* @returns {boolean}
|
||||
*
|
||||
* @example
|
||||
* const isEnded = useSelector(selectIsVideoEnded);
|
||||
* if (isEnded) {
|
||||
* // 비디오 완료 시 처리
|
||||
* }
|
||||
*/
|
||||
export const selectIsVideoEnded = (state) => {
|
||||
const playerState = selectPlayerState(state);
|
||||
if (!playerState?.duration || playerState.duration === 0) {
|
||||
return false;
|
||||
}
|
||||
// 마지막 1초 이내일 때 완료로 간주
|
||||
return playerState.currentTime >= playerState.duration - 1;
|
||||
};
|
||||
|
||||
/**
|
||||
* 에러 메시지 조회
|
||||
*
|
||||
* @param {Object} state - Redux state
|
||||
* @returns {string | null}
|
||||
*
|
||||
* @example
|
||||
* const error = useSelector(selectPlayerError);
|
||||
*/
|
||||
export const selectPlayerError = (state) => {
|
||||
const playerState = selectPlayerState(state);
|
||||
return playerState?.error || null;
|
||||
};
|
||||
|
||||
/**
|
||||
* 자동 반복 활성화 여부 조회
|
||||
*
|
||||
* @param {Object} state - Redux state
|
||||
* @returns {boolean}
|
||||
*
|
||||
* @example
|
||||
* const autoReplayEnabled = useSelector(selectAutoReplayEnabled);
|
||||
*/
|
||||
export const selectAutoReplayEnabled = (state) => {
|
||||
const playerState = selectPlayerState(state);
|
||||
return playerState?.autoReplayEnabled || false;
|
||||
};
|
||||
|
||||
/**
|
||||
* 비디오 표시 여부 조회
|
||||
*
|
||||
* @param {Object} state - Redux state
|
||||
* @returns {boolean}
|
||||
*
|
||||
* @example
|
||||
* const isVisible = useSelector(selectIsPlayerVisible);
|
||||
*/
|
||||
export const selectIsPlayerVisible = (state) => {
|
||||
const playerState = selectPlayerState(state);
|
||||
return playerState?.isVisible !== false;
|
||||
};
|
||||
|
||||
/**
|
||||
* 같은 비디오 재생 중인지 확인
|
||||
* bannerId와 비디오 ID 모두 일치해야 true
|
||||
*
|
||||
* @param {string} bannerId - 비교할 배너 ID
|
||||
* @param {number} patnrId - 비교할 Partner ID
|
||||
* @param {number} showId - 비교할 Show ID
|
||||
* @returns {Function} selector 함수
|
||||
*
|
||||
* @example
|
||||
* const isSameVideo = useSelector(selectIsSameVideoPlaying('banner0', 123, 456));
|
||||
*/
|
||||
export const selectIsSameVideoPlaying = (bannerId, patnrId, showId) => (state) => {
|
||||
const playerState = selectPlayerState(state);
|
||||
return (
|
||||
playerState?.currentlyPlayingBannerId === bannerId &&
|
||||
playerState?.patnrId === patnrId &&
|
||||
playerState?.showId === showId
|
||||
);
|
||||
};
|
||||
|
||||
export default selectPlayerState;
|
||||
@@ -0,0 +1,323 @@
|
||||
/**
|
||||
* @fileoverview Validation functions for PlayerState
|
||||
* playerState의 유효성 검사 및 데이터 정규화 함수들
|
||||
* @author ShopTime Dev Team
|
||||
*/
|
||||
|
||||
/**
|
||||
* playerState 유효성 검사
|
||||
* 필수 필드와 enum 값을 확인하고 논리적 일관성을 검증
|
||||
*
|
||||
* @param {Object} playerState - 검사할 playerState 객체
|
||||
*
|
||||
* @returns {Object} 검증 결과 {valid: boolean, errors: string[]}
|
||||
*
|
||||
* @example
|
||||
* const { valid, errors } = validatePlayerState(playerState);
|
||||
* if (!valid) {
|
||||
* console.error('Validation errors:', errors);
|
||||
* }
|
||||
*/
|
||||
export const validatePlayerState = (playerState) => {
|
||||
const errors = [];
|
||||
|
||||
if (!playerState) {
|
||||
return {
|
||||
valid: false,
|
||||
errors: ['playerState is null or undefined'],
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof playerState !== 'object') {
|
||||
return {
|
||||
valid: false,
|
||||
errors: ['playerState must be an object'],
|
||||
};
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// 필수 필드 검사
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
if (!playerState.currentlyPlayingBannerId && playerState.isPlaying) {
|
||||
errors.push('currentlyPlayingBannerId is required when isPlaying is true');
|
||||
}
|
||||
|
||||
if (playerState.patnrId === null || playerState.patnrId === undefined) {
|
||||
if (playerState.isPlaying) {
|
||||
errors.push('patnrId is required when isPlaying is true');
|
||||
}
|
||||
} else if (!Number.isInteger(playerState.patnrId)) {
|
||||
errors.push('patnrId must be an integer');
|
||||
}
|
||||
|
||||
if (playerState.showId === null || playerState.showId === undefined) {
|
||||
if (playerState.isPlaying) {
|
||||
errors.push('showId is required when isPlaying is true');
|
||||
}
|
||||
} else if (!Number.isInteger(playerState.showId)) {
|
||||
errors.push('showId must be an integer');
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// enum 값 검사
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
const validPlayStates = ['playing', 'paused', 'stopped', 'auto-replaying', 'loading'];
|
||||
if (!validPlayStates.includes(playerState.playState)) {
|
||||
errors.push(
|
||||
`Invalid playState: "${playerState.playState}". Must be one of: ${validPlayStates.join(', ')}`
|
||||
);
|
||||
}
|
||||
|
||||
const validVideoSizes = ['fullscreen', 'banner', 'minimized', '1px'];
|
||||
if (!validVideoSizes.includes(playerState.videoSize)) {
|
||||
errors.push(
|
||||
`Invalid videoSize: "${playerState.videoSize}". Must be one of: ${validVideoSizes.join(', ')}`
|
||||
);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// 데이터 타입 검사
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
if (typeof playerState.modal !== 'boolean') {
|
||||
errors.push(`modal must be a boolean, got ${typeof playerState.modal}`);
|
||||
}
|
||||
|
||||
if (typeof playerState.isPaused !== 'boolean') {
|
||||
errors.push(`isPaused must be a boolean, got ${typeof playerState.isPaused}`);
|
||||
}
|
||||
|
||||
if (typeof playerState.isPlaying !== 'boolean') {
|
||||
errors.push(`isPlaying must be a boolean, got ${typeof playerState.isPlaying}`);
|
||||
}
|
||||
|
||||
if (!Number.isFinite(playerState.duration)) {
|
||||
errors.push(`duration must be a finite number, got ${playerState.duration}`);
|
||||
}
|
||||
|
||||
if (!Number.isFinite(playerState.currentTime)) {
|
||||
errors.push(`currentTime must be a finite number, got ${playerState.currentTime}`);
|
||||
}
|
||||
|
||||
if (!Number.isFinite(playerState.startTime)) {
|
||||
errors.push(`startTime must be a finite number, got ${playerState.startTime}`);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// 논리적 일관성 검사
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
// playState와 isPaused/isPlaying의 일관성
|
||||
if (playerState.playState === 'paused' && !playerState.isPaused) {
|
||||
errors.push('Inconsistency: playState is "paused" but isPaused is false');
|
||||
}
|
||||
|
||||
if (playerState.playState === 'paused' && playerState.isPlaying) {
|
||||
errors.push('Inconsistency: playState is "paused" but isPlaying is true');
|
||||
}
|
||||
|
||||
if (
|
||||
(playerState.playState === 'playing' || playerState.playState === 'auto-replaying') &&
|
||||
!playerState.isPlaying
|
||||
) {
|
||||
errors.push(
|
||||
`Inconsistency: playState is "${playerState.playState}" but isPlaying is false`
|
||||
);
|
||||
}
|
||||
|
||||
if (playerState.playState === 'playing' && playerState.isPaused) {
|
||||
errors.push('Inconsistency: playState is "playing" but isPaused is true');
|
||||
}
|
||||
|
||||
// 현재 시간과 전체 길이의 관계
|
||||
if (
|
||||
playerState.duration > 0 &&
|
||||
playerState.currentTime > playerState.duration + 1
|
||||
) {
|
||||
errors.push(
|
||||
`currentTime (${playerState.currentTime}) exceeds duration (${playerState.duration})`
|
||||
);
|
||||
}
|
||||
|
||||
// 현재 시간이 음수일 수 없음
|
||||
if (playerState.currentTime < 0) {
|
||||
errors.push(`currentTime cannot be negative: ${playerState.currentTime}`);
|
||||
}
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* playerState 유효성을 빠르게 검사 (필수 필드만)
|
||||
* validatePlayerState보다 가벼운 버전
|
||||
*
|
||||
* @param {Object} playerState - 검사할 playerState 객체
|
||||
*
|
||||
* @returns {boolean}
|
||||
*
|
||||
* @example
|
||||
* if (isValidPlayerState(playerState)) {
|
||||
* usePlayerState(playerState);
|
||||
* }
|
||||
*/
|
||||
export const isValidPlayerState = (playerState) => {
|
||||
return (
|
||||
playerState &&
|
||||
typeof playerState === 'object' &&
|
||||
typeof playerState.playState === 'string' &&
|
||||
typeof playerState.isPaused === 'boolean' &&
|
||||
typeof playerState.isPlaying === 'boolean'
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* playerState 일관성 복구
|
||||
* 논리적 오류를 자동으로 수정
|
||||
*
|
||||
* @param {Object} playerState - 정규화할 playerState
|
||||
*
|
||||
* @returns {Object} 정규화된 playerState
|
||||
*
|
||||
* @example
|
||||
* const normalized = normalizePlayerState(brokenState);
|
||||
* dispatch(updatePlayerState(normalized));
|
||||
*/
|
||||
export const normalizePlayerState = (playerState) => {
|
||||
if (!playerState) return null;
|
||||
|
||||
const normalized = { ...playerState };
|
||||
|
||||
// playState 기반으로 isPaused, isPlaying 수정
|
||||
if (normalized.playState === 'paused') {
|
||||
normalized.isPaused = true;
|
||||
normalized.isPlaying = false;
|
||||
} else if (normalized.playState === 'playing' || normalized.playState === 'auto-replaying') {
|
||||
normalized.isPaused = false;
|
||||
normalized.isPlaying = true;
|
||||
} else {
|
||||
normalized.isPaused = false;
|
||||
normalized.isPlaying = false;
|
||||
}
|
||||
|
||||
// currentTime 범위 조정
|
||||
normalized.currentTime = Math.max(0, Math.min(
|
||||
normalized.currentTime || 0,
|
||||
normalized.duration || 0
|
||||
));
|
||||
|
||||
// startTime 범위 조정
|
||||
normalized.startTime = Math.max(0, normalized.startTime || 0);
|
||||
|
||||
return normalized;
|
||||
};
|
||||
|
||||
/**
|
||||
* playerState 일관성 검사 (상세 로그)
|
||||
* 개발/디버깅용 함수
|
||||
*
|
||||
* @param {Object} playerState - 검사할 playerState
|
||||
* @param {boolean} [throwOnError=false] - 에러 발생 시 exception 던질지 여부
|
||||
*
|
||||
* @returns {Object} 검증 결과 {valid: boolean, errors: string[]}
|
||||
*
|
||||
* @example
|
||||
* const result = validatePlayerStateWithLogging(playerState, true);
|
||||
* // 에러 발생 시 console에 상세 로그 출력
|
||||
*/
|
||||
export const validatePlayerStateWithLogging = (playerState, throwOnError = false) => {
|
||||
const result = validatePlayerState(playerState);
|
||||
|
||||
if (!result.valid) {
|
||||
console.error('[PlayerState Validation Error]', {
|
||||
playerState,
|
||||
errors: result.errors,
|
||||
});
|
||||
|
||||
if (throwOnError) {
|
||||
throw new Error(`PlayerState validation failed: ${result.errors.join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* playerState의 특정 필드 값이 유효한 enum 값인지 확인
|
||||
*
|
||||
* @param {string} fieldName - 확인할 필드명 ('playState' 또는 'videoSize')
|
||||
* @param {*} value - 확인할 값
|
||||
*
|
||||
* @returns {boolean}
|
||||
*
|
||||
* @example
|
||||
* if (isValidEnumValue('playState', 'playing')) {
|
||||
* // 유효한 값
|
||||
* }
|
||||
*/
|
||||
export const isValidEnumValue = (fieldName, value) => {
|
||||
const enums = {
|
||||
playState: ['playing', 'paused', 'stopped', 'auto-replaying', 'loading'],
|
||||
videoSize: ['fullscreen', 'banner', 'minimized', '1px'],
|
||||
};
|
||||
|
||||
const validValues = enums[fieldName];
|
||||
if (!validValues) {
|
||||
console.warn(`[PlayerState Validator] Unknown field: ${fieldName}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
return validValues.includes(value);
|
||||
};
|
||||
|
||||
/**
|
||||
* playerState 객체에서 유효한 필드들만 추출
|
||||
* 잘못된 필드를 제거하고 필수 필드를 보장
|
||||
*
|
||||
* @param {Object} dirtyState - 검증되지 않은 playerState
|
||||
* @param {Object} defaultState - 기본 playerState (필드 참고용)
|
||||
*
|
||||
* @returns {Object} 정제된 playerState
|
||||
*
|
||||
* @example
|
||||
* const clean = cleanPlayerState(dirtyState, initialPlayerState);
|
||||
*/
|
||||
export const cleanPlayerState = (dirtyState, defaultState) => {
|
||||
if (!dirtyState || typeof dirtyState !== 'object') {
|
||||
return { ...defaultState };
|
||||
}
|
||||
|
||||
const clean = { ...defaultState };
|
||||
|
||||
// 유효한 필드들만 복사
|
||||
Object.keys(defaultState).forEach((key) => {
|
||||
if (key in dirtyState) {
|
||||
clean[key] = dirtyState[key];
|
||||
}
|
||||
});
|
||||
|
||||
// playState enum 검증
|
||||
if (!isValidEnumValue('playState', clean.playState)) {
|
||||
clean.playState = defaultState.playState;
|
||||
}
|
||||
|
||||
// videoSize enum 검증
|
||||
if (!isValidEnumValue('videoSize', clean.videoSize)) {
|
||||
clean.videoSize = defaultState.videoSize;
|
||||
}
|
||||
|
||||
return clean;
|
||||
};
|
||||
|
||||
export default {
|
||||
validatePlayerState,
|
||||
isValidPlayerState,
|
||||
normalizePlayerState,
|
||||
validatePlayerStateWithLogging,
|
||||
isValidEnumValue,
|
||||
cleanPlayerState,
|
||||
};
|
||||
Reference in New Issue
Block a user