diff --git a/com.twin.app.shoptime/src/actions/mediaActions.js b/com.twin.app.shoptime/src/actions/mediaActions.js index abe44e77..9b4e4e7b 100644 --- a/com.twin.app.shoptime/src/actions/mediaActions.js +++ b/com.twin.app.shoptime/src/actions/mediaActions.js @@ -238,10 +238,10 @@ export const switchMediaToModal = (modalContainerId, modalClassName) => (dispatc export const minimizeModalMedia = () => (dispatch, getState) => { const panels = getState().panels.panels; - console.log('[minimizeModalMedia] ========== Called =========='); - console.log('[minimizeModalMedia] Total panels:', panels.length); + console.log('[Minimize] ========== Called =========='); + console.log('[Minimize] Total panels:', panels.length); console.log( - '[minimizeModalMedia] All panels:', + '[Minimize] All panels:', JSON.stringify( panels.map((p) => ({ name: p.name, modal: p.panelInfo?.modal })), null, @@ -253,15 +253,13 @@ export const minimizeModalMedia = () => (dispatch, getState) => { (panel) => panel.name === panel_names.MEDIA_PANEL && panel.panelInfo?.modal ); - console.log('[minimizeModalMedia] Found modalMediaPanel:', !!modalMediaPanel); + console.log('[Minimize] Found modalMediaPanel:', !!modalMediaPanel); if (modalMediaPanel) { console.log( - '[minimizeModalMedia] modalMediaPanel.panelInfo:', + '[Minimize] modalMediaPanel.panelInfo:', JSON.stringify(modalMediaPanel.panelInfo, null, 2) ); - console.log( - '[minimizeModalMedia] ✅ Minimizing modal MediaPanel (modal=false, isMinimized=true)' - ); + console.log('[Minimize] ✅ Minimizing modal MediaPanel (modal=false, isMinimized=true)'); dispatch( updatePanel({ name: panel_names.MEDIA_PANEL, @@ -269,13 +267,14 @@ export const minimizeModalMedia = () => (dispatch, getState) => { ...modalMediaPanel.panelInfo, modal: false, // fullscreen 모드로 전환 isMinimized: true, // modal-minimized 클래스 적용 (1px 크기) + shouldShrinkTo1px: true, // shrink 플래그 추가 // modalContainerId, modalClassName 등은 복원을 위해 유지 // isPaused는 변경하지 않음 - 재생은 계속됨 }, }) ); } else { - console.log('[minimizeModalMedia] ❌ No modal MediaPanel found - cannot minimize'); + console.log('[Minimize] ❌ No modal MediaPanel found - cannot minimize'); } }; @@ -286,10 +285,10 @@ export const minimizeModalMedia = () => (dispatch, getState) => { export const restoreModalMedia = () => (dispatch, getState) => { const panels = getState().panels.panels; - console.log('[restoreModalMedia] ========== Called =========='); - console.log('[restoreModalMedia] Total panels:', panels.length); + console.log('[Restore]] ========== Called =========='); + console.log('[Restore] Total panels:', panels.length); console.log( - '[restoreModalMedia] All panels:', + '[Restore] All panels:', JSON.stringify( panels.map((p) => ({ name: p.name, @@ -325,6 +324,7 @@ export const restoreModalMedia = () => (dispatch, getState) => { ...minimizedMediaPanel.panelInfo, modal: true, // modal 모드로 복원 (원래 위치로 복귀) isMinimized: false, // 최소화 해제 + shouldShrinkTo1px: false, // shrink 플래그 초기화 }, }) ); diff --git a/com.twin.app.shoptime/src/components/VideoPlayer/VideoPlayer.v3.js b/com.twin.app.shoptime/src/components/VideoPlayer/VideoPlayer.v3.js new file mode 100644 index 00000000..c52529d3 --- /dev/null +++ b/com.twin.app.shoptime/src/components/VideoPlayer/VideoPlayer.v3.js @@ -0,0 +1,2674 @@ +/** + * Provides Sandstone-themed video player components. + * + * @module sandstone/VideoPlayer + * @exports Video + * @exports VideoPlayer + * @exports VideoPlayerBase + */ + +import React from 'react'; +import ReactDOM from 'react-dom'; + +import classNames from 'classnames'; +import DurationFmt from 'ilib/lib/DurationFmt'; +import PropTypes from 'prop-types'; +import shallowEqual from 'recompose/shallowEqual'; + +import { off, on } from '@enact/core/dispatcher'; +import { + adaptEvent, + call, + forKey, + forward, + forwardWithPrevent, + handle, + preventDefault, + returnsTrue, + stopImmediate, +} from '@enact/core/handle'; +import ApiDecorator from '@enact/core/internal/ApiDecorator'; +import EnactPropTypes from '@enact/core/internal/prop-types'; +import { is } from '@enact/core/keymap'; +import { platform } from '@enact/core/platform'; +import { Job, memoize, perfNow } from '@enact/core/util'; +import { I18nContextDecorator } from '@enact/i18n/I18nDecorator'; +import { toUpperCase } from '@enact/i18n/util'; +import Skinnable from '@enact/sandstone/Skinnable'; +import { getDirection, Spotlight } from '@enact/spotlight'; +import { SpotlightContainerDecorator } from '@enact/spotlight/SpotlightContainerDecorator'; +import { Spottable } from '@enact/spotlight/Spottable'; +import Announce from '@enact/ui/AnnounceDecorator/Announce'; +import ComponentOverride from '@enact/ui/ComponentOverride'; +import { FloatingLayerDecorator } from '@enact/ui/FloatingLayer'; +import { FloatingLayerContext } from '@enact/ui/FloatingLayer/FloatingLayerDecorator'; +import Marquee from '@enact/ui/Marquee'; +import Slottable from '@enact/ui/Slottable'; +import Touchable from '@enact/ui/Touchable'; + +import { panel_names } from '../../utils/Config'; +import { $L } from '../../utils/helperMethods'; +import { SpotlightIds } from '../../utils/SpotlightIds'; +import ThemeIndicator from '../../views/DetailPanel/components/indicator/ThemeIndicator'; +import ThemeIndicatorArrow from '../../views/DetailPanel/components/indicator/ThemeIndicatorArrow'; +import PlayerOverlayContents from '../../views/PlayerPanel/PlayerOverlay/PlayerOverlayContents'; +import PlayerOverlayQRCode from '../../views/PlayerPanel/PlayerOverlay/PlayerOverlayQRCode'; +import Loader from '../Loader/Loader'; +import { MediaControls, MediaSlider, secondsToTime, Times } from '../MediaPlayer'; +import VideoOverlayWithPhoneNumber from '../VideoOverlayWithPhoneNumber/VideoOverlayWithPhoneNumber'; +import FeedbackContent from './FeedbackContent'; +import FeedbackTooltip from './FeedbackTooltip'; +import Media from './Media'; +import MediaTitle from './MediaTitle'; +import Overlay from './Overlay'; +import TReactPlayer from './TReactPlayer'; +import Video from './Video'; +import css from './VideoPlayer.v3.module.less'; +import { updateVideoPlayState } from '../../actions/playActions'; + +const isEnter = is('enter'); +const isLeft = is('left'); +const isRight = is('right'); + +const jumpBackKeyCode = 37; +const jumpForwardKeyCode = 39; +const controlsHandleAboveSelectionKeys = [13, 16777221, jumpBackKeyCode, jumpForwardKeyCode]; +const getControlsHandleAboveHoldConfig = ({ frequency, time }) => ({ + events: [{ name: 'hold', time }], + frequency, +}); +const shouldJump = ({ disabled, no5WayJump }, { mediaControlsVisible, sourceUnavailable }) => + !no5WayJump && !mediaControlsVisible && !(disabled || sourceUnavailable); +const calcNumberValueOfPlaybackRate = (rate) => { + const pbArray = String(rate).split('/'); + return pbArray.length > 1 ? parseInt(pbArray[0]) / parseInt(pbArray[1]) : parseInt(rate); +}; +const SpottableBtn = Spottable('button'); +const SpottableDiv = Touchable(Spottable('div')); +const RootContainer = SpotlightContainerDecorator( + { + enterTo: 'default-element', + defaultElement: [`.${css.controlsHandleAbove}`, `.${css.controlsFrame}`], + }, + 'div' +); + +const ControlsContainer = SpotlightContainerDecorator( + { + enterTo: '', + straightOnly: true, + }, + 'div' +); + +const memoGetDurFmt = memoize( + (/* locale */) => + new DurationFmt({ + length: 'medium', + style: 'clock', + useNative: false, + }) +); + +const getDurFmt = (locale) => { + if (typeof window === 'undefined') return null; + + return memoGetDurFmt(locale); +}; + +const forwardWithState = (type) => adaptEvent(call('addStateToEvent'), forwardWithPrevent(type)); + +const forwardToggleMore = forward('onToggleMore'); + +// provide forwarding of events on media controls +const forwardControlsAvailable = forward('onControlsAvailable'); +const forwardPlay = forwardWithState('onPlay'); +const forwardPause = forwardWithState('onPause'); +const forwardRewind = forwardWithState('onRewind'); +const forwardFastForward = forwardWithState('onFastForward'); +const forwardJumpBackward = forwardWithState('onJumpBackward'); +const forwardJumpForward = forwardWithState('onJumpForward'); + +const AnnounceState = { + // Video is loaded but additional announcements have not been made + READY: 0, + + // The title should be announced + TITLE: 1, + + // The title has been announce + TITLE_READ: 2, + + // The infoComponents should be announce + INFO: 3, + + // All announcements have been made + DONE: 4, +}; + +/** + * Every callback sent by [VideoPlayer]{@link sandstone/VideoPlayer} receives a status package, + * which includes an object with the following key/value pairs as the first argument: + * + * @typedef {Object} videoStatus + * @memberof sandstone/VideoPlayer + * @property {String} type - Type of event that triggered this callback + * @property {Number} currentTime - Playback index of the media in seconds + * @property {Number} duration - Media's entire duration in seconds + * @property {Boolean} paused - Playing vs paused state. `true` means the media is paused + * @property {Number} playbackRate - Current playback rate, as a number + * @property {Number} proportionLoaded - A value between `0` and `1` representing the proportion of the media that has loaded + * @property {Number} proportionPlayed - A value between `0` and `1` representing the proportion of the media that has already been shown + * + * @public + */ + +/** + * A set of playback rates when media fast forwards, rewinds, slow-forwards, or slow-rewinds. + * + * The number used for each operation is proportional to the normal playing speed, 1. If the rate + * is less than 1, it will play slower than normal speed, and, if it is larger than 1, it will play + * faster. If it is negative, it will play backward. + * + * The order of numbers represents the incremental order of rates that will be used for each + * operation. Note that all rates are expressed as strings and fractions are used rather than decimals + * (e.g.: `'1/2'`, not `'0.5'`). + * + * @typedef {Object} playbackRateHash + * @memberof sandstone/VideoPlayer + * @property {String[]} fastForward - An array of playback rates when media fast forwards + * @property {String[]} rewind - An array of playback rates when media rewinds + * @property {String[]} slowForward - An array of playback rates when media slow-forwards + * @property {String[]} slowRewind - An array of playback rates when media slow-rewinds + * + * @public + */ + +/** + * A player for video {@link sandstone/VideoPlayer.VideoPlayerBase}. + * + * @class VideoPlayerBase + * @memberof sandstone/VideoPlayer + * @ui + * @public + */ +const VideoPlayerBase = class extends React.Component { + static displayName = 'VideoPlayerBase'; + + static propTypes = /** @lends sandstone/VideoPlayer.VideoPlayerBase.prototype */ { + /** + * passed by AnnounceDecorator for accessibility + * + * @type {Function} + * @private + */ + announce: PropTypes.func, + + /** + * The time (in milliseconds) before the control buttons will hide. + * + * Setting this to 0 or `null` disables closing, requiring user input to open and close. + * + * @type {Number} + * @default 5000 + * @public + */ + autoCloseTimeout: PropTypes.number, + + /** + * Removes interactive capability from this component. This includes, but is not limited to, + * key-press events, most clickable buttons, and prevents the showing of the controls. + * + * @type {Boolean} + * @public + */ + disabled: PropTypes.bool, + + /** + * Amount of time (in milliseconds) after which the feedback text/icon part of the slider's + * tooltip will automatically hidden after the last action. + * Setting this to 0 or `null` disables feedbackHideDelay; feedback will always be present. + * + * @type {Number} + * @default 3000 + * @public + */ + feedbackHideDelay: PropTypes.number, + + /** + * Components placed below the title. + * + * Typically these will be media descriptor icons, like how many audio channels, what codec + * the video uses, but can also be a description for the video or anything else that seems + * appropriate to provide information about the video to the user. + * + * @type {Node} + * @public + */ + infoComponents: PropTypes.node, + backButton: PropTypes.node, + promotionTitle: PropTypes.string, + /** + * The number of milliseconds that the player will pause before firing the + * first jump event on a right or left pulse. + * + * @type {Number} + * @default 400 + * @public + */ + initialJumpDelay: PropTypes.number, + + /** + * The number of seconds the player should skip forward or backward when a "jump" button is + * pressed. + * + * @type {Number} + * @default 30 + * @public + */ + jumpBy: PropTypes.number, + + /** + * The number of milliseconds that the player will throttle before firing a + * jump event on a right or left pulse. + * + * @type {Number} + * @default 200 + * @public + */ + jumpDelay: PropTypes.number, + + /** + * Manually set the loading state of the media, in case you have information that + * `VideoPlayer` does not have. + * + * @type {Boolean} + * @public + */ + loading: PropTypes.bool, + + /** + * The current locale as a + * {@link https://tools.ietf.org/html/rfc5646|BCP 47 language tag}. + * + * @type {String} + * @public + */ + locale: PropTypes.string, + + /** + * Overrides the default media control component to support customized behaviors. + * + * The provided component will receive the following props from `VideoPlayer`: + * + * * `initialJumpDelay` - Time (in ms) to wait before starting a jump + * * `jumpDelay` - - Time (in ms) to wait between jumps + * * `mediaDisabled` - `true` when the media controls are not interactive + * * `no5WayJump` - `true` when 5-way jumping is disabled + * * `onClose` - Called when cancel key is pressed when the media controls are visible + * * `onFastForward` - Called when the media is fast forwarded via a key event + * * `onJump` - Called when the media jumps either forward or backward + * * `onJumpBackwardButtonClick` - Called when the jump backward button is pressed + * * `onJumpForwardButtonClick` - Called when the jump forward button is pressed + * * `onKeyDown` - Called when a key is pressed + * * `onPause` - Called when the media is paused via a key event + * * `onPlay` - Called when the media is played via a key event + * * `onRewind` - Called when the media is rewound via a key event + * * `onToggleMore` - Called when the more components are hidden or shown + * * `paused` - `true` when the media is paused + * * `spotlightId` - The spotlight container Id for the media controls + * * `spotlightDisabled` - `true` when spotlight is disabled for the media controls + * * `visible` - `true` when the media controls should be displayed + * + * @type {Component|Element} + * @default sandstone/MediaPlayer.MediaControls + * @public + */ + mediaControlsComponent: EnactPropTypes.componentOverride, + + /** + * Amount of time (in milliseconds), after the last user action, that the `miniFeedback` + * will automatically hide. + * Setting this to 0 or `null` disables `miniFeedbackHideDelay`; `miniFeedback` will always + * be present. + * + * @type {Number} + * @default 2000 + * @public + */ + miniFeedbackHideDelay: PropTypes.number, + + /** + * Disable audio for this video. + * + * In a TV context, this is handled by the remote control, not programmatically in the + * VideoPlayer API. + * + * @type {Boolean} + * @default false + * @public + */ + muted: PropTypes.bool, + + /** + * Prevents the default behavior of using left and right keys for seeking. + * + * @type {Boolean} + * @public + */ + no5WayJump: PropTypes.bool, + + /** + * Prevents the default behavior of playing a video immediately after it's loaded. + * + * @type {Boolean} + * @default false + * @public + */ + noAutoPlay: PropTypes.bool, + + /** + * Prevents the default behavior of showing media controls immediately after it's loaded. + * + * @type {Boolean} + * @default false + * @public + */ + noAutoShowMediaControls: PropTypes.bool, + + /** + * Hides media slider feedback when fast forward or rewind while media controls are hidden. + * + * @type {Boolean} + * @default false + * @public + */ + noMediaSliderFeedback: PropTypes.bool, + + /** + * Removes the mini feedback. + * + * @type {Boolean} + * @default false + * @public + */ + noMiniFeedback: PropTypes.bool, + + /** + * Removes the media slider. + * + * @type {Boolean} + * @default false + * @public + */ + noSlider: PropTypes.bool, + + /** + * Disables Spotlight focus on the media slider while keeping it visible. + * + * @type {Boolean} + * @default false + * @public + */ + disableSliderFocus: PropTypes.bool, + + /** + * Removes spinner while loading. + * + * @type {Boolean} + * @public + */ + noSpinner: PropTypes.bool, + + /** + * Called when the player's controls change availability, whether they are shown + * or hidden. + * + * The current status is sent as the first argument in an object with a key `available` + * which will be either `true` or `false`. (e.g.: `onControlsAvailable({available: true})`) + * + * @type {Function} + * @public + */ + onControlsAvailable: PropTypes.func, + + /** + * Called when the video is fast forwarded. + * + * @type {Function} + * @public + */ + onFastForward: PropTypes.func, + + /** + * Called when the user clicks the JumpBackward button. + * + * Is passed a {@link sandstone/VideoPlayer.videoStatus} as the first argument. + * + * @type {Function} + * @public + */ + onJumpBackward: PropTypes.func, + + /** + * Called when the user clicks the JumpForward button. + * + * Is passed a {@link sandstone/VideoPlayer.videoStatus} as the first argument. + * + * @type {Function} + * @public + */ + onJumpForward: PropTypes.func, + + /** + * Called when video is paused + * + * @type {Function} + * @public + */ + onPause: PropTypes.func, + + /** + * Called when video is played + * + * @type {Function} + * @public + */ + onPlay: PropTypes.func, + + /** + * Called when video is rewound. + * + * @type {Function} + * @public + */ + onRewind: PropTypes.func, + + /** + * Called when the user is moving the VideoPlayer's Slider knob independently of + * the current playback position. + * + * It is passed an object with a `seconds` key (float value) to indicate the current time + * index. It can be used to update the `thumbnailSrc` to the reflect the current scrub + * position. + * + * @type {Function} + * @public + */ + onScrub: PropTypes.func, + + /** + * Called when seek is attempted while `seekDisabled` is true. + * + * @type {Function} + */ + onSeekFailed: PropTypes.func, + + /** + * Called when seeking outside of the current `selection` range. + * + * By default, the seek will still be performed. Calling `preventDefault()` on the event + * will prevent the seek operation. + * + * @type {Function} + * @public + */ + onSeekOutsideSelection: PropTypes.func, + + /** + * Called when the visibility of more components is changed + * + * Event payload includes: + * + * * `type` - Type of event, `'onToggleMore'` + * * `showMoreComponents` - `true` when the components are visible` + * * `liftDistance` - The distance, in pixels, the component animates + *` + * @type {Function} + * @public + */ + onToggleMore: PropTypes.func, + + /** + * Pauses the video when it reaches either the start or the end of the video during rewind, + * slow rewind, fast forward, or slow forward. + * + * @type {Boolean} + * @default false + * @public + */ + pauseAtEnd: PropTypes.bool, + + /** + * Mapping of playback rate names to playback rate values that may be set. + * + * @type {sandstone/VideoPlayer.playbackRateHash} + * @default { + * fastForward: ['2', '4', '8', '16'], + * rewind: ['-2', '-4', '-8', '-16'], + * slowForward: ['1/4', '1/2'], + * slowRewind: ['-1/2', '-1'] + * } + * @public + */ + playbackRateHash: PropTypes.shape({ + fastForward: PropTypes.arrayOf(PropTypes.string), + rewind: PropTypes.arrayOf(PropTypes.string), + slowForward: PropTypes.arrayOf(PropTypes.string), + slowRewind: PropTypes.arrayOf(PropTypes.string), + }), + + /** + * Disables seek function. + * + * Note that jump by arrow keys will also be disabled when `true`. + * + * @type {Boolean} + * @public + */ + seekDisabled: PropTypes.bool, + + /** + * A range of the video to display as selected. + * + * The value of `selection` may either be: + * * `null` or `undefined` for no selection, + * * a single-element array with the start time of the selection + * * a two-element array containing both the start and end time of the selection in seconds + * + * When the start time is specified, the media slider will show filled starting at that + * time to the current time. + * + * When the end time is specified, the slider's background will be filled between the two + * times. + * + * @type {Number[]} + * @public + */ + selection: PropTypes.arrayOf(PropTypes.number), + + /** + * Registers the VideoPlayer component with an + * {@link core/internal/ApiDecorator.ApiDecorator}. + * + * @type {Function} + * @private + */ + setApiProvider: PropTypes.func, + + /** + * The video source. + * + * Any children `` tag elements of [VideoPlayer]{@link sandstone/VideoPlayer} will + * be sent directly to the `videoComponent` as video sources. + * + * @type {Node} + * @see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/source + * @public + */ + source: PropTypes.node, + track: PropTypes.node, + + /** + * Disables spotlight navigation into the component. + * + * @type {Boolean} + * @public + */ + spotlightDisabled: PropTypes.bool, + + /** + * The spotlight container ID for the player. + * + * @type {String} + * @public + * @default 'videoPlayer' + */ + spotlightId: PropTypes.string, + + /** + * The thumbnail component to be used instead of the built-in version. + * + * The internal thumbnail style will not be applied to this component. This component + * follows the same rules as the built-in version. + * + * @type {String|Component|Element} + * @public + */ + thumbnailComponent: EnactPropTypes.renderableOverride, + + /** + * Thumbnail image source to show on the slider knob. + * + * This is a standard {@link sandstone/Image} component so it supports all of the same + * options for the `src` property. If no `thumbnailComponent` and no `thumbnailSrc` is set, + * no tooltip will display. + * + * @type {String|Object} + * @public + */ + thumbnailSrc: PropTypes.oneOfType([PropTypes.string, PropTypes.object]), + + /** + * Enables the thumbnail transition from opaque to translucent. + * + * @type {Boolean} + * @public + */ + thumbnailUnavailable: PropTypes.bool, + + /** + * Title for the video being played. + * + * @type {String|Node} + * @public + */ + title: PropTypes.oneOfType([PropTypes.string, PropTypes.node]), + + /** + * The time (in milliseconds) before the title disappears from the controls. + * + * Setting this to `0` disables hiding. + * + * @type {Number} + * @default 5000 + * @public + */ + titleHideDelay: PropTypes.number, + + /** + * Video component to use. + * + * The default renders an `HTMLVideoElement`. Custom video components must have a similar + * API structure, exposing the following APIs: + * + * Properties: + * * `currentTime` {Number} - Playback index of the media in seconds + * * `duration` {Number} - Media's entire duration in seconds + * * `error` {Boolean} - `true` if video playback has errored. + * * `loading` {Boolean} - `true` if video playback is loading. + * * `paused` {Boolean} - Playing vs paused state. `true` means the media is paused + * * `playbackRate` {Number} - Current playback rate, as a number + * * `proportionLoaded` {Number} - A value between `0` and `1` + * representing the proportion of the media that has loaded + * * `proportionPlayed` {Number} - A value between `0` and `1` representing the + * proportion of the media that has already been shown + * + * Events: + * * `onLoadStart` - Called when the video starts to load + * * `onUpdate` - Sent when any of the properties were updated + * + * Methods: + * * `play()` - play video + * * `pause()` - pause video + * * `load()` - load video + * + * The [`source`]{@link sandstone/VideoPlayer.Video.source} property is passed to + * the video component as a child node. + * + * @type {Component|Element} + * @default {@link ui/Media.Media} + * @public + */ + videoComponent: EnactPropTypes.componentOverride, + + introTime: PropTypes.number, + onClickSkipIntro: PropTypes.func, + onIntroDisabled: PropTypes.func, + modalClassName: PropTypes.any, + src: PropTypes.string, //for ReactPlayer + reactPlayerConfig: PropTypes.any, //for ReactPlayer + qrCurrentItem: PropTypes.any, + modalScale: PropTypes.number, + setBelowContentsVisible: PropTypes.func, + belowContentsVisible: PropTypes.bool, + tabContainerVersion: PropTypes.number, + tabIndexV2: PropTypes.number, + dispatch: PropTypes.func, + }; + + static contextType = FloatingLayerContext; + + static defaultProps = { + autoCloseTimeout: 3000, + disableSliderFocus: false, + feedbackHideDelay: 3000, + initialJumpDelay: 400, + jumpBy: 30, + jumpDelay: 200, + mediaControlsComponent: MediaControls, + miniFeedbackHideDelay: 2000, + playbackRateHash: { + fastForward: ['2', '4', '8', '16'], + rewind: ['-2', '-4', '-8', '-16'], + slowForward: ['1/4', '1/2'], + slowRewind: ['-1/2', '-1'], + }, + spotlightId: 'videoPlayer', + titleHideDelay: 5000, + //ReactPlayer + videoComponent: typeof window === 'object' && !window.PalmSystem ? TReactPlayer : Media, + width: '100%', //for ReactPlayer + height: '100%', //for ReactPlayer + }; + + constructor(props) { + super(props); + + // Internal State + this.video = null; + this.currentLiveTimeSeconds = props.currentLiveTimeSeconds; + this.liveTotalTime = props.liveTotalTime; + this.type = props.type; + this.pulsedPlaybackRate = null; + this.pulsedPlaybackState = null; + this.prevCommand = props.noAutoPlay ? 'pause' : 'play'; + this.showMiniFeedback = false; + this.speedIndex = 0; + this.id = this.generateId(); + this.selectPlaybackRates('fastForward'); + this.sliderKnobProportion = 0; + this.mediaControlsSpotlightId = props.spotlightId + '_mediaControls'; + this.jumpButtonPressed = null; + + // Re-render-necessary State + this.state = { + currentLiveTimeSeconds: props.currentLiveTimeSeconds, + liveTotalTime: props.liveTotalTime, + announce: AnnounceState.READY, + currentTime: 0, + duration: 0, + error: false, + loading: true, + paused: props.noAutoPlay, + playbackRate: 1, + titleOffsetHeight: 0, + bottomOffsetHeight: 0, + lastFocusedTarget: '', + // Non-standard state computed from properties + bottomControlsRendered: false, + feedbackAction: 'idle', + feedbackVisible: false, + infoVisible: false, + mediaControlsVisible: false, + mediaSliderVisible: false, + miniFeedbackVisible: false, + proportionLoaded: 0, + proportionPlayed: 0, + sourceUnavailable: true, + titleVisible: true, + thumbnailUrl: null, + }; + + if (props.setApiProvider) { + props.setApiProvider(this); + } + } + + static getDerivedStateFromProps(nextProps, prevState) { + if ( + nextProps.currentLiveTimeSeconds !== prevState.currentLiveTimeSeconds || + nextProps.liveTotalTime !== prevState.liveTotalTime + ) { + return { + currentLiveTimeSeconds: nextProps.currentLiveTimeSeconds, + liveTotalTime: nextProps.liveTotalTime, + }; + } + return null; + } + + componentDidMount() { + on('mousemove', this.activityDetected); + if (platform.touch) { + on('touchmove', this.activityDetected); + } + if (typeof document !== 'undefined') { + document.addEventListener('keydown', this.handleGlobalKeyDown, { + capture: true, + }); + document.addEventListener('wheel', this.activityDetected, { + capture: true, + }); + } + this.startDelayedFeedbackHide(); + if (this.context && typeof this.context === 'function') { + this.floatingLayerController = this.context(() => {}); + } + } + + shouldComponentUpdate(nextProps, nextState) { + if ( + // Use shallow props compare instead of source comparison to support possible changes + // from mediaComponent. + shallowEqual(this.props, nextProps) && + !this.state.miniFeedbackVisible && + this.state.miniFeedbackVisible === nextState.miniFeedbackVisible && + !this.state.mediaSliderVisible && + this.state.mediaSliderVisible === nextState.mediaSliderVisible && + this.state.loading === nextState.loading && + this.props.loading === nextProps.loading && + (this.state.currentTime !== nextState.currentTime || + this.state.proportionPlayed !== nextState.proportionPlayed || + this.state.sliderTooltipTime !== nextState.sliderTooltipTime) + ) { + return false; + } + + return true; + } + + componentDidUpdate(prevProps, prevState) { + if (prevProps.currentLiveTimeSeconds !== this.props.currentLiveTimeSeconds) { + this.currentLiveTimeSeconds = this.props.currentLiveTimeSeconds; + } + if (prevProps.liveTotalTime !== this.props.liveTotalTime) { + this.liveTotalTime = this.props.liveTotalTime; + } + if (prevProps.panelInfo.modal !== this.props.panelInfo.modal) { + if (!this.props.panelInfo.modal) { + this.showControls(); + } + } + + // TabContainerV2와 mediaControls 동기화 + if ( + this.props.tabContainerVersion === 2 && + this.props.belowContentsVisible !== undefined && + prevProps.belowContentsVisible !== this.props.belowContentsVisible + ) { + if (this.props.belowContentsVisible && !this.state.mediaControlsVisible) { + // TabContainerV2가 표시될 때 controls도 표시 + this.showControls(); + } else if (!this.props.belowContentsVisible && this.state.mediaControlsVisible) { + // TabContainerV2가 숨겨질 때 controls도 숨김 + this.hideControls(); + } + } + + if ( + (!this.state.mediaControlsVisible && + prevState.mediaControlsVisible !== this.state.mediaControlsVisible) || + (!this.state.mediaSliderVisible && + prevState.mediaSliderVisible !== this.state.mediaSliderVisible) + ) { + this.floatingLayerController.notify({ action: 'closeAll' }); + } + if (this.props.spotlightId !== prevProps.spotlightId) { + this.mediaControlsSpotlightId = this.props.spotlightId + '_mediaControls'; + } + if (this.state.mediaSliderVisible && !prevState.mediaSliderVisible) { + forwardControlsAvailable({ available: true }, this.props); + } else if (!this.state.mediaSliderVisible && prevState.mediaSliderVisible) { + forwardControlsAvailable({ available: false }, this.props); + } + if (!this.state.mediaControlsVisible && prevState.mediaControlsVisible) { + forwardControlsAvailable({ available: false }, this.props); + this.stopAutoCloseTimeout(); + + if (!this.props.spotlightDisabled) { + // If last focused item were in the media controls or slider, we need to explicitly + // blur the element when MediaControls hide. See ENYO-5648 + const current = Spotlight.getCurrent(); + const bottomControls = document && document.querySelector(`.${css.bottom}`); + if (current && bottomControls && bottomControls.contains(current)) { + current.blur(); + } + + // when in pointer mode, the focus call below will only update the last focused for + // the video player and not set the active container to the video player which will + // cause focus to land back on the media controls button when spotlight restores + // focus. + if (Spotlight.getPointerMode()) { + Spotlight.setActiveContainer(this.props.spotlightId); + } + + // Set focus to the hidden spottable control - maintaining focus on available spottable + // controls, which prevents an additional 5-way attempt in order to re-show media controls + Spotlight.focus(`.${css.controlsHandleAbove}`); + } + } else if (this.state.mediaControlsVisible && !prevState.mediaControlsVisible) { + forwardControlsAvailable({ available: true }, this.props); + this.startAutoCloseTimeout(); + + if (!this.props.spotlightDisabled) { + const current = Spotlight.getCurrent(); + if (!current || this.player.contains(current)) { + // Set focus within media controls when they become visible. + if (Spotlight.focus(SpotlightIds.PLAYER_SKIPINTRO) && this.jumpButtonPressed === 0) { + this.jumpButtonPressed = null; + } else if ( + !Spotlight.focus(SpotlightIds.PLAYER_SKIPINTRO) && + Spotlight.focus(this.mediaControlsSpotlightId) && + this.jumpButtonPressed === 0 + ) { + this.jumpButtonPressed = null; + } + } + } + } + if ( + !this.props.panelInfo.modal && + prevProps.sideContentsVisible !== this.props.sideContentsVisible + ) { + if (this.props.sideContentsVisible) { + forwardControlsAvailable({ available: false }, this.props); + this.stopAutoCloseTimeout(); + } else { + forwardControlsAvailable({ available: true }, this.props); + this.startAutoCloseTimeout(); + } + } + // Once video starts loading it queues bottom control render until idle + if ( + this.state.bottomControlsRendered && + !prevState.bottomControlsRendered && + !this.state.mediaControlsVisible + ) { + this.showControls(); + } + if ( + (prevProps.isSubtitleActive !== this.props.isSubtitleActive || + prevProps.captionEnable !== this.props.captionEnable) && + this.props.isYoutube && + this.video && + this.props.captionEnable + ) { + if (this.video.getOptions('captions').length > 0) { + if (this.props.isSubtitleActive) { + this.video?.loadModule('captions'); + this.video?.setOption('captions', 'track', { languageCode: 'en' }); + } else { + this.video?.unloadModule('captions'); + } + } + } + if (prevProps.thumbnailUrl !== this.props.thumbnailUrl) { + this.setState({ thumbnailUrl: this.props.thumbnailUrl }); + } + if (prevProps.src !== this.props.src && this.props.src) { + this.setState({ loading: true }); + } + //for old browser + const progressbarNode = document.querySelector(`[role="progressbar"]`); + if (progressbarNode) { + const pBGNode = progressbarNode.childNodes?.[0]?.childNodes?.[0]; + const pProgressNode = progressbarNode.childNodes?.[0]?.childNodes?.[1]; + if (pBGNode && pProgressNode) { + pBGNode.style.width = this.state.proportionLoaded * 100 + '%'; + pProgressNode.style.width = this.state.proportionPlayed * 100 + '%'; + } + } + } + + componentWillUnmount() { + off('mousemove', this.activityDetected); + if (platform.touch) { + off('touchmove', this.activityDetected); + } + if (typeof document !== 'undefined') { + document.removeEventListener('keydown', this.handleGlobalKeyDown, { + capture: true, + }); + document.removeEventListener('wheel', this.activityDetected, { + capture: true, + }); + } + this.stopRewindJob(); + this.stopAutoCloseTimeout(); + this.stopDelayedTitleHide(); + this.stopDelayedFeedbackHide(); + this.stopDelayedMiniFeedbackHide(); + this.announceJob.stop(); + this.renderBottomControl.stop(); + this.slider5WayPressJob.stop(); + if (this.floatingLayerController) { + this.floatingLayerController.unregister(); + } + } + + // + // Internal Methods + // + + announceJob = new Job( + (msg, clear) => this.announceRef && this.announceRef.announce(msg, clear), + 200 + ); + + announce = (msg, clear) => { + this.announceJob.start(msg, clear); + }; + + activityDetected = () => { + this.startAutoCloseTimeout(); + }; + + startAutoCloseTimeout = () => { + // If this.state.more is used as a reference for when this function should fire, timing for + // detection of when "more" is pressed vs when the state is updated is mismatched. Using an + // instance variable that's only set and used for this express purpose seems cleanest. + + // TabContainerV2가 표시 중이면 자동으로 닫지 않음 + if (this.props.tabContainerVersion === 2 && this.props.belowContentsVisible) { + return; + } + + if (this.props.autoCloseTimeout && !this.props.sideContentsVisible) { + this.autoCloseJob.startAfter(this.props.autoCloseTimeout); + } + }; + + stopAutoCloseTimeout = () => { + this.autoCloseJob.stop(); + }; + + generateId = () => { + return Math.random().toString(36).substr(2, 8); + }; + + isTimeBeyondSelection(time) { + const { selection } = this.props; + + // if selection isn't set or only contains the starting value, there isn't a valid selection + // with which to test the time + if (selection != null && selection.length >= 2) { + const [start, end] = selection; + + return time > end || time < start; + } + + return false; + } + + preventTimeChange(time) { + return ( + this.isTimeBeyondSelection(time) && + !forwardWithPrevent( + 'onSeekOutsideSelection', + { type: 'onSeekOutsideSelection', time }, + this.props + ) + ); + } + + /** + * If the announce state is either ready to read the title or ready to read info, advance the + * state to "read". + * + * @returns {Boolean} Returns true to be used in event handlers + * @private + */ + markAnnounceRead = () => { + if (this.state.announce === AnnounceState.TITLE) { + this.setState({ announce: AnnounceState.TITLE_READ }); + } else if (this.state.announce === AnnounceState.INFO) { + this.setState({ announce: AnnounceState.DONE }); + } + + return true; + }; + + /** + * Shows media controls. + * + * @function + * @memberof sandstone/VideoPlayer.VideoPlayerBase.prototype + * @public + */ + showControls = () => { + if (this.props.disabled) { + return; + } + + // this.startDelayedFeedbackHide(); + // this.startDelayedTitleHide(); + + this.setState(({ announce }) => { + if (announce === AnnounceState.READY) { + // if we haven't read the title yet, do so this time + announce = AnnounceState.TITLE; + } else if (announce === AnnounceState.TITLE) { + // if we have read the title, advance to INFO so title isn't read again + announce = AnnounceState.TITLE_READ; + } + + return { + announce, + bottomControlsRendered: true, + // feedbackAction: "idle", + feedbackVisible: true, + mediaControlsVisible: true, + mediaSliderVisible: true, + miniFeedbackVisible: false, + titleVisible: true, + }; + }); + }; + + onSpotlightFocus = () => { + this.showControls(); + + if (this.state.lastFocusedTarget) { + setTimeout(() => { + Spotlight.focus(this.state.lastFocusedTarget); + }); + } else { + setTimeout(() => { + Spotlight.focus(SpotlightIds.PLAYER_TAB_BUTTON); + }); + } + }; + + /** + * Hides media controls. + * + * @function + * @memberof sandstone/VideoPlayer.VideoPlayerBase.prototype + * @public + */ + hideControls = () => { + if (this.state.mediaControlsVisible) { + const current = Spotlight.getCurrent(); + + if (current) { + const lastFocusedTarget = current.getAttribute('data-spotlight-id'); + + if (lastFocusedTarget !== this.state.lastFocusedTarget) { + this.setState({ lastFocusedTarget: lastFocusedTarget }); + } + } + } + + this.stopDelayedFeedbackHide(); + this.stopDelayedMiniFeedbackHide(); + this.stopDelayedTitleHide(); + this.stopAutoCloseTimeout(); + this.setState({ + feedbackAction: 'idle', + feedbackVisible: false, + mediaControlsVisible: false, + mediaSliderVisible: false, + miniFeedbackVisible: false, + infoVisible: false, + }); + this.markAnnounceRead(); + }; + + /** + * Toggles the media controls. + * + * @function + * @memberof sandstone/VideoPlayer.VideoPlayerBase.prototype + * @public + */ + toggleControls = () => { + if (this.state.mediaControlsVisible) { + this.hideControls(); + } else { + this.showControls(); + } + }; + + doAutoClose = () => { + this.stopDelayedFeedbackHide(); + this.stopDelayedTitleHide(); + this.setState(({ mediaSliderVisible, miniFeedbackVisible }) => ({ + feedbackVisible: false, + mediaControlsVisible: false, + mediaSliderVisible: mediaSliderVisible && miniFeedbackVisible, + infoVisible: false, + })); + this.markAnnounceRead(); + }; + + autoCloseJob = new Job(this.doAutoClose); + + startDelayedTitleHide = () => { + if (this.props.titleHideDelay) { + this.hideTitleJob.startAfter(this.props.titleHideDelay); + } + }; + + stopDelayedTitleHide = () => { + this.hideTitleJob.stop(); + }; + + hideTitle = () => { + this.setState({ titleVisible: false }); + }; + + hideTitleJob = new Job(this.hideTitle); + + startDelayedFeedbackHide = () => { + if (this.props.feedbackHideDelay) { + this.hideFeedbackJob.startAfter(this.props.feedbackHideDelay); + } + }; + + stopDelayedFeedbackHide = () => { + this.hideFeedbackJob.stop(); + }; + + showFeedback = () => { + if (this.state.mediaControlsVisible) { + this.setState({ + feedbackVisible: true, + }); + } else { + const shouldShowSlider = + this.pulsedPlaybackState !== null || calcNumberValueOfPlaybackRate(this.playbackRate) !== 1; + + if ( + this.showMiniFeedback && + (!this.state.miniFeedbackVisible || this.state.mediaSliderVisible !== shouldShowSlider) + ) { + this.setState(({ loading, duration, error }) => ({ + mediaSliderVisible: shouldShowSlider && !this.props.noMediaSliderFeedback, + miniFeedbackVisible: !(loading || !duration || error), + })); + } + } + }; + + hideFeedback = () => { + if (this.state.feedbackVisible && this.state.feedbackAction !== 'focus') { + this.setState({ + feedbackVisible: false, + feedbackAction: 'idle', + }); + } + }; + + hideFeedbackJob = new Job(this.hideFeedback); + + startDelayedMiniFeedbackHide = (delay = this.props.miniFeedbackHideDelay) => { + if (delay) { + this.hideMiniFeedbackJob.startAfter(delay); + } + }; + + stopDelayedMiniFeedbackHide = () => { + this.hideMiniFeedbackJob.stop(); + }; + + hideMiniFeedback = () => { + if (this.state.miniFeedbackVisible) { + this.showMiniFeedback = false; + this.setState({ + mediaSliderVisible: false, + miniFeedbackVisible: false, + }); + } + }; + + hideMiniFeedbackJob = new Job(this.hideMiniFeedback); + + handle = handle.bind(this); + + showControlsFromPointer = () => { + Spotlight.setPointerMode(false); + this.showControls(); + }; + + clearPulsedPlayback = () => { + this.pulsedPlaybackRate = null; + this.pulsedPlaybackState = null; + }; + + // only show mini feedback if playback controls are invoked by a key event + shouldShowMiniFeedback = (ev) => { + if (ev.type === 'keyup') { + this.showMiniFeedback = true; + } + return true; + }; + + handleLoadStart = () => { + this.firstPlayReadFlag = true; + this.prevCommand = this.props.noAutoPlay ? 'pause' : 'play'; + this.speedIndex = 0; + this.setState({ + announce: AnnounceState.READY, + currentTime: 0, + sourceUnavailable: true, + proportionPlayed: 0, + proportionLoaded: 0, + }); + + if (!this.props.noAutoShowMediaControls) { + if (!this.state.bottomControlsRendered) { + this.renderBottomControl.idle(); + } else { + this.showControls(); + } + } + }; + + handlePlay = this.handle(forwardPlay, this.shouldShowMiniFeedback, () => this.play()); + + handlePause = this.handle(forwardPause, this.shouldShowMiniFeedback, () => this.pause()); + + handleRewind = this.handle(forwardRewind, this.shouldShowMiniFeedback, () => this.rewind()); + + handleFastForward = this.handle(forwardFastForward, this.shouldShowMiniFeedback, () => + this.fastForward() + ); + + handleJump = ({ keyCode, withControl }) => { + if (this.props.seekDisabled) { + forward('onSeekFailed', {}, this.props); + } else { + const jumpBy = (is('left', keyCode) ? -1 : 1) * this.props.jumpBy; + const time = Math.min(this.state.duration, Math.max(0, this.state.currentTime + jumpBy)); + + if (this.preventTimeChange(time)) return; + + if (withControl) { + this.jump(jumpBy); + } else { + this.showMiniFeedback = true; + this.jump(jumpBy); + this.announceJob.startAfter( + 500, + secondsToTime(this.video.currentTime, getDurFmt(this.props.locale), { + includeHour: true, + }) + ); + } + } + }; + + // new function + // onClickLeftJump = (ev) => { + // ev.stopPropagation(); + // this.handleJump({ keyCode: 37, withControl: true }); + // }; + + // // new function + // onClickRightJump = (ev) => { + // ev.stopPropagation(); + // this.handleJump({ keyCode: 39, withControl: true }); + // }; + + // // new function + // SpotToRight = (ev) => { + // Spotlight.focus("jumpright"); + // ev.stopPropagation(); + // }; + + // // new function + // SpotToLeft = (ev) => { + // Spotlight.focus("jumpleft"); + // ev.stopPropagation(); + // }; + // new function + // SpotToTitle = (ev) => { + // Spotlight.focus(SpotlightIds.PLAYER_TITLE_LAYER); + // ev.stopPropagation(); + // }; + // SpotToTitle2 = (ev) => { + // Spotlight.focus(SpotlightIds.PLAYER_TITLE_LAYER); + // Spotlight.focus(SpotlightIds.PLAYER_SKIPINTRO); + // ev.stopPropagation(); + // }; + + handleGlobalKeyDown = this.handle( + returnsTrue(this.activityDetected), + forKey('down'), + () => + !this.state.mediaControlsVisible && + !Spotlight.getCurrent() && + Spotlight.getPointerMode() && + !this.props.spotlightDisabled, + preventDefault, + stopImmediate, + this.showControlsFromPointer + ); + + handleControlsHandleAboveHoldPulse = () => { + if (shouldJump(this.props, this.state)) { + this.handleJump({ + keyCode: this.jumpButtonPressed === -1 ? jumpBackKeyCode : jumpForwardKeyCode, + }); + } + }; + + handleControlsHandleAboveKeyDown = ({ keyCode }) => { + if (isEnter(keyCode)) { + this.jumpButtonPressed = 0; + } else if (isLeft(keyCode)) { + this.jumpButtonPressed = -1; + } else if (isRight(keyCode)) { + this.jumpButtonPressed = 1; + } + }; + + handleControlsHandleAboveKeyUp = ({ keyCode }) => { + if (isEnter(keyCode) || isLeft(keyCode) || isRight(keyCode)) { + this.jumpButtonPressed = null; + } + }; + + handleControlsHandleAboveDown = () => { + if (this.jumpButtonPressed === 0) { + this.showControls(); + } else if (this.jumpButtonPressed === -1 || this.jumpButtonPressed === 1) { + const keyCode = this.jumpButtonPressed === -1 ? jumpBackKeyCode : jumpForwardKeyCode; + + if (shouldJump(this.props, this.state)) { + this.handleJump({ keyCode }); + } else { + Spotlight.move(getDirection(keyCode)); + } + } + }; + + // + // Media Interaction Methods + // + handleEvent = (ev) => { + const el = this.video; + + const updatedState = { + // Standard media properties + currentTime: 0, + duration: 0, + paused: false, + playbackRate: 0, + // lastFocusedTarget: "", + // Non-standard state computed from properties + error: false, + loading: true, + // 지나온 바 + proportionLoaded: false, + //벨류( 노브 ) + proportionPlayed: 0, + sliderTooltipTime: 0, + // note: `el.loading && this.state.sourceUnavailable == false` is equivalent to `oncanplaythrough` + sourceUnavailable: false, + }; + if (!el) { + console.log('yhcho VideoPlayer no el '); + updatedState.error = true; + this.setState(updatedState); + return; + } + + this.type = this.props.type; + + updatedState.currentTime = + this.type === 'LIVE' ? this.state.currentLiveTimeSeconds : el.currentTime; + + updatedState.duration = this.type === 'LIVE' ? this.state.liveTotalTime : el.duration; + + //last time error : for youtube + if (this.props.isYoutube && ev.type === 'onProgress') { + if (updatedState.duration - updatedState.currentTime < 1.0) { + updatedState.currentTime = updatedState.duration; + } + this.props.setCurrentTime(updatedState.currentTime); + } + const proportionPlayed = updatedState.currentTime / updatedState.duration || 0; + updatedState.paused = el.playbackRate !== 1 || el.paused; + updatedState.playbackRate = el.playbackRate; + updatedState.error = el.error; + updatedState.loading = el.loading; + updatedState.proportionLoaded = el.proportionLoaded; + updatedState.proportionPlayed = proportionPlayed || el.proportionPlayed || 0; + updatedState.sliderTooltipTime = el.currentTime; + updatedState.sourceUnavailable = (el.loading && this.state.sourceUnavailable) || el.error; + + // If there's an error, we're obviously not loading, no matter what the readyState is. + + if (updatedState.error) updatedState.loading = false; + + const isRewind = this.prevCommand === 'rewind' || this.prevCommand === 'slowRewind'; + const isForward = this.prevCommand === 'fastForward' || this.prevCommand === 'slowForward'; + if ( + this.props.pauseAtEnd && + ((el.currentTime === 0 && isRewind) || (el.currentTime === el.duration && isForward)) + ) { + this.pause(); + } + if ( + this.props.isYoutube && + this.props.captionEnable && + this.props.isSubtitleActive && + ev?.type === 'onStart' && + el.getOptions('captions').length > 0 + ) { + el.loadModule('captions'); + el.setOption('captions', 'track', { languageCode: 'en' }); + } + if (updatedState.loading) { + updatedState.thumbnailUrl = this.props.thumbnailUrl; + } else { + updatedState.thumbnailUrl = null; + } + this.setState(updatedState); + + // Redux에 비디오 재생 상태 업데이트 + if (this.props.dispatch) { + this.props.dispatch( + updateVideoPlayState({ + isPlaying: !updatedState.paused, + isPaused: updatedState.paused, + currentTime: updatedState.currentTime, + duration: updatedState.duration, + playbackRate: updatedState.playbackRate, + }) + ); + } + }; + + renderBottomControl = new Job(() => { + if (!this.state.bottomControlsRendered) { + this.setState({ bottomControlsRendered: true }); + } + }); + + /** + * Returns an object with the current state of the media including `currentTime`, `duration`, + * `paused`, `playbackRate`, `proportionLoaded`, and `proportionPlayed`. + * + * @function + * @memberof sandstone/VideoPlayer.VideoPlayerBase.prototype + * @returns {Object} + * @public + */ + getMediaState = () => { + return { + currentTime: this.state.currentTime, + duration: this.state.duration, + paused: this.state.paused, + playbackRate: this.video?.playbackRate, + proportionLoaded: this.state.proportionLoaded, + proportionPlayed: this.state.proportionPlayed, + }; + }; + + /** + * The primary means of interacting with the `