import { CustomEventType, Html5EventType } from '../../../../event/event-type'; import Track from '../../../../track/track'; import VideoTrack from '../../../../track/video-track'; import AudioTrack from '../../../../track/audio-track'; import PKTextTrack from '../../../../track/text-track'; import { RequestType } from '../../../../enums/request-type'; import BaseMediaSourceAdapter from '../base-media-source-adapter'; import { getSuitableSourceForResolution } from '../../../../utils/resolution'; import { filterTracksByRestriction } from '../../../../utils/restrictions'; import * as Utils from '../../../../utils/util'; import FairPlay from '../../../../drm/fairplay'; import Env from '../../../../utils/env'; import Error from '../../../../error/error'; import defaultConfig from './native-adapter-default-config.json'; import type { FairPlayDrmConfigType } from './fairplay-drm-handler'; import { FairPlayDrmHandler } from './fairplay-drm-handler'; import { IDrmProtocol, IMediaSourceAdapter, PKABRRestrictionObject, PKDrmConfigObject, PKDrmDataObject, PKMediaSourceObject, PKRequestObject, PKVideoDimensionsObject } from '../../../../types'; import { FairPlayDrmHandlerV2 } from './fairplay-drm-handler-v2'; import { ManifestHandler } from './manifest-handler'; const BACK_TO_FOCUS_TIMEOUT: number = 1000; const MAX_MEDIA_RECOVERY_ATTEMPTS: number = 3; const NUDGE_SEEK_AFTER_FOCUS: number = 0.1; const SAFARI_BUFFERED_SEGMENTS_COUNT: number = 3; const LIVE_DURATION_INTERVAL_MS = 1000; const NATIVE_TEXT_CLASS = 'playkit-native-text'; /** * An illustration of media source extension for progressive download * @classdesc * @implements {IMediaSourceAdapter} */ export default class NativeAdapter extends BaseMediaSourceAdapter { /** * The id of the Adapter * @member {string} id * @static * @public */ public static id: string = 'NativeAdapter'; /** * The adapter logger * @member {any} _logger * @private * @static */ protected static _logger = BaseMediaSourceAdapter.getLogger(NativeAdapter.id); /** * static video element for canPlayType testing * @member {} TEST_VIDEO * @type {HTMLVideoElement} * @static */ public static TEST_VIDEO: HTMLVideoElement = Utils.Dom.createElement('video'); /** * The DRM protocols implementations for native adapter. * @type {Array} * @private * @static */ private static _drmProtocols: Array = [FairPlay]; /** * The DRM protocol for the current playback. * @type {?Function} * @private * @static */ private static _drmProtocol: IDrmProtocol | null = null; /** * The supported progressive mime types by the native adapter. * @member {Array} _progressiveMimeTypes * @static * @private */ private static _progressiveMimeTypes: Array = ['video/mp4', 'audio/mp3']; /** * The DRM handler playback. * @type {?FairPlayDrmHandler} * @private */ private _drmHandler: FairPlayDrmHandler | FairPlayDrmHandlerV2 | undefined; /** * The original progressive sources * @member {Array} - _progressiveSources * @private */ private _progressiveSources: Array; /** * The player tracks. * @member {Array} - _playerTracks * @private */ private _playerTracks!: Array; /** * The id of _liveDurationChangeInterval * @member {?number} - _liveDurationChangeInterval * @private */ protected _liveDurationChangeInterval: number | undefined; /** * The live edge value * @member {number} - _liveEdge * @private */ private _liveEdge: number; /** * Id for interval that starts upon waiting/stalling event and check if we are offline * @member {?TimeoutID} - _heartbeatTimeoutId * @private */ private _heartbeatTimeoutId: number | undefined; /** * video dimensions for native check if video track change and which video dimensions is the selected track * @member {?PKVideoDimensionsObject} - video dimensions * @private */ private _videoDimensions: PKVideoDimensionsObject | undefined; private _loadPromiseReject: ((error: Error) => any) | undefined; private _lastTimeUpdate: number = 0; private _waitingEventTriggered: boolean = false; private _segmentDuration: number = 0; private _startTimeOfDvrWindowInterval: number | undefined; private _startTimeOfDvrWindow: number = 0; /** * A counter to track the number of attempts to recover from media error * @type {number} * @private */ private _mediaErrorRecoveryAttempts: number = 0; /** * The last time detach occurred * @type {number} * @private */ private _lastTimeDetach: number = NaN; /** * The start time after attach * @type {number} * @private */ private _startTimeAttach: number = NaN; /** * Map from our text track to native text track * @type {number} * @private */ private _nativeTextTracksMap = {}; /** * * @type {number} * @private */ private _captionsHidden: boolean = false; /** * Handler for HLS manifest parsing * @type {ManifestHandler} * @private */ private _hlsManifestHandler: ManifestHandler = new ManifestHandler(); /** * Checks if NativeAdapter can play a given mime type. * @function canPlayType * @param {string} mimeType - The mime type to check * @returns {boolean} - Whether the native adapter can play a specific mime type * @static */ public static canPlayType(mimeType: string): boolean { if (mimeType === 'application/x-mpegURL' && !Env.isIOS && (Env.isChrome || Env.isEdge) ) { return false; } let canPlayType = false; if (typeof mimeType === 'string') { canPlayType = !!NativeAdapter.TEST_VIDEO.canPlayType(mimeType.toLowerCase()); } NativeAdapter._logger.debug('canPlayType result for mimeType:' + mimeType + ' is ' + canPlayType.toString()); return canPlayType; } /** * Checks if NativeAdapter can play a given drm data. * @function canPlayDrm * @param {Array} drmData - The drm data to check. * @param {PKDrmConfigObject} drmConfig - The drm config. * @returns {boolean} - Whether the native adapter can play a specific drm data. * @static */ public static canPlayDrm(drmData: Array, drmConfig: PKDrmConfigObject): boolean { NativeAdapter._drmProtocol = null; for (const drmProtocol of NativeAdapter._drmProtocols) { if (drmProtocol.isConfigured(drmData, drmConfig)) { NativeAdapter._drmProtocol = drmProtocol; break; } } if (!NativeAdapter._drmProtocol) { for (const drmProtocol of NativeAdapter._drmProtocols) { if (drmProtocol.canPlayDrm(drmData)) { NativeAdapter._drmProtocol = drmProtocol; } } } return !!NativeAdapter._drmProtocol; } /** * Factory method to create media source adapter. * @function createAdapter * @param {HTMLVideoElement} videoElement - The video element that the media source adapter work with. * @param {PKMediaSourceObject} source - The source Object. * @param {Object} config - The player configuration. * @returns {IMediaSourceAdapter} - New instance of the run time media source adapter. * @static */ public static createAdapter(videoElement: HTMLVideoElement, source: PKMediaSourceObject, config: any): IMediaSourceAdapter { NativeAdapter._logger.debug('createAdapter'); const adapterConfig: any = { displayTextTrack: false, progressiveSources: [] }; if (Utils.Object.hasPropertyPath(config, 'text.useNativeTextTrack')) { adapterConfig.displayTextTrack = Utils.Object.getPropertyPath(config, 'text.useNativeTextTrack'); } if (adapterConfig.displayTextTrack !== false) { videoElement.classList.add(NATIVE_TEXT_CLASS); } if (Utils.Object.hasPropertyPath(config, 'sources.progressive')) { adapterConfig.progressiveSources = Utils.Object.getPropertyPath(config, 'sources.progressive'); } if (Utils.Object.hasPropertyPath(config, 'text')) { adapterConfig.enableCEA708Captions = config.text.enableCEA708Captions; adapterConfig.captionsTextTrack1Label = config.text.captionsTextTrack1Label; adapterConfig.captionsTextTrack1LanguageCode = config.text.captionsTextTrack1LanguageCode; adapterConfig.captionsTextTrack2Label = config.text.captionsTextTrack2Label; adapterConfig.captionsTextTrack2LanguageCode = config.text.captionsTextTrack2LanguageCode; } if (Utils.Object.hasPropertyPath(config, 'playback')) { if (Utils.Object.hasPropertyPath(config.playback, 'options.html5.native')) { Utils.Object.mergeDeep(adapterConfig, config.playback.options.html5.native); } } if (Utils.Object.hasPropertyPath(config, 'abr')) { const abr = config.abr; if (abr.defaultBandwidthEstimate) { adapterConfig.abrEwmaDefaultEstimate = abr.defaultBandwidthEstimate; } if (abr.restrictions) { Utils.Object.createPropertyPath(adapterConfig, 'abr.restrictions', abr.restrictions); } } adapterConfig.network = config.network; return new this(videoElement, source, adapterConfig); } /** * @constructor * @param {HTMLVideoElement} videoElement - The video element which bind to NativeAdapter * @param {PKMediaSourceObject} source - The source object * @param {Object} config - The player configuration */ constructor(videoElement: HTMLVideoElement, source: PKMediaSourceObject, config: any) { super(videoElement, source, config); NativeAdapter._logger.debug('Creating adapter'); this._config = Utils.Object.mergeDeep({}, defaultConfig, this._config); this._progressiveSources = config.progressiveSources; this._liveEdge = 0; this._setStarTimeOfDvrWindowInterval(); } /** * dispatches an error (is given to a class the cannot dispatch, like static fair play class) * @private * @param {Error} error - the error to dispatch * @returns {void} */ private _dispatchErrorCallback(error: Error): void { this._trigger(Html5EventType.ERROR, error); } /** * dispatches the license response time after received * @private * @param {number} data - an object containing data regarding the license load * @returns {void} */ private _dispatchDRMLicenseLoaded(data: any): void { this._trigger(CustomEventType.DRM_LICENSE_LOADED, data); } /** * Sets the DRM playback in case such needed. * @private * @returns {void} */ private _maybeSetDrmPlayback(): void { if (NativeAdapter._drmProtocol && this._sourceObj && this._sourceObj.drmData) { const drmConfig: FairPlayDrmConfigType = { licenseUrl: '', certificate: '', network: this._config.network, useKIDHeader: !!this._config.useKIDHeader }; NativeAdapter._drmProtocol.setDrmPlayback(drmConfig, this._sourceObj.drmData); if (this._config.useFairPlayHandlerV2) { this._drmHandler = new FairPlayDrmHandlerV2( this._videoElement, drmConfig, (error) => this._dispatchErrorCallback(error), (data) => this._dispatchDRMLicenseLoaded(data) ); } else { this._drmHandler = new FairPlayDrmHandler( this._videoElement, drmConfig, (error) => this._dispatchErrorCallback(error), (data) => this._dispatchDRMLicenseLoaded(data) ); } } } /** * Set the suitable progressive source according the current resolution * @function _setProgressiveSource * @returns {void} * @private */ private _setProgressiveSource(): void { const suitableTrack = getSuitableSourceForResolution(this._progressiveSources, this._videoElement.offsetWidth, this._videoElement.offsetHeight); if (suitableTrack) { this._sourceObj = suitableTrack; } } /** * Checks if the playback source is progressive * @function _isProgressivePlayback * @returns {boolean} - is progressive source * @private */ private _isProgressivePlayback(): boolean { return this._sourceObj ? NativeAdapter._progressiveMimeTypes.includes(this._sourceObj.mimetype.toLowerCase()) : false; } /** * Load the video source * @param {number} startTime - Optional time to start the video from. * @function load * @returns {Promise} - The loaded data */ public load(startTime?: number): Promise<{tracks: Track[]}> { this._maybeSetDrmPlayback(); if (!this._loadPromise) { this._loadPromise = new Promise<{tracks: Track[]}>((resolve, reject) => { this._lastTimeUpdate = startTime || 0; const realStartTime = typeof startTime === 'number' ? startTime : -1; const playbackStartTime = this._startTimeAttach || realStartTime; this._loadPromiseReject = reject; this._eventManager.listenOnce(this._videoElement, Html5EventType.LOADED_DATA, () => this._onLoadedData(resolve, playbackStartTime)); this._eventManager.listenOnce(this._videoElement, Html5EventType.PLAYING, () => this._onPlaying(playbackStartTime)); this._eventManager.listen(this._videoElement, Html5EventType.TIME_UPDATE, () => this._onTimeUpdate()); this._eventManager.listen(this._videoElement, Html5EventType.PLAY, () => this._resetHeartbeatTimeout()); this._eventManager.listen(this._videoElement, Html5EventType.PAUSE, () => this._clearHeartbeatTimeout()); this._eventManager.listen(this._videoElement, Html5EventType.ENDED, () => this._clearHeartbeatTimeout()); this._eventManager.listen(this._videoElement, Html5EventType.ABORT, () => this._clearHeartbeatTimeout()); this._eventManager.listen(this._videoElement, Html5EventType.SEEKED, () => this._syncCurrentTime()); this._eventManager.listen(this._videoElement, Html5EventType.WAITING, () => (this._waitingEventTriggered = true)); this._eventManager.listen(this._videoElement, Html5EventType.PLAYING, () => (this._waitingEventTriggered = false)); // Sometimes when playing live in safari and switching between tabs the currentTime goes back with no seek events this._eventManager.listen(window, 'focus', () => { setTimeout(() => { if (Env.isIOS) { // In IOS HLS, sometimes when coming back from lock screen/Idle mode, the stream will get stuck, and only a small seek nudge will fix it. this._videoElement.currentTime = this._videoElement.currentTime > NUDGE_SEEK_AFTER_FOCUS ? this._videoElement.currentTime - NUDGE_SEEK_AFTER_FOCUS : 0; } this._syncCurrentTime(); }, BACK_TO_FOCUS_TIMEOUT); }); if (this._isProgressivePlayback()) { this._setProgressiveSource(); } if (this._sourceObj && this._sourceObj.url) { this._setSrc().then(() => { this._trigger(CustomEventType.ABR_MODE_CHANGED, {mode: this._isProgressivePlayback() ? 'manual' : 'auto'}); this._videoElement.load(); }); } else { this._videoElement.load(); } }); } return this._loadPromise; } /** * handle decode error - reload the video and seek to last currentTime * @param {?MediaError}error - the error object to be printed to log * @private * @returns {void} */ private _handleDecodeError(error: MediaError): void { NativeAdapter._logger.debug('handleDecodeError', error); const prevCurrTime = this._videoElement.currentTime; const prevActiveAudioTrack = this._getActivePKAudioTrack(); const prevActiveTextTrack = this._getActivePKTextTrack(); this._videoElement.load(); this._eventManager.listenOnce(this._videoElement, Html5EventType.PLAYING, () => { this._mediaErrorRecoveryAttempts = 0; }); this._eventManager.listenOnce(this._videoElement, Html5EventType.CAN_PLAY, () => { NativeAdapter._logger.debug('CAN_PLAY'); this._videoElement.currentTime = prevCurrTime; this._videoElement.play(); this._videoElement.pause(); if (prevActiveAudioTrack) { this.selectAudioTrack(prevActiveAudioTrack); } if (prevActiveTextTrack) { this.selectTextTrack(prevActiveTextTrack); } else { this.disableNativeTextTracks(); } }); } public handleMediaError(error?: MediaError): boolean { if (this._loadPromiseReject) { this._loadPromiseReject(new Error(Error.Severity.CRITICAL, Error.Category.MEDIA, Error.Code.NATIVE_ADAPTER_LOAD_FAILED, error)); return true; } else if (error && error.code === MediaError.MEDIA_ERR_DECODE) { this._mediaErrorRecoveryAttempts++; if (this._mediaErrorRecoveryAttempts <= MAX_MEDIA_RECOVERY_ATTEMPTS) { this._handleDecodeError(error); return true; } } return false; } /** * attach media - return the media source to handle the video tag * @public * @returns {void} */ public attachMediaSource(): void { this._startTimeAttach = this._lastTimeDetach; this._lastTimeDetach = NaN; } /** * detach media - will remove the media source from handling the video * @public * @returns {void} */ public detachMediaSource(): void { this._lastTimeDetach = this._videoElement.currentTime; if (this._videoElement && this._videoElement.src) { Utils.Dom.setAttribute(this._videoElement, 'src', ''); Utils.Dom.removeAttribute(this._videoElement, 'src'); } this._loadPromise = undefined; } private _setSrc(): Promise { const pkRequest: PKRequestObject = {url: this._sourceObj ? this._sourceObj.url : '', body: null, headers: {}}; let requestFilterPromise; if (typeof Utils.Object.getPropertyPath(this._config, 'network.requestFilter') === 'function') { try { NativeAdapter._logger.debug('Apply request filter'); requestFilterPromise = this._config.network.requestFilter(RequestType.MANIFEST, pkRequest); } catch (error) { requestFilterPromise = Promise.reject(error); } } requestFilterPromise = requestFilterPromise || Promise.resolve(pkRequest); this._hlsManifestHandler.reset(); return requestFilterPromise .then(updatedRequest => { return this._hlsManifestHandler.fetchManifestData(updatedRequest.url).then(() => { if (this._config.useSourceTag) { const source = document.createElement('source'); const mimetype = this._sourceObj?.mimetype.toLowerCase() || ''; source.setAttribute('src', updatedRequest.url); source.setAttribute('type', mimetype); if (this._config.useMediaOptionAttribute) { let options = {}; // https://webostv.developer.lge.com/develop/guides/mediaoption-parameter if (this._config.mediaOptionAttribute) { /** * Undocumented option. * Usage example: * var video = document.querySelector('video'); * video.addEventListener("umsmediainfo", function(e) { * console.log(JSON.parse(e.detail)); * }); * { * useUMSMediaInfo: true * } **/ options = this._config.mediaOptionAttribute; } if (this._config.abrEwmaDefaultEstimate) { const bps = {start: this._config.abrEwmaDefaultEstimate}; Utils.Object.createPropertyPath(options, 'option.adaptiveStreaming.bps', bps); } NativeAdapter._logger.debug('Setting mediaOption -', options); const mediaOption = encodeURI(JSON.stringify(options)); source.setAttribute('type', mimetype + ';mediaOption=' + mediaOption); } this._videoElement.appendChild(source); } else { this._videoElement.src = updatedRequest.url; } }); }) .catch(error => { this._trigger(Html5EventType.ERROR, new Error(Error.Severity.CRITICAL, Error.Category.NETWORK, Error.Code.REQUEST_FILTER_ERROR, error)); }); } /** * play event handler. * @param {number} startTime - Optional time to start the video from. * @private * @returns {void} */ private _onPlaying(startTime?: number): void { if (this.isLive()) { this._setStartTime(startTime); } } /** * Loaded data event handler. * @param {Function} resolve - The resolve promise function. * @param {number} startTime - Optional time to start the video from. * @private * @returns {void} */ private _onLoadedData(resolve: (value: (PromiseLike<{tracks: Track[]}> | {tracks: Track[]})) => void, startTime?: number): void { const parseTracksAndResolve = () : void => { this._playerTracks = this._getParsedTracks(); this._addNativeAudioTrackChangeListener(); this._addNativeTextTrackChangeListener(); this._addNativeTextTrackAddedListener(); NativeAdapter._logger.debug('The source has been loaded successfully'); this._loadPromiseReject = undefined; resolve({tracks: this._playerTracks}); if (this.isLive()) { this._handleLiveDurationChange(); } }; if (startTime !== undefined && startTime > -1) { this._videoElement.currentTime = startTime; } if (this._videoElement.textTracks.length > 0) { parseTracksAndResolve(); } else { this._eventManager.listenOnce(this._videoElement, Html5EventType.CAN_PLAY, parseTracksAndResolve.bind(this)); } this._startTimeAttach = NaN; } private _setStartTime(startTime?: number): void { if (typeof startTime === 'number' && startTime > -1) { this._videoElement.currentTime = startTime; } } private _onTimeUpdate(): void { if (!this._videoElement.paused) { if (this._videoElement.currentTime > this._lastTimeUpdate) { if (this._waitingEventTriggered) { this._waitingEventTriggered = false; this._trigger(Html5EventType.PLAYING); } this._resetHeartbeatTimeout(); } else if (this._videoElement.currentTime < this._lastTimeUpdate) { this._syncCurrentTime(); } else { this._waitingEventTriggered = true; this._trigger(Html5EventType.WAITING); } } this._handleVideoTracksChange(); } private _syncCurrentTime(): void { this._lastTimeUpdate = this._videoElement.currentTime; } private _resetHeartbeatTimeout(): void { this._lastTimeUpdate = this._videoElement.currentTime; this._clearHeartbeatTimeout(); const onTimeout = () : void => { this._clearHeartbeatTimeout(); this._trigger( Html5EventType.ERROR, new Error( Error.Severity.CRITICAL, Error.Category.NETWORK, Error.Code.TIMEOUT, `The player exceeded max buffer time of ${this._config.heartbeatTimeout} ms. No progress has been done during this time.` ) ); }; this._heartbeatTimeoutId = setTimeout(onTimeout, this._config.heartbeatTimeout); } private _clearHeartbeatTimeout(): void { if (this._heartbeatTimeoutId) { clearTimeout(this._heartbeatTimeoutId); this._heartbeatTimeoutId = undefined; } } private _handleVideoTracksChange(): void { if (!this._isProgressivePlayback()) { // @ts-expect-error - Property 'videoTracks' does not exist on type 'HTMLVideoElement' const {videoHeight, videoWidth, videoTracks = []} = this._videoElement; if (!this._videoDimensions || videoHeight !== this._videoDimensions.videoHeight || videoWidth !== this._videoDimensions.videoWidth) { this._videoDimensions = {videoHeight, videoWidth}; const setting = { language: '', height: videoHeight, width: videoWidth, active: true, // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore index: Array.from(videoTracks).findIndex((track) => track.selected) }; this._onTrackChanged(new VideoTrack(setting)); } } } /** * Destroys the native adapter. * @function destroy * @returns {Promise<*>} - The destroy promise. */ public destroy(): Promise { NativeAdapter._logger.debug('destroy'); return new Promise((resolve, reject) => { //must be called before super config cause it requires config which is reset in super destroy this._maybeRemoveSourceTag(); super.destroy().then( () => { this._videoElement.classList.remove(NATIVE_TEXT_CLASS); this._drmHandler && this._drmHandler.destroy(); this._hlsManifestHandler.reset(); this._waitingEventTriggered = false; this._progressiveSources = []; this._loadPromise = undefined; this._nativeTextTracksMap = {}; this._loadPromiseReject = undefined; this._liveEdge = 0; this._lastTimeUpdate = 0; this._lastTimeDetach = NaN; this._startTimeAttach = NaN; this._videoDimensions = undefined; this._clearHeartbeatTimeout(); clearInterval(this._startTimeOfDvrWindowInterval); if (this._liveDurationChangeInterval) { clearInterval(this._liveDurationChangeInterval); this._liveDurationChangeInterval = undefined; } resolve(); }, () => reject ); }); } /** * remove source tag * @function _maybeRemoveSourceTag * @returns {void} * @private */ private _maybeRemoveSourceTag(): void { if (this._config.useSourceTag && this._videoElement) { const source = this._videoElement.firstChild as HTMLElement; if (source) { source.setAttribute('src', ''); source.removeAttribute('src'); this._videoElement.removeChild(source); } } } /** * Get the parsed tracks * @function _getParsedTracks * @returns {Array} - The parsed tracks * @private */ private _getParsedTracks(): Array { const videoTracks = this._getParsedVideoTracks(); const audioTracks = this._getParsedAudioTracks(); const textTracks = this._getParsedTextTracks(); return [...videoTracks, ...audioTracks, ...textTracks]; } /** * Get the parsed video tracks * @function _getParsedVideoTracks * @returns {Array} - The parsed video tracks * @private */ private _getParsedVideoTracks(): Array { if (this._isProgressivePlayback()) { return this._getParsedProgressiveVideoTracks(); } else { return this._getParsedAdaptiveVideoTracks(); } } /** * Get the parsed progressive video tracks * @function _getParsedProgressiveVideoTracks * @returns {Array} - The parsed progressive video tracks * @private */ private _getParsedProgressiveVideoTracks(): Array { const videoTracks = this._progressiveSources; const parsedTracks: VideoTrack[] = []; if (videoTracks) { for (let i = 0; i < videoTracks.length; i++) { const settings = { id: videoTracks[i].id, bandwidth: videoTracks[i].bandwidth, width: videoTracks[i].width, height: videoTracks[i].height, active: this._sourceObj ? videoTracks[i].id === this._sourceObj.id : false, index: i }; parsedTracks.push(new VideoTrack(settings)); } } return parsedTracks; } /** * Get the parsed adaptive video tracks * @function _getParsedAdaptiveVideoTracks * @returns {Array} - The parsed adaptive video tracks * @private */ private _getParsedAdaptiveVideoTracks(): Array { //TODO check adaptation in safari hls // @ts-expect-error - Property 'videoTracks' does not exist on type 'HTMLVideoElement' const videoTracks = this._videoElement.videoTracks; const parsedTracks: VideoTrack[] = []; if (videoTracks) { for (let i = 0; i < videoTracks.length; i++) { const settings = { //TODO calculate width/height/bandwidth id: videoTracks[i].id, active: videoTracks[i].selected, label: videoTracks[i].label, language: videoTracks[i].language, index: i }; parsedTracks.push(new VideoTrack(settings)); } } return parsedTracks; } /** * Get the parsed audio tracks * @function _getParsedAudioTracks * @returns {Array} - The parsed audio tracks * @private */ private _getParsedAudioTracks(): Array { // @ts-expect-error - Property 'audioTracks' does not exist on type 'HTMLVideoElement' const audioTracks = this._videoElement.audioTracks; const parsedTracks: AudioTrack[] = []; if (audioTracks) { for (let i = 0; i < audioTracks.length; i++) { const settings = { id: audioTracks[i].id, active: audioTracks[i].enabled, label: audioTracks[i].label, language: audioTracks[i].language, index: i, kind: audioTracks[i].kind, flavorId: this._hlsManifestHandler.extractFlavorId(i) }; parsedTracks.push(new AudioTrack(settings)); } } return parsedTracks; } /** * Get the parsed text tracks * @function _getParsedTextTracks * @returns {Array} - The parsed text tracks * @private */ private _getParsedTextTracks(): Array { const captionsTextTrackLabels = [this._config.captionsTextTrack1Label, this._config.captionsTextTrack2Label]; const captionsTextTrackLanguageCodes = [this._config.captionsTextTrack1LanguageCode, this._config.captionsTextTrack2LanguageCode]; const textTracks = this._videoElement.textTracks; const parsedTracks: PKTextTrack[] = []; if (textTracks) { for (let i = 0; i < textTracks.length; i++) { if (!PKTextTrack.isExternalTrack(textTracks[i])) { const settings = { kind: textTracks[i].kind, active: textTracks[i].mode === PKTextTrack.MODE.SHOWING, label: textTracks[i].label, language: textTracks[i].language, available: true }; if (settings.kind === PKTextTrack.KIND.SUBTITLES) { const newTrack: PKTextTrack = new PKTextTrack(settings); parsedTracks.push(newTrack); this._nativeTextTracksMap[newTrack.index] = textTracks[i]; } else if (settings.kind === PKTextTrack.KIND.CAPTIONS && this._config.enableCEA708Captions) { settings.label = settings.label || captionsTextTrackLabels.shift(); settings.language = settings.language || captionsTextTrackLanguageCodes.shift(); settings.available = this._captionsHidden; const newTrack: PKTextTrack = new PKTextTrack(settings); parsedTracks.push(newTrack); this._nativeTextTracksMap[newTrack.index] = textTracks[i]; } } } if (!this._captionsHidden) { this._maybeShow708Captions(); } } return parsedTracks; } private _maybeShow708Captions(): void { const captions = Array.from(this._videoElement.textTracks).filter(track => track.kind === PKTextTrack.KIND.CAPTIONS); const activeCaption = captions.find(track => track.mode === PKTextTrack.MODE.SHOWING || track.mode === PKTextTrack.MODE.HIDDEN); const textTrack = activeCaption || captions[0]; if (textTrack) { textTrack.mode = PKTextTrack.MODE.HIDDEN; this._captionsHidden = true; this._eventManager.listenOnce(textTrack, 'cuechange', () => { const textTracks = this._getPKTextTracks(); textTracks.forEach(track => (track.available = true) && (track.mode = PKTextTrack.MODE.DISABLED)); this._trigger(CustomEventType.TRACKS_CHANGED, {tracks: this._playerTracks}); }); } } /** * Select a video track * @function selectVideoTrack * @param {VideoTrack} videoTrack - the track to select * @returns {void} * @public */ public selectVideoTrack(videoTrack: VideoTrack): void { if (this._isProgressivePlayback()) { this._selectProgressiveVideoTrack(videoTrack); } else { this.selectAdaptiveVideoTrack(videoTrack); } } /** * Select a progressive video track * @function _selectProgressiveVideoTrack * @param {VideoTrack} videoTrack - the track to select * @returns {void} * @public */ private _selectProgressiveVideoTrack(videoTrack: VideoTrack): void { const videoTracks = this._progressiveSources; if (videoTrack instanceof VideoTrack && videoTracks && videoTracks[videoTrack.index]) { const currentTime = this._videoElement.currentTime; const paused = this._videoElement.paused; videoTrack.active = true; this._sourceObj = videoTracks[videoTrack.index]; this._eventManager.listenOnce(this._videoElement, Html5EventType.LOADED_DATA, () => { if (Env.browser.name === 'Android Browser') { // In android browser we have to seek only after some playback. this._eventManager.listenOnce(this._videoElement, Html5EventType.DURATION_CHANGE, () => { this._videoElement.currentTime = currentTime; }); this._eventManager.listenOnce(this._videoElement, Html5EventType.SEEKED, () => { this._onTrackChanged(videoTrack); if (paused) { this._videoElement.pause(); } }); this._videoElement.play(); } else { this._eventManager.listenOnce(this._videoElement, Html5EventType.SEEKED, () => { this._onTrackChanged(videoTrack); }); this._videoElement.currentTime = currentTime; if (!paused) { this._videoElement.play(); } } }); this._setSrc(); } } /** * Select a native video track * @function selectAdaptiveVideoTrack * @param {VideoTrack} videoTrack - the track to select * @returns {void} * @public */ private selectAdaptiveVideoTrack(videoTrack: VideoTrack): void { // @ts-expect-error - Property 'videoTracks' does not exist on type 'HTMLVideoElement' const videoTracks = this._videoElement.videoTracks; if (videoTrack instanceof VideoTrack && videoTracks && videoTracks[videoTrack.index]) { this._disableVideoTracks(); videoTracks[videoTrack.index].selected = true; this._onTrackChanged(videoTrack); } } /** * Select an audio track * @function selectAudioTrack * @param {AudioTrack} audioTrack - the audio track to select * @returns {void} * @public */ public selectAudioTrack(audioTrack: AudioTrack): void { NativeAdapter._logger.debug('selectAudioTrack'); // @ts-expect-error - Property 'audioTracks' does not exist on type 'HTMLVideoElement' const audioTracks = this._videoElement.audioTracks; if (audioTrack instanceof AudioTrack && audioTracks && audioTracks[audioTrack.index]) { this._removeNativeAudioTrackChangeListener(); this._switchAudioTrack(audioTrack.index); this._onTrackChanged(audioTrack); this._addNativeAudioTrackChangeListener(); } } /** * Remove the onchange listenr of the video element AudioTrackList. * @private * @returns {void} */ private _removeNativeAudioTrackChangeListener(): void { // @ts-expect-error - Property 'audioTracks' does not exist on type 'HTMLVideoElement' if (this._videoElement.audioTracks) { // @ts-expect-error - Property 'audioTracks' does not exist on type 'HTMLVideoElement' this._eventManager.unlisten(this._videoElement.audioTracks, 'change'); } } /** * Add the onchange listenr of the video element AudioTrackList. * @private * @returns {void} */ private _addNativeAudioTrackChangeListener(): void { // @ts-expect-error - Property 'audioTracks' does not exist on type 'HTMLVideoElement' if (this._videoElement.audioTracks) { // @ts-expect-error - Property 'audioTracks' does not exist on type 'HTMLVideoElement' this._eventManager.listen(this._videoElement.audioTracks, 'change', () => this._onNativeAudioTrackChange()); } } private _getPKAudioTracks(): Array { return this._playerTracks.filter((track) => track instanceof AudioTrack).map((at) => at as AudioTrack); } private _getActivePKAudioTrack(): AudioTrack | undefined { const pkAudioTracks = this._getPKAudioTracks(); return pkAudioTracks.find(track => track.active === true); } /** * Handler of the video element AudioTrackList onchange event. * @private * @returns {void} */ private _onNativeAudioTrackChange(): void { const getActiveVidAudioTrackIndex = (): number => { // @ts-expect-error - Property 'audioTracks' does not exist on type 'HTMLVideoElement' for (let i = 0; i < this._videoElement.audioTracks.length; i++) { // @ts-expect-error - Property 'audioTracks' does not exist on type 'HTMLVideoElement' const audioTrack = this._videoElement.audioTracks[i]; if (audioTrack.enabled) { return i; } } return -1; }; const vidIndex = getActiveVidAudioTrackIndex(); const activeAudioTrack = this._getActivePKAudioTrack(); const pkIndex = activeAudioTrack ? activeAudioTrack.index : -1; if (vidIndex !== pkIndex) { const audioTracks = this._getPKAudioTracks(); const pkAudioTrack = audioTracks.find(track => track.index === vidIndex); if (pkAudioTrack) { NativeAdapter._logger.debug('Native selection of track, update the player audio track (' + pkIndex + ' -> ' + vidIndex + ')'); this._onTrackChanged(pkAudioTrack); } } } /** * Select a text track * @function selectTextTrack * @param {PKTextTrack} textTrack - The playkit text track * @returns {void} * @public */ public selectTextTrack(textTrack: PKTextTrack): void { if (textTrack instanceof PKTextTrack && PKTextTrack.isNativeTextTrack(textTrack)) { this._removeNativeTextTrackChangeListener(); const selectedTrack = this._nativeTextTracksMap[textTrack.index]; if (selectedTrack) { this.disableNativeTextTracks(); selectedTrack.mode = this._getDisplayTextTrackModeString(); this._onTrackChanged(textTrack); this._addNativeTextTrackChangeListener(); } } } /** * Remove the onchange listenr of the video element TextTrackList. * @private * @returns {void} */ private _removeNativeTextTrackChangeListener(): void { if (this._videoElement.textTracks) { this._eventManager.unlisten(this._videoElement.textTracks, 'change'); } } /** * Add the onchange listenr of the video element TextTrackList. * @private * @returns {void} */ private _addNativeTextTrackChangeListener(): void { if (this._videoElement.textTracks) { this._eventManager.listen(this._videoElement.textTracks, 'change', () => this._onNativeTextTrackChange()); } } private _getPKTextTracks(): Array { return this._playerTracks.filter(track => track instanceof PKTextTrack) as PKTextTrack[]; } private _getActivePKTextTrack(): PKTextTrack | undefined { const pkTextTracks = this._getPKTextTracks(); return pkTextTracks.find(track => track.active === true); } /** * Handler of the video element TextTrackList onchange event. * @private * @returns {void} */ private _onNativeTextTrackChange(): void { const pkTextTracks = this._getPKTextTracks(); const pkOffTrack = pkTextTracks.find(track => track.language === 'off'); const getActiveVidTextTrackIndex = (): number => { for (const textTrackId in this._nativeTextTracksMap) { if (this._getDisplayTextTrackModeString() === this._nativeTextTracksMap[textTrackId].mode) { return Number(textTrackId); } } return -1; }; const vidIndex: number = getActiveVidTextTrackIndex(); const activePKtextTrack = this._getActivePKTextTrack(); const pkIndex = activePKtextTrack ? activePKtextTrack.index : -1; if (vidIndex !== pkIndex) { // In case no text track with 'showing' mode // we need to set the off track if (vidIndex === -1) { if (pkOffTrack) { NativeAdapter._logger.debug('Native selection of track, update the player text track (' + pkIndex + ' -> off)'); this._onTrackChanged(pkOffTrack); } } else { // In case the text track on the video element is // different then the text track of the player // we need to set the correct one const pkTextTrack = pkTextTracks.find(track => track.index === vidIndex); if (pkTextTrack) { NativeAdapter._logger.debug('Native selection of track, update the player text track (' + pkIndex + ' -> ' + vidIndex + ')'); this._onTrackChanged(pkTextTrack); } } } } /** * Returns the mode (hidden / showing) of the native text track should have according to the displayTextTrack config. * Both 'showing' and 'hidden' indicates the the text track is active and trigger cue events but 'hidden' hides it * from the UI. * @returns {string} the mode string * @private */ private _getDisplayTextTrackModeString(): string { return this._config.displayTextTrack ? PKTextTrack.MODE.SHOWING : PKTextTrack.MODE.HIDDEN; } /** * Add the onaddtrack listenr of the video element TextTrackList. * @private * @returns {void} */ private _addNativeTextTrackAddedListener(): void { if (!this._config.displayTextTrack && this._videoElement.textTracks) { this._eventManager.listen(this._videoElement.textTracks, 'addtrack', () => this._onNativeTextTrackAdded()); } } /** * Handler of the video element TextTrackList onaddtrack event. * @private * @returns {void} */ private _onNativeTextTrackAdded(): void { this._playerTracks = this._getParsedTracks(); this._trigger(CustomEventType.TRACKS_CHANGED, {tracks: this._playerTracks}); } /** * Hide the text track * @function hideTextTrack * @returns {void} * @public */ public hideTextTrack(): void { this.disableNativeTextTracks(); } /** * Enables adaptive bitrate * @function enableAdaptiveBitrate * @returns {void} * @public */ public enableAdaptiveBitrate(): void { NativeAdapter._logger.warn('Enabling adaptive bitrate is not supported for native playback'); } /** * Checking if adaptive bitrate switching is enabled. * For progressive playback will always returns false. * For adaptive playback will always returns true. * @function isAdaptiveBitrateEnabled * @returns {boolean} - Whether adaptive bitrate is enabled. * @public */ public isAdaptiveBitrateEnabled(): boolean { return !this._isProgressivePlayback(); } /** * Apply ABR restriction * @function applyABRRestriction * @param {PKABRRestrictionObject} restrictions - abr restrictions config * @returns {void} * @public */ public applyABRRestriction(restrictions: PKABRRestrictionObject): void { Utils.Object.createPropertyPath(this._config, 'abr.restrictions', restrictions); this._maybeApplyAbrRestrictions(restrictions); } /** * apply ABR restrictions * @private * @param {PKABRRestrictionObject} restrictions - abt config object * @returns {void} */ private _maybeApplyAbrRestrictions(restrictions: PKABRRestrictionObject): void { if (this._isProgressivePlayback()) { const videoTracks = this._playerTracks.filter(track => track instanceof VideoTrack); const availableTracks = filterTracksByRestriction(videoTracks as VideoTrack[], restrictions); const activeTrackInRange = availableTracks.find(track => track.active); if (!activeTrackInRange && availableTracks.length) { this.selectVideoTrack(availableTracks[0]); } else { NativeAdapter._logger.warn('Invalid restrictions, there are not tracks within the restriction range'); } } } /** * Disables all the existing video tracks. * @private * @returns {void} */ private _disableVideoTracks(): void { // @ts-expect-error - Property 'audioTracks' does not exist on type 'HTMLVideoElement' const videoTracks = this._videoElement.videoTracks; if (videoTracks) { for (let i = 0; i < videoTracks.length; i++) { videoTracks[i].selected = false; } } } /** * Switch an audio track * @param {Number} index - the audio track index to select * @private * @returns {void} */ protected _switchAudioTrack(index: number): void { NativeAdapter._logger.debug('_switchAudioTracks'); // @ts-expect-error - Property 'audioTracks' does not exist on type 'HTMLVideoElement' const videoElementAudioTracks = this._videoElement.audioTracks; if (videoElementAudioTracks) { for (let i = 0; i < videoElementAudioTracks.length; i++) { if (i === index) { videoElementAudioTracks[i].enabled = true; } else { videoElementAudioTracks[i].enabled = false; } } } } /** * Returns the live edge * @returns {number} - live edge * @private */ protected _getLiveEdge(): number { if (this._videoElement.seekable.length) { return this._videoElement.seekable.end(this._videoElement.seekable.length - 1); } else if (this._videoElement.buffered.length) { return this._videoElement.buffered.end(this._videoElement.buffered.length - 1); } else { return this._videoElement.duration; } } public get liveDuration(): number { return this._getLiveEdge(); } /** * Seeking to live edge. * @function seekToLiveEdge * @returns {void} * @public */ public seekToLiveEdge(): void { try { this._videoElement.currentTime = this._getLiveEdge(); } catch (e) { return; } } public getSegmentDuration(): number { return this._segmentDuration; } /** * Checking if the current playback is live. * @function isLive * @returns {boolean} - Whether playback is live. * @public */ public isLive(): boolean { return this._videoElement.duration === Infinity; } /** * Handling live duration change (as safari doesn't trigger durationchange event on live playback) * @function _handleLiveDurationChange * @returns {void} * @private */ private _handleLiveDurationChange(): void { this._liveDurationChangeInterval = setInterval(() => { this._calculateSegmentDuration(); const liveEdge = this._getLiveEdge(); if (this._liveEdge !== liveEdge) { this._liveEdge = liveEdge; this._videoElement.dispatchEvent(new Event(Html5EventType.DURATION_CHANGE)); } }, LIVE_DURATION_INTERVAL_MS); } /** * Calculate the segment duration * @function _calculateSegmentDuration * @returns {void} * @private */ private _calculateSegmentDuration(): void { if (this._videoElement.seekable.length > 0 && this._videoElement.seekable.start(0) === 0) { const {buffered, seekable} = this._videoElement; if (buffered.length && seekable.length) { this._segmentDuration = (buffered.end(buffered.length - 1) - seekable.end(seekable.length - 1)) / SAFARI_BUFFERED_SEGMENTS_COUNT; } } else { const liveEdge = this._getLiveEdge(); if (this._liveEdge && this._liveEdge !== liveEdge) { this._segmentDuration = liveEdge - this._liveEdge; } } } /** * Get the start time of DVR window in live playback in seconds. * @returns {Number} - start time of DVR window. * @public */ private _getStartTimeOfDvrWindow(): number { if (this.isLive() && this._videoElement.seekable.length) { return this._videoElement.seekable.start(0); } else { return 0; } } public getStartTimeOfDvrWindow(): number { return this._startTimeOfDvrWindow; } private _setStarTimeOfDvrWindowInterval(): void { const intervalTime = 1000; this._startTimeOfDvrWindowInterval = setInterval(() => { //get Segment duration const duration = this._segmentDuration; if ( !this._waitingEventTriggered && //not in wait this._getStartTimeOfDvrWindow() && //value is not Zero duration && //Duration exist Math.abs(this._getStartTimeOfDvrWindow() - this._startTimeOfDvrWindow) <= duration * 2 //Gap is less than twice the duration ) { this._startTimeOfDvrWindow += intervalTime / 1000; } else { this._startTimeOfDvrWindow = this._getStartTimeOfDvrWindow(); } }, intervalTime); } public getDrmInfo(): PKDrmDataObject | null { return this._drmHandler ? this._drmHandler!.getDrmInfo() : null; } }