zombiebox
Version:
ZombieBox is a JavaScript framework for development of Smart TV and STB applications
570 lines (480 loc) • 11.1 kB
JavaScript
/*
* 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 'zb/device/errors/unsupported-feature';
import StateMachine from '../state-machine';
import EventPublisher from '../events/event-publisher';
import {ResolutionInfoItem} from './resolutions';
import {Type as DRMType} from './drm/drm';
import IStatefulVideo, {State, StateDiagram} from './interfaces/i-stateful-video';
import IViewPort from './interfaces/i-video';
// This is a workaround for @suppress erroneously triggering no-undefined-types in eslint jsdoc plugin
// TODO: should be addressed in eslint-plugin-jsdoc
const checkTypes = null;
/**
* @abstract
* @implements {IStatefulVideo}
*/
export default class AbstractStatefulVideo extends EventPublisher {
/**
* @param {ResolutionInfoItem} panelResolution
* @param {ResolutionInfoItem} appResolution
*/
constructor(panelResolution, appResolution) {
super();
/**
* @type {ResolutionInfoItem}
* @protected
*/
this._panelResolution = panelResolution;
/**
* @type {ResolutionInfoItem}
* @protected
*/
this._appResolution = appResolution;
/**
* @type {StateMachine<State>}
* @protected
*/
this._stateMachine = new StateMachine(StateDiagram, State.IDLE);
/**
* @type {IViewPort}
* @protected
*/
this._viewport;
/**
* Fired with: {State}, {State}
* @const {string}
*/
this.EVENT_STATE_EXIT = 'state-exit';
/**
* Fired with: {State}, {State}
* @const {string}
*/
this.EVENT_STATE_ENTER = 'state-enter';
/**
* Fired with: nothing
* @const {string}
*/
this.EVENT_WILL_PLAY = 'will-play';
/**
* Fired with: nothing
* @const {string}
*/
this.EVENT_WILL_PAUSE = 'will-pause';
/**
* Fired with: nothing
* @const {string}
*/
this.EVENT_WILL_STOP = 'will-stop';
/**
* Fired with: {number}
* @const {string}
*/
this.EVENT_WILL_SEEK = 'will-seek';
/**
* Fired with: {number}
* @const {string}
*/
this.EVENT_WILL_CHANGE_RATE = 'will-change-rate';
/**
* Fired with: {number}
* @const {string}
*/
this.EVENT_WILL_CHANGE_VOLUME = 'will-change-volume';
/**
* Fired with: nothing
* @const {string}
*/
this.EVENT_IDLE = 'idle';
/**
* Fired with: nothing
* @const {string}
*/
this.EVENT_LOADING = 'loading';
/**
* Fired with: nothing
* @const {string}
*/
this.EVENT_READY = 'ready';
/**
* Fired with: nothing
* @const {string}
*/
this.EVENT_PLAYING = 'playing';
/**
* Fired with: nothing
* @const {string}
*/
this.EVENT_PAUSED = 'paused';
/**
* Fired with: nothing
* @const {string}
*/
this.EVENT_WAITING = 'waiting';
/**
* Fired with: nothing
* @const {string}
*/
this.EVENT_SEEKING = 'seeking';
/**
* Fired with: nothing
* @const {string}
*/
this.EVENT_ENDED = 'ended';
/**
* Fired with: nothing
* @const {string}
*/
this.EVENT_ERROR = 'error';
/**
* Fired with: nothing
* @const {string}
*/
this.EVENT_INVALID = 'invalid';
/**
* Fired with: nothing
* @const {string}
*/
this.EVENT_DESTROYED = 'destroyed';
/**
* Fired with: {number}
* @const {string}
*/
this.EVENT_TIME_UPDATE = 'time-update';
/**
* Volume has been changed
* Fired with: {number}
* @const {string}
*/
this.EVENT_VOLUME_CHANGE = 'volume-change';
/**
* Fired with: {number}
* @const {string}
*/
this.EVENT_RATE_CHANGE = 'rate-change';
/**
* Fired with: {number}
* @const {string}
*/
this.EVENT_SEEKED = 'seeked';
/**
* Fired with: {string}, {string|undefined}
* @const {string}
*/
this.EVENT_DEBUG_MESSAGE = 'debug-message';
this._stateMachine.on(
this._stateMachine.EVENT_STATE_EXIT,
(event, oldState, newState) => this._fireEvent(this.EVENT_STATE_EXIT, oldState, newState)
);
this._stateMachine.on(
this._stateMachine.EVENT_STATE_ENTER,
(event, newState, oldState) => this._onStateEnter(newState, oldState)
);
this._decorateInterface();
}
/**
* @abstract
* @override
*/
prepare(url, options = {}) {}
/**
* @abstract
* @override
*/
destroy() {}
/**
* @abstract
* @override
*/
play() {}
/**
* @abstract
* @override
*/
pause() {}
/**
* @abstract
* @override
*/
stop() {}
/**
* @override
*/
getState() {
return this._stateMachine.getCurrentState();
}
/**
* @override
*/
getStateTransition() {
return this._stateMachine.getPendingTransition();
}
/**
* @abstract
* @override
*/
getUrl() {}
/**
* @abstract
* @override
*/
getViewport() {}
/**
* @abstract
* @override
*/
getDuration() {}
/**
* @abstract
* @override
*/
getPosition() {}
/**
* @abstract
* @override
*/
setPosition(position) {}
/**
* @abstract
* @override
*/
getPlaybackRate() {}
/**
* @abstract
* @override
*/
setPlaybackRate(rate) {}
/**
* @abstract
* @override
*/
getMuted() {}
/**
* @abstract
* @override
*/
setMuted(muted) {}
/**
* @override
*/
toggleMuted() {
this.setMuted(!this.getMuted());
return this.getMuted();
}
/**
* @abstract
* @override
*/
getVolume() {}
/**
* @abstract
* @override
*/
setVolume(volume) {}
/**
* @override
*/
volumeDown(step = -1) {
this.setVolume(this.getVolume() + step);
return this.getVolume();
}
/**
* @override
*/
volumeUp(step = 1) {
this.setVolume(this.getVolume() + step);
return this.getVolume();
}
/**
* @override
*/
attachDRM(client) {
throw new UnsupportedFeature('DRM protection');
}
/**
* @override
*/
detachDRM(type) {
throw new UnsupportedFeature('DRM protection');
}
/**
* @abstract
* @override
*/
getEngine() {}
/**
* @param {State|Array<State>} stateOrSeveral
* @param {string=} message
* @throws {StateError}
* @protected
*/
_assertStateIs(stateOrSeveral, message) {
const expectedStates = Array.isArray(stateOrSeveral) ? stateOrSeveral : [stateOrSeveral];
const currentState = this._stateMachine.getCurrentState();
if (!expectedStates.includes(currentState)) {
throw new StateError(currentState, expectedStates, message);
}
}
/**
* @param {State|Array<State>} stateOrSeveral
* @param {string=} message
* @throws {StateError}
* @protected
*/
_assertStateIsNot(stateOrSeveral, message) {
const unexpectedStates = Array.isArray(stateOrSeveral) ? stateOrSeveral : [stateOrSeveral];
const expectedStates = Object.keys(State).filter((state) => !unexpectedStates.includes(state));
this._assertStateIs(expectedStates, message);
}
/**
* @param {State} newState
* @param {State} oldState
* @protected
*/
_onStateEnter(newState, oldState) {
this._fireEvent(this.EVENT_STATE_ENTER, newState, oldState);
const stateEvents = {
[State.IDLE]: this.EVENT_IDLE,
[State.LOADING]: this.EVENT_LOADING,
[State.READY]: this.EVENT_READY,
[State.PLAYING]: this.EVENT_PLAYING,
[State.PAUSED]: this.EVENT_PAUSED,
[State.WAITING]: this.EVENT_WAITING,
[State.SEEKING]: this.EVENT_SEEKING,
[State.ENDED]: this.EVENT_ENDED,
// [State.ERROR]: this.EVENT_ERROR, // Error event is expected to be fired separately
[State.INVALID]: this.EVENT_INVALID,
[State.DESTROYED]: this.EVENT_DESTROYED
};
if (stateEvents.hasOwnProperty(newState)) {
this._fireEvent(stateEvents[newState]);
}
}
/**
* @private [sic!]
*/
_decorateInterface() {
const restrict = (method, states, message) => {
let key;
// As a special case, everything is allowed in ERROR state at application's risk
const statesAndError = states.includes(State.ERROR) ? states : states.concat([State.ERROR]);
// Find the actual implementation of the method in prototype chain
for (let prototype = this; prototype; prototype = Object.getPrototypeOf(prototype)) {
key = Object.getOwnPropertyNames(prototype).find((key) => prototype[key] === method);
if (key) {
break;
}
}
const restricted = (...params) => {
this._assertStateIs(statesAndError, message);
return method.apply(this, params);
};
/** @suppress {checkTypes} */
this[key] = restricted;
};
// eslint-disable-next-line no-unused-vars
const {IDLE, LOADING, READY, PLAYING, PAUSED, WAITING, SEEKING, ENDED, ERROR, INVALID, DESTROYED} = State;
const StatesWithMediaSourceKnown = [LOADING, READY, PLAYING, PAUSED, WAITING, SEEKING, ENDED];
const StatesWithMediaSourceReady = [READY, PLAYING, PAUSED, WAITING, SEEKING, ENDED];
restrict(
this.prepare,
[IDLE],
'prepare() can only be called while Video is not initialized with media file'
);
restrict(
this.attachDRM,
[IDLE],
'attachDRM() can only be called while Video is not initialized with media file'
);
restrict(
this.detachDRM,
[IDLE],
'detachDRM() can only be called while Video is not initialized with media file'
);
restrict(
this.play,
[READY, PAUSED],
'play() can only be called when Video is ready to play'
);
restrict(
this.pause,
[PLAYING],
'pause() can only be called when Video is playing'
);
restrict(
this.stop,
StatesWithMediaSourceKnown,
'stop() can only be called when Video is initialized with a media file'
);
restrict(
this.getUrl,
StatesWithMediaSourceKnown,
'getUrl() can only be called when Video is initialized with a media file'
);
restrict(
this.getDuration,
StatesWithMediaSourceReady,
'getDuration() can only be called when Video has loaded the media'
);
restrict(
this.getPosition,
StatesWithMediaSourceReady,
'getPosition() can only be called when Video has loaded the media'
);
restrict(
this.setPosition,
[PLAYING, PAUSED, WAITING, SEEKING, ENDED],
'setPosition() can only be called when Video has loaded the media'
);
restrict(
this.getPlaybackRate,
StatesWithMediaSourceReady,
'getPlaybackRate() can only be called when Video has loaded the media'
);
restrict(
this.setPlaybackRate,
StatesWithMediaSourceReady,
'setPlaybackRate() can only be called when Video has loaded the media'
);
// No restrictions on: destroy, getMuted, setMuted, toggleMuted, getVolume, setVolume, volumeDown, volumeUp
}
/**
* @param {DRMType} type
* @return {boolean}
*/
static isDRMSupported(type) { // eslint-disable-line no-unused-vars
return false;
}
/**
* @return {boolean}
*/
static canHandleMultiDRM() {
return false;
}
}
/**
*/
export class StateError extends Error {
/**
* @param {State} currentState
* @param {Array<State>} states
* @param {string=} message
*/
constructor(currentState, states, message = 'Incorrect state') {
super(`${message}; Current state is ${currentState}, expected: ${states.join(' or ')}`);
/**
* @type {State}
*/
this.currentState = currentState;
/**
* @type {Array<State>}
*/
this.expectedStates = states;
}
}