feat: Add MediaSlider and Times to MediaPlayer.v2

MediaPlayer.v2에 필수 기능 추가 (Fullscreen 모드용)

주요 변경사항:
- MediaSlider 추가 (비디오 진행 바)
- Times 컴포넌트 추가 (현재/전체 시간 표시)
- proportionLoaded/Played 상태 관리
- DurationFmt 헬퍼 추가
- Slider 이벤트 핸들러 구현

상태 추가 (9개):
- proportionLoaded: 로딩된 비율
- proportionPlayed: 재생된 비율

Import 추가:
- MediaSlider, Times, secondsToTime from '../MediaPlayer'
- DurationFmt from 'ilib/lib/DurationFmt'
- memoize from '@enact/core/util'

UI 구조:
- controlsContainer
  - sliderContainer (Times + MediaSlider)
  - controlsButtons (Play/Pause + Back)

조건부 렌더링:
- Modal 모드 (modal=true): 오버레이 없음
- Fullscreen 모드 (modal=false): MediaSlider + Times 표시

CSS 업데이트:
- controlsContainer: gradient background
- sliderContainer: flex layout
- times: min-width 80px
- controlsButtons: centered layout
This commit is contained in:
Claude
2025-11-10 08:22:38 +00:00
parent 64d1e553ed
commit 726dcd9381
2 changed files with 137 additions and 35 deletions

View File

@@ -11,15 +11,18 @@
import React, { useState, useRef, useEffect, useCallback, useMemo, forwardRef, useImperativeHandle } from 'react'; import React, { useState, useRef, useEffect, useCallback, useMemo, forwardRef, useImperativeHandle } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import DurationFmt from 'ilib/lib/DurationFmt';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { platform } from '@enact/core/platform'; import { platform } from '@enact/core/platform';
import { memoize } from '@enact/core/util';
import Spotlight from '@enact/spotlight'; import Spotlight from '@enact/spotlight';
import { SpotlightContainerDecorator } from '@enact/spotlight/SpotlightContainerDecorator'; import { SpotlightContainerDecorator } from '@enact/spotlight/SpotlightContainerDecorator';
import { Spottable } from '@enact/spotlight/Spottable'; import { Spottable } from '@enact/spotlight/Spottable';
import Touchable from '@enact/ui/Touchable'; import Touchable from '@enact/ui/Touchable';
import Loader from '../Loader/Loader'; import Loader from '../Loader/Loader';
import { MediaSlider, Times, secondsToTime } from '../MediaPlayer';
import Overlay from './Overlay'; import Overlay from './Overlay';
import Media from './Media'; import Media from './Media';
import TReactPlayer from './TReactPlayer'; import TReactPlayer from './TReactPlayer';
@@ -35,6 +38,20 @@ const RootContainer = SpotlightContainerDecorator(
'div' 'div'
); );
// DurationFmt memoization
const memoGetDurFmt = memoize(
() => new DurationFmt({
length: 'medium',
style: 'clock',
useNative: false,
})
);
const getDurFmt = () => {
if (typeof window === 'undefined') return null;
return memoGetDurFmt();
};
/** /**
* MediaPlayer.v2 컴포넌트 * MediaPlayer.v2 컴포넌트
*/ */
@@ -96,6 +113,8 @@ const MediaPlayerV2 = forwardRef((props, ref) => {
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [controlsVisible, setControlsVisible] = useState(false); const [controlsVisible, setControlsVisible] = useState(false);
const [sourceUnavailable, setSourceUnavailable] = useState(true); const [sourceUnavailable, setSourceUnavailable] = useState(true);
const [proportionLoaded, setProportionLoaded] = useState(0);
const [proportionPlayed, setProportionPlayed] = useState(0);
// ========== Refs ========== // ========== Refs ==========
const videoRef = useRef(null); const videoRef = useRef(null);
@@ -147,14 +166,21 @@ const MediaPlayerV2 = forwardRef((props, ref) => {
const el = videoRef.current; const el = videoRef.current;
if (!el) return; if (!el) return;
const newCurrentTime = el.currentTime || 0;
const newDuration = el.duration || 0;
// 상태 업데이트 // 상태 업데이트
setCurrentTime(el.currentTime || 0); setCurrentTime(newCurrentTime);
setDuration(el.duration || 0); setDuration(newDuration);
setPaused(el.paused); setPaused(el.paused);
setLoading(el.loading || false); setLoading(el.loading || false);
setError(el.error || null); setError(el.error || null);
setSourceUnavailable((el.loading && sourceUnavailable) || el.error); setSourceUnavailable((el.loading && sourceUnavailable) || el.error);
// Proportion 계산
setProportionLoaded(el.proportionLoaded || 0);
setProportionPlayed(newDuration > 0 ? newCurrentTime / newDuration : 0);
// 콜백 호출 // 콜백 호출
if (ev.type === 'timeupdate' && onTimeUpdate) { if (ev.type === 'timeupdate' && onTimeUpdate) {
onTimeUpdate(ev); onTimeUpdate(ev);
@@ -247,8 +273,31 @@ const MediaPlayerV2 = forwardRef((props, ref) => {
error, error,
playbackRate: videoRef.current?.playbackRate || 1, playbackRate: videoRef.current?.playbackRate || 1,
proportionPlayed: duration > 0 ? currentTime / duration : 0, proportionPlayed: duration > 0 ? currentTime / duration : 0,
proportionLoaded,
}; };
}, [currentTime, duration, paused, loading, error]); }, [currentTime, duration, paused, loading, error, proportionLoaded]);
// ========== Slider Event Handlers ==========
const handleSliderChange = useCallback(({ value }) => {
const time = value * duration;
seek(time);
}, [duration, seek]);
const handleKnobMove = useCallback((ev) => {
if (!videoRef.current) return;
const seconds = Math.floor(ev.proportion * videoRef.current.duration);
if (!isNaN(seconds)) {
// Scrub 시 시간 표시 업데이트
// 필요시 onScrub 콜백 호출 가능
}
}, []);
const handleSliderKeyDown = useCallback((ev) => {
// Spotlight 키 이벤트 처리
// 위/아래 키로 controls 이동 등
// 기본 동작은 MediaSlider 내부에서 처리
}, []);
// ========== Video Click Handler (Modal 전환) ========== // ========== Video Click Handler (Modal 전환) ==========
const handleVideoClick = useCallback(() => { const handleVideoClick = useCallback(() => {
@@ -398,34 +447,69 @@ const MediaPlayerV2 = forwardRef((props, ref) => {
</> </>
)} )}
{/* Simple Controls */} {/* Controls with MediaSlider */}
{controlsVisible && !isModal && ( {controlsVisible && !isModal && (
<div className={css.simpleControls}> <div className={css.controlsContainer}>
<button {/* Slider Section */}
className={css.playPauseBtn} <div className={css.sliderContainer}>
onClick={(e) => { {/* Times - Total */}
e.stopPropagation(); <Times
if (paused) { className={css.times}
play(); noCurrentTime
} else { total={duration}
pause(); formatter={getDurFmt()}
} />
}}
>
{paused ? '▶' : '⏸'}
</button>
{onBackButton && ( {/* Times - Current */}
<Times
className={css.times}
noTotalTime
current={currentTime}
formatter={getDurFmt()}
/>
{/* MediaSlider */}
<MediaSlider
backgroundProgress={proportionLoaded}
disabled={disabled || sourceUnavailable}
value={proportionPlayed}
visible={controlsVisible}
spotlightDisabled={spotlightDisabled}
onChange={handleSliderChange}
onKnobMove={handleKnobMove}
onKeyDown={handleSliderKeyDown}
spotlightId={`${spotlightId}-slider`}
/>
</div>
{/* Buttons Section */}
<div className={css.controlsButtons}>
<button <button
className={css.backBtn} className={css.playPauseBtn}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
onBackButton(e); if (paused) {
play();
} else {
pause();
}
}} }}
> >
Back {paused ? '▶' : '⏸'}
</button> </button>
)}
{onBackButton && (
<button
className={css.backBtn}
onClick={(e) => {
e.stopPropagation();
onBackButton(e);
}}
>
Back
</button>
)}
</div>
</div> </div>
)} )}
</Overlay> </Overlay>

View File

@@ -757,25 +757,43 @@
}); });
} }
// ========== MediaPlayer.v2 Simple Controls ========== // ========== MediaPlayer.v2 Controls ==========
.simpleControls { .controlsContainer {
position: absolute; position: absolute;
bottom: 40px; bottom: 0;
left: 50%; left: 0;
transform: translateX(-50%); right: 0;
padding: 20px 40px 30px;
background: linear-gradient(to top, rgba(0, 0, 0, 0.9) 0%, rgba(0, 0, 0, 0.7) 60%, transparent 100%);
z-index: 10;
display: flex;
flex-direction: column;
gap: 16px;
}
.sliderContainer {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
}
.times {
min-width: 80px;
text-align: center;
}
.controlsButtons {
display: flex; display: flex;
gap: 20px; gap: 20px;
justify-content: center;
align-items: center; align-items: center;
z-index: 10;
padding: 20px;
background: rgba(0, 0, 0, 0.7);
border-radius: 12px;
} }
.playPauseBtn { .playPauseBtn {
width: 80px; width: 60px;
height: 80px; height: 60px;
font-size: 32px; font-size: 24px;
background: rgba(255, 255, 255, 0.2); background: rgba(255, 255, 255, 0.2);
border: 2px solid rgba(255, 255, 255, 0.6); border: 2px solid rgba(255, 255, 255, 0.6);
border-radius: 50%; border-radius: 50%;