@skylineos/clsp-player
Version:
Skyline Technology Solutions' CLSP Video Player. Stream video in near-real-time in modern browsers.
842 lines (690 loc) • 25.2 kB
JavaScript
import isNil from 'lodash/isNil';
import { v4 as uuidv4 } from 'uuid';
import EventEmitter from '../../utils/EventEmitter';
import utils from '../../utils/utils';
import ClspClient from '../../ClspClient/ClspClient';
import MSEWrapper from './MSE/MSEWrapper';
import MediaSourceWrapper from './MSE/MediaSourceWrapper';
import StreamConfiguration from '../StreamConfiguration';
import Conduit from '../../ClspClient/Conduit/Conduit';
const DEFAULT_ENABLE_METRICS = false;
const DEFAULT_SEGMENT_INTERVAL_SAMPLE_SIZE = 5;
const DEFAULT_DRIFT_CORRECTION_CONSTANT = 2;
/**
* Responsible for receiving stream input and routing it to the media source
* buffer for rendering on the video tag. There is some 'light' reworking of
* the binary data that is required.
*
* @todo - this class should have no knowledge of videojs or its player, since
* it is supposed to be capable of playing video by itself. The plugin that
* uses this player should have all of the videojs logic, and none should
* exist here.
*/
export default class IovPlayer extends EventEmitter {
/**
* @static
*
* The events that this IovPlayer will emit.
*/
static events = {
METRIC: 'metric',
UNSUPPORTED_MIME_CODEC: 'unsupportedMimeCodec',
FIRST_FRAME_SHOWN: 'firstFrameShown',
VIDEO_RECEIVED: 'videoReceived',
VIDEO_INFO_RECEIVED: 'videoInfoReceived',
REINITIALZE_ERROR: 'reinitialize-error',
IFRAME_DESTROYED_EXTERNALLY: Conduit.events.IFRAME_DESTROYED_EXTERNALLY,
RECONNECT_FAILURE: Conduit.events.RECONNECT_FAILURE,
ROUTER_EVENT_ERROR: Conduit.events.ROUTER_EVENT_ERROR,
JWT_AUTHORIZATION_FAILURE: Conduit.events.JWT_AUTHORIZATION_FAILURE,
};
// @todo @metrics
// static METRIC_TYPES = [
// 'sourceBuffer.bufferTimeEnd',
// 'video.currentTime',
// 'video.drift',
// 'video.driftCorrection',
// 'video.intervalBetweenSegments',
// 'video.segmentIntervalAverage',
// ];
/**
* Construct a new IovPlayer
*
* @param {string} logId
* a string that identifies this IovPlayer in log messages
* @param {HTMLElement} containerElement
* The container element that will contain the iframe
* @param {HTMLElement} videoElement
* The video element that will be used to play the CLSP stream
*/
static factory (
logId,
id,
containerElement,
videoElement,
) {
return new IovPlayer(
logId,
id,
containerElement,
videoElement,
);
}
static generateClientId () {
// This MUST be globally unique! The CLSP server will broadcast the stream
// to a topic that contains this id, so if there is ANY other client
// connected that has the same id anywhere in the world, the stream to all
// clients that use that topic will fail. This is why we use guids rather
// than an incrementing integer.
//
// We prefix it with the project name and version for server debugging
// purposes.
//
// Note - this must not contain the '+' character
// @todo - the caller should be able to provide a prefix or something
return [
utils.name,
utils.version.replace('+', '-build-'),
uuidv4(),
].join('--');
}
isInitialized = false;
isInitializing = false;
isRestarting = false;
isTryingToPlay = false;
isStopping = false;
isStopped = false;
clspClientCount = 0;
clspClient = null;
streamConfiguration = null;
firstFrameShown = false;
mseWrapper = null;
moov = null;
latestSegmentReceivedAt = null;
segmentIntervalAverage = null;
intervalBetweenSegments = null;
segmentIntervals = [];
ENABLE_METRICS = DEFAULT_ENABLE_METRICS;
SEGMENT_INTERVAL_SAMPLE_SIZE = DEFAULT_SEGMENT_INTERVAL_SAMPLE_SIZE;
DRIFT_CORRECTION_CONSTANT = DEFAULT_DRIFT_CORRECTION_CONSTANT;
/**
* @private
*
* Construct a new IovPlayer
*
* @param {string} logId
* a string that identifies this IovPlayer in log messages
* @param {HTMLElement} containerElement
* The container element that will contain the iframe
* @param {HTMLElement} videoElement
* The video element that will be used to play the CLSP stream
*/
constructor (
logId,
id,
containerElement,
videoElement,
) {
super(logId);
if (isNil(id)) {
throw new Error('Tried to construct without an id');
}
if (!containerElement) {
throw new Error('Tried to construct without a container element');
}
if (!videoElement) {
throw new Error('Tried to construct without a video element');
}
this.id = id;
this.containerElement = containerElement;
this.videoElement = videoElement;
// @todo @metrics
// this.metrics = {};
}
setStreamConfiguration (streamConfiguration) {
if (this.isDestroyed) {
this.logger.error('Tried to setStreamConfiguration while destroyed');
return;
}
if (!StreamConfiguration.isStreamConfiguration(streamConfiguration)) {
throw new Error('Tried to set invalid streamConfiguration!');
}
this.streamConfiguration = streamConfiguration;
}
async initialize (force = false) {
if (this.isDestroyed) {
this.logger.error('Tried to initialize while destroyed');
return;
}
if (this.isInitializing) {
this.logger.info('Initialization already in progress');
return;
}
if (this.isInitialized && !force) {
this.logger.info('Already initialized');
return;
}
this.isInitialized = false;
this.isInitializing = true;
this.logger.info(`Initializing with ${this.streamConfiguration.streamName}`);
try {
this.clientId = IovPlayer.generateClientId();
if (this.clspClient) {
await this.clspClient.destroy();
}
this.clspClient = null;
this.clspClient = ClspClient.factory(
this.#generateClspClientLogId(),
this.clientId,
this.streamConfiguration,
this.containerElement,
);
// @todo - don't use the conduit events here
this.clspClient.conduit.on(ClspClient.events.RECONNECT_SUCCESS, async () => {
this.logger.info('ClspClient reconnected, restarting...');
// @todo - is there a more performant way to do this?
try {
await this.restart();
}
catch (error) {
this.logger.error('Error while restarting after reconnection!');
this.logger.error(error);
// @todo - there could be a dedicated event for this...
this.emit(IovPlayer.events.RECONNECT_FAILURE, { error });
}
});
this.clspClient.conduit.on(ClspClient.events.RECONNECT_FAILURE, ({ error }) => {
this.emit(IovPlayer.events.RECONNECT_FAILURE, { error });
});
this.clspClient.conduit.on(ClspClient.events.ROUTER_EVENT_ERROR, ({ error }) => {
this.emit(IovPlayer.events.ROUTER_EVENT_ERROR, { error });
});
// @todo - the intended resync logic doesn't currently work, so we have
// to emit an error on successful resync to force a restart...
this.clspClient.conduit.on(ClspClient.events.RESYNC_STREAM_COMPLETE, (data) => {
this.logger.warn(`Resyncing stream ${data.streamName}...`);
// spoofing an error...
this.emit(IovPlayer.events.RECONNECT_FAILURE, {
error: new Error('Resync detected'),
});
// @todo - we should be able to do something like this...
// // No need to await since we're inside an event listener
// this.#reinitializeMseWrapper();
});
this.clspClient.conduit.on(ClspClient.events.VIDEO_SEGMENT_RECEIVED, (data) => {
this.#showVideoSegment(data.clspMessage.payloadBytes);
});
this.clspClient.conduit.on(ClspClient.events.IFRAME_DESTROYED_EXTERNALLY, () => {
if (this.isDestroyed) {
this.logger.info('Iframe was destroyed externally while in process of destroying');
return;
}
this.emit(IovPlayer.events.IFRAME_DESTROYED_EXTERNALLY);
});
this.clspClient.conduit.on(ClspClient.events.JWT_AUTHORIZATION_FAILURE, (data) => {
this.emit(IovPlayer.events.JWT_AUTHORIZATION_FAILURE, { error: data.error, playerId: this.id });
});
await this.clspClient.initialize();
this.isInitialized = true;
}
finally {
this.isInitializing = false;
}
}
async restart () {
// @todo - in the early return conditions, we should be throwing errors of
// different types...
if (this.isDestroyed) {
this.logger.error('Tried to restart while destroyed');
return;
}
if (this.isRestarting) {
this.logger.info('Restart already in progress');
return;
}
if (this.isInitializing) {
this.logger.info('Cannot restart, initialization already in progress');
return;
}
this.logger.info('restart');
this.isRestarting = true;
try {
// @todo - this has not yet been tested for memory leaks
// If the src attribute is missing, it means we must reinitialize. This can
// happen if the video was loaded while the page was not visible, e.g.
// document.hidden === true, which can happen when switching tabs.
// @todo - is there a more "proper" way to do this?
const needToReinitialize = !this.videoElement.src;
await this.stop();
await this.initialize(needToReinitialize);
await this.play();
if (needToReinitialize) {
try {
await this.#html5Play();
}
catch (error) {
this.logger.error('Error while trying to play CLSP video from video element...');
throw error;
}
}
}
finally {
this.isRestarting = false;
}
}
/**
* Note that if an error is thrown during play, the IovPlayer instance should
* be destroyed to free resources.
*/
async play () {
if (this.isDestroyed) {
this.logger.info('Tried to play while destroyed');
return;
}
if (!this.isInitialized || this.isInitializing) {
throw new Error('Tried to play before initializing!');
}
if (this.isStopping) {
// @todo - what to do in this case?
}
if (this.isTryingToPlay) {
this.logger.info('Already trying to play');
return;
}
this.logger.info('play');
this.isTryingToPlay = true;
try {
const {
mimeCodec,
moov,
} = await this.clspClient.conduit.play();
if (!MediaSourceWrapper.isMimeCodecSupported(mimeCodec)) {
this.emit(IovPlayer.events.UNSUPPORTED_MIME_CODEC, {
mimeCodec,
});
throw new Error(`Unsupported mime codec: ${mimeCodec}`);
}
this.moov = moov;
this.mimeCodec = mimeCodec;
await this.#reinitializeMseWrapper(false);
this.isPlaying = true;
this.isStopped = false;
}
finally {
this.isTryingToPlay = false;
}
}
/**
* @returns {Promise}
*/
async stop () {
if (this.isDestroyComplete) {
throw new Error('Tried to stop after destroyed!');
}
if (!this.isInitialized) {
throw new Error('Tried to stop before initializing!');
}
if (this.isStopped) {
this.logger.info('Already stopped');
return;
}
if (this.isStopping) {
this.logger.info('Already stopping');
return;
}
this.logger.info('stop...');
this.isStopping = true;
this.moov = null;
try {
try {
await this.clspClient.conduit.stop();
}
catch (error) {
this.logger.error('failed to stop the clspClient');
this.logger.error(error);
}
// Don't wait until the next play event or the destruction of this player
// to clear the MSE
try {
await this.#destroyMseWrapper();
}
catch (error) {
this.logger.error('Failed to destroy mseWrapper while stopping, continuing anyway...');
this.logger.error(error);
}
this.logger.info('stop succeeded');
}
catch (error) {
this.logger.error('stop failed...');
throw error;
}
finally {
this.isStopping = false;
// @todo - it may not be stopped...
this.isStopped = true;
}
}
async #destroyMseWrapper () {
if (this.mseWrapper) {
// @todo - this can sometimes be called when it "shouldn't" be, like when
// changeSrc is called on the Iov. When reusing a videoElement, this
// causes the stream that should be playing to be wiped out. Having this
// commented out shouldn't cause a memory issue since it's always reset
// during reinitializeMseWrapper, and when the Iov is destroyed.
// this.videoElement.src = '';
try {
await this.mseWrapper.destroy();
}
finally {
this.mseWrapper = null;
}
}
}
async #reinitializeMseWrapper (shouldEmitOnError = true) {
if (this.isDestroyed) {
this.logger.warn('Tried to reinitializeMseWrapper while destroyed...');
return;
}
this.logger.info('reinitializeMseWrapper');
try {
await this.#destroyMseWrapper();
}
catch (error) {
this.logger.error('Failed to destroy mseWrapper while reinitializing mseWrapper, continuing anyway...');
this.logger.error(error);
}
this.mseWrapper = null;
this.mseWrapper = MSEWrapper.factory(
this.logId,
this.videoElement,
);
this.mseWrapper.on(MSEWrapper.events.METRIC, ({ type, value }) => {
this.#metric(type, value);
});
// Error events
this.mseWrapper.on(MSEWrapper.events.SHOW_VIDEO_SEGMENT_ERROR, ({ error }) => {
// internal error, this has been observed to happen the tab
// in the browser where this video player lives is hidden
// then reselected. 'ex' is undefined the error is bug
// within the MSE C++ implementation in the browser.
this.logger.error('Error while showing video segment!');
this.logger.error(error);
// No need to await since we're inside an event listener
this.#reinitializeMseWrapper();
});
this.mseWrapper.on(MSEWrapper.events.MEDIA_SOURCE_ERROR, (event) => {
this.logger.warn('mediaSource.generic --> mediaSource error');
// @todo - sometimes, this error is an event rather than an error!
// If different onError calls use different method signatures, that
// needs to be accounted for in the MSEWrapper, and the actual error
// that was thrown must ALWAYS be the first argument here. As a
// shortcut, we can log `...args` here instead.
this.logger.error(event);
// No need to await since we're inside an event listener
this.#reinitializeMseWrapper();
});
this.mseWrapper.on(MSEWrapper.events.SOURCE_BUFFER_ERROR, (event) => {
this.logger.debug('sourceBuffer.generic --> sourceBuffer error');
// console.log(event);
// No need to await since we're inside an event listener
this.#reinitializeMseWrapper();
});
this.mseWrapper.on(MSEWrapper.events.STREAM_FROZEN, () => {
this.logger.info('stream appears to be frozen - reinitializing...');
// No need to await since we're inside an event listener
this.#reinitializeMseWrapper();
});
// Non-Error events
this.mseWrapper.on(MSEWrapper.events.VIDEO_SEGMENT_SHOWN, async ({ info }) => {
if (this.isDestroyed) {
this.logger.warn('Got video segement while destroyed...');
return;
}
this.logger.silly('On Append Finish...');
if (!this.firstFrameShown) {
this.firstFrameShown = true;
// Since the video element can be shared with other IovPlayers, we only
// associate the video element with an IovPlayer after that IovPlayer
// has shown its first frame.
this.videoElement.id = this.clientId;
this.videoElement.dataset.name = this.streamConfiguration.streamName;
this.emit(IovPlayer.events.FIRST_FRAME_SHOWN);
}
this.drift = info.bufferTimeEnd - this.videoElement.currentTime;
this.#metric('sourceBuffer.bufferTimeEnd', info.bufferTimeEnd);
this.#metric('video.currentTime', this.videoElement.currentTime);
this.#metric('video.drift', this.drift);
if (this.drift > ((this.segmentIntervalAverage / 1000) + this.DRIFT_CORRECTION_CONSTANT)) {
this.#metric('video.driftCorrection', 1);
this.videoElement.currentTime = info.bufferTimeEnd;
}
if (this.videoElement.paused === true) {
this.logger.info('Video is paused!');
try {
await this.#html5Play();
}
catch (error) {
this.logger.error('Error while trying to play CLSP video from video element...');
this.logger.error(error);
// No need to await since we're inside an event listener
this.#reinitializeMseWrapper();
}
}
});
try {
await this.mseWrapper.initialize();
// @todo - this is what actually gives the HTML5 video element the
// ability to play video. Remember, though, this videoElement is
// potentially shared with other IovPlayers. Therefore, it would be
// better if:
// 1 - it was guaranteed that the stream was actually being received from
// the SFS prior to potentially taking src control away from another,
// already-playing IovPlayer
// 2 - since there is this struggle for control between IovPlayers, it
// would be even better if there were a way for the IovPlayerController
// to handle this...
// Therefore, this needs to have some relationship to either the play
// operation or VIDEO_SEGMENT_RECEIVED events or something...
this.videoElement.src = '';
this.videoElement.src = this.mseWrapper.mediaSource.asObjectURL();
// Error events
this.mseWrapper.mediaSource.on(MediaSourceWrapper.events.SOURCE_OPEN_ERROR, ({ error }) => {
this.logger.error('Failed to initialize mediaSource - Error on "sourceopen" event:');
this.logger.error(error);
// No need to await since we're inside an event listener
this.#reinitializeMseWrapper();
});
// Non-Error events
// When the MediaSource first becomes ready, send it the moov
// @todo - all of this logic should be handled by the MSEWrapper!!
this.mseWrapper.mediaSource.on(MediaSourceWrapper.events.SOURCE_OPEN, async (event) => {
this.logger.info('on mediaSource sourceopen');
try {
try {
await this.mseWrapper.initializeSourceBuffer(this.mimeCodec);
}
catch (error) {
this.logger.warn('Error while initializing SourceBuffer!');
throw error;
}
this.emit(IovPlayer.events.VIDEO_INFO_RECEIVED);
try {
this.mseWrapper.appendMoov(this.moov);
}
catch (error) {
// internal error, this has been observed to happen the tab
// in the browser where this video player lives is hidden
// then reselected. 'ex' is undefined the error is bug
// within the MSE C++ implementation in the browser.
this.logger.warn('Error while appending moov!');
throw error;
}
}
catch (error) {
this.logger.warn(error);
// @todo - this is not the proper event to emit here, but it will
// work for now becauase it will force the iov to recreate the player.
// In testing, it was found that under certain circumstances, the
// mimeCodec can be undefined, yet the SourceBuffer will still somehow
// be attempted to be initialized.
// One of the conditions that causes this problem is when resync
// stream is triggered.
this.emit(IovPlayer.events.REINITIALZE_ERROR, { error });
}
});
this.mseWrapper.mediaSource.on(MediaSourceWrapper.events.SOURCE_ENDED, async (event) => {
this.logger.info('on mediaSource sourceended');
try {
await this.stop();
}
catch (error) {
this.logger.error('Error while stopping in SOURCE_ENDED event!');
this.logger.error(error);
}
});
}
catch (error) {
if (shouldEmitOnError) {
// Because this method is called in so many places, handle the error here.
// This also makes it so that all of the event listeners don't have to
// catch the error thrown by this method, which if not caught would result
// in an uncaught error exception.
this.emit(IovPlayer.events.REINITIALZE_ERROR, { error });
}
else {
throw error;
}
}
}
async #html5Play () {
// @see - https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/play
await this.videoElement.play();
}
#generateClspClientLogId () {
return `${this.logId}.clspClient:${++this.clspClientCount}`;
}
#showVideoSegment (videoSegement) {
// @todo - this seems like a hack...
if (this.isStopped) {
return;
}
this.emit(IovPlayer.events.VIDEO_RECEIVED);
// @todo @metrics
this.#getSegmentIntervalMetrics();
// Sometimes, the first moof arrives before the mseWrapper has finished
// being initialized, so we will need to drop those frames
if (!this.mseWrapper) {
return;
}
// If the document is hidden, don't pass the moofs to the appropriate
// handler. The moofs occur at a rate that will exhaust the browser
// tab resources, ultimately resulting in a crash if given enough time.
// @todo - this check should probably be moved to the MSEWrapper
if (utils.isDocumentHidden()) {
this.logger.info('Document is in hidden state, not appending moof');
return;
}
try {
this.mseWrapper.showVideoSegment(videoSegement);
this.clspClient.conduit.segmentUsed(videoSegement);
}
catch (error) {
// internal error, this has been observed to happen the tab
// in the browser where this video player lives is hidden
// then reselected. 'ex' is undefined the error is bug
// within the MSE C++ implementation in the browser.
this.logger.error('Error while trying to show video segment!');
this.logger.error(error);
// no need to await this since it's only ever called in an event handler
this.#reinitializeMseWrapper();
}
}
/**
* @private
*
* Track segment interval metrics to help account for drift
*
* @returns {void}
*/
#getSegmentIntervalMetrics () {
if (!this.latestSegmentReceivedAt) {
this.latestSegmentReceivedAt = Date.now();
return;
}
const previousSegmentReceivedAt = this.latestSegmentReceivedAt;
this.latestSegmentReceivedAt = Date.now();
this.intervalBetweenSegments = this.latestSegmentReceivedAt - previousSegmentReceivedAt;
// @todo - Do we really need to check for the case where two segments
// arrive at exactly the same time?
if (!this.intervalBetweenSegments) {
return;
}
if (this.segmentIntervals.length >= this.SEGMENT_INTERVAL_SAMPLE_SIZE) {
this.segmentIntervals.shift();
}
this.segmentIntervals.push(this.intervalBetweenSegments);
let segmentIntervalSum = 0;
for (let i = 0; i < this.segmentIntervals.length; i++) {
segmentIntervalSum += this.segmentIntervals[i];
}
this.segmentIntervalAverage = segmentIntervalSum / this.segmentIntervals.length;
this.#metric('video.intervalBetweenSegments', this.intervalBetweenSegments);
this.#metric('video.segmentIntervalAverage', this.segmentIntervalAverage);
}
// @todo @metrics
/**
* @private
*
* @deprecated
*/
#metric (type, value) {
// if (!this.ENABLE_METRICS) {
// return;
// }
// if (!IovPlayer.METRIC_TYPES.includes(type)) {
// // @todo - should this throw?
// return;
// }
// switch (type) {
// case 'video.driftCorrection': {
// if (!this.metrics[type]) {
// this.metrics[type] = 0;
// }
// this.metrics[type] += value;
// break;
// }
// default: {
// this.metrics[type] = value;
// }
// }
// this.trigger('metric', {
// type,
// value: this.metrics[type],
// });
}
/**
* @async
*/
async _destroy () {
try {
await this.stop();
}
catch (error) {
this.logger.error('Error encountered while trying to stop during Iov Player destroy, continuing with destroy...');
this.logger.error(error);
}
await this.clspClient.destroy();
this.clspClient = null;
// The caller must destroy the streamConfiguration
this.streamConfiguration = null;
this.firstFrameShown = null;
this.moov = null;
this.latestSegmentReceivedAt = null;
this.segmentIntervalAverage = null;
this.intervalBetweenSegments = null;
this.segmentIntervals = null;
// @todo @metrics
// this.metrics = null;
await super._destroy();
}
}