UNPKG

zombiebox

Version:

ZombieBox is a JavaScript framework for development of Smart TV and STB applications

721 lines (621 loc) 15.9 kB
/* * This file is part of the ZombieBox package. * * Copyright © 2012-2021, Interfaced * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ import UnsupportedFeature from '../errors/unsupported-feature'; import {remove, div, node} from '../../html'; import AbstractStatefulVideo from '../abstract-stateful-video'; import {ResolutionInfoItem} from '../resolutions'; import {State, PrepareOption} from '../interfaces/i-stateful-video'; import {InvalidTransitionError, PendingTransitionError} from '../../state-machine'; import HTML5ViewPort from './HTML5-view-port'; const { IDLE, LOADING, READY, PLAYING, PAUSED, WAITING, SEEKING, ENDED, ERROR, INVALID, DESTROYED } = State; /** */ export default class StatefulHtml5Video extends AbstractStatefulVideo { /** * @param {ResolutionInfoItem} panelResolution * @param {ResolutionInfoItem} appResolution */ constructor(panelResolution, appResolution) { super(panelResolution, appResolution); /** * @type {ResolutionInfoItem} * @protected */ this._panelResolution = panelResolution; /** * @type {ResolutionInfoItem} * @protected */ this._appResolution = appResolution; /** * @type {HTML5ViewPort} * @protected */ this._viewport; /** * @type {?HTMLDivElement} * @protected */ this._container = null; /** * @type {?HTMLVideoElement} * @protected */ this._videoElement = null; /** * @type {?HTMLSourceElement} * @protected */ this._sourceElement = null; /** * @type {?State} * @protected */ this._stateBeforeSeeking = null; /** * @type {?number} * @protected */ this._requestedStartPosition = null; /** * @type {StartPositionState} * @protected */ this._startPositionState = StartPositionState.NONE; /** * @type {boolean} * @protected */ this._receivedCanplay = false; // When we encounter State Machine errors in interface methods it's user's responsibility // If state errors happen in these handlers it means Video implementation is erroneous this._onNativeEventGuarded = (event) => { try { this._onNativeEvent(event); } catch (error) { if (error instanceof InvalidTransitionError || error instanceof PendingTransitionError) { this._onError(error); } else { throw error; } } }; try { this._init(); } catch (e) { this._stateMachine.setState(INVALID); } } /** * @override */ prepare(url, options = {}) { this._stateMachine.startTransitionTo(LOADING); this._sourceElement = /** @type {HTMLSourceElement} */ (node('source')); this._sourceElement.setAttribute('src', url); this._stateMachine.setState(LOADING); if (PrepareOption.TYPE in options) { this._sourceElement.setAttribute('type', options[PrepareOption.TYPE]); } this._sourceElement.addEventListener('error', this._onNativeEventGuarded); this._videoElement.appendChild(this._sourceElement); if (PrepareOption.START_POSITION in options) { this._requestedStartPosition = options[PrepareOption.START_POSITION]; this._reapplyStartPosition(); } this._videoElement.load(); // Wait for canplay event } /** * @override */ play() { this._fireEvent(this.EVENT_WILL_PLAY); this._stateMachine.startTransitionTo(PLAYING); try { this._videoElement.play(); } catch (e) { this._onError(e); } } /** * @override */ pause() { this._fireEvent(this.EVENT_WILL_PAUSE); this._stateMachine.startTransitionTo(PAUSED); try { this._videoElement.pause(); } catch (e) { this._onError(e); } } /** * @override */ stop() { this._fireEvent(this.EVENT_WILL_STOP); this._stateMachine.startTransitionTo(IDLE); if (this._sourceElement) { this._videoElement.removeChild(this._sourceElement); this._sourceElement = null; } this._startPositionState = StartPositionState.NONE; this._receivedCanplay = false; this._videoElement.load(); } /** * @override */ destroy() { this._stateMachine.abortPendingTransition(); this._stateMachine.startTransitionTo(DESTROYED); this._destroyDOM(); this._stateMachine.setState(DESTROYED); } /** * @override */ getUrl() { return this._videoElement.currentSrc; } /** * @override */ getDuration() { return this._videoElement.duration * 1000; } /** * @override */ setMuted(value) { this._videoElement.muted = value; } /** * @override */ getMuted() { return this._videoElement.muted; } /** * @override */ setVolume(value) { const normalized = Math.max(0, Math.min(100, value)); if (normalized === this.getVolume()) { return; } this._fireEvent(this.EVENT_WILL_CHANGE_VOLUME, normalized); this._videoElement.volume = normalized / 100; } /** * @override */ getVolume() { return Math.round(this._videoElement.volume * 100); } /** * @override */ getPosition() { return this._videoElement.currentTime * 1000; } /** * @override */ setPosition(milliseconds) { this._fireEvent(this.EVENT_WILL_SEEK, milliseconds); const currentState = this._stateMachine.getCurrentState(); this._stateBeforeSeeking = currentState === SEEKING ? this._stateBeforeSeeking : currentState; if (!this._stateMachine.isIn(SEEKING)) { this._stateMachine.startTransitionTo(SEEKING); } this._videoElement.currentTime = milliseconds / 1000; } /** * @override */ getPlaybackRate() { return this._videoElement.playbackRate; } /** * @override */ setPlaybackRate(value) { this._fireEvent(this.EVENT_WILL_CHANGE_RATE, value); try { this._videoElement.playbackRate = value; } catch (error) { if (error instanceof DOMException && error.code === DOMException.NOT_SUPPORTED_ERR) { throw new UnsupportedFeature(`Playback rate of ${value}.`); } throw error; } } /** * @override */ getViewport() { return this._viewport; } /** * @return {?HTMLVideoElement} */ getEngine() { return this._videoElement; } /** * @protected */ _init() { this._container = div('html5-video-container'); this._container.style.backgroundColor = 'black'; this._container.style.position = 'absolute'; this._container.style.overflow = 'hidden'; document.body.insertBefore(this._container, document.body.firstChild); this._videoElement = /** @type {HTMLVideoElement} */ (node('video')); this._videoElement.style.position = 'relative'; this._container.appendChild(this._videoElement); for (const event of NativeEvents) { this._videoElement.addEventListener(event, this._onNativeEventGuarded); } this._viewport = new HTML5ViewPort( this._panelResolution, this._appResolution, this._container, this._videoElement ); this._viewport.updateViewPort(); } /** * @param {Event} event * @protected */ _onNativeEvent(event) { const eventMap = { 'error': this._onNativeError, 'loadedmetadata': this._onNativeLoadedMetadata, 'loadeddata': this._onNativeLoadedData, 'canplay': this._onNativeCanplay, 'playing': this._onNativePlaying, 'pause': this._onNativePause, 'emptied': this._onNativeEmptied, 'ended': this._onNativeEnded, 'seeking': this._onNativeSeeking, 'seeked': this._onNativeSeeked, 'waiting': this._onNativeWaiting, 'timeupdate': this._onNativeTimeupdate, 'volumechange': this._onNativeVolumechange, 'ratechange': this._onNativeRatechange }; if ( this._stateMachine.isIn(DESTROYED) || this._stateMachine.isTransitingTo(DESTROYED) || this._stateMachine.isIn(ERROR) ) { this._fireEvent( this.EVENT_DEBUG_MESSAGE, `html5 ${event.type} ignored because video is in ${this._stateMachine.getCurrentState()} state` ); return; } this._fireEvent(this.EVENT_DEBUG_MESSAGE, `html5 ${event.type}`); const handler = eventMap[event.type]; if (handler) { handler.call(this, event); } } /** * @param {Event} event * @protected */ _onNativeError(event) { this._onError(event); } /** * @protected */ _onNativeLoadedMetadata() { // First attempt to correct starting position – works in Safari and webOS with HLS this._reapplyStartPosition(); } /** * @protected */ _onNativeLoadedData() { // Second attempt to correct starting position – helps with other formats on webOS this._reapplyStartPosition(); } /** * @protected */ _onNativeCanplay() { this._receivedCanplay = true; if ( ( this._startPositionState === StartPositionState.NONE || this._startPositionState === StartPositionState.APPLIED ) && this._stateMachine.isIn(LOADING) ) { this._stateMachine.setState(READY); } } /** * @protected */ _onNativePlaying() { this._stateMachine.setState(PLAYING); } /** * @protected */ _onNativePause() { // html5 "pause" event fires right before "ended" event, we don't want Paused state in this case if (this._videoElement.ended) { return; } this._stateMachine.setState(PAUSED); } /** * @protected */ _onNativeEmptied() { const pendingTransition = this._stateMachine.getPendingTransition(); if (pendingTransition && pendingTransition.to === IDLE) { this._stateMachine.setState(IDLE); } } /** * @protected */ _onNativeEnded() { this._stateMachine.setState(ENDED); } /** * @protected */ _onNativeSeeking() { const currentState = this._stateMachine.getCurrentState(); if ( this._startPositionState !== StartPositionState.NONE && currentState === LOADING ) { this._startPositionState = StartPositionState.APPLYING; return; } this._stateMachine.setState(SEEKING); } /** * @protected */ _onNativeSeeked() { const currentState = this._stateMachine.getCurrentState(); if (currentState === LOADING) { if (this._startPositionState === StartPositionState.APPLYING) { this._startPositionState = StartPositionState.APPLIED; } if ( this._startPositionState === StartPositionState.APPLIED && this._videoElement.readyState >= NativeReadyState.HAVE_CURRENT_DATA ) { if (this._receivedCanplay) { this._stateMachine.setState(READY); } } return; } this._fireEvent(this.EVENT_SEEKED, this._videoElement.currentTime * 1000); if ( this._videoElement.readyState < NativeReadyState.HAVE_FUTURE_DATA && this._stateBeforeSeeking === PLAYING ) { this._stateMachine.setState(WAITING); this._stateBeforeSeeking = null; return; } // Since we seeked from the natural end of media, assume the intention was to restart playback if (this._stateBeforeSeeking === ENDED) { this._stateMachine.startTransitionTo(PLAYING); try { this._videoElement.play(); } catch (e) { this._onError(e); } this._stateBeforeSeeking = null; return; } if (this._stateBeforeSeeking === PAUSED && !this._videoElement.ended) { this._stateMachine.setState(PAUSED); this._stateBeforeSeeking = null; return; } // Playing and Ended states will be handled in "playing" and "ended" event handlers this._stateBeforeSeeking = null; } /** * @protected */ _onNativeWaiting() { const currentState = this._stateMachine.getCurrentState(); const pendingTransition = this._stateMachine.getPendingTransition(); if (currentState === SEEKING || pendingTransition && pendingTransition.to === SEEKING) { return; } if (!pendingTransition) { this._stateMachine.setState(WAITING); } } /** * @protected */ _onNativeTimeupdate() { const currentState = this._stateMachine.getCurrentState(); const pendingTransition = this._stateMachine.getPendingTransition(); if (currentState === PLAYING && !pendingTransition) { this._fireEvent(this.EVENT_TIME_UPDATE, this._videoElement.currentTime * 1000); } } /** * @protected */ _onNativeVolumechange() { const volume = Math.round(this._videoElement.volume * 100); this._fireEvent(this.EVENT_VOLUME_CHANGE, volume); } /** * @protected */ _onNativeRatechange() { const rate = this._videoElement.playbackRate; this._fireEvent(this.EVENT_RATE_CHANGE, rate); } /** * @protected */ _reapplyStartPosition() { const needsCorrection = this._requestedStartPosition !== null && Math.round(this._requestedStartPosition / 1000) !== Math.round(this._videoElement.currentTime); if (!needsCorrection) { this._startPositionState = this._requestedStartPosition === null ? StartPositionState.NONE : StartPositionState.APPLIED; return; } this._startPositionState = StartPositionState.REQUESTED; this._fireEvent( this.EVENT_DEBUG_MESSAGE, 'Applying start position ' + `State: ${this._startPositionState} ` + `Current: ${this._videoElement.currentTime}, requested: ${this._requestedStartPosition}` ); try { this._videoElement.currentTime = this._requestedStartPosition / 1000; } catch (e) { if (e instanceof DOMException && e.code === DOMException.INVALID_STATE_ERR) { // Ignore } else { throw e; } } } /** * @param {ErrorEvent|Event|Error} eventOrError * @protected */ _onError(eventOrError) { this._stateMachine.abortPendingTransition(); const pieces = []; if (eventOrError instanceof ErrorEvent) { pieces.push('ErrorEvent'); pieces.push(eventOrError.message); pieces.push(eventOrError.error && eventOrError.error.toString()); } else if (eventOrError instanceof Event) { const error = eventOrError.target.error || this._videoElement.error; if (!error) { pieces.push('Mysterious Error event'); } else { if (error instanceof MediaError) { pieces.push('MediaError'); } pieces.push(error.code); pieces.push(error.message); } pieces.push(`fired by ${eventOrError.target.nodeName} element`); } else if (eventOrError instanceof Error) { pieces.push(eventOrError.name); pieces.push(eventOrError['code']); pieces.push(eventOrError.message); } else { pieces.push(eventOrError.toString()); } const message = pieces.filter((p) => p !== undefined).join(' '); this._stateMachine.setState(ERROR); this._fireEvent(this.EVENT_ERROR, message); } /** * @protected */ _destroyDOM() { for (const event of NativeEvents) { this._videoElement.removeEventListener(event, this._onNativeEventGuarded); } if (this._sourceElement) { this._sourceElement.removeEventListener('error', this._onNativeEventGuarded); } remove(this._container); this._sourceElement = null; this._videoElement = null; this._container = null; } } /** * @enum {number} */ export const NativeReadyState = { HAVE_NOTHING: 0, HAVE_METADATA: 1, HAVE_CURRENT_DATA: 2, HAVE_FUTURE_DATA: 3, HAVE_ENOUGH_DATA: 4 }; /** * @type {!Array<string>} */ const NativeEvents = [ 'abort', 'canplay', 'canplaythrough', 'durationchange', 'emptied', 'ended', 'error', 'loadeddata', 'loadedmetadata', 'loadstart', 'pause', 'play', 'playing', 'progress', 'ratechange', 'seeked', 'seeking', 'stalled', 'suspend', 'timeupdate', 'volumechange', 'waiting' ]; /** * Some engines with some media formats sometimes quietly fail to apply start position at initialisation. * We attempt to force it again at a better time(s). * HLS in Safari is the easiest way to reproduce this, webOS also does that with HLS and some other. * Such attempts to force it may happen before or after canplay event. * All this warrant a more complex tracking of something as trivial as starting position * @enum {string} * @protected */ export const StartPositionState = { NONE: 'none', // No were requested in start options, playback is starting from media start REQUESTED: 'requested', // A non-zero value was given as START_POSITION and will be set APPLYING: 'applying', // An attempt was made to set starting position APPLIED: 'applied' // Successfully set requested position };