[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:
2025-10-26 17:47:16 +09:00
parent 8c0c621d9f
commit efbe4c866c
7 changed files with 2105 additions and 0 deletions

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

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

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

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

View File

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

View File

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

View File

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