rx-player
Version:
Canal+ HTML5 Video Player
1,948 lines • 66.7 kB
text/typescript
/**
* Copyright 2015 CANAL+ Group
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* This file is used to abstract the notion of text, audio and video tracks
* switching for an easier API management.
*/
import config from "../../config";
import type { IAdaptationChoice, IRepresentationsChoice } from "../../core/types";
import { MediaError } from "../../errors";
import log from "../../log";
import type {
IAdaptationMetadata,
IManifestMetadata,
IPeriodMetadata,
} from "../../manifest";
import {
getSupportedAdaptations,
isRepresentationPlayable,
toAudioTrack,
toTextTrack,
toVideoTrack,
} from "../../manifest";
import type {
IAudioRepresentationsSwitchingMode,
IAudioTrack,
IAudioTrackSwitchingMode,
ITrackUpdateEventPayload,
IAvailableAudioTrack,
IAvailableTextTrack,
IAvailableVideoTrack,
IBrokenRepresentationsLockContext,
IPeriod,
ITextTrack,
IVideoRepresentationsSwitchingMode,
IVideoTrack,
IVideoTrackSwitchingMode,
IPlayerError,
ITrackType,
INoPlayableTrackEventPayload,
} from "../../public_types";
import arrayFind from "../../utils/array_find";
import assert, { assertUnreachable } from "../../utils/assert";
import EventEmitter from "../../utils/event_emitter";
import isNullOrUndefined from "../../utils/is_null_or_undefined";
import objectAssign from "../../utils/object_assign";
import SharedReference from "../../utils/reference";
import TrackDispatcher from "./track_dispatcher";
/**
* Class helping with the management of the audio, video and text tracks and
* qualities.
*
* The `TracksStore` allows to choose a track and qualities for different types
* of media through a simpler API.
*
* @class TracksStore
*/
export default class TracksStore extends EventEmitter<ITracksStoreEvents> {
/**
* Store track selection information, per Period.
* Sorted by Period's start time ascending
*/
private _storedPeriodInfo: ITSPeriodObject[];
/**
* If `true`, the current `TracksStore` instance has been disposed. It
* shouldn't perform side-effects anymore.
*/
private _isDisposed: boolean;
/**
* Period information that was before in `_storedPeriodInfo` but has since
* been removed is added to the `_cachedPeriodInfo` cache as a weak reference.
*
* This allows to still retrieve old track information for Periods which are
* for example not in the Manifest anymore as long as the same Period's
* reference is still kept.
*/
private _cachedPeriodInfo: WeakMap<IPeriodMetadata, ITSPeriodObject>;
/** Tells if trick mode has been enabled by the RxPlayer user */
private _isTrickModeTrackEnabled: boolean;
/**
* In absence of another setting, this is the default "switching mode" for the
* audio track.
* See type documentation.
*/
private _defaultAudioTrackSwitchingMode: IAudioTrackSwitchingMode;
/** Specifies the behavior when audio or video tracks are not playable. */
private onTracksNotPlayableForType: {
audio: "error" | "continue";
video: "error" | "continue";
text: "error" | "continue";
};
constructor(args: {
preferTrickModeTracks: boolean;
defaultAudioTrackSwitchingMode: IAudioTrackSwitchingMode | undefined;
onTracksNotPlayableForType: {
audio: "error" | "continue";
video: "error" | "continue";
text: "error" | "continue";
};
}) {
super();
this._storedPeriodInfo = [];
this._isDisposed = false;
this._cachedPeriodInfo = new WeakMap();
this._isTrickModeTrackEnabled = args.preferTrickModeTracks;
this._defaultAudioTrackSwitchingMode =
args.defaultAudioTrackSwitchingMode ??
config.getCurrent().DEFAULT_AUDIO_TRACK_SWITCHING_MODE;
this.onTracksNotPlayableForType = args.onTracksNotPlayableForType;
}
/**
* Return Array of Period information, to allow an outside application to
* modify the track of any Period.
* @returns {Array.<Object>}
*/
public getAvailablePeriods(): IPeriod[] {
return this._storedPeriodInfo.reduce((acc: IPeriod[], p) => {
if (p.isPeriodAdvertised) {
acc.push(toExposedPeriod(p.period));
}
return acc;
}, []);
}
/**
* Callack that needs to be called as codec support is either first known or
* updated on the Manifest.
*/
public onManifestCodecSupportUpdate(): void {
this._selectInitialTrackIfNeeded();
}
/**
* Check that a period has a supported track for the given track type.
* Trigger an error with `MANIFEST_INCOMPATIBLE_CODECS_ERROR` if no codecs are
* supported and `onTracksNotPlayableForType[ttype]` is "error".
*
* @param period - The period to check
* @param ttype - The track type.
*/
private checkPeriodHasSupportedTrack(
period: IPeriodMetadata,
ttype: "video" | "audio",
): void {
const adaptationsForType = period.adaptations[ttype];
const audioVideoAdaptations = [
...(period.adaptations.audio ?? []),
...(period.adaptations.video ?? []),
];
const periodHasAdaptationForType =
adaptationsForType !== undefined && adaptationsForType.length > 0;
if (!periodHasAdaptationForType) {
// The content does not have audio/video media, e.g. it's an audio only content.
// there is no track of this type to select.
return;
}
const isTypeUnsupported = adaptationsForType.every(
(adapt) => adapt.supportStatus.hasSupportedCodec === false,
);
const isAudioAndVideoUnsupported = audioVideoAdaptations.every(
(adapt) => adapt.supportStatus.hasSupportedCodec === false,
);
if (isTypeUnsupported) {
const err = new MediaError(
"MANIFEST_INCOMPATIBLE_CODECS_ERROR",
"No supported " + ttype + " adaptations",
{ tracks: undefined },
);
if (isAudioAndVideoUnsupported) {
// Both video and audio are unsupported or the content is audio/video only.
// The content cannot be played without this media type as there is no other media type.
this.trigger("error", err);
// The previous event trigger could have had side-effects, so we
// re-check if we're still mostly in the same state
if (this._isDisposed) {
return; // Someone disposed the `TracksStore` on the previous side-effect
}
this.dispose();
return;
} else if (this.onTracksNotPlayableForType[ttype] === "continue") {
// audio or video is not playable, but let's continue the playback without audio
// or without video because of the option was set to "continue".
this.trigger("warning", err);
} else {
this.trigger("error", err);
// The previous event trigger could have had side-effects, so we
// re-check if we're still mostly in the same state
if (this._isDisposed) {
return; // Someone disposed the `TracksStore` on the previous side-effect
}
this.dispose();
return;
}
}
}
/**
* Update the list of Periods handled by the TracksStore and make a
* track choice decision for each of them.
* @param {Object} manifest - The new Manifest object
*/
public onManifestUpdate(manifest: IManifestMetadata) {
const { periods } = manifest;
// We assume that they are always sorted chronologically
// In dev mode, perform a runtime check that this is the case
if ((__ENVIRONMENT__.CURRENT_ENV as number) === (__ENVIRONMENT__.DEV as number)) {
for (let i = 1; i < periods.length; i++) {
assert(periods[i - 1].start <= periods[i].start);
}
}
/** Periods which have just been added. */
const addedPeriods: ITSPeriodObject[] = [];
for (const period of periods) {
["audio" as const, "video" as const].forEach((ttype) => {
this.checkPeriodHasSupportedTrack(period, ttype);
});
if (this._isDisposed) {
return;
}
}
let newPListIdx = 0;
for (let i = 0; i < this._storedPeriodInfo.length; i++) {
const oldPeriod = this._storedPeriodInfo[i].period;
const newPeriod = periods[newPListIdx];
if (newPeriod === undefined) {
// We reached the end of the new Periods, remove remaining old Periods
for (let j = this._storedPeriodInfo.length - 1; j >= i; j--) {
this._storedPeriodInfo[j].inManifest = false;
if (isPeriodItemRemovable(this._storedPeriodInfo[j])) {
this._removePeriodObject(j);
}
}
} else if (oldPeriod === newPeriod) {
newPListIdx++;
this.resetSelectedTrackIfNotAvailableAnymore(
this._storedPeriodInfo[i],
newPeriod,
"video",
);
this.resetSelectedTrackIfNotAvailableAnymore(
this._storedPeriodInfo[i],
newPeriod,
"audio",
);
this.resetSelectedTrackIfNotAvailableAnymore(
this._storedPeriodInfo[i],
newPeriod,
"text",
);
// (If not, what do?)
} else if (oldPeriod.start <= newPeriod.start) {
// This old Period does not exist anymore.
this._storedPeriodInfo[i].inManifest = false;
if (isPeriodItemRemovable(this._storedPeriodInfo[i])) {
this._removePeriodObject(i);
i--;
}
} else {
const newPeriodInfo = generatePeriodInfo(newPeriod, true);
// oldPeriod.start > newPeriod.start: insert newPeriod before
this._storedPeriodInfo.splice(i, 0, newPeriodInfo);
addedPeriods.push(newPeriodInfo);
newPListIdx++;
// Note: we don't increment `i` on purpose here, as we want to check the
// same oldPeriod at the next loop iteration
}
}
if (newPListIdx < periods.length) {
// Add further new Period
const periodsToAdd = periods
.slice(newPListIdx)
.map((p) => generatePeriodInfo(p, true));
this._storedPeriodInfo.push(...periodsToAdd);
addedPeriods.push(...periodsToAdd);
}
for (const storedPeriodInfo of this._storedPeriodInfo) {
storedPeriodInfo.audio.dispatcher?.refresh();
storedPeriodInfo.video.dispatcher?.refresh();
storedPeriodInfo.text.dispatcher?.refresh();
}
}
public onDecipherabilityUpdates() {
for (const storedPeriodInfo of this._storedPeriodInfo) {
storedPeriodInfo.audio.dispatcher?.refresh();
storedPeriodInfo.video.dispatcher?.refresh();
storedPeriodInfo.text.dispatcher?.refresh();
}
}
/**
* Reset the stored settings if the track is not available anymore in
* the new manifest.
* @param periodInfo
* @param newPeriod
* @param type
* @returns
*/
private resetSelectedTrackIfNotAvailableAnymore(
periodInfo: ITSPeriodObject,
newPeriod: IPeriodMetadata,
type: ITrackType,
) {
const wantedTrack = periodInfo[type].storedSettings;
if (isNullOrUndefined(wantedTrack)) {
return;
}
const supportedAdaptations = getSupportedAdaptations(newPeriod, type);
const stillHere = supportedAdaptations.some(
(a) => a.id === wantedTrack.adaptation.id,
);
if (stillHere) {
// The track still exists, it don't need to be resetted.
return;
}
log.warn(`Track`, `Chosen ${type} Adaptation not available anymore`);
if (type === "video") {
periodInfo.video.storedSettings = this.getDefaultStoredSettingsForAdaptation(
supportedAdaptations,
"video",
);
} else if (type === "audio") {
periodInfo.audio.storedSettings = this.getDefaultStoredSettingsForAdaptation(
supportedAdaptations,
"audio",
);
} else if (type === "text") {
periodInfo.text.storedSettings = this.getDefaultStoredSettingsForAdaptation(
supportedAdaptations,
"text",
);
}
this.trigger("trackUpdate", {
period: toExposedPeriod(newPeriod),
trackType: type,
reason: "missing",
});
// The previous event trigger could have had side-effects, so we
// re-check if we're still mostly in the same state
if (this._isDisposed) {
return; // The current TracksStore is disposed, we can abort
}
const periodItem = getPeriodItem(this._storedPeriodInfo, periodInfo.period.id);
if (
periodItem !== undefined &&
periodItem.isPeriodAdvertised &&
periodItem[type].storedSettings === null
) {
periodItem[type].dispatcher?.updateTrack(null);
}
}
// Overloading signatures
private getDefaultStoredSettingsForAdaptation(
supportedAdaptations: IAdaptationMetadata[],
type: "text",
): null;
private getDefaultStoredSettingsForAdaptation(
supportedAdaptations: IAdaptationMetadata[],
type: "audio",
): IAudioStoredSettings | null;
private getDefaultStoredSettingsForAdaptation(
supportedAdaptations: IAdaptationMetadata[],
type: "video",
): IVideoStoredSettings | null;
private getDefaultStoredSettingsForAdaptation<B extends ITrackType>(
supportedAdaptations: IAdaptationMetadata[],
type: B,
): IVideoStoredSettings | IAudioStoredSettings | null {
const { DEFAULT_VIDEO_TRACK_SWITCHING_MODE } = config.getCurrent();
if (type === "text") {
return null;
}
if (supportedAdaptations.length === 0) {
return null;
}
switch (type) {
case "text":
return null;
case "video": {
const adaptationBase = supportedAdaptations[0];
const adaptation = getRightVideoTrack(
adaptationBase,
this._isTrickModeTrackEnabled,
);
const lockedRepresentations = new SharedReference<IRepresentationsChoice | null>(
null,
);
return {
adaptationBase,
adaptation,
switchingMode: DEFAULT_VIDEO_TRACK_SWITCHING_MODE,
lockedRepresentations,
};
}
case "audio": {
return {
adaptation: supportedAdaptations[0],
switchingMode: this._defaultAudioTrackSwitchingMode,
lockedRepresentations: new SharedReference<IRepresentationsChoice | null>(null),
};
}
default: {
assertUnreachable(type);
}
}
}
/**
* Add shared reference to choose Adaptation for new "audio", "video" or
* "text" Period.
*
* Note that such reference has to be removed through `removeTrackReference`
* so ressources can be freed.
* @param {string} bufferType - The concerned buffer type
* @param {Period} period - The concerned Period.
* @param {Object} adaptationRef - A reference through which
* the choice will be given.
*/
public addTrackReference(
bufferType: "audio" | "text" | "video",
period: IPeriodMetadata,
adaptationRef: SharedReference<IAdaptationChoice | null | undefined>,
): void {
log.debug("Track", "Adding Track Reference", { bufferType, periodId: period.id });
let periodObj = getPeriodItem(this._storedPeriodInfo, period.id);
if (periodObj === undefined) {
// The Period has not yet been added.
periodObj = generatePeriodInfo(period, false);
let found = false;
for (let i = 0; i < this._storedPeriodInfo.length; i++) {
if (this._storedPeriodInfo[i].period.start > period.start) {
this._storedPeriodInfo.splice(i, 0, periodObj);
found = true;
}
}
if (!found) {
this._storedPeriodInfo.push(periodObj);
}
}
if (periodObj[bufferType].dispatcher !== null) {
log.error(`Track`, `Subject already added for type and Period`, {
bufferType,
periodId: period.id,
});
periodObj[bufferType].dispatcher.dispose();
}
const dispatcher = new TrackDispatcher(adaptationRef);
periodObj[bufferType].dispatcher = dispatcher;
dispatcher.addEventListener("noPlayableRepresentation", () => {
this.handleMissingOrUnplayableTrack(period, bufferType, false);
});
dispatcher.addEventListener("noPlayableLockedRepresentation", () => {
// TODO check that it doesn't already lead to segment loading or MediaSource
// reloading
if (periodObj === undefined) {
return;
}
this.unlockVideoRepresentations(periodObj);
this.trigger("brokenRepresentationsLock", {
period: { id: period.id, start: period.start, end: period.end },
trackType: bufferType,
});
});
this._selectInitialTrackIfNeeded();
// Ensure `newAvailablePeriods` is sent
if (this._shouldAdvertisePeriod(periodObj)) {
periodObj.isPeriodAdvertised = true;
this.trigger("newAvailablePeriods", [
{
id: period.id,
start: period.start,
end: period.end,
},
]);
if (this._isDisposed) {
return;
}
}
// Ensure the initial track is set for each type now)
const trackTypes: ITrackType[] = ["audio", "video", "text"];
for (const ttype of trackTypes) {
const trackObj = periodObj[ttype];
if (
periodObj.isPeriodAdvertised &&
trackObj.dispatcher !== null &&
!trackObj.dispatcher.hasSetTrack() &&
trackObj.storedSettings !== undefined
) {
trackObj.dispatcher.updateTrack(trackObj.storedSettings);
}
if (this._isDisposed) {
return;
}
}
}
/**
* Throws an error if neither audio nor video tracks are selected for the given period.
*
* This indicates that the application or user has not selected any tracks for playback,
* which is considered an invalid state.
*
* @param {object} period - The period to check for selected tracks.
* @returns {void}
* @throws {erorr} If no audio or video tracks are set for the period.
*/
private throwIfTracksAreNotSetForPeriod(period: IPeriodMetadata): void {
const periodItem = getPeriodItem(this._storedPeriodInfo, period.id);
if (periodItem !== undefined) {
const hasNoTrackAtAll = ["audio" as const, "video" as const].every(
(ttype) => periodItem[ttype].storedSettings === null,
);
if (hasNoTrackAtAll) {
const err = new MediaError(
"NO_AUDIO_VIDEO_TRACKS",
"No audio and no video tracks are set.",
);
this.trigger("error", err);
this.dispose();
}
}
}
/**
* Handles the case when no playable representations are available, or when no initial track has been set,
* for a given period and track type (e.g., `'video'` or `'audio'`).
*
* It attempts to fall back to another available track.
*
* If no fallback tracks are available, the function may either:
* - Throw an error
* - Continue playback by disabling the affected media type (audio/video)
*
* The behavior depends on how the `onVideoTracksNotPlayable` and `onAudioTracksNotPlayable` options are configured.
*
* @param {Object} period - The period object containing the adaptations.
* @param {string} trackType - The type of media track (e.g., `'video'` or `'audio'`) that became unplayable.
*@param {boolean} isInitialSelection - Indicates if this occurs during initial track selection.
* @returns {void}
*/
private handleMissingOrUnplayableTrack(
period: IPeriodMetadata,
trackType: ITrackType,
isInitialSelection: boolean,
): void {
const { fallbackTrack, noSourceMedia } = getFallbackTrack(period, trackType);
const typeInfo = getPeriodItem(this._storedPeriodInfo, period.id)?.[trackType];
if (typeInfo === undefined) {
log.warn("Track", "Could not find period", { periodId: period.id });
return;
}
const initialStoredSettings = typeInfo.storedSettings;
const hasStoredSettingsChanged = (): boolean => {
return typeInfo.storedSettings !== initialStoredSettings;
};
if (fallbackTrack !== null) {
let switchingMode: "direct" | "seamless" | "reload";
if (trackType === "audio") {
switchingMode = this._defaultAudioTrackSwitchingMode;
} else if (trackType === "text") {
switchingMode = "direct";
} else {
switchingMode = "reload";
}
const storedSettings = {
adaptation: fallbackTrack,
switchingMode,
lockedRepresentations: new SharedReference<IRepresentationsChoice | null>(null),
};
typeInfo.storedSettings = storedSettings;
if (!isInitialSelection) {
// "trackUpdate" events are not sent for the initial track.
// See documentation: #Player_Events.md, section ###trackUpdate
this.trigger("trackUpdate", {
period: toExposedPeriod(period),
trackType,
reason: "no-playable-representation",
});
}
// The previous event trigger could have had side-effects, so we
// re-check if we're still mostly in the same state
if (this._isDisposed) {
return; // Someone disposed the `TracksStore` on the previous side-effect
}
// Check again that no track change occurred in the meantime
if (typeInfo.storedSettings === storedSettings) {
typeInfo.dispatcher?.updateTrack(storedSettings);
}
} else if (fallbackTrack === null && !noSourceMedia) {
this.trigger("noPlayableTrack", {
trackType,
period: { id: period.id, start: period.start, end: period.end },
});
// The previous event trigger could have had side-effects, so we
// re-check if we're still mostly in the same state
if (this._isDisposed) {
return; // Someone disposed the `TracksStore` on the previous side-effect
}
const fallbackBehavior = this.onTracksNotPlayableForType[trackType];
if (hasStoredSettingsChanged()) {
// The previous "noPlayableTrack" event might have caused changes,
// so we re-check to see if the selected track has been updated.
// If it has, we exit early because the API consumer likely adjusted the settings,
// and throwing an error now would be out of sync with their changes.
} else if (fallbackBehavior === "continue") {
log.warn("Track", `No playable ${trackType}, continuing without ${trackType}`);
typeInfo.storedSettings = null;
if (!isInitialSelection) {
// "trackUpdate" events are not sent for the initial track.
// See documentation: #Player_Events.md, section ###trackUpdate
this.trigger("trackUpdate", {
period: toExposedPeriod(period),
trackType,
reason: "no-playable-representation",
});
}
if (typeInfo.storedSettings !== null || this._isDisposed) {
// The previous "trackUpdate" event might have caused changes,
// so we re-check to see if the selected track has been updated.
// If it has, we exit early because the API consumer likely adjusted the settings,
// and throwing an error now would be out of sync with their changes.
} else {
typeInfo.dispatcher?.updateTrack(null);
}
} else if (fallbackBehavior === "error") {
const noRepErr = new MediaError(
"NO_PLAYABLE_REPRESENTATION",
`No ${trackType} Representation can be played`,
{ tracks: undefined },
);
this.trigger("error", noRepErr);
}
} else if (fallbackTrack === null && noSourceMedia) {
log.debug(
`Track`,
`The period does not have adaptation for ${trackType} there is no track to choose`,
);
typeInfo.storedSettings = null;
if (!isInitialSelection) {
// "trackUpdate" events are not sent for the initial track.
// See documentation: #Player_Events.md, section ###trackUpdate
this.trigger("trackUpdate", {
period: toExposedPeriod(period),
trackType,
reason: "no-playable-representation",
});
}
if (typeInfo.storedSettings !== null || this._isDisposed) {
// The previous "trackUpdate" event might have caused changes,
// so we re-check to see if the selected track has been updated.
// If it has, we exit early because the API consumer likely adjusted the settings,
// and throwing an error now would be out of sync with their changes.
} else {
typeInfo.dispatcher?.updateTrack(null);
}
}
// The previous event trigger could have had side-effects, so we
// re-check if we're still mostly in the same state
if (this._isDisposed) {
return; // Someone disposed the `TracksStore` on the previous side-effect
}
if (trackType !== "text") {
// Allow missing text tracks; only enforce for audio/video
this.throwIfTracksAreNotSetForPeriod(period);
}
}
/**
* Remove shared reference to choose an "audio", "video" or "text" Adaptation
* for a Period.
* @param {string} bufferType - The concerned buffer type
* @param {string} periodId - The concerned Period's `id`.
*/
public removeTrackReference(
bufferType: "audio" | "text" | "video",
periodId: string,
): void {
log.debug("Track", "Removing Track Reference", { bufferType, periodId });
let periodIndex;
for (let i = 0; i < this._storedPeriodInfo.length; i++) {
const periodI = this._storedPeriodInfo[i];
if (periodI.period.id === periodId) {
periodIndex = i;
break;
}
}
if (periodIndex === undefined) {
log.warn(`Track`, `type not found for period`, { bufferType, periodId });
return;
}
const periodObj = this._storedPeriodInfo[periodIndex];
const choiceItem = periodObj[bufferType];
if (choiceItem?.dispatcher === null) {
log.warn(`Track`, `TrackDispatcher already removed for type and Period`, {
bufferType,
periodId,
});
return;
}
choiceItem.dispatcher.dispose();
choiceItem.dispatcher = null;
if (isPeriodItemRemovable(periodObj)) {
this._removePeriodObject(periodIndex);
}
}
/**
* Allows to recuperate a "Period Object" - used in get/set methods of the
* `TracksStore` - by giving the Period itself.
*
* This method should be preferred when possible over `getPeriodObjectFromId`
* because it is able to fallback on an internal cache in case the
* corresponding Period is not stored anymore.
* This for example could happen when a Period has been removed from the
* Manifest yet may still be needed (e.g. because its linked segments might
* still live in the buffers).
*
* Note however that this cache-retrieval logic is based on a Map whose key
* is the Period's JavaScript reference. As such, the cache won't be used if
* `Period` corresponds to a copy of the original `Period` object.
*
* @param {Object} period
* @returns {Object}
*/
public getPeriodObjectFromPeriod(period: IPeriodMetadata): ITSPeriodObject | undefined {
const periodObj = getPeriodItem(this._storedPeriodInfo, period.id);
if (periodObj === undefined && period !== undefined) {
return this._cachedPeriodInfo.get(period);
}
return periodObj;
}
/**
* Allows to recuperate a "Period Object" - used in get/set methods of the
* `TracksStore` - by giving the Period's id.
*
* Note that unlike `getPeriodObjectFromPeriod` this method is only going to look
* into currently stored Period and as such old Periods not in the Manifest
* anymore might not be retrievable.
* If you want to retrieve Period objects linked to such Period, you might
* prefer to use `getPeriodObjectFromPeriod` (which necessitates the original
* Period object).
*
* @param {string} periodId - The concerned Period's id
* @returns {Object}
*/
public getPeriodObjectFromId(periodId: string): ITSPeriodObject | undefined {
return getPeriodItem(this._storedPeriodInfo, periodId);
}
public disableVideoTrickModeTracks(): void {
if (!this._isTrickModeTrackEnabled) {
return;
}
this._isTrickModeTrackEnabled = false;
this._resetVideoTrackChoices("trickmode-disabled");
}
public enableVideoTrickModeTracks(): void {
if (this._isTrickModeTrackEnabled) {
return;
}
this._isTrickModeTrackEnabled = true;
this._resetVideoTrackChoices("trickmode-enabled");
}
/**
* Reset the TracksStore's Period objects:
* - All Period which are not in the manifest currently will be removed.
* - All References used to communicate the wanted track will be removed.
*
* You might want to call this API when restarting playback.
*/
public resetPeriodObjects(): void {
log.debug("Track", "Resetting Period Objects");
for (let i = this._storedPeriodInfo.length - 1; i >= 0; i--) {
const storedObj = this._storedPeriodInfo[i];
storedObj.audio.dispatcher?.dispose();
storedObj.audio.dispatcher = null;
storedObj.video.dispatcher?.dispose();
storedObj.video.dispatcher = null;
storedObj.text.dispatcher?.dispose();
storedObj.text.dispatcher = null;
if (!storedObj.inManifest) {
this._removePeriodObject(i);
}
}
}
/**
* @returns {boolean}
*/
public isTrickModeEnabled(): boolean {
return this._isTrickModeTrackEnabled;
}
/**
* Set audio track based on the ID of its Adaptation for a given added Period.
* @param {Object} params
* @param {Object} params.periodRef - The concerned Period's object.
* @param {string} params.trackId - adaptation id of the wanted track.
* @param {string} params.switchingMode - Behavior when replacing the track by
* another.
* @param {Object|null} params.lockedRepresentations - Audio Representations
* that should be locked after switching to that track.
* `null` if no Audio Representation should be locked.
* @param {number} params.relativeResumingPosition
*/
public setAudioTrack(payload: {
periodRef: ITSPeriodObject;
trackId: string;
switchingMode: IAudioTrackSwitchingMode | undefined;
lockedRepresentations: string[] | null;
relativeResumingPosition: number | undefined;
}): void {
const {
periodRef,
trackId,
switchingMode,
lockedRepresentations,
relativeResumingPosition,
} = payload;
return this._setAudioOrTextTrack({
bufferType: "audio",
periodRef,
trackId,
switchingMode: switchingMode ?? this._defaultAudioTrackSwitchingMode,
lockedRepresentations,
relativeResumingPosition,
});
}
/**
* Set text track based on the ID of its Adaptation for a given added Period.
* @param {Object} periodObj - The concerned Period's object.
* @param {string} wantedId - adaptation id of the wanted track.
*/
public setTextTrack(periodObj: ITSPeriodObject, wantedId: string): void {
return this._setAudioOrTextTrack({
bufferType: "text",
periodRef: periodObj,
trackId: wantedId,
switchingMode: "direct",
lockedRepresentations: null,
relativeResumingPosition: undefined,
});
}
/**
* Set audio track based on the ID of its Adaptation for a given added Period.
* @param {Object} params
* @param {string} params.bufferType
* @param {Object} params.periodRef - The concerned Period's object.
* @param {string} params.trackId - adaptation id of the wanted track.
* @param {string} params.switchingMode - Behavior when replacing the track by
* another.
* @param {Array.<string>|null} params.lockedRepresentations - Audio
* Representations that should be locked after switchingMode to that track.
* `null` if no Audio Representation should be locked.
* @param {number|undefined} params.relativeResumingPosition
*/
private _setAudioOrTextTrack({
bufferType,
periodRef,
trackId,
switchingMode,
lockedRepresentations,
relativeResumingPosition,
}: {
bufferType: "audio" | "text";
periodRef: ITSPeriodObject;
trackId: string;
switchingMode: IAudioTrackSwitchingMode;
lockedRepresentations: string[] | null;
relativeResumingPosition: number | undefined;
}): void {
if (!periodRef.isPeriodAdvertised) {
throw new Error("Wanted Period not yet advertised.");
}
const period = periodRef.period;
const wantedAdaptation = arrayFind(
period.adaptations[bufferType] ?? [],
({ id, supportStatus }) =>
supportStatus.hasSupportedCodec !== false &&
supportStatus.isDecipherable !== false &&
id === trackId,
);
if (wantedAdaptation === undefined) {
throw new Error(`Wanted ${bufferType} track not found.`);
}
const typeInfo = periodRef[bufferType];
let lockedRepresentationsRef: SharedReference<IRepresentationsChoice | null>;
if (lockedRepresentations === null) {
lockedRepresentationsRef = new SharedReference<IRepresentationsChoice | null>(null);
} else {
const representationsToLock = this._getRepresentationsToLock(
wantedAdaptation,
lockedRepresentations,
);
const repSwitchingMode =
bufferType === "audio"
? this._defaultAudioTrackSwitchingMode
: ("direct" as const);
lockedRepresentationsRef = new SharedReference<IRepresentationsChoice | null>({
representationIds: representationsToLock,
switchingMode: repSwitchingMode,
});
}
const storedSettings = {
adaptation: wantedAdaptation,
switchingMode,
lockedRepresentations: lockedRepresentationsRef,
relativeResumingPosition,
};
typeInfo.storedSettings = storedSettings;
this.trigger("trackUpdate", {
period: toExposedPeriod(period),
trackType: bufferType,
reason: "manual",
});
// The previous event trigger could have had side-effects, so we
// re-check if we're still mostly in the same state
if (this._isDisposed) {
return; // Someone disposed the `TracksStore` on the previous side-effect
}
const newPeriodItem = getPeriodItem(this._storedPeriodInfo, period.id);
if (
newPeriodItem !== undefined &&
newPeriodItem[bufferType].storedSettings === storedSettings
) {
newPeriodItem[bufferType].dispatcher?.updateTrack(storedSettings);
}
}
/**
* Set video track based on the ID of its Adaptation for a given added Period.
* @param {Object} params
* @param {Object} params.periodRef - The concerned Period's object.
* @param {string} params.trackId - adaptation id of the wanted track.
* @param {string} params.switchingMode - Behavior when replacing the track by
* another.
* @param {Array.<string>|null} params.lockedRepresentations - Video
* Representations that should be locked after switching to that track.
* `null` if no Video Representation should be locked.
* @param {number|undefined} params.relativeResumingPosition
*/
public setVideoTrack(payload: {
periodRef: ITSPeriodObject;
trackId: string;
switchingMode: IVideoTrackSwitchingMode | undefined;
lockedRepresentations: string[] | null;
relativeResumingPosition: number | undefined;
}): void {
const {
periodRef,
trackId,
switchingMode,
lockedRepresentations,
relativeResumingPosition,
} = payload;
if (!periodRef.isPeriodAdvertised) {
throw new Error("Wanted Period not yet advertised.");
}
const period = periodRef.period;
const wantedAdaptation = arrayFind(
period.adaptations.video ?? [],
({ id, supportStatus }) =>
supportStatus.isDecipherable !== false &&
supportStatus.hasSupportedCodec !== false &&
id === trackId,
);
if (wantedAdaptation === undefined) {
throw new Error("Wanted video track not found.");
}
const { DEFAULT_VIDEO_TRACK_SWITCHING_MODE } = config.getCurrent();
const typeInfo = periodRef.video;
const newAdaptation = getRightVideoTrack(
wantedAdaptation,
this._isTrickModeTrackEnabled,
);
let lockedRepresentationsRef;
if (lockedRepresentations === null) {
lockedRepresentationsRef = new SharedReference<IRepresentationsChoice | null>(null);
} else {
const representationsToLock = this._getRepresentationsToLock(
wantedAdaptation,
lockedRepresentations,
);
const repSwitchingMode = DEFAULT_VIDEO_TRACK_SWITCHING_MODE;
lockedRepresentationsRef = new SharedReference<IRepresentationsChoice | null>({
representationIds: representationsToLock,
switchingMode: repSwitchingMode,
});
}
const storedSettings = {
adaptationBase: wantedAdaptation,
switchingMode: switchingMode ?? DEFAULT_VIDEO_TRACK_SWITCHING_MODE,
adaptation: newAdaptation,
relativeResumingPosition,
lockedRepresentations: lockedRepresentationsRef,
};
typeInfo.storedSettings = storedSettings;
this.trigger("trackUpdate", {
period: toExposedPeriod(period),
trackType: "video",
reason: "manual",
});
// The previous event trigger could have had side-effects, so we
// re-check if we're still mostly in the same state
if (this._isDisposed) {
return; // Someone disposed the `TracksStore` on the previous side-effect
}
const newPeriodItem = getPeriodItem(this._storedPeriodInfo, period.id);
if (
newPeriodItem !== undefined &&
newPeriodItem.video.storedSettings === storedSettings
) {
newPeriodItem.video.dispatcher?.updateTrack(storedSettings);
}
}
/**
* Disable the current text track for a given period.
*
* @param {Object} periodObj - The concerned Period's object
* @param {string} bufferType - The type of track to disable.
* @throws Error - Throws if the period given has not been added
*/
public disableTrack(
periodObj: ITSPeriodObject,
bufferType: "audio" | "video" | "text",
): void {
if (!periodObj.isPeriodAdvertised) {
throw new Error("Wanted Period not yet advertised.");
}
const trackInfo = periodObj[bufferType];
if (trackInfo.storedSettings === null) {
return;
}
if (bufferType !== "text") {
// Potentially unneeded, but let's be clean
periodObj[bufferType].storedSettings?.lockedRepresentations.finish();
}
trackInfo.storedSettings = null;
this.trigger("trackUpdate", {
period: toExposedPeriod(periodObj.period),
trackType: bufferType,
reason: "manual",
});
// The previous event trigger could have had side-effects, so we
// re-check if we're still mostly in the same state
if (this._isDisposed) {
return; // Someone disposed the `TracksStore` on the previous side-effect
}
const newPeriodItem = getPeriodItem(this._storedPeriodInfo, periodObj.period.id);
if (
newPeriodItem !== undefined &&
newPeriodItem[bufferType].storedSettings === null
) {
newPeriodItem[bufferType].dispatcher?.updateTrack(null);
}
if (newPeriodItem !== undefined) {
const hasNoTrackAtAll = ["audio" as const, "video" as const].every(
(ttype) => newPeriodItem[ttype].storedSettings === null,
);
if (hasNoTrackAtAll) {
const err = new MediaError(
"NO_AUDIO_VIDEO_TRACKS",
"No audio and no video tracks are set.",
);
this.trigger("error", err);
this.dispose();
return;
}
}
}
/**
* Returns an object describing the chosen audio track for the given audio
* Period.
*
* Returns `null` is the the current audio track is disabled or not
* set yet.a pas bcp de marge de manoeuvre j'ai l'impression
*
* Returns `undefined` if the given Period's id is not currently found in the
* `TracksStore`. The cause being most probably that the corresponding
* Period is not available anymore.
* If you're in that case and if still have the corresponding JavaScript
* reference to the wanted Period, you can call `getOldAudioTrack` with it. It
* will try retrieving the choice it made from its cache.
* @param {Object} periodObj - The concerned Period's object
* @returns {Object|null|undefined} - The audio track chosen for this Period.
* `null` if audio tracks were disabled and `undefined` if the Period is not
* known.
*/
public getChosenAudioTrack(
periodObj: ITSPeriodObject,
filterPlayableRepresentations: boolean,
): IAudioTrack | null {
return isNullOrUndefined(periodObj.audio.storedSettings)
? null
: toAudioTrack(
periodObj.audio.storedSettings.adaptation,
filterPlayableRepresentations,
);
}
/**
* Returns an object describing the chosen text track for the given text
* Period.
*
* Returns null is the the current text track is disabled or not
* set yet.
*
* @param {Object} periodObj - The concerned Period's object
* @returns {Object|null} - The text track chosen for this Period
*/
public getChosenTextTrack(periodObj: ITSPeriodObject): ITextTrack | null {
return isNullOrUndefined(periodObj.text.storedSettings)
? null
: toTextTrack(periodObj.text.storedSettings.adaptation);
}
/**
* Returns an object describing the chosen video track for the given video
* Period.
*
* Returns null is the the current video track is disabled or not
* set yet.
*
* @param {Object} periodObj - The concerned Period's object
* @returns {Object|null} - The video track chosen for this Period
*/
public getChosenVideoTrack(
periodObj: ITSPeriodObject,
filterPlayableRepresentations: boolean,
): IVideoTrack | null {
if (isNullOrUndefined(periodObj.video.storedSettings)) {
return null;
}
return toVideoTrack(
periodObj.video.storedSettings.adaptation,
filterPlayableRepresentations,
);
}
/**
* Returns all available audio tracks for a given Period, as an array of
* objects.
*
* Returns `undefined` if the given Period's id is not known.
*
* @param {Object} periodObj - The concerned Period's object
* @param {boolean} filterPlayableRepresentations - If `true`, only
* representations considered to be "playable" will be included in the
* returned response.
* If `false`, the response should contain all linked representations.
* @returns {Array.<Object>}
*/
public getAvailableAudioTracks(
periodObj: ITSPeriodObject,
filterPlayableRepresentations: boolean,
): IAvailableAudioTrack[] | undefined {
const storedSettings = periodObj.audio.storedSettings;
const currentId = !isNullOrUndefined(storedSettings)
? storedSettings.adaptation.id
: null;
const adaptations = getSupportedAdaptations(periodObj.period, "audio");
return adaptations.map((adaptation: IAdaptationMetadata) => {
const active = currentId === null ? false : currentId === adaptation.id;
return objectAssign(toAudioTrack(adaptation, filterPlayableRepresentations), {
active,
});
});
}
/**
* Returns all available text tracks for a given Period, as an array of
* objects.
*
* Returns `undefined` if the given Period's id is not known.
*
* @param {Object} periodObj - The concerned Period's object
* @returns {Array.<Object>}
*/
public getAvailableTextTracks(
periodObj: ITSPeriodObject,
): IAvailableTextTrack[] | undefined {
const storedSettings = periodObj.text.storedSettings;
const currentId = !isNullOrUndefined(storedSettings)
? storedSettings.adaptation.id
: null;
const adaptations = getSupportedAdaptations(periodObj.period, "text");
return adaptations.map((adaptation) => {
const active = currentId === null ? false : currentId === adaptation.id;
return objectAssign(toTextTrack(adaptation), { active });
});
}
/**
* Returns all available video tracks for a given Period, as an array of
* objects.
*
* Returns `undefined` if the given Period's id is not known.
*
* @param {Object} periodObj - The concerned Period's object
* @param {boolean} filterPlayableRepresentations - If `true`, only
* representations considered to be "playable" will be included in the
* returned response.
* If `false`, the response should contain all linked representations.
* @returns {Array.<Object>}
*/
public getAvailableVideoTracks(
periodObj: ITSPeriodObject,
filterPlayableRepresentations: boolean,
): IAvailableVideoTrack[] | undefined {
const storedSettings = periodObj.video.storedSettings;
const currentId = isNullOrUndefined(storedSettings)
? undefined
: storedSettings.adaptation.id;
const adaptations = getSupportedAdaptations(periodObj.period, "video");
return adaptations.map((adaptation) => {
const active = currentId === null ? false : currentId === adaptation.id;
const track = toVideoTrack(adaptation, filterPlayableRepresentations);
const trickModeTracks =
track.trickModeTracks !== undefined
? track.trickModeTracks.map((trickModeAdaptation) => {
const isActive =
currentId === null ? false : currentId === trickModeAdaptation.id;
return objectAssign(trickModeAdaptation, { active: isActive });
})
: [];
const availableTrack = objectAssign(track, { active });
if (trickModeTracks !== undefined) {
availableTrack.trickModeTracks = trickModeTracks;
}
return availableTrack;
});
}
public getLockedAudioRepresentations(periodObj: ITSPeriodObject): string[] | null {
const { storedSettings } = periodObj.audio;
if (isNullOrUndefined(storedSettings)) {
return null;
}
const lastLockedSettings = storedSettings.lockedRepresentations.getValue();
return lastLockedSettings === null ? null : lastLockedSettings.representationIds;
}
public getLockedVideoRepresentations(periodObj: ITSPeriodObject): string[] | null {
const { storedSettings } = periodObj.video;
if (isNullOrUndefined(storedSettings)) {
return null;
}
const lastLockedSettings = storedSettings.lockedRepresentations.getValue();
return lastLockedSettings === null ? null : lastLockedSettings.representationIds;
}
public lockAudioRepresentations(
periodObj: ITSPeriodObject,
lockSettings: IAudioRepresentationsLockSettings,
): void {
const { storedSettings } = periodObj.audio;
if (isNullOrUndefined(storedSettings)) {
return;
}
const { DEFAULT_AUDIO_REPRESENTATIONS_SWITCHING_MODE } = config.getCurrent();
const filtered = this._getRepresentationsToLock(
storedSettings.adaptation,
lockSettings.representations,
);
const switchingMode =
lockSettings.switchingMode ?? DEFAULT_AUDIO_REPRESENTATIONS_SWITCHING_MODE;
storedSettings.lockedRepresentations.setValue({
representationIds: filtered,
switchingMode,
});
}
public lockVideoRepresentations(
periodObj: ITSPeriodObject,
lockSettings: IVideoRepresentationsLockSettings,
): void {
const { storedSettings } = periodObj.video;
if (isNullOrUndefined(storedSettings)) {
return;
}
const { DEFAULT_VIDEO_REPRESENTATIONS_SWITCHING_MODE } = config.getCurrent();
const filtered = this._getRepresentationsToLock(
storedSettings.adaptation,
lockSettings.representations,
);
const switchingMode =
lockSettings.switchingMode ?? DEFAULT_VIDEO_REPRESENTATIONS_SWITCHING_MODE;
storedSettings.lockedRepresentations.setValue({
representationIds: filtered,
switchingMode,
});
}
public unlockAudioRepresentations(periodObj: ITSPeriodObject): void {
const { storedSettings } = periodObj.audio;
if (
isNullOrUndefined(storedSettings) ||
storedSettings.lockedRepresentations.getValue() === null
) {
return;
}
storedSettings.lockedRepresentations.setValue(null);
}
public unlockVideoRepresentations(periodObj: ITSPeriodObject): void {
const { storedSettings } = periodObj.video;
if (
isNullOrUndefined(storedSettings) ||
storedSettings.lockedRepresentations.getValue() === null
) {
return;
}
storedSettings.lockedRepresentations.setValue(null);
}
public dispose(): void {
this._isDisposed = true;
while (true) {
const lastPeriod = this._storedPeriodInfo.pop();
if (lastPeriod === undefined) {
return;
}
lastPeriod.isRemoved = true;
}
}
private _resetVideoTrackChoices(reason: "trickmode-enabled" | "trickmode-disabled") {
for (let i = 0; i < this._storedPeriodInfo.length; i++) {
const periodObj = this._storedPeriodInfo[i];
if (!isNullOrUndefined(periodObj.video.storedSettings)) {
const chosenBaseTrack = periodObj.video.storedSettings.adaptationBase;
if (chosenBaseTrack !== null) {
const chosenTrack = getRightVideoTrack(
chosenBaseTrack,
this._isTrickModeTrackEnabled,
);
periodObj.video.storedSettings.adaptationBase = chosenBaseTrack;
periodObj.video.storedSettings.adaptation = chosenTrack;
}
}
}
// Clone the current Period list to not be influenced if Periods are removed
// or added while the loop is running.
const sliced = this._storedPeriodInfo.slice();
for (let i = 0; i < sliced.length; i++) {
const period = sliced[i].period;
const videoItem = sliced[i].video;
const storedSettings = videoItem.storedSettings;
if (storedSettings !== undefined) {
this.trigger("trackUpdate", {
period: toExposedPeriod(period),
trackType: "video",
reason,
});
// The previous event trigger could have had side-effects, so we
// re-check if we're still mostly in the same state
if (this._isDisposed) {
return; // Someone disposed the `TracksStore` on the previous side-effect
}
const newPeriodItem = getPeriodItem(this._storedPeriodInfo, period.id);
if (
newPeriodItem !== undefined &&
newPeriodItem.isPeriodAdvertised &&
newPeriodItem.video.storedSettings === storedSettings
) {
newPeriodItem.video.dispatcher?.updateTrack(storedSettings);
}
}
}
}
private _removePeriodObject(index: number) {
assert(index < this._storedPeriodInfo.length);
const oldPeriodItem = this._storedPeriodInfo[index];
this._storedPeriodInfo[index].isRemoved = true;
this._storedPeriodInfo.splice(index, 1);
this._cachedPeriodInfo.set(oldPeriodItem.period, oldPeriodItem);
}
private _getRepresentationsToLock(
adaptation: IAdaptationMetadata,
representationIds: string[],
): string[] {
const filtered = representationIds.reduce((acc: string[], repId) => {
const foundRep = arrayFind(adaptation.representations, (r) => {
return r.id === repId;
});
if (foundRep === undefined) {
log.warn("Track", "Wanted locked Representation not found.");
} else {
acc.push(foundRep.id);
}
return acc;
}, []);
if (filtered.length === 0) {
throw new Error(
"Cannot lock Representations: " + "None of the given Representation id are found",
);
}
return filtered;
}
/**
* Check or re-check all Periods for which both an initial track can be chosen
* and for which the `newAvailablePeriods` event can be triggered.
*/
private _selectInitialTrackIfNeeded(): void {
const { DEFAULT_VIDEO_TRACK_SWITCHING_MODE } = config.getCurrent();
const periodsToAdvertise: IPeriod[] = [];
const toDispatchTrack: ITSPeriodObject[] = [];
for (const trackStorePeriod of this._storedPeriodInfo) {
const { period } = trackStorePeriod;
if (
trackStorePeriod.audio.storedSettings !== undefined &&
trackStorePeriod.video.storedSettings !== undefined &&
trackStorePeriod.text.storedSettings !== undefined
) {
// already processed, continue
continue;
}
const adaptations: IAdaptationMetadata[] = [
...(period.adaptations.audio ?? []),
...(period.adaptations.video ?? []),
];
const hasCodecWithUndefinedSupport = adaptations.every(
(a) => a.supportStatus.hasCodecWithUndefinedSupport,
);
if (adaptations.length > 0 && hasCodecWithUndefinedSupport) {
// Not all codecs for that Period are known yet.
// Await until this is the case.
continue;
}
const audioAdaptation: IAdaptationMetadata | undefined = getSupportedAdaptations(
period,
"audio",
)[0];
if (audioAdaptation === undefined) {
trackStorePeriod.audio.storedSettings = null;
this.handleMissingOrUnplayableTrack(period, "audio", true);
if (this._isDisposed) {
return;
}
} else {
trackStorePeriod.audio.storedSettings = {
adaptation: audioAdaptation,
switchingMode: this._defaultAudioTrackSwitchingMode,
lockedRepresentations: new SharedReference<IRepresentationsChoice | null>(null),
};
}
const baseVideoAdaptation: IAdaptationMetadata | undefined =
getSupportedAdaptations(period, "video")[0];
if (baseVideoAdaptation === undefined) {
trackStorePeriod.video.storedSettings = null;
this.handleMissingOrUnplayableTrack(period, "video", true);
if (this._isDisposed) {
return;
}
} else {
const videoAdaptation = getRightVideoTrack(
baseVideoAdaptation,
this._isTrickModeTrackEnabled,
);
trackStorePeriod.video.storedSettings = {
adaptation: videoAdaptation,
adaptationBase: baseVideoAdaptation,
switchingMode: DEFAULT_VIDEO_TRACK_SWITCHING_MODE,
lockedRepresentations: new SharedReference<IRepresentationsChoice | null>(null),
};
}
let textAdaptation: IAdaptationMetadata | null = null;
const forcedSubtitles = (period.adaptations.text ?? []).filter(
(ad) => ad.isForcedSubtitles === true,
);
if (forcedSubtitles.length > 0) {
if (audioAdaptation !== null && audioAdaptation !== undefined) {
const sameLanguage = arrayFind(
forcedSubtitles,
(f) => f.normalizedLanguage === audioAdaptation.normalizedLanguage,
);
if (sameLanguage !== undefined) {
textAdaptation = sameLanguage;
}
}
if (textAdaptation === null) {
textAdaptation =
arrayFind(forcedSubtitles, (f) => f.normalizedLanguage === undefined) ?? null;
}
}
trackStorePeriod.text.storedSettings =
textAdaptation === null
? null
: {
adaptation: textAdaptation,
switchingMode: "direct" as const,
lockedRepresentations: new SharedReference<IRepresentationsChoice | null>(
null,
),
};
toDispatchTrack.push(trackStorePeriod);
if (this._shouldAdvertisePeriod(trackStorePeriod)) {
trackStorePeriod.isPeriodAdvertised = true;
periodsToAdvertise.push({ id: period.id, start: period.start, end: period.end });
}
}
if (periodsToAdvertise.length > 0) {
this.trigger("newAvailablePeriods", periodsToAdvertise);
if (this._isDisposed) {
return;
}
}
for (const trackStorePeriod of toDispatchTrack) {
if (!trackStorePeriod.isPeriodAdvertised) {
continue;
}
const bufferTypes: ITrackType[] = ["audio", "video", "text"];
for (const bufferType of bufferTypes) {
const trackInfo = trackStorePeriod[bufferType];
if (
trackInfo.dispatcher !== null &&
trackInfo.storedSettings !== undefined &&
!trackInfo.dispatcher.hasSetTrack()
) {
trackInfo.dispatcher.updateTrack(trackInfo.storedSettings);
if (this._isDisposed) {
return;
}
}
}
}
}
/**
* Returns `true` once a Period can be advertised through a `newAvailablePeriods`
* event, after which track can begin to be set and updated.
* @param {Object} periodObj
* @returns {boolean}
*/
private _shouldAdvertisePeriod(periodObj: ITSPeriodObject): boolean {
return (
!periodObj.isPeriodAdvertised &&
periodObj.text.dispatcher !== null &&
periodObj.video.dispatcher !== null &&
periodObj.audio.dispatcher !== null
);
}
}
/**
* Returns element in the given `periods` Array that corresponds to the
* `period` given.
* Returns `undefined` if that `period` is not found.
* @param {Object} periods
* @param {string} periodId
* @returns {Object|undefined}
*/
function getPeriodItem(
periods: ITSPeriodObject[],
periodId: string,
): ITSPeriodObject | undefined {
for (let i = 0; i < periods.length; i++) {
const periodI = periods[i];
if (periodI.period.id === periodId) {
return periodI;
}
}
}
/**
* A `ITSPeriodObject` should only be removed once all References linked to it
* do not exist anymore, to keep the possibility of making track choices.
* @param {Object} periodObj
* @returns {boolean}
*/
function isPeriodItemRemovable(periodObj: ITSPeriodObject): boolean {
return (
!periodObj.inManifest &&
periodObj.text?.dispatcher === null &&
periodObj.audio?.dispatcher === null &&
periodObj.video?.dispatcher === null
);
}
function getRightVideoTrack(
adaptation: IAdaptationMetadata,
isTrickModeEnabled: boolean,
): IAdaptationMetadata {
if (isTrickModeEnabled && adaptation.trickModeTracks?.[0] !== undefined) {
return adaptation.trickModeTracks[0];
}
return adaptation;
}
/**
* Generate an `ITSPeriodObject` object for the given Period, selecting the
* default track for each type.
* @param {Object} period
* @param {boolean} inManifest
* @returns {object}
*/
function generatePeriodInfo(
period: IPeriodMetadata,
inManifest: boolean,
): ITSPeriodObject {
return {
period,
inManifest,
isPeriodAdvertised: false,
isRemoved: false,
audio: { storedSettings: undefined, dispatcher: null },
video: { storedSettings: undefined, dispatcher: null },
text: { storedSettings: undefined, dispatcher: null },
};
}
function toExposedPeriod(p: IPeriodMetadata): IPeriod {
return { start: p.start, end: p.end, id: p.id };
}
/**
* Retrieves a fallback track for the given period and track type.
*
* This function is used when the current track becomes unavailable,
* and a fallback track must be selected based on the period and media type.
*
* @param {Object} period - The period object that contains information about the available tracks.
* @param {string} trackType - The type of media track to fallback to (e.g., "audio", "video", "text").
* @returns {object|null} A fallback track matching the type, or `null` if none is available.
*/
function getFallbackTrack(
period: IPeriodMetadata,
trackType: ITrackType,
): {
fallbackTrack: IAdaptationMetadata | null;
noSourceMedia: boolean;
} {
const periodHasAdaptationForType =
period.adaptations[trackType] !== undefined &&
period.adaptations[trackType].length > 0;
if (!periodHasAdaptationForType) {
return {
fallbackTrack: null,
noSourceMedia: true,
};
}
const firstPlayableAdaptation = findFirstPlayableAdaptation(period, trackType);
return {
fallbackTrack: firstPlayableAdaptation ?? null,
noSourceMedia: false,
};
}
function findFirstPlayableAdaptation(
period: IPeriodMetadata,
type: "audio" | "text" | "video",
): IAdaptationMetadata | undefined {
const firstPlayableAdaptation = arrayFind(
period.adaptations[type] ?? [],
(adaptation) => {
if (
adaptation.supportStatus.hasSupportedCodec === false ||
adaptation.supportStatus.isDecipherable === false
) {
return false;
}
const playableRepresentations = adaptation.representations.filter(
(r) => isRepresentationPlayable(r) === true,
);
return playableRepresentations.length > 0;
},
);
return firstPlayableAdaptation;
}
/** Every information stored for a single Period. */
export interface ITSPeriodObject {
/** The Period in question. */
period: IPeriodMetadata;
/**
* If `true`, this Period was present at the last `updatePeriodList` call,
* meaning it's probably still in the Manifest.
*
* If `false`, this Period was not. In that case it is probably just here
* because some audio/video/text buffer still contains data of the given type.
*/
inManifest: boolean;
/**
* Set to `true` once a `newAvailablePeriods` event has been sent for this
* particular Period.
*
* Once this event has been sent, we can begin to select a audio, video and
* text track for that Period (but **NOT** before).
*/
isPeriodAdvertised: boolean;
/**
* Information on the selected audio track and Representations for this Period.
*/
audio: IAudioPeriodInfo;
/**
* Information on the selected text track and Representations for this Period.
*/
text: ITextPeriodInfo;
/**
* Information on the selected video track and Representations for this Period.
*/
video: IVideoPeriodInfo;
/**
* If `true`, this object was since cleaned-up.
*/
isRemoved: boolean;
}
/**
* Internal representation of audio track preferences for a given `Period` of
* the Manifest.
*/
interface IAudioPeriodInfo {
/**
* Information on the last audio track settings wanted by the user.
* `null` if no audio track is wanted.
* `undefined` if not set yet.
*/
storedSettings: IAudioStoredSettings | null | undefined;
/**
* Tracks are internally emitted through RxJS's `Subject`s.
* A `TrackDispatcher` allows to facilitate and centralize the management of
* that Subject so that the right wanted track and qualities are emitted
* through it.
*
* `null` if no `Subject` has been linked for this `Period` and buffer type
* for now.
*/
dispatcher: TrackDispatcher | null;
}
interface IAudioStoredSettings {
/** Contains the last `Adaptation` wanted by the user. */
adaptation: IAdaptationMetadata;
/** "Switching mode" in which the track switch should happen. */
switchingMode: IAudioTrackSwitchingMode;
/**
* Contains the last locked `Representation`s for this `Adaptation` wanted
* by the user.
* `null` if no Representation is locked.
*/
lockedRepresentations: SharedReference<IRepresentationsChoice | null>;
}
/**
* Internal representation of text track preferences for a given `Period` of
* the Manifest.
*/
export interface ITextPeriodInfo {
/**
* Information on the last text track settings wanted.
* `null` if no text track is wanted.
* `undefined` if not set yet.
*/
storedSettings:
| {
/** Contains the last `Adaptation` wanted by the user. */
adaptation: IAdaptationMetadata;
/** "Switching mode" in which the track switch should happen. */
switchingMode: "direct";
/**
* Contains the last locked `Representation`s for this `Adaptation` wanted
* by the user.
* `null` if no Representation is locked.
*/
lockedRepresentations: SharedReference<IRepresentationsChoice | null>;
}
| null
| undefined;
/**
* Tracks are internally emitted through RxJS's `Subject`s.
* A `TrackDispatcher` allows to facilitate and centralize the management of
* that Subject so that the right wanted track and qualities are emitted
* through it.
*
* `null` if no `Subject` has been linked for this `Period` and buffer type
* for now.
*/
dispatcher: TrackDispatcher | null;
}
/**
* Internal representation of video track preferences for a given `Period` of
* the Manifest.
*/
export interface IVideoPeriodInfo {
/**
* Information on the `id` of the last video track settings wanted.
* `null` if no video track is wanted.
* `undefined` if not set yet.
*/
storedSettings: IVideoStoredSettings | null | undefined;
/**
* Tracks are internally emitted through RxJS's `Subject`s.
* A `TrackDispatcher` allows to facilitate and centralize the management of
* that Subject so that the right wanted track and qualities are emitted
* through it.
*
* `null` if no `Subject` has been linked for this `Period` and buffer type
* for now.
*/
dispatcher: TrackDispatcher | null;
}
interface IVideoStoredSettings {
/**
* The wanted Adaptation itself (may be different from `adaptationBase` when
* a trickmode track is chosen, in which case `adaptationBase` is the
* Adaptation the trickmode track is linked to and `adaptation` is the
* trickmode track).
*/
adaptation: IAdaptationMetadata;
/** "Switching mode" in which the track switch should happen. */
switchingMode: IVideoTrackSwitchingMode;
/**
* The "base" Adaptation for `storedSettings` (if a trickmode track was
* chosen, this is the Adaptation the trickmode track is linked to, and not
* the trickmode track itself).
*/
adaptationBase: IAdaptationMetadata;
/**
* Contains the last locked `Representation`s for this `Adaptation` wanted
* by the user.
* `null` if no Representation is locked.
*/
lockedRepresentations: SharedReference<IRepresentationsChoice | null>;
}
/** Events emitted by the TracksStore. */
interface ITracksStoreEvents {
newAvailablePeriods: IPeriod[];
brokenRepresentationsLock: IBrokenRepresentationsLockContext;
trackUpdate: ITrackUpdateEventPayload;
error: unknown;
warning: IPlayerError;
noPlayableTrack: INoPlayableTrackEventPayload;
}
export interface IAudioRepresentationsLockSettings {
representations: string[];
switchingMode?: IAudioRepresentationsSwitchingMode | undefined;
}
export interface IVideoRepresentationsLockSettings {
representations: string[];
switchingMode?: IVideoRepresentationsSwitchingMode | undefined;
}