UNPKG

@100mslive/hls-player

Version:

HLS client library which uses HTML5 Video element and Media Source Extension for playback

397 lines (378 loc) 13.1 kB
import { HlsPlayerStats, HlsStats } from '@100mslive/hls-stats'; import Hls, { ErrorData, HlsConfig, Level, LevelParsed } from 'hls.js'; import { HMSHLSTimedMetadata } from './HMSHLSTimedMetadata'; import { HMSHLSErrorFactory } from '../error/HMSHLSErrorFactory'; import { HMSHLSException } from '../error/HMSHLSException'; import { HMSHLSPlayerEventEmitter, HMSHLSPlayerListeners, IHMSHLSPlayerEventEmitter } from '../interfaces/events'; import { HMSHLSLayer } from '../interfaces/IHMSHLSLayer'; import IHMSHLSPlayer from '../interfaces/IHMSHLSPlayer'; import { HLS_DEFAULT_ALLOWED_MAX_LATENCY_DELAY, HLSPlaybackState, HMSHLSPlayerEvents } from '../utilies/constants'; import { mapLayer, mapLayers } from '../utilies/utils'; export class HMSHLSPlayer implements IHMSHLSPlayer, IHMSHLSPlayerEventEmitter { private _hls: Hls; private _hlsUrl: string; private _hlsStats: HlsStats; private _videoEl: HTMLVideoElement; private _emitter: HMSHLSPlayerEventEmitter; private _subscribeHlsStats?: (() => void) | null = null; private _isLive: boolean; private _volume: number; private _metaData: HMSHLSTimedMetadata; private readonly TAG = '[HMSHLSPlayer]'; /** * Initiliaze the player with hlsUrl and video element * @remarks If video element is not passed, we will create one and call a method getVideoElement get element * @param hlsUrl required - Pass hls url to * @param videoEl optional field - HTML video element */ constructor(hlsUrl: string, videoEl?: HTMLVideoElement) { this._hls = new Hls(this.getPlayerConfig()); this._emitter = new HMSHLSPlayerEventEmitter(); this._hlsUrl = hlsUrl; this._videoEl = videoEl || this.createVideoElement(); if (!hlsUrl) { throw HMSHLSErrorFactory.HLSMediaError.hlsURLNotFound(); } else if (!hlsUrl.endsWith('m3u8')) { throw HMSHLSErrorFactory.HLSMediaError.hlsURLNotFound('Invalid URL, pass m3u8 url'); } this._hls.loadSource(hlsUrl); this._hls.attachMedia(this._videoEl); this._isLive = true; this._volume = this._videoEl.volume * 100; this._hlsStats = new HlsStats(this._hls, this._videoEl); this.listenHLSEvent(); this._metaData = new HMSHLSTimedMetadata(this._hls, this._videoEl, this.emitEvent); this.seekToLivePosition(); } /** * @remarks It will create a video element with playiniline true. * @returns HTML video element */ private createVideoElement(): HTMLVideoElement { if (this._videoEl) { return this._videoEl; } const video: HTMLVideoElement = document.createElement('video'); video.playsInline = true; video.controls = false; video.autoplay = true; return video; } /** * @returns get html video element */ getVideoElement(): HTMLVideoElement { return this._videoEl; } /** * Subscribe to hls stats */ private subscribeStats = (interval = 2000) => { this._subscribeHlsStats = this._hlsStats.subscribe((state: HlsPlayerStats) => { this.emitEvent(HMSHLSPlayerEvents.STATS, state); }, interval); }; /** * Unsubscribe to hls stats */ private unsubscribeStats = () => { if (this._subscribeHlsStats) { this._subscribeHlsStats(); } }; // reset the controller reset() { if (this._hls && this._hls.media) { this._hls.detachMedia(); this.unsubscribeStats(); } if (this._metaData) { this._metaData.unregisterListener(); } if (Hls.isSupported()) { this._hls.off(Hls.Events.MANIFEST_LOADED, this.manifestLoadedHandler); this._hls.off(Hls.Events.LEVEL_UPDATED, this.levelUpdatedHandler); this._hls.off(Hls.Events.ERROR, this.handleHLSException); } if (this._videoEl) { this._videoEl.removeEventListener('play', this.playEventHandler); this._videoEl.removeEventListener('pause', this.pauseEventHandler); this._videoEl.removeEventListener('timeupdate', this.handleTimeUpdateListener); this._videoEl.removeEventListener('volumechange', this.volumeEventHandler); } this.removeAllListeners(); } on = <E extends HMSHLSPlayerEvents>(eventName: E, listener: HMSHLSPlayerListeners<E>) => { this._emitter.on(eventName, listener); }; off = <E extends HMSHLSPlayerEvents>(eventName: E, listener: HMSHLSPlayerListeners<E>) => { this._emitter.off(eventName, listener); }; emitEvent = <E extends HMSHLSPlayerEvents>( eventName: E, eventObject: Parameters<HMSHLSPlayerListeners<E>>[0], ): boolean => { if (eventName === HMSHLSPlayerEvents.ERROR) { const hlsError = eventObject as HMSHLSException; if (hlsError?.isTerminal) { // send analytics event window?.__hms?.sdk?.sendHLSAnalytics(hlsError); } } return this._emitter.emitEvent(eventName, eventObject); }; private removeAllListeners = <E extends HMSHLSPlayerEvents>(eventName?: E): void => { this._emitter.removeAllListeners(eventName); }; public get volume(): number { return this._volume; } setVolume(volume: number) { this._videoEl.volume = volume / 100; this._volume = volume; } getLayer(): HMSHLSLayer | null { if (this._hls && this._hls.currentLevel !== -1) { const currentLevel = this._hls?.levels.at(this._hls?.currentLevel); return currentLevel ? mapLayer(currentLevel) : null; } return null; } setLayer(layer: HMSHLSLayer): void { if (this._hls) { const current = this._hls.levels.findIndex((level: Level) => { return level?.attrs?.RESOLUTION === layer?.resolution; }); this._hls.nextLevel = current; } return; } /** * set current stream to Live */ async seekToLivePosition() { let end = 0; if (this._videoEl.buffered.length > 0) { end = this._videoEl.buffered.end(this._videoEl.buffered.length - 1); } this._videoEl.currentTime = this._hls.liveSyncPosition || end; if (this._videoEl.paused) { try { await this.playVideo(); } catch (err) { console.error(this.TAG, 'Attempt to jump to live position Failed.', err); } } } /** * Play stream */ play = async () => { await this.playVideo(); }; /** * Pause stream */ pause = () => { this.pauseVideo(); }; /** * It will update the video element current time * @param seekValue Pass currentTime in second */ seekTo = (seekValue: number) => { this._videoEl.currentTime = seekValue; }; hasCaptions = () => { return this._hls.subtitleTracks.length > 0; }; toggleCaption = () => { // no subtitles, do nothing if (!this.hasCaptions()) { return; } this._hls.subtitleDisplay = !this._hls.subtitleDisplay; this.emitEvent(HMSHLSPlayerEvents.CAPTION_ENABLED, this._hls.subtitleDisplay); }; private playVideo = async () => { try { if (this._videoEl.paused) { await this._videoEl.play(); } } catch (error) { console.debug(this.TAG, 'Play failed with error', (error as Error).message); if ((error as Error).name === 'NotAllowedError') { this.emitEvent(HMSHLSPlayerEvents.AUTOPLAY_BLOCKED, HMSHLSErrorFactory.HLSMediaError.autoplayFailed()); } } }; private pauseVideo = () => { if (!this._videoEl.paused) { this._videoEl.pause(); } }; private playEventHandler = () => { this.emitEvent(HMSHLSPlayerEvents.PLAYBACK_STATE, { state: HLSPlaybackState.playing, }); }; private pauseEventHandler = () => { this.emitEvent(HMSHLSPlayerEvents.PLAYBACK_STATE, { state: HLSPlaybackState.paused, }); }; private volumeEventHandler = () => { this._volume = Math.round(this._videoEl.volume * 100); }; private reConnectToStream = () => { window.addEventListener( 'online', () => { this._hls.startLoad(); }, { once: true, }, ); }; // eslint-disable-next-line complexity private handleHLSException = (_: any, data: ErrorData) => { console.error(this.TAG, `error type ${data.type} with details ${data.details} is fatal ${data.fatal}`); const details = data.error?.message || data.err?.message || ''; const detail = { details: details, fatal: data.fatal, }; if (!detail.fatal) { return; } switch (data.details) { case Hls.ErrorDetails.MANIFEST_INCOMPATIBLE_CODECS_ERROR: { const error = HMSHLSErrorFactory.HLSMediaError.manifestIncompatibleCodecsError(detail); this.emitEvent(HMSHLSPlayerEvents.ERROR, error); break; } case Hls.ErrorDetails.FRAG_DECRYPT_ERROR: { const error = HMSHLSErrorFactory.HLSMediaError.fragDecryptError(detail); this.emitEvent(HMSHLSPlayerEvents.ERROR, error); break; } case Hls.ErrorDetails.BUFFER_INCOMPATIBLE_CODECS_ERROR: { const error = HMSHLSErrorFactory.HLSMediaError.bufferIncompatibleCodecsError(detail); this.emitEvent(HMSHLSPlayerEvents.ERROR, error); break; } // Below ones are network related errors case Hls.ErrorDetails.MANIFEST_LOAD_ERROR: { const error = HMSHLSErrorFactory.HLSNetworkError.manifestLoadError(detail); this.emitEvent(HMSHLSPlayerEvents.ERROR, error); break; } case Hls.ErrorDetails.MANIFEST_PARSING_ERROR: { const error = HMSHLSErrorFactory.HLSNetworkError.manifestParsingError(detail); this.emitEvent(HMSHLSPlayerEvents.ERROR, error); break; } case Hls.ErrorDetails.LEVEL_LOAD_ERROR: { const error = HMSHLSErrorFactory.HLSNetworkError.layerLoadError(detail); if (!navigator.onLine) { this.reConnectToStream(); } else { this.emitEvent(HMSHLSPlayerEvents.ERROR, error); } break; } default: { const error = HMSHLSErrorFactory.HLSError(detail, data.type, data.details); this.emitEvent(HMSHLSPlayerEvents.ERROR, error); break; } } }; private manifestLoadedHandler = (_: any, { levels }: { levels: LevelParsed[] }) => { const layers: HMSHLSLayer[] = mapLayers(this.removeAudioLevels(levels)); this.emitEvent(HMSHLSPlayerEvents.MANIFEST_LOADED, { layers, }); }; private levelUpdatedHandler = (_: any, { level }: { level: number }) => { const qualityLayer: HMSHLSLayer = mapLayer(this._hls.levels[level]); this.emitEvent(HMSHLSPlayerEvents.LAYER_UPDATED, { layer: qualityLayer, }); }; private handleTimeUpdateListener = (_: Event) => { this.emitEvent(HMSHLSPlayerEvents.CURRENT_TIME, this._videoEl.currentTime); const live = this._hls.liveSyncPosition ? this._hls.liveSyncPosition - this._videoEl.currentTime <= HLS_DEFAULT_ALLOWED_MAX_LATENCY_DELAY : false; if (this._isLive !== live) { this._isLive = live; this.emitEvent(HMSHLSPlayerEvents.SEEK_POS_BEHIND_LIVE_EDGE, { isLive: this._isLive, }); } }; /** * Listen to hlsjs and video related events */ private listenHLSEvent() { if (Hls.isSupported()) { this._hls.on(Hls.Events.MANIFEST_LOADED, this.manifestLoadedHandler); this._hls.on(Hls.Events.LEVEL_UPDATED, this.levelUpdatedHandler); this._hls.on(Hls.Events.ERROR, this.handleHLSException); this.subscribeStats(); } else if (this._videoEl.canPlayType('application/vnd.apple.mpegurl')) { // code for ios safari, mseNot Supported. this._videoEl.src = this._hlsUrl; } this._videoEl.addEventListener('timeupdate', this.handleTimeUpdateListener); this._videoEl.addEventListener('play', this.playEventHandler); this._videoEl.addEventListener('pause', this.pauseEventHandler); this._videoEl.addEventListener('volumechange', this.volumeEventHandler); } /** * 1 min retries before user came online, reason room automatically disconnected if user is offline for more than 1mins * Retries logic will run exponential like (1, 2, 4, 8, 8, 8, 8, 8, 8, 8secs) * there will be total 10 retries */ private getPlayerConfig(): Partial<HlsConfig> { return { enableWorker: true, maxBufferLength: 20, backBufferLength: 10, abrBandWidthUpFactor: 1, playlistLoadPolicy: { default: { maxTimeToFirstByteMs: 8000, maxLoadTimeMs: 20000, timeoutRetry: { maxNumRetry: 10, retryDelayMs: 1000, maxRetryDelayMs: 8000, backoff: 'exponential', }, errorRetry: { maxNumRetry: 10, retryDelayMs: 1000, maxRetryDelayMs: 8000, backoff: 'exponential', }, }, }, }; } /** * @param {Array} levels array * @returns a new array with only video levels. */ private removeAudioLevels(levels: LevelParsed[]) { return levels.filter(({ videoCodec, width, height }) => !!videoCodec || !!(width && height)); } /** * @returns true if HLS player is supported in the browser */ public static isSupported(): boolean { return Hls.isSupported(); } }