@playkit-js/playkit-js-dash
Version:
[](https://github.com/kaltura/playkit-js-dash/actions/workflows/run_canary_full_flow.yaml) [ • 50.5 kB
text/typescript
import shaka from 'shaka-player';
import {
AudioTrack,
BaseMediaSourceAdapter,
Error,
EventType,
RequestType,
TextTrack as PKTextTrack,
Track,
TimedMetadata,
createTextTrackCue,
Utils,
VideoTrack,
ImageTrack,
ThumbnailInfo,
PKABRRestrictionObject,
filterTracksByRestriction, PKDrmDataObject, PKMediaSourceObject, IMediaSourceAdapter, FakeEvent, IDrmProtocol, PKResponseObject, PKRequestObject, PKDrmConfigObject
} from '@playkit-js/playkit-js';
import {Widevine} from './drm/widevine';
import {PlayReady} from './drm/playready';
import DefaultConfig from './default-config.json';
import './assets/style.css';
import {DashManifestParser} from './parser/dash-manifest-parser';
import {DashThumbnailController} from './dash-thumbnail-controller';
type ShakaEventType = {[event: string]: string};
/**
* Shaka events enum
* @type {Object}
* @const
*/
const ShakaEvent: ShakaEventType = {
ERROR: 'error',
ADAPTATION: 'adaptation',
BUFFERING: 'buffering',
DRM_SESSION_UPDATE: 'drmsessionupdate',
EMSG: 'emsg'
};
type ErrorEventsType = {[errorCode: string]: {'timeStamp': number, 'count': number}};
/**
* the interval in which to sample player size changes
* @type {number}
* @const
*/
const ABR_RESTRICTION_UPDATE_INTERVAL = 1000;
/**
* the interval for stall detection in milliseconds
* @type {number}
* @const
*/
const STALL_DETECTION_INTERVAL = 500;
/**
* the minimal threshold for stall detection in seconds
* @type {number}
* @const
*/
const MIN_STALL_DETECTION_THRESHOLD = 2;
/**
* the threshold needed to break the stall
* @type {number}
* @const
*/
const STALL_BREAK_THRESHOLD = 0.1;
/**
* Adapter of shaka lib for dash content
* @classdesc
*/
export default class DashAdapter extends BaseMediaSourceAdapter {
/**
* The id of Adapter
* @member {string} id
* @static
* @public
*/
public static id = 'DashAdapter';
/**
* The adapter logger
* @member {any} _logger
* @private
* @static
*/
protected static _logger = BaseMediaSourceAdapter.getLogger(DashAdapter.id);
public static textContainerClass = "shaka-text-container";
/**
* The supported mime type by the dash adapter
* @member {string} _dashMimeType
* @static
* @private
*/
private static _dashMimeType = 'application/dash+xml';
/**
* The DRM protocols implementations for dash adapter.
* @type {Array<Function>}
* @private
* @static
*/
private static _drmProtocols: Array<IDrmProtocol> = [Widevine, PlayReady];
/**
* The DRM protocols available for the current playback.
* @type {Array<Function>}
* @private
* @static
*/
private static _availableDrmProtocol: Array<IDrmProtocol> = [];
/**
* The shaka Lib
* @member {any} _shakaLib
* @private
*/
private _shakaLib: typeof shaka = shaka;
/**
* The shaka player instance
* @member {any} _shaka
* @private
*/
private _shaka!: shaka.Player;
/**
* an object containing all the events we bind and unbind to.
* @member {Object} - _adapterEventsBindings
* @type {Object}
* @private
*/
private _adapterEventsBindings: {[name: string]: (event: FakeEvent) => any} = {
[ShakaEvent.ERROR]: event => this._onError(event),
[ShakaEvent.ADAPTATION]: () => this._onAdaptation(),
[ShakaEvent.BUFFERING]: event => this._onBuffering(event),
[ShakaEvent.DRM_SESSION_UPDATE]: () => this._onDrmSessionUpdate(),
[ShakaEvent.EMSG]: event => this._onEmsg(event),
[EventType.WAITING]: () => this._onWaiting(),
[EventType.PLAYING]: () => this._onPlaying()
};
/**
* The buffering state flag
* @member {boolean} - _buffering
* @type {boolean}
* @private
*/
private _buffering: boolean = false;
/**
* Whether 'waiting' event has been sent by the HTMLVideoElement
* @member {boolean} - _waitingSent
* @type {boolean}
* @private
*/
private _waitingSent: boolean = false;
/**
* Whether 'playing' event has been sent by the HTMLVideoElement
* @member {boolean} - _playingSent
* @type {boolean}
* @private
*/
private _playingSent: boolean = false;
/**
* Video size update timer
* @type {null|number}
* @private
*/
private _videoSizeUpdateTimer: number | null = null;
/**
* stall interval to break the stall on Smart TV
* @type {null|IntervalID}
* @private
*/
private _stallInterval: number | null = null;
/**
* 3016 is the number of the video error at shaka, we already listens to it in the html5 class
* @member {number} - VIDEO_ERROR_CODE
* @type {number}
* @private
*/
public VIDEO_ERROR_CODE: number = 3016;
/**
* The last time detach occurred
* @type {number}
* @private
*/
private _lastTimeDetach: number = NaN;
/**
* Whether the request filter threw an error
* @type {boolean}
* @private
*/
private _requestFilterError: boolean = false;
/**
* Whether the response filter threw an error
* @type {boolean}
* @private
*/
private _responseFilterError: boolean = false;
/**
* Whether async destroy is in progress
* @type {boolean}
* @private
*/
private _isDestroyInProgress: boolean = false;
/**
* Custom dash manifest parser.
* @type {DashManifestParser}
* @private
*/
private _manifestParser: DashManifestParser | null | undefined;
/**
* error counter to change severity
* @type ErrorEventsType
* @private
*/
private _errorCounter: ErrorEventsType= {}
/**
* Dash thumbnail controller.
* @type {DashThumbnailController}
* @private
*/
private _thumbnailController: DashThumbnailController | undefined | null;
private _isStartOver: boolean = true;
private _seekRangeStart: number = 0;
private _startOverTimeout!: number;
private _isLive: boolean = false;
private _isStaticLive: boolean = false;
private _selectedVideoTrack: VideoTrack | undefined | null = null;
private _playbackActualUri: string | undefined;
public applyTextTrackStyles(sheet: CSSStyleSheet, styles: any, containerId: string): void {
const flexAlignment = {
left: 'flex-start',
center: 'center',
right: 'flex-end',
}
sheet.insertRule(`#${containerId} .${DashAdapter.textContainerClass} { align-items: ${flexAlignment[styles.textAlign]}!important; }`, 0);
sheet.insertRule(`#${containerId} .${DashAdapter.textContainerClass} > * { ${styles.toCSS()} }`, 0);
}
/**
* 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 {
const adapterConfig: any = Utils.Object.copyDeep(DefaultConfig);
if (Utils.Object.hasPropertyPath(config, 'text.useNativeTextTrack')) {
adapterConfig.textTrackVisibile = Utils.Object.getPropertyPath(config, 'text.useNativeTextTrack');
}
if (Utils.Object.hasPropertyPath(config, 'text.useShakaTextTrackDisplay')) {
adapterConfig.useShakaTextTrackDisplay = Utils.Object.getPropertyPath(config, 'text.useShakaTextTrackDisplay');
adapterConfig.textTrackVisibile = adapterConfig.textTrackVisibile || adapterConfig.useShakaTextTrackDisplay;
}
if (Utils.Object.hasPropertyPath(config, 'streaming')) {
const {streaming} = config;
if (typeof streaming.forceBreakStall === 'boolean') {
adapterConfig.forceBreakStall = streaming.forceBreakStall;
}
if (typeof streaming.stallDetectionThreshold === 'number') {
adapterConfig.stallDetectionThreshold = Math.max(MIN_STALL_DETECTION_THRESHOLD, streaming.stallDetectionThreshold);
}
if (typeof streaming.lowLatencyMode === 'boolean') {
adapterConfig.lowLatencyMode = streaming.lowLatencyMode;
}
if (typeof streaming.trackEmsgEvents === 'boolean') {
adapterConfig.trackEmsgEvents = streaming.trackEmsgEvents;
}
if (typeof streaming.switchDynamicToStatic === 'boolean') {
adapterConfig.switchDynamicToStatic = streaming.switchDynamicToStatic;
}
}
if (Utils.Object.hasPropertyPath(config, 'sources.options')) {
const options = config.sources.options;
adapterConfig.forceRedirectExternalStreams = options.forceRedirectExternalStreams;
adapterConfig.redirectExternalStreamsHandler = options.redirectExternalStreamsHandler;
adapterConfig.redirectExternalStreamsTimeout = options.redirectExternalStreamsTimeout;
}
if (Utils.Object.hasPropertyPath(config, 'abr')) {
const abr = config.abr;
if (typeof abr.enabled === 'boolean') {
adapterConfig.shakaConfig.abr.enabled = abr.enabled;
}
if (typeof abr.capLevelToPlayerSize === 'boolean') {
adapterConfig.capLevelToPlayerSize = abr.capLevelToPlayerSize;
}
if (abr.defaultBandwidthEstimate) {
adapterConfig.shakaConfig.abr.defaultBandwidthEstimate = abr.defaultBandwidthEstimate;
}
if (abr.restrictions) {
Utils.Object.createPropertyPath(adapterConfig, 'abr.restrictions', abr.restrictions);
}
}
//Merge shaka config with override config, override takes precedence
if (Utils.Object.hasPropertyPath(config, 'playback.options.html5.dash')) {
Utils.Object.mergeDeep(adapterConfig.shakaConfig, config.playback.options.html5.dash);
//for backward compatibility with shaka version < 4
if (Utils.Object.hasPropertyPath(adapterConfig.shakaConfig, 'manifest.dash.defaultPresentationDelay')) {
adapterConfig.shakaConfig.manifest.defaultPresentationDelay = adapterConfig.shakaConfig.manifest.dash.defaultPresentationDelay;
delete adapterConfig.shakaConfig.manifest.dash.defaultPresentationDelay;
}
}
adapterConfig.network = config.network;
return new this(videoElement, source, adapterConfig);
}
/**
* Checks if dash adapter can play a given mime type
* @function canPlayType
* @param {string} mimeType - The mime type to check
* @returns {boolean} - Whether the dash adapter can play a specific mime type
* @static
*/
public static canPlayType(mimeType: string): boolean {
const canPlayType = typeof mimeType === 'string' ? mimeType.toLowerCase() === DashAdapter._dashMimeType && DashAdapter.isMSESupported() : false;
DashAdapter._logger.debug('canPlayType result for mimeType: ' + mimeType + ' is ' + canPlayType.toString());
return canPlayType;
}
/**
* set 'bitrate' the max bandwidth (if possible)
* @param {number} bitrate the max bitrate allowed
* @returns {void}
*/
public setMaxBitrate(bitrate: number): void {
if (this._hasLowerOrEqualBitrate(bitrate)) {
this._shaka.configure({abr: {restrictions: {maxBandwidth: bitrate}}});
}
}
private _getSortedTracks(): Array<{id: number, bandwidth: number, active: boolean}> {
const tracks = this._shaka.getVariantTracks();
const sortedTracks = tracks
.map(obj => ({
id: obj.id,
bandwidth: obj.bandwidth,
active: obj.active
}))
.sort((obj1, obj2) => obj1.bandwidth - obj2.bandwidth);
return sortedTracks;
}
private _hasLowerOrEqualBitrate(maxBitrate: number): boolean {
const sortedTracks = this._getSortedTracks();
if (sortedTracks[0].bandwidth <= maxBitrate) {
return true;
}
return false;
}
/**
* Checks if dash adapter can play a given drm data.
* @param {Array<Object>} drmData - The drm data to check.
* @param {PKDrmConfigObject} drmConfig - The drm config.
* @returns {boolean} - Whether the dash adapter can play a specific drm data.
* @static
*/
public static canPlayDrm(drmData: Array<PKDrmDataObject>, drmConfig: PKDrmConfigObject): boolean {
DashAdapter._availableDrmProtocol = [];
for (const drmProtocol of DashAdapter._drmProtocols) {
if (drmProtocol.isConfigured(drmData, drmConfig)) {
DashAdapter._availableDrmProtocol.push(drmProtocol);
break;
}
}
if (!DashAdapter._availableDrmProtocol.length) {
for (const drmProtocol of DashAdapter._drmProtocols) {
if (drmProtocol.canPlayDrm(drmData)) {
DashAdapter._availableDrmProtocol.push(drmProtocol);
}
}
}
return !!DashAdapter._availableDrmProtocol.length;
}
/**
* Checks if the dash adapter is supported
* @function isSupported
* @returns {boolean} - Whether dash is supported.
* @static
*/
public static isSupported(): boolean {
const isSupported = shaka.Player.isBrowserSupported();
DashAdapter._logger.debug('isSupported:' + isSupported);
return isSupported;
}
/**
* @constructor
* @param {HTMLVideoElement} videoElement - The video element which bind to the dash adapter
* @param {PKMediaSourceObject} source - The source object
* @param {Object} config - The media source adapter configuration
*/
constructor(videoElement: HTMLVideoElement, source: PKMediaSourceObject, config: any = {}) {
super(videoElement, source, config);
DashAdapter._logger.debug('Creating adapter. Shaka version: ' + shaka.Player.version);
this._config = Utils.Object.mergeDeep({}, DefaultConfig, this._config);
this._init();
}
/**
* Runs the initialization actions of the dash adapter.
* @private
* @returns {void}
*/
private _init(): void {
//Need to call this again cause we are uninstalling the VTTCue polyfill to avoid collisions with other libs
shaka.polyfill.installAll();
this._shaka = new shaka.Player();
// This will force the player to use shaka UITextDisplayer plugin to render text tracks.
if (this._config.useShakaTextTrackDisplay) {
this._shaka.setVideoContainer(Utils.Dom.getElementBySelector('.playkit-subtitles'));
}
this._maybeSetFilters();
this._maybeSetDrmConfig();
this._maybeBreakStalls();
this._shaka.configure(this._config.shakaConfig);
this._addBindings();
}
private _clearStallInterval(): void {
if (this._stallInterval) {
clearInterval(this._stallInterval);
this._stallInterval = null;
}
}
private _stallHandler(): void {
this._clearStallInterval();
const getCurrentTimeInSeconds = (): number => {
return Date.now() / 1000;
};
const lastUpdateTime = getCurrentTimeInSeconds();
let lastCurrentTime = this._videoElement.currentTime;
this._stallInterval = window.setInterval(() => {
const stallSeconds = getCurrentTimeInSeconds() - lastUpdateTime;
//waiting for 3 sec until checking stalling
if (stallSeconds > this._config.stallDetectionThreshold && !this._videoElement.paused) {
if (lastCurrentTime === this._videoElement.currentTime) {
DashAdapter._logger.debug('stall found, break the stall');
this._videoElement.currentTime = parseFloat(this._videoElement.currentTime.toFixed(1)) + STALL_BREAK_THRESHOLD;
}
this._clearStallInterval();
}
lastCurrentTime = this._videoElement.currentTime;
}, STALL_DETECTION_INTERVAL);
}
/**
* register to event to break the stalls on smart TV
* @returns {void}
* @private
*/
private _maybeBreakStalls(): void {
if (this._config.forceBreakStall) {
this._eventManager.listen(this._videoElement, EventType.SEEKING, () => this._stallHandler());
}
}
/**
* get the redirected URL
* @param {string} url - The url to check for redirection
* @returns {Promise<string>} - the resolved url
* @private
*/
private _maybeGetRedirectedUrl(url: string): Promise<string> {
const shouldRedirect = this._config.forceRedirectExternalStreams;
const timeout = this._config.redirectExternalStreamsTimeout;
const callback = this._config.redirectExternalStreamsHandler;
return new Promise(resolve => {
if (!shouldRedirect) {
return resolve(url);
}
Utils.Http.jsonp(url, callback, {
timeout: timeout
})
.then(uri => {
resolve(uri);
})
.catch(() => resolve(url));
});
}
private _maybeSetFilters(): void {
if (typeof Utils.Object.getPropertyPath(this._config, 'network.requestFilter') === 'function') {
DashAdapter._logger.debug('Register request filter');
this._shaka.getNetworkingEngine()?.registerRequestFilter((type, request) => {
if (Object.values(RequestType).includes(type)) {
const pkRequest: PKRequestObject = {url: request.uris[0], body: request.body, headers: request.headers};
let requestFilterPromise;
try {
requestFilterPromise = this._config.network.requestFilter(type, pkRequest);
} catch (error) {
requestFilterPromise = Promise.reject(error);
}
requestFilterPromise = requestFilterPromise || Promise.resolve(pkRequest);
return requestFilterPromise
.then(updatedRequest => {
request.uris = [updatedRequest.url];
request.headers = updatedRequest.headers;
if (typeof updatedRequest.withCredentials === 'boolean') {
request.allowCrossSiteCredentials = updatedRequest.withCredentials;
}
if (request.method === 'POST') {
request.body = updatedRequest.body;
} else if (updatedRequest.body) {
DashAdapter._logger.warn(`Request with ${request.method} method cannot have body`);
}
})
.catch(error => {
this._requestFilterError = true;
throw error;
});
}
});
}
if (typeof Utils.Object.getPropertyPath(this._config, 'network.responseFilter') === 'function') {
DashAdapter._logger.debug('Register response filter');
this._shaka.getNetworkingEngine()?.registerResponseFilter((type, response) => {
if (Object.values(RequestType).includes(type)) {
const {uri: url, data, headers} = response;
const pkResponse: PKResponseObject = {url, originalUrl: this._sourceObj!.url, data, headers};
let responseFilterPromise;
try {
responseFilterPromise = this._config.network.responseFilter(type, pkResponse);
} catch (error) {
responseFilterPromise = Promise.reject(error);
}
responseFilterPromise = responseFilterPromise || Promise.resolve(pkResponse);
return responseFilterPromise
.then(updatedResponse => {
response.data = updatedResponse.data;
})
.catch(error => {
this._responseFilterError = true;
throw error;
});
}
});
}
}
/**
* Configure drm for shaka player.
* @private
* @returns {void}
*/
private _maybeSetDrmConfig(): void {
if (this._sourceObj && this._sourceObj.drmData) {
const config: any = {};
for (const drmProtocol of DashAdapter._availableDrmProtocol) {
drmProtocol.setDrmPlayback(config, this._sourceObj.drmData);
// If shaka config already has some drm configuration override the config defaults with it
if (this._config.shakaConfig.drm) {
Utils.Object.mergeDeep(config.drm, this._config.shakaConfig.drm);
}
Utils.Object.mergeDeep(this._config.shakaConfig, config);
}
}
}
/**
* apply Capping to player size restrictions
* @private
* @returns {void}
*/
private _maybeCapLevelToPlayerSize(): void {
if (this._config.capLevelToPlayerSize) {
const getRestrictions = (): any => {
return {
minHeight: 0,
maxHeight: this._videoHeight,
minWidth: 0,
maxWidth: this._videoWidth,
minBitrate: 0,
maxBitrate: Infinity
};
};
this._clearVideoUpdateTimer();
this._videoSizeUpdateTimer = window.setInterval(() => this._updateRestriction(getRestrictions()), ABR_RESTRICTION_UPDATE_INTERVAL);
this._updateRestriction(getRestrictions());
}
}
/**
* apply ABR restrictions
* @private
* @returns {void}
*/
private _maybeApplyAbrRestrictions(): void {
if (!this._config.capLevelToPlayerSize) {
this._clearVideoUpdateTimer();
if (Utils.Object.hasPropertyPath(this._config, 'abr.restrictions')) {
this._updateRestriction(this._config.abr.restrictions);
}
}
}
/**
* apply ABR restrictions by size
* @private
* @param {PKABRRestrictionObject} restrictions - abr restrictions config
* @returns {void}
*/
private _updateRestriction(restrictions: PKABRRestrictionObject): void {
const shakaRestrictionsConfig = this._getRestrictionShakaConfig(restrictions);
this._shaka.configure({
abr: {
restrictions: shakaRestrictionsConfig
}
});
}
private _getRestrictionShakaConfig(restrictions: PKABRRestrictionObject): any {
const getMinDimensions = (dim): number => {
const videoTracks = this._getVideoTracks();
return Math.min.apply(
null,
videoTracks.map(variant => variant[dim])
);
};
const restrictionsShakaConfig: any = {};
if (restrictions) {
const {maxHeight, maxWidth, maxBitrate, minHeight, minWidth, minBitrate} = restrictions;
const minHeightValue = Math.max(minHeight, 0);
const maxHeightValue = Math.max(maxHeight, getMinDimensions('height'));
if (maxHeightValue >= minHeightValue) {
restrictionsShakaConfig.minHeight = minHeightValue;
restrictionsShakaConfig.maxHeight = maxHeightValue;
} else {
DashAdapter._logger.warn('Invalid maxHeight restriction, maxHeight must be greater than minHeight', minHeight, maxHeight);
}
const minWidthValue = Math.max(minWidth, 0);
const maxWidthValue = Math.max(maxWidth, getMinDimensions('width'));
if (maxWidthValue >= minWidthValue) {
restrictionsShakaConfig.minWidth = minWidthValue;
restrictionsShakaConfig.maxWidth = maxWidthValue;
} else {
DashAdapter._logger.warn('Invalid maxWidth restriction, maxWidth must be greater than minWidth', minWidth, maxWidth);
}
const minBitrateValue = Math.max(minBitrate, 0);
const maxBitrateValue = Math.max(maxBitrate, getMinDimensions('bandwidth'));
if (maxBitrateValue >= minBitrateValue) {
restrictionsShakaConfig.minBandwidth = minBitrateValue;
restrictionsShakaConfig.maxBandwidth = maxBitrateValue;
} else {
DashAdapter._logger.warn('Invalid maxBitrate restriction, maxBitrate must be greater than minBitrate', minBitrate, maxBitrate);
}
}
return restrictionsShakaConfig;
}
/**
* attach media - return the media source to handle the video tag
* @public
* @returns {void}
*/
public attachMediaSource(): void {
if (!this._shaka) {
if (this._videoElement && this._videoElement.src) {
Utils.Dom.setAttribute(this._videoElement, 'src', '');
Utils.Dom.removeAttribute(this._videoElement, 'src');
}
this._init();
}
}
/**
* detach media - will remove the media source from handling the video
* @public
* @returns {Promise<void>} - detach promise
*/
public detachMediaSource(): Promise<void> {
if (this._shaka) {
// 1 second different between duration and current time will signal as end - will enable replay button
// @ts-expect-error - ????
if (Math.floor(this.duration - this.currentTime) === 0) {
this._lastTimeDetach = 0;
} else if (this['currentTime'] > 0) {
// @ts-expect-error - ????
this._lastTimeDetach = this.currentTime;
}
return this._reset().then(() => {
this._shaka = null as unknown as shaka.Player;
this._loadPromise = undefined;
});
} else {
return Promise.resolve();
}
}
/**
* Clear the video update timer
* @private
* @returns {void}
*/
private _clearVideoUpdateTimer(): void {
if (this._videoSizeUpdateTimer) {
clearInterval(this._videoSizeUpdateTimer);
this._videoSizeUpdateTimer = null;
}
}
private get _videoWidth(): number {
let width;
const videoElement = this._videoElement;
if (videoElement) {
width = videoElement.width || videoElement.clientWidth || videoElement.offsetWidth;
width *= this._contentScaleFactor;
}
return width;
}
private get _videoHeight(): number {
let height;
const videoElement = this._videoElement;
if (videoElement) {
height = videoElement.height || videoElement.clientHeight || videoElement.offsetHeight;
height *= this._contentScaleFactor;
}
return height;
}
private get _contentScaleFactor(): number {
let pixelRatio = 1;
try {
pixelRatio = window.devicePixelRatio;
} catch (e) {
DashAdapter._logger.debug('failed reading devicePixelRatio, assume 1');
}
return pixelRatio;
}
/**
* Add the required bindings to shaka.
* @function _addBindings
* @private
* @returns {void}
*/
private _addBindings(): void {
this._eventManager.listen(this._shaka, ShakaEvent.ADAPTATION, this._adapterEventsBindings.adaptation);
this._eventManager.listen(this._shaka, ShakaEvent.ERROR, this._adapterEventsBindings.error);
this._eventManager.listen(this._shaka, ShakaEvent.DRM_SESSION_UPDATE, this._adapterEventsBindings.drmsessionupdate);
this._eventManager.listen(this._videoElement, EventType.WAITING, this._adapterEventsBindings.waiting);
this._eventManager.listen(this._videoElement, EventType.PLAYING, this._adapterEventsBindings.playing);
this._eventManager.listen(this._videoElement, EventType.LOADED_DATA, () => this._onLoadedData());
this._eventManager.listenOnce(this._videoElement, EventType.PLAYING, () => {
this._eventManager.listen(this._shaka, ShakaEvent.BUFFERING, this._adapterEventsBindings.buffering);
});
if (this._config.trackEmsgEvents) {
this._eventManager.listen(this._shaka, ShakaEvent.EMSG, this._adapterEventsBindings.emsg);
}
// called when a resource is downloaded
this._shaka.getNetworkingEngine()?.registerResponseFilter((type, response) => {
switch (type) {
case shaka.net.NetworkingEngine.RequestType.SEGMENT:
this._trigger(EventType.FRAG_LOADED, {
miliSeconds: response.timeMs,
bytes: response.data.byteLength,
url: response.uri
});
if (this.isLive()) {
this._dispatchNativeEvent(EventType.DURATION_CHANGE);
}
break;
case shaka.net.NetworkingEngine.RequestType.MANIFEST:
this._parseManifest(response.data);
this._playbackActualUri = response.uri;
this._trigger(EventType.MANIFEST_LOADED, {miliSeconds: response.timeMs});
setTimeout(() => {
this._isLive = this._isLive || this._shaka?.isLive() as boolean;
if (this._isLive && !this._shaka?.isLive() && !this._isStaticLive && this._config.switchDynamicToStatic) {
this._sourceObj!.url = response.uri;
this._switchFromDynamicToStatic();
}
});
break;
}
});
}
private _onLoadedData(): void {
this._setLowLatencyMode();
const segmentDuration = this.getSegmentDuration();
this._seekRangeStart = this._shaka.seekRange().start;
this._startOverTimeout = window.setTimeout(() => {
if (this._shaka.seekRange().start - this._seekRangeStart >= segmentDuration) {
// in start over the seekRange().start should be permanent
this._isStartOver = false;
}
}, (segmentDuration + 1) * 1000);
}
public async _switchFromDynamicToStatic(): Promise<void> {
DashAdapter._logger.info('Switching from dynamic manifest to static');
this._dispatchNativeEvent(EventType.WAITING);
const newCurrentTime = this._videoElement.currentTime - this._seekRangeStart;
const isAdaptiveBitrateEnabled = this.isAdaptiveBitrateEnabled();
const isPaused = this._videoElement.paused;
await this.detachMediaSource();
this._isStaticLive = true;
this._isLive = true;
this.attachMediaSource();
await this.load();
this._videoElement.currentTime = newCurrentTime;
if (!isPaused) {
this._videoElement.play();
}
if (isAdaptiveBitrateEnabled) {
this._onAdaptation();
} else if (this._selectedVideoTrack) {
this.selectVideoTrack(this._selectedVideoTrack);
}
}
private _setLowLatencyMode(): void {
this._shaka.configure({
streaming: {
lowLatencyMode: typeof this._config.lowLatencyMode === 'boolean' ? this._config.lowLatencyMode : this.isLive()
}
});
}
/**
* Custom parser to retrieve image adaptation sets.
* @param {ArrayBuffer} manifestBuffer - The array buffer manifest from the response.
* @function _parseManifest
* @private
* @returns {void}
*/
private _parseManifest(manifestBuffer: ArrayBuffer | ArrayBufferView): void {
if (!this._manifestParser && DashManifestParser.isValid()) {
DashAdapter._logger.debug('Creating parser for the first time');
this._manifestParser = new DashManifestParser(manifestBuffer);
this._manifestParser.parseManifest();
}
}
/**
* Load the video source
* @param {number} startTime - Optional time to start the video from.
* @function load
* @override
*/
public async load(startTime?: number): Promise<any> {
if (!this._loadPromise) {
await this._removeMediaKeys();
this._shaka.attach(this._videoElement);
this._loadPromise = new Promise((resolve, reject) => {
if (this._sourceObj && this._sourceObj.url) {
this._trigger(EventType.ABR_MODE_CHANGED, {mode: this.isAdaptiveBitrateEnabled() ? 'auto' : 'manual'});
let shakaStartTime = typeof startTime === 'number' && startTime > -1 ? startTime : undefined;
shakaStartTime = isNaN(this._lastTimeDetach) ? shakaStartTime : this._lastTimeDetach;
this._lastTimeDetach = NaN;
this._maybeGetRedirectedUrl(this._sourceObj.url)
.then(url => {
return this._shaka.load(url, shakaStartTime);
})
.then(() => {
const data = {tracks: this._getParsedTracks()};
this._maybeCapLevelToPlayerSize();
DashAdapter._logger.debug('The source has been loaded successfully');
resolve(data);
})
.catch(error => {
reject(new Error(this._isDestroyInProgress ? Error.Severity.RECOVERABLE : error.severity, error.category, error.code, error.data));
});
}
});
}
return this._loadPromise;
}
/**
* Destroys the dash adapter
* @function destroy
* @override
* @returns {Promise<*>} - The destroy promise.
*/
public destroy(): Promise<void> {
this._isDestroyInProgress = true;
return new Promise((resolve, reject) => {
super.destroy().then(() => {
DashAdapter._logger.debug('destroy');
this._loadPromise = undefined;
this._adapterEventsBindings = {};
this._reset()
.then(resetResult => {
this._isDestroyInProgress = false;
resolve(resetResult);
})
.catch(error => {
this._isDestroyInProgress = false;
reject(error);
});
});
});
}
/**
* Returns in-stream thumbnail for a chosen time.
* @param {number} time - playback time.
* @public
* @return {?ThumbnailInfo} - Thumbnail info
*/
public getThumbnail(time: number): ThumbnailInfo | null {
if (this._thumbnailController) {
return this._thumbnailController.getThumbnail(time);
}
return null;
}
/**
* Reset shaka instance and its bindings
* @function _reset
* @private
* @returns {Promise<*>} - The destroy promise.
*/
private _reset(): Promise<void> {
this._buffering = false;
this._waitingSent = false;
this._playingSent = false;
this._isLive = false;
this._isStaticLive = false;
this._requestFilterError = false;
this._responseFilterError = false;
this._manifestParser = null;
this._thumbnailController = null;
this._errorCounter = {};
this._clearStallInterval();
this._clearVideoUpdateTimer();
clearTimeout(this._startOverTimeout);
if (this._eventManager) {
this._eventManager.removeAll();
}
if (this._shaka) {
return this._shaka.destroy();
}
return Promise.resolve();
}
/**
* Remove mediaKeys from the video element.
* mediaKeys are set if an encrypted media was previously played, and must be removed before a new encrypted media can be played.
* If mediaKeys are not null it means that shaka reset wasn't called or that it failed to remove them.
* @returns {Promise<void>} Promise that resolves when the operation finishes.
*/
public async _removeMediaKeys(): Promise<void> {
if (this._videoElement && this._videoElement.mediaKeys) {
try {
DashAdapter._logger.debug('Removing mediaKeys from the video element');
await this._videoElement.setMediaKeys(null);
DashAdapter._logger.debug('mediaKeys removed');
} catch (e) {
// non encrypted playback should still work, so we don't reject
DashAdapter._logger.warn('mediaKeys not cleared');
} finally {
// eslint-disable-next-line no-unsafe-finally
return Promise.resolve();
}
} else {
return Promise.resolve();
}
}
/**
* Get the original video tracks
* @function _getVideoTracks
* @returns {Array<Object>} - The original video tracks
* @private
*/
private _getVideoTracks(): Array<any> {
const variantTracks = this._shaka.getVariantTracks();
const activeVariantTrack = this._getActiveTrack();
const videoTracks = variantTracks.filter(variantTrack => {
return variantTrack.audioId === activeVariantTrack.audioId;
});
return videoTracks;
}
private _getActiveTrack(): shaka.extern.Track {
return this._shaka.getVariantTracks().find(variantTrack => variantTrack.active)!;
}
/**
* Get the original audio tracks
* @function _getAudioTracks
* @returns {Array<Object>} - Array of objects with unique language and label.
* @private
*/
private _getAudioTracks(): (shaka.extern.LanguageRole & { active: boolean, id: number })[] {
const variantTracks = this._shaka.getVariantTracks();
const audioTracks = this._shaka.getAudioLanguagesAndRoles();
audioTracks.forEach(track => {
const sameLangAudioVariants = variantTracks.filter(vt => vt.language === track.language);
const id = sameLangAudioVariants.map(variant => variant.id).join('_');
const active = sameLangAudioVariants.some(variant => variant.active);
track['id'] = id;
track.label = sameLangAudioVariants[0].label;
track['active'] = active;
});
return audioTracks as (shaka.extern.LanguageRole & { active: boolean, id: number })[];
}
/**
* Get the parsed tracks
* @function _getParsedTracks
* @returns {Array<Track>} - The parsed tracks
* @private
*/
private _getParsedTracks(): Array<Track> {
if (this._shaka) {
const videoTracks = this._getParsedVideoTracks();
const audioTracks = this._getParsedAudioTracks();
const textTracks = this._getParsedTextTracks();
const imageTracks = this._getParsedImageTracks();
return [...videoTracks, ...audioTracks, ...textTracks, ...imageTracks];
}
return [];
}
/**
* Get the parsed video tracks
* @function _getParsedVideoTracks
* @returns {Array<VideoTrack>} - The parsed video tracks
* @private
*/
private _getParsedVideoTracks(): VideoTrack[] {
const videoTracks = this._getVideoTracks();
const parsedTracks: VideoTrack[] = [];
if (videoTracks) {
for (let i = 0; i < videoTracks.length; i++) {
const settings = {
id: videoTracks[i].id,
bandwidth: videoTracks[i].videoBandwidth || videoTracks[i].bandwidth,
width: videoTracks[i].width,
height: videoTracks[i].height,
active: videoTracks[i].active,
index: i
};
parsedTracks.push(new VideoTrack(settings));
}
}
return parsedTracks;
}
/**
* Get the parsed audio tracks
* @function _getParsedAudioTracks
* @returns {Array<AudioTrack>} - The parsed audio tracks
* @private
*/
private _getParsedAudioTracks(): AudioTrack[] {
const audioTracks = this._getAudioTracks();
const parsedTracks: AudioTrack[] = [];
if (audioTracks) {
for (let i = 0; i < audioTracks.length; i++) {
const settings = {
id: audioTracks[i].id,
active: audioTracks[i].active,
label: audioTracks[i].label,
language: audioTracks[i].language,
index: i
};
parsedTracks.push(new AudioTrack(settings));
}
}
return parsedTracks;
}
/**
* Get the parsed text tracks
* @function _getParsedTextTracks
* @returns {Array<TextTrack>} - The parsed text tracks
* @private
*/
private _getParsedTextTracks(): Array<PKTextTrack> {
const parsedTracks: PKTextTrack[] = [];
for (const textTrack of this._shaka.getTextTracks()) {
let kind = textTrack.kind ? textTrack.kind + 's' : '';
kind = kind === '' && this._config.useShakaTextTrackDisplay ? 'captions' : kind;
const settings = {
id: textTrack.id,
kind: kind,
active: false,
default: textTrack.primary,
label: textTrack.label as string,
language: textTrack.language
};
parsedTracks.push(new PKTextTrack(settings));
}
return parsedTracks;
}
/**
* Get the parsed image tracks
* @function _getParsedImageTracks
* @returns {Array<ImageTrack>} - The parsed image tracks
* @private
*/
private _getParsedImageTracks(): ImageTrack[] {
const imageSet = this._manifestParser?.getImageSet();
const mediaTemplatePrefix = this._manifestParser?.getBaseUrl() || '';
if (imageSet) {
this._thumbnailController = new DashThumbnailController(imageSet, this._playbackActualUri!, mediaTemplatePrefix);
return this._thumbnailController.getTracks();
}
return [];
}
/**
* Select a video track
* @function selectVideoTrack
* @param {VideoTrack} videoTrack - the video track to select
* @returns {void}
* @public
*/
public selectVideoTrack(videoTrack: VideoTrack): void {
if (this._shaka) {
const videoTracks = this._getVideoTracks();
if (videoTrack instanceof VideoTrack && videoTracks) {
const selectedVideoTrack = videoTracks[videoTrack.index];
if (selectedVideoTrack) {
if (this.isAdaptiveBitrateEnabled()) {
this._shaka.configure({abr: {enabled: false}});
this._trigger(EventType.ABR_MODE_CHANGED, {mode: 'manual'});
}
if (!selectedVideoTrack.active) {
this._selectedVideoTrack = videoTrack;
this._shaka.selectVariantTrack(videoTracks[videoTrack.index], 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 {
if (this._shaka && audioTrack instanceof AudioTrack && !audioTrack.active) {
this._shaka.selectAudioLanguage(audioTrack.language);
this._onTrackChanged(audioTrack);
}
}
/**
* Select a text track
* @function selectTextTrack
* @param {TextTrack} textTrack - the track to select
* @returns {void}
* @public
*/
public selectTextTrack(textTrack: PKTextTrack): void {
if (this._shaka && textTrack instanceof PKTextTrack && !textTrack.active && (textTrack.kind === 'subtitles' || textTrack.kind === 'captions')) {
this._shaka.setTextTrackVisibility(this._config.textTrackVisibile);
this._shaka.selectTextLanguage(textTrack.language);
this._onTrackChanged(textTrack);
}
}
public selectImageTrack(imageTrack: ImageTrack): void {
if (this._shaka && this._thumbnailController && imageTrack instanceof ImageTrack && !imageTrack.active) {
this._thumbnailController.selectTrack(imageTrack);
this._onTrackChanged(imageTrack);
}
}
/**
* Hide the text track
* @function hideTextTrack
* @returns {void}
* @public
*/
public hideTextTrack(): void {
if (this._shaka) {
this._shaka.setTextTrackVisibility(false);
}
}
/**
* Enables adaptive bitrate switching
* @function enableAdaptiveBitrate
* @returns {void}
* @public
*/
public enableAdaptiveBitrate(): void {
if (this._shaka && !this.isAdaptiveBitrateEnabled()) {
this._trigger(EventType.ABR_MODE_CHANGED, {mode: 'auto'});
this._shaka.configure({abr: {enabled: true}});
}
}
/**
* Checking if adaptive bitrate switching is enabled.
* @function isAdaptiveBitrateEnabled
* @returns {boolean} - Whether adaptive bitrate is enabled.
* @public
*/
public isAdaptiveBitrateEnabled(): boolean {
if (this._shaka) {
const shakaConfig = this._shaka.getConfiguration();
return shakaConfig.abr.enabled;
}
return false;
}
/**
* 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();
if (!this.isAdaptiveBitrateEnabled()) {
const videoTracks = this._getParsedVideoTracks();
const availableTracks = filterTracksByRestriction(videoTracks, this._config.abr.restrictions);
if (availableTracks.length) {
const activeTrackInRange = availableTracks.find(track => track.active);
if (!activeTrackInRange) {
this.selectVideoTrack(availableTracks[0]);
}
}
}
}
/**
* Returns the live edge
* @returns {number} - live edge
* @private
*/
protected _getLiveEdge(): number {
return this._shaka ? this._shaka.seekRange().end : NaN;
}
/**
* Seeking to live edge.
* @function seekToLiveEdge
* @returns {void}
* @public
*/
public seekToLiveEdge(): void {
if (this._shaka && this._videoElement.readyState > 0) {
this._videoElement.currentTime = this._getLiveEdge();
}
}
/**
* Checking if the current playback is live.
* @function isLive
* @returns {boolean} - Whether playback is live.
* @public
*/
public isLive(): boolean {
return this._shaka?.isLive() || this._isLive;
}
/**
* Gets the segment duration of the stream
* @return {number} - Segment duration in seconds
*/
public get liveDuration(): number {
return this._getLiveEdge();
}
/**
* Gets the live duration
* @return {number} - live duration
*/
public getSegmentDuration(): number {
if (this._shaka) {
return this._shaka.getStats().maxSegmentDuration;
}
return 0;
}
/**
* An handler to shaka adaptation event
* @function _onAdaptation
* @returns {void}
* @private
*/
private _onAdaptation(): void {
const selectedVideoTrack = this._getParsedVideoTracks().find(videoTrack => videoTrack.active)!;
this._onTrackChanged(selectedVideoTrack);
}
/**
* An handler to shaka error event
* @function _onError
* @param {any} event - the error event
* @returns {void}
* @private
*/
private _onError(event: any): void {
if (event && event.detail) {
let error = event.detail;
//don't handle video element errors, they are already handled by the player
if (error.code === this.VIDEO_ERROR_CODE) {
return;
}
if ((this._requestFilterError || this._responseFilterError) && error.data[0] instanceof shaka.util.Error) {
// When the request/response filter of the license request throws an error,
// shaka wraps the request/response filter error (code 1006/1007) with a license request error (code 6007)
// so extract the inner error
error = error.data[0];
this._requestFilterError ? (this._requestFilterError = false) : (this._responseFilterError = false);
}
error.severity = this._shouldErrorChangeSeverity(error.code) ? Error.Severity.CRITICAL : error.severity;
this._trigger(EventType.ERROR, new Error(error.severity, error.category, error.code, error.data));
DashAdapter._logger.error(error);
if (error.severity === Error.Severity.CRITICAL) {
this.destroy();
}
}
}
private _shouldErrorChangeSeverity(errorCode): boolean {
const secondsErrorRepeat = 30
const errorsCounterToChangeSeverity = 3;
const getCurrentTimeInSeconds = (): number => {
return Date.now() / 1000;
};
if(!(errorCode in this._errorCounter)){
this._errorCounter[errorCode] = {
count: 1,
timeStamp: getCurrentTimeInSeconds()
}
return false;
}
this._errorCounter[errorCode].count += 1;
if (this._errorCounter[errorCode].count > errorsCounterToChangeSeverity) {
delete this._errorCounter[errorCode];
return true;
}
const interval = getCurrentTimeInSeconds() - this._errorCounter[errorCode].timeStamp;
if (interval > secondsErrorRepeat) {
this._errorCounter[errorCode].timeStamp = getCurrentTimeInSeconds();
this._errorCounter[errorCode].count = 1;
return false;
}
return false;
}
/**
* An handler to shaka buffering event
* @function _onBuffering
* @param {any} event - the buffering event
* @returns {void}
* @private
*/
private _onBuffering(event: any): void {
if (event.buffering) {
if (!this._waitingSent) {
// The player enters the buffering state and 'waiting' event hasn't been sent by the HTMLVideoElement.
this._dispatchNativeEvent(EventType.WAITING);
this._buffering = true;
}
} else {
this._buffering = false;
if (!this._videoElement.paused && !this._playingSent) {
//the player leaves the buffering state. and 'playing' event hasn't been sent by the HTMLVideoElement.
this._dispatchNativeEvent(EventType.PLAYING);
}
}
}
private _dispatchNativeEvent(type: string): void {
let event;
if (typeof window['Event'] === 'function') {
event = new Event(type);
} else {
event = document.createEvent('Event');
event.initEvent(type, true, true);
}
this._videoElement.dispatchEvent(event);
}
/**
* An handler to shaka drm session update event
* @function _onDrmSessionUpdate
* @returns {void}
* @private
*/
private _onDrmSessionUpdate(): void {
this._trigger(EventType.DRM_LICENSE_LOADED, {
licenseTime: this._shaka.getStats().licenseTime,
scheme: this._shaka.drmInfo()?.keySystem
});
}
/**
* An handler to shaka emsg event
* @function _onEmsg
* @param {any} event - the emsg event
* @returns {void}
* @private
*/
private _onEmsg(event: any): void {
const {detail, type} = event;
let metadataTrack = Array.from<TextTrack>(this._videoElement.textTracks).find(track => track.label === type);
if (!metadataTrack) {
metadataTrack = this._videoElement.addTextTrack(PKTextTrack.KIND.METADATA, type);
}
const {startTime, endTime, id, ...metadata} = detail;
const timedMetadata = new TimedMetadata(startTime, endTime, id, TimedMetadata.TYPE.EMSG, metadata);
const textTrackCue = createTextTrackCue(timedMetadata);
metadataTrack.addCue(textTrackCue!);
this._trigger(EventType.TIMED_METADATA_ADDED, {cues: [timedMetadata]});
}
/**
* An handler to HTMLVideoElement waiting event
* @function _onWaiting
* @returns {void}
* @private
*/
private _onWaiting(): void {
this._waitingSent = true;
this._playingSent = false;
}
/**
* An handler to HTMLVideoElement playing event
* @function _onPlaying
* @returns {void}
* @private
*/
private _onPlaying(): void {
this._playingSent = true;
this._waitingSent = false;
if (this._buffering) {
// The player is in buffering state.
this._dispatchNativeEvent(EventType.WAITING);
}
}
/**
* Get the start time of DVR window in live playback in seconds.
* @returns {Number} - start time of DVR window.
* @public
*/
public getStartTimeOfDvrWindow(): number {
if (this.isLive() && this._shaka) {
return (this._isStartOver ? this._seekRangeStart : this._shaka.seekRange().start) + this._shaka.getConfiguration().streaming.safeSeekOffset;
}
return 0;
}
/**
* gets the target buffer of the player
* @returns {number} - buffer length target in seconds
*/
public get targetBuffer(): number {
let targetBufferVal = NaN;
if (!this._shaka) return NaN;
if (this.isLive()) {
targetBufferVal = this._getLiveEdge() - this._videoElement.currentTime;
} else {
// consideration of the end of the playback in the target buffer calc
targetBufferVal = this._videoElement.duration - this._videoElement.currentTime;
}
targetBufferVal = Math.min(targetBufferVal, this._shaka.getConfiguration().streaming.bufferingGoal + this._shaka.getStats().maxS