/** * 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.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, }; }); }; /** * 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 `