UNPKG

@skylineos/clsp-player

Version:

Skyline Technology Solutions' CLSP Video Player. Stream video in near-real-time in modern browsers.

445 lines (346 loc) 12.3 kB
import isNil from 'lodash/isNil'; import { v4 as uuidv4 } from 'uuid'; import IovPlayer from './IovPlayer'; import EventEmitter from '../../utils/EventEmitter'; const DEFAULT_MAX_RETRIES_ON_PLAY_ERROR = 100; export default class IovPlayerCollection extends EventEmitter { static events = { // Needed for videojs plugin VIDEO_RECEIVED: IovPlayer.events.VIDEO_RECEIVED, // Needed for videojs plugin VIDEO_INFO_RECEIVED: IovPlayer.events.VIDEO_INFO_RECEIVED, FIRST_FRAME_SHOWN: IovPlayer.events.FIRST_FRAME_SHOWN, IFRAME_DESTROYED_EXTERNALLY: IovPlayer.events.IFRAME_DESTROYED_EXTERNALLY, DISPLAY_ERROR_MSG: IovPlayer.events.DISPLAY_ERROR_MSG, }; static factory (logId) { return new IovPlayerCollection(logId); } /** * @private */ constructor (logId) { super(logId); this.initialize(); this.MAX_RETRIES_ON_PLAY_ERROR = DEFAULT_MAX_RETRIES_ON_PLAY_ERROR; } initialize () { this.logger.info('Initializing'); this.players = {}; this.playerStack = []; this.playerLogMessageIds = {}; this.retryAttempts = {}; this.mostRecentlyPlayedId = null; this.mostRecentlyAddedId = null; this.totalPlayersAdded = 0; this.pendingRemoval = {}; } generatePlayerLogId (streamName) { return `${this.logId}.player:${this.totalPlayersAdded}:${streamName}`; } async create ( containerElement, videoElement, streamConfiguration, ) { const iovPlayer = this._create( containerElement, videoElement, streamConfiguration, ); const id = iovPlayer.id; this.#add(iovPlayer); await this.#play(id); return id; } _create ( containerElement, videoElement, streamConfiguration, ) { if (!containerElement) { throw new Error('A container element is required to create an IovPlayer'); } if (!videoElement) { throw new Error('A video element is required to create an IovPlayer'); } if (!streamConfiguration) { throw new Error('stream configuration is required to create an IovPlayer'); } this.totalPlayersAdded++; const id = uuidv4(); const iovPlayer = IovPlayer.factory( this.generatePlayerLogId(streamConfiguration.streamName), id, containerElement, videoElement, ); iovPlayer.setStreamConfiguration(streamConfiguration); // Tell the IOV to start tearing it all down... iovPlayer.on(IovPlayer.events.IFRAME_DESTROYED_EXTERNALLY, () => { if (this.isDestroyed) { this.logger.info('Iframe was destroyed externally while in process of destroying'); return; } this.emit(IovPlayerCollection.events.IFRAME_DESTROYED_EXTERNALLY, { id }); }); // Needed for videojs plugin iovPlayer.on(IovPlayer.events.VIDEO_RECEIVED, () => { this.emit(IovPlayerCollection.events.VIDEO_RECEIVED, { id }); }); // Needed for videojs plugin iovPlayer.on(IovPlayer.events.VIDEO_INFO_RECEIVED, () => { this.emit(IovPlayerCollection.events.VIDEO_INFO_RECEIVED, { id }); }); iovPlayer.on(IovPlayer.events.FIRST_FRAME_SHOWN, async () => { // Needed for videojs plugin this.emit(IovPlayerCollection.events.FIRST_FRAME_SHOWN, { id }); this.mostRecentlyPlayedId = id; try { // Only destroy all previous rather than all other players because a // more recently added player may exist that has not had its first // frame shown yet this.logger.info(`First frame received for ${this.playerLogMessageIds[id]}, destroying all previous players...`); this.destroyAllPreviousPlayers(id); } catch (error) { this.logger.error(`Error while destroying previous players for ${this.playerLogMessageIds[id]}`); this.logger.error(error); } }); iovPlayer.on(IovPlayer.events.RECONNECT_FAILURE, async () => { try { this.logger.info(`Critical player error RECONNECT_FAILURE for player ${this.playerLogMessageIds[id]}`); await this.#handleCriticalIovPlayerError(id, IovPlayer.events.RECONNECT_FAILURE); } catch (error) { this.logger.error(`Error while handling player error RECONNECT_FAILURE for player ${this.playerLogMessageIds[id]}`); this.logger.error(error); } }); iovPlayer.on(IovPlayer.events.ROUTER_EVENT_ERROR, async () => { try { this.logger.info(`Critical player error ROUTER_EVENT_ERROR for player ${this.playerLogMessageIds[id]}`); await this.#handleCriticalIovPlayerError(id, IovPlayer.events.ROUTER_EVENT_ERROR); } catch (error) { this.logger.error(`Error while handling player error ROUTER_EVENT_ERROR for player ${this.playerLogMessageIds[id]}`); this.logger.error(error); } }); iovPlayer.on(IovPlayer.events.REINITIALZE_ERROR, async () => { try { this.logger.info(`Critical player error REINITIALZE_ERROR for player ${this.playerLogMessageIds[id]}`); await this.#handleCriticalIovPlayerError(id, IovPlayer.events.REINITIALZE_ERROR); } catch (error) { this.logger.error(`Error while handling player error REINITIALZE_ERROR for player ${this.playerLogMessageIds[id]}`); this.logger.error(error); } }); iovPlayer.on(IovPlayer.events.JWT_AUTHORIZATION_FAILURE, (data) => { this.logger.error(`Critical JWT authorization error: ${data.error}`); // prevent any retries this.retryAttempts[id] = this.MAX_RETRIES_ON_PLAY_ERROR + 1; }); return iovPlayer; } async #handleCriticalIovPlayerError (id, eventName) { if (id === this.mostRecentlyPlayedId || id === this.mostRecentlyAddedId) { this.logger.info(`Active IovPlayer ${this.playerLogMessageIds[id]} emitted the "${eventName}" event, recreating it...`); try { await this.#recreate(id); } catch (error) { this.logger.error(`Error while recreating ${this.playerLogMessageIds[id]}`); this.logger.error(error); } return; } this.logger.info(`Stale IovPlayer ${this.playerLogMessageIds[id]} emitted the "${eventName}" event, destroying it...`); try { await this.remove(id); } catch (error) { this.logger.error(`Error while removing Stale IovPlayer ${this.playerLogMessageIds[id]} during "${eventName}" event, continuing anyway...`); this.logger.error(error); } } async destroyAllPreviousPlayers (id) { const index = this.playerStack.indexOf(id); if (index === -1) { return; } const idsToRemove = this.playerStack.slice(0, index); try { await this.removeMany(idsToRemove); } catch (error) { this.logger.error(`Error(s) while destroying players before ${this.playerLogMessageIds[id]}, continuing anyway...`); this.logger.error(error); } } #replaceInStack (oldId, newId) { const index = this.playerStack.indexOf(oldId); if (index === -1) { throw new Error(`Cannot replace ${this.playerLogMessageIds[oldId]} in stack because it doesn't exist in the stack...`); } this.playerStack[index] = newId; } async #recreate (oldId) { const oldIovPlayer = this.get(oldId); if (oldIovPlayer === null) { throw new Error(`Cannot recreate IovPlayer with non-existent id ${oldId}`); } const oldPlayerLogMessageId = this.playerLogMessageIds[oldId]; const iovPlayer = this._create( oldIovPlayer.containerElement, oldIovPlayer.videoElement, oldIovPlayer.streamConfiguration, ); const id = iovPlayer.id; this.players[id] = iovPlayer; this.playerLogMessageIds[id] = this.totalPlayersAdded; this.retryAttempts[id] = 0; this.#replaceInStack(oldId, id); if (this.mostRecentlyAddedId === oldId) { this.mostRecentlyAddedId = id; } if (this.mostRecentlyPlayedId === oldId) { this.mostRecentlyPlayedId = id; } // We do not need to await this this.remove(oldId).catch((error) => { this.logger.error(`Error while removing player ${oldPlayerLogMessageId} while recreating, continuing anyway...`); this.logger.error(error); }); await this.#play(id); } async #play (id) { const iovPlayer = this.get(id); if (iovPlayer === null) { throw new Error(`Cannot play - IovPlayer id ${id} doesn't exist`); } try { await iovPlayer.initialize(); // @todo - should the play method only resolve once the first frame has // been shown? right now it resolves on first moof recevied await iovPlayer.play(); iovPlayer.logger.info('Play succeeded!'); } catch (error) { this.logger.error(`Error while trying to play IovPlayer ${this.playerLogMessageIds[id]}...`); this.logger.error(error); if (this.MAX_RETRIES_ON_PLAY_ERROR > 0 && this.retryAttempts[id] <= this.MAX_RETRIES_ON_PLAY_ERROR) { this.logger.warn(`Retry play attempt #${this.retryAttempts[id]++} for player ${this.playerLogMessageIds[id]}...`); await this.#play(id); } else { this.logger.error('Destroying IovPlayer after exhausting play and retry attempts'); // emit an error event to the iov this.emit(IovPlayerCollection.events.DISPLAY_ERROR_MSG, { error }); await this.remove(id); throw error; } } } has (id) { if (isNil(id)) { return false; } if (this.pendingRemoval[id]) { return false; } return Object.prototype.hasOwnProperty.call(this.players, id); } get (id) { if (!this.has(id)) { return null; } return this.players[id]; } #add (iovPlayer) { const id = iovPlayer.id; if (this.has(id)) { throw new Error('Cannot add an iovPlayer without a unique id'); } this.players[id] = iovPlayer; this.playerLogMessageIds[id] = this.totalPlayersAdded; this.retryAttempts[id] = 0; this.mostRecentlyAddedId = id; this.playerStack.push(id); } async remove (id) { const iovPlayer = this.get(id); if (iovPlayer === null) { return; } iovPlayer.logger.info('IovPlayerCollection - removing player...'); const playerLogMessageId = this.playerLogMessageIds[id]; this.pendingRemoval[id] = true; // It is no longer the most recently played if (this.mostRecentlyPlayedId === id) { this.mostRecentlyPlayedId = null; } // remove it from the stack const index = this.playerStack.indexOf(id); if (index !== -1) { this.playerStack.splice(index, 1); } // it is no longer the most recently added if (this.mostRecentlyAddedId === id) { const stackLength = this.playerStack.length; if (stackLength === 0) { this.mostRecentlyAddedId = null; } else { this.mostRecentlyAddedId = this.playerStack[stackLength - 1]; } } // remove it from all internal state delete this.players[id]; delete this.playerLogMessageIds[id]; delete this.pendingRemoval[id]; try { await iovPlayer.destroy(); } catch (error) { this.logger.error(`Error destroying IovPlayer ${playerLogMessageId} while removing, continuing anyway`); this.logger.error(error); } } async removeMany (ids) { const removeOperations = ids.map((id) => this.remove(id)); const results = await Promise.allSettled(removeOperations); const errors = results.reduce((acc, cur) => { if (cur.status !== 'fulfilled') { acc.push(cur); } return acc; }, []); if (errors.length) { this.logger.error('Error(s) encountered while destroying IovPlayer(s) during removeMany...'); errors.forEach((error) => { this.logger.error(error.reason); }); throw errors[0].reason; } } async removeAll () { const playerIds = Object.keys(this.players); await this.removeMany(playerIds); } async _destroy () { try { await this.removeAll(); } catch (error) { this.logger.error('Error(s) encountered while removing while destroying, continuing anyway...'); this.logger.error(error); } this.players = null; this.playerLogMessageIds = null; this.pendingRemoval = null; this.playerStack = null; } }