[251011] feat: MediaPlayer 추가

🕐 커밋 시간: 2025. 10. 11. 09:07:17

📊 변경 통계:
  • 총 파일: 7개
  • 추가: +416줄
  • 삭제: -518줄

📁 추가된 파일:
  + com.twin.app.shoptime/src/actions/mediaActions.js
  + com.twin.app.shoptime/src/views/MediaPanel/MediaPanel.jsx
  + com.twin.app.shoptime/src/views/MediaPanel/MediaPanel.module.less
  + com.twin.app.shoptime/src/views/MediaPanel/README.md

📝 수정된 파일:
  ~ com.twin.app.shoptime/src/reducers/panelReducer.js
  ~ com.twin.app.shoptime/src/utils/Config.js
  ~ com.twin.app.shoptime/src/views/MainView/MainView.jsx

🔧 함수 변경 내용:
  📄 com.twin.app.shoptime/src/views/MainView/MainView.jsx (javascript):
     Added: resetWatchRecord()
     Deleted: resetWatchRecord()
  📄 com.twin.app.shoptime/src/views/MediaPanel/MediaPanel.jsx (javascript):
     Added: MediaPanel()

🔧 주요 변경 내용:
  • 핵심 비즈니스 로직 개선
  • 공통 유틸리티 함수 최적화
  • 개발 문서 및 가이드 개선

Performance: 코드 최적화로 성능 개선 기대
This commit is contained in:
2025-10-11 09:07:20 +09:00
parent 36bd48224c
commit 0db2966f86
7 changed files with 1238 additions and 518 deletions

View File

@@ -0,0 +1,321 @@
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react';
import classNames from 'classnames';
import { useDispatch } from 'react-redux';
import Spotlight from '@enact/spotlight';
import SpotlightContainerDecorator from '@enact/spotlight/SpotlightContainerDecorator';
import { sendBroadCast } from '../../actions/commonActions';
import { pauseModalMedia, resumeModalMedia } from '../../actions/mediaActions';
import * as PanelActions from '../../actions/panelActions';
import TPanel from '../../components/TPanel/TPanel';
import Media from '../../components/VideoPlayer/Media';
import TReactPlayer from '../../components/VideoPlayer/TReactPlayer';
import { VideoPlayer } from '../../components/VideoPlayer/VideoPlayer';
import usePrevious from '../../hooks/usePrevious';
import { panel_names } from '../../utils/Config';
import css from './MediaPanel.module.less';
const Container = SpotlightContainerDecorator(
{ enterTo: 'default-element', preserveld: true },
'div'
);
const YOUTUBECONFIG = {
playerVars: {
controls: 0,
autoplay: 1,
disablekb: 1,
enablejsapi: 1,
listType: 'user_uploads',
fs: 0,
rel: 0,
showinfo: 0,
loop: 0,
iv_load_policy: 3,
modestbranding: 1,
wmode: 'opaque',
cc_lang_pref: 'en',
cc_load_policy: 0,
playsinline: 1,
},
};
const MediaPanel = ({ isTabActivated, panelInfo, isOnTop, spotlightId, ...props }) => {
const dispatch = useDispatch();
const videoPlayer = useRef(null);
const [modalStyle, setModalStyle] = React.useState({});
const [modalScale, setModalScale] = React.useState(1);
const [currentTime, setCurrentTime] = React.useState(0);
const [videoLoaded, setVideoLoaded] = React.useState(false);
const [isSubtitleActive, setIsSubtitleActive] = React.useState(true);
const panelInfoRef = usePrevious(panelInfo);
// modal/full screen에 따른 일시정지/재생 처리
useEffect(() => {
console.log('[MediaPanel] isOnTop:', {
isOnTop,
panelInfo,
});
if (panelInfo && panelInfo.modal) {
if (!isOnTop) {
console.log('[MediaPanel] Not on top - pausing video');
dispatch(pauseModalMedia());
} else if (isOnTop && panelInfo.isPaused) {
console.log('[MediaPanel] Back on top - resuming video');
dispatch(resumeModalMedia());
}
}
}, [isOnTop, panelInfo, dispatch]);
// videoPlayer ref를 통한 직접 제어
useEffect(() => {
if (panelInfo?.modal && videoPlayer.current) {
if (panelInfo.isPaused) {
console.log('[MediaPanel] Executing pause via videoPlayer.current');
videoPlayer.current.pause();
} else if (panelInfo.isPaused === false) {
console.log('[MediaPanel] Executing play via videoPlayer.current');
videoPlayer.current.play();
}
}
}, [panelInfo?.isPaused, panelInfo?.modal]);
const getPlayer = useCallback((ref) => {
videoPlayer.current = ref;
}, []);
// modal 스타일 설정
useEffect(() => {
if (panelInfo.modal && panelInfo.modalContainerId) {
const node = document.querySelector(`[data-spotlight-id="${panelInfo.modalContainerId}"]`);
if (node) {
const { width, height, top, left } = node.getBoundingClientRect();
const style = {
width: width + 'px',
height: height + 'px',
top: top + 'px',
left: left + 'px',
position: 'fixed',
overflow: 'visible',
};
setModalStyle(style);
let scale = 1;
if (typeof window === 'object') {
scale = width / window.innerWidth;
setModalScale(scale);
}
} else {
setModalStyle(panelInfo.modalStyle || {});
setModalScale(panelInfo.modalScale || 1);
}
} else if (isOnTop && !panelInfo.modal && videoPlayer.current) {
if (videoPlayer.current?.getMediaState()?.paused) {
videoPlayer.current.play();
}
if (videoPlayer.current.areControlsVisible && !videoPlayer.current.areControlsVisible()) {
videoPlayer.current.showControls();
}
}
}, [panelInfo, isOnTop]);
const onClickBack = useCallback(
(ev) => {
// modal에서 full로 전환된 경우 다시 modal로 돌아감
if (panelInfo.modalContainerId && !panelInfo.modal) {
// 다시 modal로 돌리는 로직은 startVideoPlayer 액션을 사용할 수도 있지만
// 여기서는 단순히 패널을 pop
dispatch(PanelActions.popPanel());
ev?.stopPropagation();
return;
}
if (!panelInfo.modal) {
dispatch(PanelActions.popPanel());
ev?.stopPropagation();
return;
}
},
[dispatch, panelInfo]
);
const currentPlayingUrl = useMemo(() => {
return panelInfo?.showUrl;
}, [panelInfo?.showUrl]);
const isYoutube = useMemo(() => {
if (currentPlayingUrl && currentPlayingUrl.includes('youtu')) {
return true;
}
return false;
}, [currentPlayingUrl]);
const currentSubtitleUrl = useMemo(() => {
return panelInfo?.subtitle;
}, [panelInfo?.subtitle]);
const reactPlayerSubtitleConfig = useMemo(() => {
if (isSubtitleActive && currentSubtitleUrl) {
return {
file: {
attributes: {
crossOrigin: 'true',
},
tracks: [{ kind: 'subtitles', src: currentSubtitleUrl, default: true }],
},
youtube: YOUTUBECONFIG,
};
} else {
return {
youtube: YOUTUBECONFIG,
};
}
}, [currentSubtitleUrl, isSubtitleActive]);
const videoType = useMemo(() => {
if (currentPlayingUrl) {
if (currentPlayingUrl.toLowerCase().endsWith('.mp4')) {
return 'video/mp4';
} else if (currentPlayingUrl.toLowerCase().endsWith('.mpd')) {
return 'application/dash+xml';
} else if (currentPlayingUrl.toLowerCase().endsWith('.m3u8')) {
return 'application/mpegurl';
}
}
return 'application/mpegurl';
}, [currentPlayingUrl]);
const videoThumbnailUrl = useMemo(() => {
return panelInfo?.thumbnailUrl;
}, [panelInfo?.thumbnailUrl]);
const mediainfoHandler = useCallback((ev) => {
const type = ev.type;
if (type !== 'timeupdate' && type !== 'durationchange') {
console.log('mediainfoHandler....', type, ev, videoPlayer.current?.getMediaState());
}
if (ev === 'hlsError' && isNaN(Number(videoPlayer.current?.getMediaState().playbackRate))) {
dispatch(
sendBroadCast({
type: 'videoError',
moreInfo: { reason: 'hlsError' },
})
);
return;
}
switch (type) {
case 'timeupdate': {
setCurrentTime(videoPlayer.current?.getMediaState()?.currentTime);
break;
}
case 'error': {
dispatch(
sendBroadCast({
type: 'videoError',
moreInfo: { reason: videoPlayer.current?.getMediaState().error },
})
);
break;
}
case 'loadeddata': {
setVideoLoaded(true);
break;
}
default:
break;
}
}, []);
const onEnded = useCallback(
(e) => {
console.log('[MediaPanel] Video ended');
// 비디오 종료 시 패널 닫기
Spotlight.pause();
setTimeout(() => {
Spotlight.resume();
dispatch(PanelActions.popPanel(panel_names.MEDIA_PANEL));
}, 1500);
e?.stopPropagation();
e?.preventDefault();
},
[dispatch]
);
useLayoutEffect(() => {
const videoContainer = document.querySelector(`.${css.videoContainer}`);
if (videoContainer && panelInfo.thumbnailUrl && !videoLoaded) {
videoContainer.style.background = `url(${panelInfo.thumbnailUrl}) center center / contain no-repeat`;
videoContainer.style.backgroundColor = 'black';
}
}, [panelInfo.thumbnailUrl, videoLoaded]);
useEffect(() => {
setVideoLoaded(false);
}, [currentPlayingUrl]);
return (
<TPanel
isTabActivated={false}
{...props}
className={classNames(
css.videoContainer,
panelInfo.modal && css.modal,
!isOnTop && css.background
)}
handleCancel={onClickBack}
spotlightId={spotlightId}
>
<Container spotlightRestrict="self-only" spotlightId="spotlightId-media-video-container">
{currentPlayingUrl && (
<VideoPlayer
setApiProvider={getPlayer}
disabled={panelInfo.modal}
onEnded={onEnded}
noAutoPlay={false}
autoCloseTimeout={3000}
onBackButton={onClickBack}
spotlightDisabled={panelInfo.modal}
isYoutube={isYoutube}
src={currentPlayingUrl}
style={panelInfo.modal ? modalStyle : {}}
modalScale={panelInfo.modal ? modalScale : 1}
modalClassName={panelInfo.modal && panelInfo.modalClassName}
onError={mediainfoHandler}
onTimeUpdate={mediainfoHandler}
onLoadedData={mediainfoHandler}
onLoadedMetadata={mediainfoHandler}
onDurationChange={mediainfoHandler}
reactPlayerConfig={reactPlayerSubtitleConfig}
thumbnailUrl={videoLoaded ? '' : videoThumbnailUrl}
videoComponent={
(typeof window === 'object' && !window.PalmSystem) || isYoutube ? TReactPlayer : Media
}
// VideoOverlay props - 단순화
type="MEDIA"
panelInfo={panelInfo}
captionEnable={false}
setIsSubtitleActive={setIsSubtitleActive}
setCurrentTime={setCurrentTime}
>
{typeof window === 'object' && window.PalmSystem && (
<source src={currentPlayingUrl} type={videoType} />
)}
{isSubtitleActive &&
!panelInfo.modal &&
typeof window === 'object' &&
window.PalmSystem &&
currentSubtitleUrl && <track kind="subtitles" src={currentSubtitleUrl} default />}
</VideoPlayer>
)}
</Container>
</TPanel>
);
};
export default React.memo(MediaPanel);