From b430e3737dae6840855e5d8c1059e8d2563150fa Mon Sep 17 00:00:00 2001 From: Paul Berberian Date: Mon, 21 Jul 2025 16:16:39 +0200 Subject: [PATCH 1/2] feat: add disableAudioTrack API We previously never added the `disableAudioTrack` API for two reasons: 1. It's often much less needed than a `disableTextTrack` API and than a `disableVideoTrack` API (second one for "audio-only") 2. It was unclear what behavior the RxPlayer should have if both audio and video were disabled Since @Florent-Bouisset work on the new `onAudioTracksNotPlayable` / `onVideoTracksNotPlayable` options however, we do have a concrete way to deal with the absence of both audio and video: the `noPlayableTrack` `error` event. So no that there's no real barrier to add that API, I'm adding it. --- This does not seem to really be needed right now by the different applications we work with, I just wanted to add that API now because it facilitates the writing of edge-cases-tests: I especially wanted to add a test for a pretty rare situation where there's no audio selected yet we're fallbacking from all video tracks due to DRM constraints. I'm doing this now because now that advanced DRM integration tests are merged, we can begin to add tests for all those advanced edge cases scenarios to ensure we're keeping them under control. --- demo/scripts/controllers/knobs/AudioTrack.tsx | 34 +++++++------ demo/scripts/modules/player/index.ts | 4 ++ .../Tutorials/Selecting_a_Track.md | 5 +- doc/api/Player_Events.md | 4 +- doc/api/Track_Selection/disableAudioTrack.md | 37 ++++++++++++++ doc/api/Track_Selection/disableVideoTrack.md | 3 ++ doc/reference/API_Reference.md | 3 ++ src/main_thread/api/public_api.ts | 17 +++++++ .../media_element_tracks_store.ts | 23 ++++++++- .../audio_video_not_supported.test.js | 43 ++++++++++++++++ .../scenarios/dash_multi-track.test.js | 50 ++++++++++++++----- 11 files changed, 192 insertions(+), 31 deletions(-) create mode 100644 doc/api/Track_Selection/disableAudioTrack.md diff --git a/demo/scripts/controllers/knobs/AudioTrack.tsx b/demo/scripts/controllers/knobs/AudioTrack.tsx index 0b1c13e5b7..fdfa107a70 100644 --- a/demo/scripts/controllers/knobs/AudioTrack.tsx +++ b/demo/scripts/controllers/knobs/AudioTrack.tsx @@ -34,27 +34,33 @@ function AudioTrackKnob({ return [["Not available"], 0]; } return [ - availableAudioTracks.map((audioTrack) => { - return ( - translateAudioTrackCode(audioTrack.normalized) + - (audioTrack.audioDescription ? " " + AUDIO_DESCRIPTION_ICON : "") - ); - }), + ["no audio track"].concat( + availableAudioTracks.map((audioTrack) => { + return ( + translateAudioTrackCode(audioTrack.normalized) + + (audioTrack.audioDescription ? " " + AUDIO_DESCRIPTION_ICON : "") + ); + }), + ), currentAudioTrack - ? Math.max(findAudioTrackIndex(currentAudioTrack, availableAudioTracks), 0) + ? 1 + Math.max(findAudioTrackIndex(currentAudioTrack, availableAudioTracks), 0) : 0, ]; }, [availableAudioTracks, currentAudioTrack]); const onAudioTrackChange = React.useCallback( ({ index }: { index: number }) => { - const track = availableAudioTracks[index]; - if (track !== undefined) { - player.actions.setAudioTrack(track); + if (index > 0) { + const track = availableAudioTracks[index - 1]; + if (track !== undefined) { + player.actions.setAudioTrack(track); + } else { + // eslint-disable-next-line no-console + console.error("Error: audio track not found"); + } } else { - // eslint-disable-next-line no-console - console.error("Error: audio track not found"); + player.actions.disableAudioTrack(); } }, [availableAudioTracks, player], @@ -62,10 +68,10 @@ function AudioTrackKnob({ return ( This option may have no effect in DirectFile mode (see loadVideo options). diff --git a/doc/reference/API_Reference.md b/doc/reference/API_Reference.md index cda7782113..0ed6a0b7b5 100644 --- a/doc/reference/API_Reference.md +++ b/doc/reference/API_Reference.md @@ -288,6 +288,9 @@ events and so on. - [`disableVideoTrack`](../api/Track_Selection/disableVideoTrack.md): Disable the current video track. +- [`disableAudioTrack`](../api/Track_Selection/disableAudioTrack.md): Disable the current + audio track. + - [`getVideoRepresentation`](../api/Representation_Selection/getVideoRepresentation.md): Returns the currently-loading video Representation. diff --git a/src/main_thread/api/public_api.ts b/src/main_thread/api/public_api.ts index c59af4493f..ffa2bdeee1 100644 --- a/src/main_thread/api/public_api.ts +++ b/src/main_thread/api/public_api.ts @@ -2403,6 +2403,23 @@ class Player extends EventEmitter { ); } + /** + * Disable audio track for the current content. + * @param {string|undefined} [periodId] + */ + disableAudioTrack(periodId?: string | undefined): void { + if (this._priv_contentInfos === null) { + return; + } + const { isDirectFile, mediaElementTracksStore } = this._priv_contentInfos; + if (isDirectFile && mediaElementTracksStore !== null) { + return mediaElementTracksStore.disableAudioTrack(); + } + return this._priv_callTracksStoreGetterSetter(periodId, undefined, (tcm, periodRef) => + tcm.disableTrack(periodRef, "audio"), + ); + } + lockAudioRepresentations(arg: string[] | ILockedAudioRepresentationsSettings): void { if (this._priv_contentInfos === null) { throw new Error("No content loaded"); diff --git a/src/main_thread/tracks_store/media_element_tracks_store.ts b/src/main_thread/tracks_store/media_element_tracks_store.ts index 0eaf829fee..1cf382710a 100644 --- a/src/main_thread/tracks_store/media_element_tracks_store.ts +++ b/src/main_thread/tracks_store/media_element_tracks_store.ts @@ -212,9 +212,10 @@ export default class MediaElementTracksStore extends EventEmitter} audioTracks + */ +function disableAudioTracks(audioTracks: Array<{ nativeTrack: ICompatAudioTrack }>) { + for (let i = 0; i < audioTracks.length; i++) { + const { nativeTrack } = audioTracks[i]; + nativeTrack.enabled = false; + } +} diff --git a/tests/integration/scenarios/audio_video_not_supported.test.js b/tests/integration/scenarios/audio_video_not_supported.test.js index 0eba6e4afc..78348f3d3a 100644 --- a/tests/integration/scenarios/audio_video_not_supported.test.js +++ b/tests/integration/scenarios/audio_video_not_supported.test.js @@ -180,6 +180,49 @@ describe("Content with video or audio not supported", function () { ); }); + it("should trigger an error if the video is not supported and the audio is disabled", async function () { + player.setWantedBufferAhead(15); + const { url, transport } = manifestVideoNotSupportedInfos; + let playerError; + let noPlayableTrackEvent; + player.addEventListener("error", (err) => { + playerError = err; + }); + player.addEventListener("noPlayableTrack", (event) => { + noPlayableTrackEvent = event; + }); + player.loadVideo({ + url, + transport, + autoPlay: true, + onVideoTracksNotPlayable: "continue", + }); + await waitForState(player, "PLAYING", [ + "LOADING", + "LOADED", + "BUFFERING", + "SEEKING", + "RELOADING", + ]); + expect(noPlayableTrackEvent).not.toBe(undefined); + expect(noPlayableTrackEvent.trackType).toBe("video"); + + const waitStopped = waitForState(player, "STOPPED", [ + "LOADING", + "LOADED", + "BUFFERING", + "SEEKING", + "RELOADING", + "PLAYING", + ]); + player.disableAudioTrack(); + await waitStopped; + expect(playerError).not.toBe(undefined); + expect(playerError.message).toBe( + "NO_AUDIO_VIDEO_TRACKS: No audio and no video tracks are set.", + ); + }); + it("should trigger an error if audio only content is not supported", async function () { player.setWantedBufferAhead(15); const { url, transport } = manifestAudioOnlyInfos; diff --git a/tests/integration/scenarios/dash_multi-track.test.js b/tests/integration/scenarios/dash_multi-track.test.js index 8070630b69..0597876fb5 100644 --- a/tests/integration/scenarios/dash_multi-track.test.js +++ b/tests/integration/scenarios/dash_multi-track.test.js @@ -140,6 +140,11 @@ describe("DASH multi-track content (SegmentTimeline)", function () { expect(currentVideoTrack).to.equal(null); } + function checkNoAudioTrack() { + const currentAudioTrack = player.getAudioTrack(); + expect(currentAudioTrack).to.equal(null); + } + function checkVideoTrack(codecRules, isSignInterpreted) { const currentVideoTrack = player.getVideoTrack(); @@ -225,18 +230,23 @@ describe("DASH multi-track content (SegmentTimeline)", function () { const videoTrackPreference = videoTrackPreferences[i]; if (audioTrackPreference !== undefined) { - const availableAudioTracks = player.getAvailableAudioTracks(period.id); - expect(availableAudioTracks).not.to.be.empty; - const wantedAudioTrack = chooseWantedAudioTrack( - availableAudioTracks, - audioTrackPreference, - ); - if (wantedAudioTrack !== undefined) { - player.setAudioTrack({ - trackId: wantedAudioTrack.id, - periodId: period.id, - }); - expect(player.getAudioTrack(period.id).id).to.equal(wantedAudioTrack.id); + if (audioTrackPreference === null) { + player.disableAudioTrack(period.id); + expect(player.getAudioTrack(period.id)).to.equal(null); + } else { + const availableAudioTracks = player.getAvailableAudioTracks(period.id); + expect(availableAudioTracks).not.to.be.empty; + const wantedAudioTrack = chooseWantedAudioTrack( + availableAudioTracks, + audioTrackPreference, + ); + if (wantedAudioTrack !== undefined) { + player.setAudioTrack({ + trackId: wantedAudioTrack.id, + periodId: period.id, + }); + expect(player.getAudioTrack(period.id).id).to.equal(wantedAudioTrack.id); + } } } @@ -401,6 +411,22 @@ describe("DASH multi-track content (SegmentTimeline)", function () { checkNoVideoTrack(); }); + it("should allow the initial disabling of the audio tracks", async () => { + player.addEventListener("newAvailablePeriods", () => { + updateTracks([null, null], [], []); + }); + await loadContent(); + checkNoAudioTrack(); + checkNoTextTrack(); + checkVideoTrack({ all: true, test: /avc1\.42C014/ }, true); + + await goToSecondPeriod(); + checkNoAudioTrack(); + checkNoTextTrack(); + checkVideoTrack({ all: false, test: /avc1\.42C014/ }, undefined); + checkVideoTrack({ all: false, test: /avc1\.640028/ }, undefined); + }); + it("setting the current track should not be persisted between Periods", async () => { await loadContent(); await checkAfterSleepWithBackoff({ maxTimeMs: 500 }, () => { From 6683edb473df0ece1f15ad68d054f1db946f7d74 Mon Sep 17 00:00:00 2001 From: Paul Berberian Date: Tue, 29 Jul 2025 16:17:12 +0200 Subject: [PATCH 2/2] Allow application to reset an audio/video track when fallbacking from all the others' one (#1716) * Allow application to reset an audio/video track when fallbacking from all the others' one Based on #1715 This commit shows an issue that can arise in the very rare scenario where: 1. An application decides to load a content with no audio track (could be also with no video track). 2. We fallback for some reason from all video tracks (or audio tracks if it's video that has been disabled in `1.`) - in the test here this is due to the impossibility to fetch their licenses. 3. We should here receive a `noPlayableTrack` event indicating that no video track is playable, allowing us to re-set our audio choice to still be able to play something **BEFORE** the `NO_AUDIO_VIDEO_TRACKS` error that arises when no video nor audio is selected. However we don't here, we receive directly a `NO_AUDIO_VIDEO_TRACKS` error and stop playback. * fix: let a time window to switch track when receiving noPlayableTrack before sending an error * simplify code * remove debug setup * fix: don't throw the trackUpdate event on initial track selection * coden review * code review 2 * re-add the comment --------- Co-authored-by: Florent Bouisset --- src/main_thread/api/public_api.ts | 1 + src/main_thread/tracks_store/tracks_store.ts | 317 +++++++++++-------- tests/integration/scenarios/drm_base.js | 50 ++- 3 files changed, 238 insertions(+), 130 deletions(-) diff --git a/src/main_thread/api/public_api.ts b/src/main_thread/api/public_api.ts index ffa2bdeee1..19e84a05ea 100644 --- a/src/main_thread/api/public_api.ts +++ b/src/main_thread/api/public_api.ts @@ -2708,6 +2708,7 @@ class Player extends EventEmitter { onTracksNotPlayableForType: { audio: contentInfos.onAudioTracksNotPlayable, video: contentInfos.onVideoTracksNotPlayable, + text: "continue", }, }); contentInfos.tracksStore = tracksStore; diff --git a/src/main_thread/tracks_store/tracks_store.ts b/src/main_thread/tracks_store/tracks_store.ts index c9668a6351..354840b75d 100644 --- a/src/main_thread/tracks_store/tracks_store.ts +++ b/src/main_thread/tracks_store/tracks_store.ts @@ -107,6 +107,7 @@ export default class TracksStore extends EventEmitter { private onTracksNotPlayableForType: { audio: "error" | "continue"; video: "error" | "continue"; + text: "error" | "continue"; }; constructor(args: { @@ -115,6 +116,7 @@ export default class TracksStore extends EventEmitter { onTracksNotPlayableForType: { audio: "error" | "continue"; video: "error" | "continue"; + text: "error" | "continue"; }; }) { super(); @@ -491,7 +493,7 @@ export default class TracksStore extends EventEmitter { periodObj[bufferType].dispatcher = dispatcher; dispatcher.addEventListener("noPlayableRepresentation", () => { - this.onNoPlayableRepresentation(period, bufferType); + this.handleMissingOrUnplayableTrack(period, bufferType, false); }); dispatcher.addEventListener("noPlayableLockedRepresentation", () => { // TODO check that it doesn't already lead to segment loading or MediaSource @@ -519,10 +521,6 @@ export default class TracksStore extends EventEmitter { }, ]); - for (const eventToEmit of periodObj.noPlayableTrackEventToDispatch) { - this.trigger("noPlayableTrack", eventToEmit); - } - if (this._isDisposed) { return; } @@ -547,130 +545,180 @@ export default class TracksStore extends EventEmitter { } /** - * Handle the noPlayableRepresentation event, trigger an error if no fallback is possible. - * and can trigger event "noPlayableTrack" - * @param period - The period that has no playable representation - * @param trackType - The media type that is not playable + * 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 onNoPlayableRepresentation( + 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, - ): Array<{ - trackType: ITrackType; - period: IPeriod; - }> { - const noPlayableTrackToTrigger = []; - - const periodHasAdaptationForType = - period.adaptations[trackType] !== undefined && - period.adaptations[trackType].length > 0; - if (!periodHasAdaptationForType) { - log.debug( - `TS: The period does not have adaptation for ${trackType} there is no track to choose`, - ); - return []; + isInitialSelection: boolean, + ): void { + const { fallbackTrack, noSourceMedia } = getFallbackTrack(period, trackType); + const typeInfo = getPeriodItem(this._storedPeriodInfo, period.id)?.[trackType]; + if (typeInfo === undefined) { + log.warn(`TS: Could not find period periodId=${period.id}`); + return; } + const initialStoredSettings = typeInfo.storedSettings; + const hasStoredSettingsChanged = (): boolean => { + return typeInfo.storedSettings !== initialStoredSettings; + }; - const firstPlayableAdaptation = findFirstPlayableAdaptation(period, trackType); - if ( - firstPlayableAdaptation === undefined && - (trackType === "text" || this.onTracksNotPlayableForType[trackType] === "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". - log.warn(`TS: No playable ${trackType}, continuing without ${trackType}`); - noPlayableTrackToTrigger.push({ - trackType, - period: { id: period.id, start: period.start, end: period.end }, - }); + 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(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 + return; // Someone disposed the `TracksStore` on the previous side-effect } - } else if (firstPlayableAdaptation === undefined) { - const noRepErr = new MediaError( - "NO_PLAYABLE_REPRESENTATION", - `No ${trackType} Representation can be played`, - { tracks: undefined }, - ); - noPlayableTrackToTrigger.push({ + + // 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 + return; // Someone disposed the `TracksStore` on the previous side-effect } - this.trigger("error", noRepErr); - this.dispose(); - return []; - } - let typeInfo = getPeriodItem(this._storedPeriodInfo, period.id)?.[trackType]; - if (isNullOrUndefined(typeInfo)) { - return noPlayableTrackToTrigger; - } - const selectedAdaptation = getPeriodItem(this._storedPeriodInfo, period.id)?.[ - trackType - ]?.storedSettings; - if (selectedAdaptation === null) { - // The track type has been explicitly disabled, there is no need to select - // a new track as a fallback. - return noPlayableTrackToTrigger; - } - const switchingMode = - trackType === "audio" ? this._defaultAudioTrackSwitchingMode : "reload"; - const storedSettings = - firstPlayableAdaptation !== undefined - ? { - adaptation: firstPlayableAdaptation, - switchingMode, - lockedRepresentations: new SharedReference( - null, - ), - } - : null; - typeInfo.storedSettings = storedSettings; - - this.trigger("trackUpdate", { - period: toExposedPeriod(period), - trackType, - reason: "no-playable-representation", - }); + 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(`TS: 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( + `TS: 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 - } - typeInfo = getPeriodItem(this._storedPeriodInfo, period.id)?.[trackType]; - if (isNullOrUndefined(typeInfo) || typeInfo.storedSettings !== storedSettings) { - return noPlayableTrackToTrigger; + return; // Someone disposed the `TracksStore` on the previous side-effect } - // Check that we're not both disabling audio and video here - if (storedSettings === null) { - 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(); - return []; - } - } + if (trackType !== "text") { + // Allow missing text tracks; only enforce for audio/video + this.throwIfTracksAreNotSetForPeriod(period); } - - typeInfo.dispatcher?.updateTrack(storedSettings); - return noPlayableTrackToTrigger; } /** @@ -1477,9 +1525,7 @@ export default class TracksStore extends EventEmitter { )[0]; if (audioAdaptation === undefined) { trackStorePeriod.audio.storedSettings = null; - trackStorePeriod.noPlayableTrackEventToDispatch.push( - ...this.onNoPlayableRepresentation(period, "audio"), - ); + this.handleMissingOrUnplayableTrack(period, "audio", true); if (this._isDisposed) { return; } @@ -1495,9 +1541,7 @@ export default class TracksStore extends EventEmitter { getSupportedAdaptations(period, "video")[0]; if (baseVideoAdaptation === undefined) { trackStorePeriod.video.storedSettings = null; - trackStorePeriod.noPlayableTrackEventToDispatch.push( - ...this.onNoPlayableRepresentation(period, "video"), - ); + this.handleMissingOrUnplayableTrack(period, "video", true); if (this._isDisposed) { return; } @@ -1563,13 +1607,6 @@ export default class TracksStore extends EventEmitter { continue; } - if (trackStorePeriod.noPlayableTrackEventToDispatch.length > 0) { - for (const noPlayableTrackEvent of trackStorePeriod.noPlayableTrackEventToDispatch) { - this.trigger("noPlayableTrack", noPlayableTrackEvent); - } - trackStorePeriod.noPlayableTrackEventToDispatch = []; - } - const bufferTypes: ITrackType[] = ["audio", "video", "text"]; for (const bufferType of bufferTypes) { const trackInfo = trackStorePeriod[bufferType]; @@ -1667,7 +1704,6 @@ function generatePeriodInfo( audio: { storedSettings: undefined, dispatcher: null }, video: { storedSettings: undefined, dispatcher: null }, text: { storedSettings: undefined, dispatcher: null }, - noPlayableTrackEventToDispatch: [], }; } @@ -1675,6 +1711,39 @@ 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", @@ -1733,16 +1802,6 @@ export interface ITSPeriodObject { * If `true`, this object was since cleaned-up. */ isRemoved: boolean; - - /** - * NoPlayableTrack events that should be emitted when the period has been advertised. - * - * This ensure the events relates to a period that was communicated via the public API. - */ - noPlayableTrackEventToDispatch: Array<{ - trackType: ITrackType; - period: IPeriod; - }>; } /** diff --git a/tests/integration/scenarios/drm_base.js b/tests/integration/scenarios/drm_base.js index 872ec1cde1..03a7c7e269 100644 --- a/tests/integration/scenarios/drm_base.js +++ b/tests/integration/scenarios/drm_base.js @@ -25,7 +25,11 @@ describe("DRM: Basic use cases", function () { ...opts, }); if (isNullOrUndefined(error)) { - await waitForPlayerState(player, "LOADED", ["LOADING"]); + try { + await waitForPlayerState(player, "LOADED", ["LOADING"]); + } catch (err) { + throw player.getError() ?? err; + } expect(player.getError()).toBeNull(); } else { await waitForPlayerState(player, "STOPPED", ["LOADING"]); @@ -688,4 +692,48 @@ describe("DRM: Basic use cases", function () { expect(player.getAudioRepresentation().id).toEqual("15-585f233f"); expect(player.getError()).toBeNull(); }); + + it("should let a time window for an audio track reset if no license for video can be fetched while audio is disabled", async function () { + const noPlayableTracksReceived = []; + player.addEventListener("newAvailablePeriods", (periods) => { + expect(player.getVideoTrack(periods[0].id)).not.toBeNull(); + player.disableAudioTrack(periods[0].id); + }); + player.addEventListener("noPlayableTrack", (npt) => { + noPlayableTracksReceived.push(npt); + const period = player.getAvailablePeriods()[0]; + player.setAudioTrack({ + periodId: period.id, + trackId: player.getAvailableAudioTracks(period.id)[0].id, + }); + }); + await loadEncryptedContent({ + onVideoTracksNotPlayable: "continue", + keySystems: [ + { + type: "com.microsoft.playready", + onKeyOutputRestricted: "fallback", + getLicense: generateGetLicenseForFakeLicense({ + failingKeyIds: { + "90953e096cb249a3a2607a5fefead499": { + fallbackOnLastTry: true, + }, + "80399bf58a2140148053e27e748e98c0": { + fallbackOnLastTry: true, + }, + "80399bf58a2140148053e27e748e98c1": { + fallbackOnLastTry: true, + }, + }, + }), + }, + ], + }); + expect(player.getAvailableVideoTracks()).toEqual([]); + expect(noPlayableTracksReceived.length).toEqual(1); + expect(noPlayableTracksReceived[0].trackType).toEqual("video"); + expect(noPlayableTracksReceived[0].period.id).toEqual( + player.getAvailablePeriods()[0].id, + ); + }); });