From a39f0aaf048c4976e3caf00f114a341b1a7658cc Mon Sep 17 00:00:00 2001 From: Paul Berberian Date: Fri, 19 Aug 2022 13:52:47 +0200 Subject: [PATCH 01/86] Refactor the whole Init into a ContentInitializer concept This PR is a necessary stepping stone for multiple projects in parallel: 1. First it is part of the ongoing long-term project of reducing (removing?) our depency to the RxJS library - mainly to improve the code's approachability and debuggability. It has been here completely removed from the `src/core/init` directory. 2. As it also provides a simple interface to the logic of what was called previously the `Init`, it also greatly facilitates the implementation of running the RxPlayer's core in a WebWorker if wanted (basically: in another thread), while still allowing to run everything in main by default. Originally only a proof-of-concept, it's a path that appears more and more credible to improve the situation seen with some applications (regarding rebuffering avoidance with low-latency contents, smoothness of UI interactions when parsing huge MPDs, better bandwidth calculations and other subjects that could profit from a player running concurrently to the UI). This PR simplifies this work by allowing the definition of multiple now-called `ContentInitializer`: the one already-defined for main thread buffering, and another one, spawning a WebWorker, for the worker flavor. It appears that (unsurprisingly), the `Init` was the almost-only place where this split needs to be done ("almost" because text track rendering and [in some case](https://chromestatus.com/feature/5177263249162240) MSE APIs, also need to be performed in the main thread). The selection of the right one could be performed as late as load time. 3. Provide an elegant solution for the previously weird MediaSource/Directfile paths. Previously, they were each linked to different functions found in the `init` directory, with no apparent structure. Now, they both are `ContentInitializer` implementations, just with different constructor parameters. 4. Clean-up and provide some structure to the `init` directory. The work has been huge and need extensive testing and re-reading (I intent to re-check everything multiple times!), so it may stay as a PR for some time and I don't plan for now to merge it until the next release has been, uh, released. --- src/README.md | 4 +- src/compat/event_listeners.ts | 43 +- src/core/README.md | 2 +- src/core/api/playback_observer.ts | 15 +- src/core/api/public_api.ts | 615 ++++++--------- src/core/api/utils.ts | 153 +++- src/core/decrypt/attach_media_keys.ts | 5 +- src/core/fetchers/index.ts | 2 - src/core/fetchers/manifest/index.ts | 2 - .../fetchers/manifest/manifest_fetcher.ts | 398 ++++------ src/core/init/README.md | 76 +- .../init/directfile_content_initializer.ts | 223 ++++++ src/core/init/emit_loaded_event.ts | 71 -- src/core/init/end_of_stream.ts | 104 --- src/core/init/events_generators.ts | 140 ---- src/core/init/index.ts | 6 +- src/core/init/initial_seek_and_play.ts | 208 ----- src/core/init/initialize_directfile.ts | 191 ----- src/core/init/initialize_media_source.ts | 389 ---------- src/core/init/link_drm_and_content.ts | 176 ----- src/core/init/load_on_media_source.ts | 237 ------ src/core/init/manifest_update_scheduler.ts | 362 --------- .../init/media_source_content_initializer.ts | 716 ++++++++++++++++++ .../stream_events_emitter.ts | 182 ----- src/core/init/throw_on_media_error.ts | 68 -- src/core/init/types.ts | 345 +++++---- src/core/init/update_playback_rate.ts | 0 .../__tests__/are_same_stream_events.test.ts | 0 .../refresh_scheduled_events_list.test.ts | 0 .../content_time_boundaries_observer.ts | 182 ++--- .../init/{ => utils}/create_media_source.ts | 111 +-- .../create_stream_playback_observer.ts | 10 +- src/core/init/utils/end_of_stream.ts | 104 +++ src/core/init/{ => utils}/get_initial_time.ts | 8 +- src/core/init/utils/get_loaded_reference.ts | 78 ++ src/core/init/utils/initial_seek_and_play.ts | 179 +++++ .../utils/initialize_content_decryption.ts | 174 +++++ .../init/utils/manifest_update_scheduler.ts | 341 +++++++++ .../{ => utils}/media_duration_updater.ts | 253 +++++-- .../{ => utils}/rebuffering_controller.ts | 463 +++++------ .../are_same_stream_events.ts | 0 .../stream_events_emitter/index.ts | 14 +- .../refresh_scheduled_events_list.ts | 2 +- .../stream_events_emitter.ts | 209 +++++ .../stream_events_emitter/types.ts | 20 +- src/core/init/utils/throw_on_media_error.ts | 74 ++ .../prepare_source_buffer.ts | 2 +- .../__tests__/initialize_features.test.ts | 2 +- src/features/initialize_features.ts | 3 +- .../list/__tests__/directfile.test.ts | 5 +- src/features/list/directfile.ts | 2 +- src/features/types.ts | 9 +- .../dash/wasm-parser/ts/dash-wasm-parser.ts | 2 +- src/utils/reference.ts | 150 ++-- 54 files changed, 3523 insertions(+), 3607 deletions(-) create mode 100644 src/core/init/directfile_content_initializer.ts delete mode 100644 src/core/init/emit_loaded_event.ts delete mode 100644 src/core/init/end_of_stream.ts delete mode 100644 src/core/init/events_generators.ts delete mode 100644 src/core/init/initial_seek_and_play.ts delete mode 100644 src/core/init/initialize_directfile.ts delete mode 100644 src/core/init/initialize_media_source.ts delete mode 100644 src/core/init/link_drm_and_content.ts delete mode 100644 src/core/init/load_on_media_source.ts delete mode 100644 src/core/init/manifest_update_scheduler.ts create mode 100644 src/core/init/media_source_content_initializer.ts delete mode 100644 src/core/init/stream_events_emitter/stream_events_emitter.ts delete mode 100644 src/core/init/throw_on_media_error.ts delete mode 100644 src/core/init/update_playback_rate.ts rename src/core/init/{ => utils}/__tests__/are_same_stream_events.test.ts (100%) rename src/core/init/{ => utils}/__tests__/refresh_scheduled_events_list.test.ts (100%) rename src/core/init/{ => utils}/content_time_boundaries_observer.ts (67%) rename src/core/init/{ => utils}/create_media_source.ts (52%) rename src/core/init/{ => utils}/create_stream_playback_observer.ts (94%) create mode 100644 src/core/init/utils/end_of_stream.ts rename src/core/init/{ => utils}/get_initial_time.ts (96%) create mode 100644 src/core/init/utils/get_loaded_reference.ts create mode 100644 src/core/init/utils/initial_seek_and_play.ts create mode 100644 src/core/init/utils/initialize_content_decryption.ts create mode 100644 src/core/init/utils/manifest_update_scheduler.ts rename src/core/init/{ => utils}/media_duration_updater.ts (51%) rename src/core/init/{ => utils}/rebuffering_controller.ts (66%) rename src/core/init/{ => utils}/stream_events_emitter/are_same_stream_events.ts (100%) rename src/core/init/{ => utils}/stream_events_emitter/index.ts (80%) rename src/core/init/{ => utils}/stream_events_emitter/refresh_scheduled_events_list.ts (98%) create mode 100644 src/core/init/utils/stream_events_emitter/stream_events_emitter.ts rename src/core/init/{ => utils}/stream_events_emitter/types.ts (68%) create mode 100644 src/core/init/utils/throw_on_media_error.ts diff --git a/src/README.md b/src/README.md index 4f1cdb6445..45ba61a152 100644 --- a/src/README.md +++ b/src/README.md @@ -37,8 +37,8 @@ To better understand the player's architecture, you can find below a Facilitate track V | Abstract the streaming ^ switching for +---------------+ | protocol | the API | | | | - +----------------+ | | +--------------------------+ | - | Content | | Init | ------> | | | + +----------------+ | Content | +--------------------------+ | + | Content | | Initializer | ------> | | | | Decryptor | <---- | (./core/init) | | Manifest Fetcher | | |(./core/decrypt)| | | |(./core/fetchers/manifest)| | | | | | | | | diff --git a/src/compat/event_listeners.ts b/src/compat/event_listeners.ts index 9d3ccfbe12..10b5993c75 100644 --- a/src/compat/event_listeners.ts +++ b/src/compat/event_listeners.ts @@ -93,8 +93,8 @@ function findSupportedEvent( */ function eventPrefixed(eventNames : string[], prefixes? : string[]) : string[] { return eventNames.reduce((parent : string[], name : string) => - parent.concat((prefixes == null ? BROWSER_PREFIXES : - prefixes) + parent.concat((prefixes === undefined ? BROWSER_PREFIXES : + prefixes) .map((p) => p + name)), []); } @@ -509,33 +509,38 @@ const onTextTrackChanges$ = /** * @param {MediaSource} mediaSource - * @returns {Observable} + * @param {Function} listener + * @param {Object} cancelSignal */ -const onSourceOpen$ = compatibleListener(["sourceopen", "webkitsourceopen"]); +const onSourceOpen = createCompatibleEventListener(["sourceopen", "webkitsourceopen"]); /** * @param {MediaSource} mediaSource - * @returns {Observable} + * @param {Function} listener + * @param {Object} cancelSignal */ -const onSourceClose$ = compatibleListener(["sourceclose", "webkitsourceclose"]); +const onSourceClose = createCompatibleEventListener(["sourceclose", "webkitsourceclose"]); /** * @param {MediaSource} mediaSource - * @returns {Observable} + * @param {Function} listener + * @param {Object} cancelSignal */ -const onSourceEnded$ = compatibleListener(["sourceended", "webkitsourceended"]); +const onSourceEnded = createCompatibleEventListener(["sourceended", "webkitsourceended"]); /** - * @param {SourceBuffer} sourceBuffer - * @returns {Observable} + * @param {MediaSource} mediaSource + * @param {Function} listener + * @param {Object} cancelSignal */ -const onUpdate$ = compatibleListener(["update"]); +const onSourceBufferUpdate = createCompatibleEventListener(["update"]); /** - * @param {MediaSource} mediaSource - * @returns {Observable} + * @param {SourceBufferList} sourceBuffers + * @param {Function} listener + * @param {Object} cancelSignal */ -const onRemoveSourceBuffers$ = compatibleListener(["onremovesourcebuffer"]); +const onRemoveSourceBuffers = createCompatibleEventListener(["removesourcebuffer"]); /** * @param {HTMLMediaElement} mediaElement @@ -624,15 +629,15 @@ export { onKeyMessage$, onKeyStatusesChange$, onLoadedMetadata$, - onRemoveSourceBuffers$, + onRemoveSourceBuffers, onSeeked, onSeeked$, onSeeking, onSeeking$, - onSourceClose$, - onSourceEnded$, - onSourceOpen$, + onSourceClose, + onSourceEnded, + onSourceOpen, onTextTrackChanges$, onTimeUpdate$, - onUpdate$, + onSourceBufferUpdate, }; diff --git a/src/core/README.md b/src/core/README.md index 7f8f4acbd4..1a0a441c63 100644 --- a/src/core/README.md +++ b/src/core/README.md @@ -11,7 +11,7 @@ Those modules are: implementing it. - - __the `Init` (_./init)__ + - __the `ContentInitializer` (_./init)__ Initialize playback and connects different modules between one another. diff --git a/src/core/api/playback_observer.ts b/src/core/api/playback_observer.ts index b12b23400c..f5b3120db4 100644 --- a/src/core/api/playback_observer.ts +++ b/src/core/api/playback_observer.ts @@ -211,7 +211,10 @@ export default class PlaybackObserver { * CancellationSignal emits. */ public listen( - cb : (observation : IPlaybackObservation) => void, + cb : ( + observation : IPlaybackObservation, + stopListening : () => void + ) => void, options? : { includeLastObservation? : boolean | undefined; clearSignal? : CancellationSignal | undefined; } ) { @@ -546,7 +549,10 @@ export interface IReadOnlyPlaybackObserver { * @returns {Function} - Allows to easily unregister the callback */ listen( - cb : (observation : TObservationType) => void, + cb : ( + observation : TObservationType, + stopListening : () => void + ) => void, options? : { includeLastObservation? : boolean | undefined; clearSignal? : CancellationSignal | undefined; } ) : void; @@ -958,7 +964,10 @@ function generateReadOnlyObserver( return mappedRef; }, listen( - cb : (observation : TDest) => void, + cb : ( + observation : TDest, + stopListening : () => void + ) => void, options? : { includeLastObservation? : boolean | undefined; clearSignal? : CancellationSignal | undefined; } ) : void { diff --git a/src/core/api/public_api.ts b/src/core/api/public_api.ts index 68d537276f..6bea32a71c 100644 --- a/src/core/api/public_api.ts +++ b/src/core/api/public_api.ts @@ -20,28 +20,11 @@ */ import { - combineLatest as observableCombineLatest, - Connectable, - concat as observableConcat, - connectable, distinctUntilChanged, - EMPTY, - filter, map, - merge as observableMerge, - mergeMap, - Observable, - of as observableOf, - share, - shareReplay, - skipWhile, - startWith, Subject, - Subscription, - switchMap, take, takeUntil, - tap, } from "rxjs"; import { events, @@ -92,6 +75,7 @@ import { IVideoTrackPreference, } from "../../public_types"; import areArraysOfNumbersEqual from "../../utils/are_arrays_of_numbers_equal"; +import assert from "../../utils/assert"; import EventEmitter, { IListener, } from "../../utils/event_emitter"; @@ -116,17 +100,8 @@ import { disposeDecryptionResources, getCurrentKeySystem, } from "../decrypt"; -import { - IManifestFetcherParsedResult, - IManifestFetcherWarningEvent, - ManifestFetcher, -} from "../fetchers"; -import initializeMediaSourcePlayback, { - IInitEvent, - ILoadedEvent, - IReloadingMediaSourceEvent, - IStalledEvent, -} from "../init"; +import { ContentInitializer } from "../init"; +import MediaSourceContentInitializer from "../init/media_source_content_initializer"; import SegmentBuffersStore, { IBufferedChunk, IBufferType, @@ -146,8 +121,10 @@ import PlaybackObserver, { import MediaElementTrackChoiceManager from "./tracks_management/media_element_track_choice_manager"; import TrackChoiceManager from "./tracks_management/track_choice_manager"; import { + constructPlayerStateReference, emitSeekEvents, - getLoadedContentState, + isLoadedState, + // emitSeekEvents, PLAYER_STATES, } from "./utils"; @@ -184,7 +161,7 @@ class Player extends EventEmitter { /** * Current state of the RxPlayer. - * Please use `getLoadedContentState()` instead. + * Please use `getPlayerState()` instead. */ public state : IPlayerState; @@ -724,16 +701,6 @@ class Player extends EventEmitter { /** Subject which will emit to stop the current content. */ const currentContentCanceller = new TaskCanceller(); - // Some logic needs the equivalent of the `currentContentCanceller` under - // an Observable form - // TODO remove the need for `stoppedContent$` - const stoppedContent$ = new Observable((obs) => { - currentContentCanceller.signal.register(() => { - obs.next(); - obs.complete(); - }); - }); - /** Future `this._priv_contentInfos` related to this content. */ const contentInfos = { url, currentContentCanceller, @@ -760,8 +727,7 @@ class Player extends EventEmitter { playbackObserver.stop(); }); - /** Emit playback events. */ - let playback$ : Connectable; + let initializer : ContentInitializer; if (!isDirectFile) { const transportFn = features.transports[transport]; @@ -781,44 +747,10 @@ class Player extends EventEmitter { segmentRequestTimeout } = networkConfig; /** Interface used to load and refresh the Manifest. */ - const manifestFetcher = new ManifestFetcher( - url, - transportPipelines, - { lowLatencyMode, - maxRetryRegular: manifestRetry, - maxRetryOffline: offlineRetry, - requestTimeout: manifestRequestTimeout }); - - /** Observable emitting the initial Manifest */ - let manifest$ : Observable; - - if (initialManifest instanceof Manifest) { - manifest$ = observableOf({ type: "parsed", - manifest: initialManifest }); - } else if (initialManifest !== undefined) { - manifest$ = manifestFetcher.parse(initialManifest, { previousManifest: null, - unsafeMode: false }); - } else { - manifest$ = manifestFetcher.fetch(url).pipe( - mergeMap((response) => response.type === "warning" ? - observableOf(response) : // bubble-up warnings - response.parse({ previousManifest: null, unsafeMode: false }))); - } - - // Load the Manifest right now and share it with every subscriber until - // the content is stopped - manifest$ = manifest$.pipe(takeUntil(stoppedContent$), - shareReplay()); - manifest$.subscribe(); - - // now that the Manifest is loading, stop previous content and reset state - // This is done after fetching the Manifest as `stop` could technically - // take time. - this.stop(); - - this._priv_currentError = null; - this._priv_contentInfos = contentInfos; + const manifestRequestSettings = { lowLatencyMode, + maxRetryRegular: manifestRetry, + maxRetryOffline: offlineRetry, + requestTimeout: manifestRequestTimeout }; const relyOnVideoVisibilityAndSize = canRelyOnVideoVisibilityAndSize(); const throttlers : IABRThrottlers = { throttle: {}, @@ -888,184 +820,124 @@ class Player extends EventEmitter { onCodecSwitch }, this._priv_bufferOptions); - const segmentRequestOptions = { regularError: segmentRetry, + const segmentRequestOptions = { lowLatencyMode, + maxRetryRegular: segmentRetry, requestTimeout: segmentRequestTimeout, - offlineError: offlineRetry }; - - // We've every options set up. Start everything now - const init$ = initializeMediaSourcePlayback({ adaptiveOptions, - autoPlay, - bufferOptions, - playbackObserver, - keySystems, - lowLatencyMode, - manifest$, - manifestFetcher, - mediaElement: videoElement, - minimumManifestUpdateInterval, - segmentRequestOptions, - speed: this._priv_speed, - startAt, - transport: transportPipelines, - textTrackOptions }) - .pipe(takeUntil(stoppedContent$)); - - playback$ = connectable(init$, { connector: () => new Subject(), - resetOnDisconnect: false }); + maxRetryOffline: offlineRetry }; + + initializer = new MediaSourceContentInitializer({ + adaptiveOptions, + autoPlay, + bufferOptions, + initialManifest, + keySystems, + lowLatencyMode, + manifestRequestSettings, + minimumManifestUpdateInterval, + transport: transportPipelines, + segmentRequestOptions, + speed: this._priv_speed, + startAt, + textTrackOptions, + url, + }); } else { - // Stop previous content and reset its state - this.stop(); - this._priv_currentError = null; if (features.directfile === null) { + this.stop(); + this._priv_currentError = null; throw new Error("DirectFile feature not activated in your build."); } - this._priv_contentInfos = contentInfos; - - this._priv_mediaElementTrackChoiceManager = - new features.directfile.mediaElementTrackChoiceManager(this.videoElement); - - const preferredAudioTracks = defaultAudioTrack === undefined ? - this._priv_preferredAudioTracks : - [defaultAudioTrack]; - this._priv_mediaElementTrackChoiceManager - .setPreferredAudioTracks(preferredAudioTracks, true); - - const preferredTextTracks = defaultTextTrack === undefined ? - this._priv_preferredTextTracks : - [defaultTextTrack]; - this._priv_mediaElementTrackChoiceManager - .setPreferredTextTracks(preferredTextTracks, true); - - this._priv_mediaElementTrackChoiceManager - .setPreferredVideoTracks(this._priv_preferredVideoTracks, true); - - this.trigger("availableAudioTracksChange", - this._priv_mediaElementTrackChoiceManager.getAvailableAudioTracks()); - this.trigger("availableVideoTracksChange", - this._priv_mediaElementTrackChoiceManager.getAvailableVideoTracks()); - this.trigger("availableTextTracksChange", - this._priv_mediaElementTrackChoiceManager.getAvailableTextTracks()); - - this.trigger("audioTrackChange", - this._priv_mediaElementTrackChoiceManager.getChosenAudioTrack() - ?? null); - this.trigger("textTrackChange", - this._priv_mediaElementTrackChoiceManager.getChosenTextTrack() - ?? null); - this.trigger("videoTrackChange", - this._priv_mediaElementTrackChoiceManager.getChosenVideoTrack() - ?? null); - - this._priv_mediaElementTrackChoiceManager - .addEventListener("availableVideoTracksChange", (val) => - this.trigger("availableVideoTracksChange", val)); - this._priv_mediaElementTrackChoiceManager - .addEventListener("availableAudioTracksChange", (val) => - this.trigger("availableAudioTracksChange", val)); - this._priv_mediaElementTrackChoiceManager - .addEventListener("availableTextTracksChange", (val) => - this.trigger("availableTextTracksChange", val)); - - this._priv_mediaElementTrackChoiceManager - .addEventListener("audioTrackChange", (val) => - this.trigger("audioTrackChange", val)); - this._priv_mediaElementTrackChoiceManager - .addEventListener("videoTrackChange", (val) => - this.trigger("videoTrackChange", val)); - this._priv_mediaElementTrackChoiceManager - .addEventListener("textTrackChange", (val) => - this.trigger("textTrackChange", val)); - - const directfileInit$ = - features.directfile.initDirectFile({ autoPlay, - keySystems, - mediaElement: videoElement, - speed: this._priv_speed, - playbackObserver, - startAt, - url }) - .pipe(takeUntil(stoppedContent$)); - - playback$ = connectable(directfileInit$, { connector: () => new Subject(), - resetOnDisconnect: false }); - } - - /** Emit an object when the player "stalls" and null when it un-stalls */ - const stalled$ = playback$.pipe( - filter((evt) : evt is IStalledEvent => evt.type === "stalled" || - evt.type === "unstalled"), - map(x => x.value), - distinctUntilChanged((prevStallReason, currStallReason) => { - return prevStallReason === null && currStallReason === null || - (prevStallReason !== null && currStallReason !== null && - prevStallReason === currStallReason); - })); - - /** Emit when the content is considered "loaded". */ - const loaded$ = playback$.pipe( - filter((evt) : evt is ILoadedEvent => evt.type === "loaded"), - share() - ); - - /** Emit when we will "reload" the MediaSource. */ - const reloading$ = playback$ - .pipe(filter((evt) : evt is IReloadingMediaSourceEvent => - evt.type === "reloading-media-source" - ), - share()); - - /** Emit when the media element emits a "seeking" event. */ - const observation$ = playbackObserver.getReference().asObservable(); - - const stateChangingEvent$ = observation$.pipe(filter(o => { - return o.event === "seeking" || o.event === "ended" || - o.event === "play" || o.event === "pause"; - })); - - /** Emit state updates once the content is considered "loaded". */ - const loadedStateUpdates$ = observableCombineLatest([ - stalled$.pipe(startWith(null)), - stateChangingEvent$.pipe(startWith(null)), - ]).pipe( - takeUntil(stoppedContent$), - map(([stalledStatus]) => - getLoadedContentState(videoElement, stalledStatus) - ) - ); - - /** Emit all player "state" updates. */ - const playerState$ = observableConcat( - observableOf(PLAYER_STATES.LOADING), // Begin with LOADING - - loaded$.pipe(switchMap((_, i) => { - const isFirstLoad = i === 0; - return observableMerge( - // Purposely subscribed first so a RELOADING triggered synchronously - // after a LOADED state is catched. - reloading$.pipe(map(() => PLAYER_STATES.RELOADING)), - // Only switch to LOADED state for the first (i.e. non-RELOADING) load - isFirstLoad ? observableOf(PLAYER_STATES.LOADED) : - EMPTY, - // Purposely put last so any other state change happens after we've - // already switched to LOADED - loadedStateUpdates$.pipe( - takeUntil(reloading$), - // For the first load, we prefer staying at the LOADED state over - // PAUSED when autoPlay is disabled. - // For consecutive loads however, there's no LOADED state. - skipWhile(state => isFirstLoad && state === PLAYER_STATES.PAUSED) - ) - ); - })) - ).pipe(distinctUntilChanged()); + this._priv_initializeMediaElementTrackChoiceManager(defaultAudioTrack, + defaultTextTrack); + initializer = new features.directfile.initDirectFile({ autoPlay, + keySystems, + speed: this._priv_speed, + startAt, + url }); + } + + // Bind events + initializer.addEventListener("error", (err) => + this._priv_onPlaybackError(err)); + initializer.addEventListener("inbandEvents", (inbandEvents) => + this.trigger("inbandEvents", inbandEvents)); + initializer.addEventListener("streamEvent", (streamEvent) => + this.trigger("streamEvent", streamEvent)); + initializer.addEventListener("streamEventSkip", (streamEventSkip) => + this.trigger("streamEventSkip", streamEventSkip)); + initializer.addEventListener("decipherabilityUpdate", (decipherabilityUpdate) => + this.trigger("decipherabilityUpdate", decipherabilityUpdate)); + initializer.addEventListener("activePeriodChanged", (periodInfo) => + this._priv_onActivePeriodChanged(periodInfo)); + initializer.addEventListener("periodStreamReady", (periodReadyInfo) => + this._priv_onPeriodStreamReady(periodReadyInfo)); + initializer.addEventListener("periodStreamCleared", (periodClearedInfo) => + this._priv_onPeriodStreamCleared(periodClearedInfo)); + initializer.addEventListener("reloadingMediaSource", () => + this._priv_onReloadingMediaSource()); + initializer.addEventListener("representationChange", (representationInfo) => + this._priv_onRepresentationChange(representationInfo)); + initializer.addEventListener("adaptationChange", (adaptationInfo) => + this._priv_onAdaptationChange(adaptationInfo)); + initializer.addEventListener("bitrateEstimationChange", (bitrateEstimationInfo) => + this._priv_onBitrateEstimationChange(bitrateEstimationInfo)); + initializer.addEventListener("manifestReady", (manifest) => + this._priv_onManifestReady(manifest)); + initializer.addEventListener("warning", (err) => + this._priv_onPlaybackWarning(err)); + initializer.addEventListener("loaded", (evt) => { + if (this._priv_contentInfos === null) { + log.error("API: Loaded event while no content is loaded"); + return; + } + this._priv_contentInfos.segmentBuffersStore = evt.segmentBuffersStore; + }); + initializer.addEventListener("addedSegment", (evt) => { + if (this._priv_contentInfos === null) { + log.error("API: Added segment while no content is loaded"); + return; + } - let playbackSubscription : Subscription|undefined; - stoppedContent$.subscribe(() => { - if (playbackSubscription !== undefined) { - playbackSubscription.unsubscribe(); + // Manage image tracks + // @deprecated + const { content, segmentData } = evt; + if (content.adaptation.type === "image") { + if (!isNullOrUndefined(segmentData) && + (segmentData as { type : string }).type === "bif") + { + const imageData = (segmentData as { data : IBifThumbnail[] }).data; + /* eslint-disable import/no-deprecated */ + this._priv_contentInfos.thumbnails = imageData; + this.trigger("imageTrackUpdate", + { data: this._priv_contentInfos.thumbnails }); + /* eslint-enable import/no-deprecated */ + } } }); + // Now, that most events are linked, prepare the next content. + initializer.prepare(); + + // Now that the content is prepared, stop previous content and reset state + // This is done after content preparation as `stop` could technically have + // a long and synchronous blocking time. + // Note that this call is done **synchronously** after all events linking. + // This is **VERY** important so: + // - the `STOPPED` state is switched to synchronously after loading a new + // content. + // - we can avoid involontarily catching events linked to the previous + // content. + this.stop(); + + // Update the RxPlayer's state at the right events + const playerStateRef = constructPlayerStateReference(initializer, + videoElement, + playbackObserver, + currentContentCanceller.signal); + currentContentCanceller.signal.register(() => { + initializer.dispose(); + }); + /** * Function updating `this._priv_reloadingMetadata` in function of the * current state and playback conditions. @@ -1091,61 +963,67 @@ class Player extends EventEmitter { } }; - playerState$.pipe( - tap(newState => { - updateReloadingMetadata(newState); - this._priv_setPlayerState(newState); + /** + * `TaskCanceller` allowing to stop emitting `"seeking"` and `"seeked"` + * events. + * `null` when such events are not emitted currently. + */ + let seekEventsCanceller : TaskCanceller | null = null; - // Previous call could have performed all kind of side-effects, thus, - // we re-check the current state associated to the RxPlayer - if (this.state === "ENDED" && this._priv_stopAtEnd) { - currentContentCanceller.cancel(); + // React to player state change + playerStateRef.onUpdate((newState : IPlayerState) => { + updateReloadingMetadata(newState); + this._priv_setPlayerState(newState); + + if (currentContentCanceller.isUsed) { + return; + } + + if (seekEventsCanceller !== null) { + if (!isLoadedState(this.state)) { + seekEventsCanceller.cancel(); + seekEventsCanceller = null; } - }), - map(state => state !== "RELOADING" && state !== "STOPPED"), - distinctUntilChanged(), - switchMap(canSendObservation => canSendObservation ? observation$ : - EMPTY), - takeUntil(stoppedContent$) - ).subscribe(o => { + } else if (isLoadedState(this.state)) { + seekEventsCanceller = new TaskCanceller({ + cancelOn: currentContentCanceller.signal, + }); + emitSeekEvents(videoElement, + playbackObserver, + () => this.trigger("seeking", null), + () => this.trigger("seeked", null), + seekEventsCanceller.signal); + } + + // Previous call could have performed all kind of side-effects, thus, + // we re-check the current state associated to the RxPlayer + if (this.state === PLAYER_STATES.ENDED && this._priv_stopAtEnd) { + currentContentCanceller.cancel(); + } + }, { emitCurrentValue: true, clearSignal: currentContentCanceller.signal }); + + // React to playback conditions change + playbackObserver.listen((observation) => { updateReloadingMetadata(this.state); - this._priv_triggerPositionUpdate(o); - }); + this._priv_triggerPositionUpdate(observation); + }, { clearSignal: currentContentCanceller.signal }); - // Link "seeking" and "seeked" events (once the content is loaded) - loaded$.pipe( - switchMap(() => emitSeekEvents(this.videoElement, observation$)), - takeUntil(stoppedContent$) - ).subscribe((evt : "seeking" | "seeked") => { - log.info(`API: Triggering "${evt}" event`); - this.trigger(evt, null); - }); + this._priv_currentError = null; + this._priv_contentInfos = contentInfos; - // Link playback events to the corresponding callbacks - playback$.subscribe({ - next: (x) => this._priv_onPlaybackEvent(x), - error: (err : Error) => this._priv_onPlaybackError(err), - complete: () => { - if (!contentInfos.currentContentCanceller.isUsed) { - log.info("API: Previous playback finished. Stopping and cleaning-up..."); - contentInfos.currentContentCanceller.cancel(); - this._priv_cleanUpCurrentContentState(); - this._priv_setPlayerState(PLAYER_STATES.STOPPED); - } - }, + currentContentCanceller.signal.register(() => { + initializer.removeEventListener(); }); // initialize the content only when the lock is inactive - this._priv_contentLock.asObservable() - .pipe( - filter((isLocked) => !isLocked), - take(1), - takeUntil(stoppedContent$) - ) - .subscribe(() => { + this._priv_contentLock.onUpdate((isLocked, stopListeningToLock) => { + if (!isLocked) { + stopListeningToLock(); + // start playback! - playbackSubscription = playback$.connect(); - }); + initializer.start(videoElement, playbackObserver); + } + }, { emitCurrentValue: true, clearSignal: currentContentCanceller.signal }); } /** @@ -2418,86 +2296,6 @@ class Player extends EventEmitter { } } - /** - * Triggered each time the playback Observable emits. - * - * React to various events. - * - * @param {Object} event - payload emitted - */ - private _priv_onPlaybackEvent(event : IInitEvent) : void { - switch (event.type) { - case "inband-events": - const inbandEvents = event.value; - this.trigger("inbandEvents", inbandEvents); - return; - case "stream-event": - this.trigger("streamEvent", event.value); - break; - case "stream-event-skip": - this.trigger("streamEventSkip", event.value); - break; - case "activePeriodChanged": - this._priv_onActivePeriodChanged(event.value); - break; - case "periodStreamReady": - this._priv_onPeriodStreamReady(event.value); - break; - case "periodStreamCleared": - this._priv_onPeriodStreamCleared(event.value); - break; - case "reloading-media-source": - this._priv_onReloadingMediaSource(); - break; - case "representationChange": - this._priv_onRepresentationChange(event.value); - break; - case "adaptationChange": - this._priv_onAdaptationChange(event.value); - break; - case "bitrateEstimationChange": - this._priv_onBitrateEstimationChange(event.value); - break; - case "manifestReady": - this._priv_onManifestReady(event.value); - break; - case "warning": - this._priv_onPlaybackWarning(event.value); - break; - case "loaded": - if (this._priv_contentInfos === null) { - log.error("API: Loaded event while no content is loaded"); - return; - } - this._priv_contentInfos.segmentBuffersStore = event.value.segmentBuffersStore; - break; - case "decipherabilityUpdate": - this.trigger("decipherabilityUpdate", event.value); - break; - case "added-segment": - if (this._priv_contentInfos === null) { - log.error("API: Added segment while no content is loaded"); - return; - } - - // Manage image tracks - // @deprecated - const { content, segmentData } = event.value; - if (content.adaptation.type === "image") { - if (!isNullOrUndefined(segmentData) && - (segmentData as { type : string }).type === "bif") - { - const imageData = (segmentData as { data : IBifThumbnail[] }).data; - /* eslint-disable import/no-deprecated */ - this._priv_contentInfos.thumbnails = imageData; - this.trigger("imageTrackUpdate", - { data: this._priv_contentInfos.thumbnails }); - /* eslint-enable import/no-deprecated */ - } - } - } - } - /** * Triggered when we received a fatal error. * Clean-up ressources and signal that the content has stopped on error. @@ -2547,7 +2345,7 @@ class Player extends EventEmitter { * Initialize various private properties and emit initial event. * @param {Object} value */ - private _priv_onManifestReady({ manifest } : { manifest : Manifest }) : void { + private _priv_onManifestReady(manifest : Manifest) : void { const contentInfos = this._priv_contentInfos; if (contentInfos === null) { log.error("API: The manifest is loaded but no content is."); @@ -3001,6 +2799,71 @@ class Player extends EventEmitter { } return activeRepresentations[currentPeriod.id]; } + + private _priv_initializeMediaElementTrackChoiceManager( + defaultAudioTrack : IAudioTrackPreference | null | undefined, + defaultTextTrack : ITextTrackPreference | null | undefined + ) : void { + assert(features.directfile !== null, + "Initializing `MediaElementTrackChoiceManager` without Directfile feature"); + assert(this.videoElement !== null, + "Initializing `MediaElementTrackChoiceManager` on a disposed RxPlayer"); + + this._priv_mediaElementTrackChoiceManager = + new features.directfile.mediaElementTrackChoiceManager(this.videoElement); + + const preferredAudioTracks = defaultAudioTrack === undefined ? + this._priv_preferredAudioTracks : + [defaultAudioTrack]; + this._priv_mediaElementTrackChoiceManager + .setPreferredAudioTracks(preferredAudioTracks, true); + + const preferredTextTracks = defaultTextTrack === undefined ? + this._priv_preferredTextTracks : + [defaultTextTrack]; + this._priv_mediaElementTrackChoiceManager + .setPreferredTextTracks(preferredTextTracks, true); + + this._priv_mediaElementTrackChoiceManager + .setPreferredVideoTracks(this._priv_preferredVideoTracks, true); + + this.trigger("availableAudioTracksChange", + this._priv_mediaElementTrackChoiceManager.getAvailableAudioTracks()); + this.trigger("availableVideoTracksChange", + this._priv_mediaElementTrackChoiceManager.getAvailableVideoTracks()); + this.trigger("availableTextTracksChange", + this._priv_mediaElementTrackChoiceManager.getAvailableTextTracks()); + + this.trigger("audioTrackChange", + this._priv_mediaElementTrackChoiceManager.getChosenAudioTrack() + ?? null); + this.trigger("textTrackChange", + this._priv_mediaElementTrackChoiceManager.getChosenTextTrack() + ?? null); + this.trigger("videoTrackChange", + this._priv_mediaElementTrackChoiceManager.getChosenVideoTrack() + ?? null); + + this._priv_mediaElementTrackChoiceManager + .addEventListener("availableVideoTracksChange", (val) => + this.trigger("availableVideoTracksChange", val)); + this._priv_mediaElementTrackChoiceManager + .addEventListener("availableAudioTracksChange", (val) => + this.trigger("availableAudioTracksChange", val)); + this._priv_mediaElementTrackChoiceManager + .addEventListener("availableTextTracksChange", (val) => + this.trigger("availableTextTracksChange", val)); + + this._priv_mediaElementTrackChoiceManager + .addEventListener("audioTrackChange", (val) => + this.trigger("audioTrackChange", val)); + this._priv_mediaElementTrackChoiceManager + .addEventListener("videoTrackChange", (val) => + this.trigger("videoTrackChange", val)); + this._priv_mediaElementTrackChoiceManager + .addEventListener("textTrackChange", (val) => + this.trigger("textTrackChange", val)); + } } Player.version = /* PLAYER_VERSION */"3.29.0"; diff --git a/src/core/api/utils.ts b/src/core/api/utils.ts index 1b3052c780..b44a628c85 100644 --- a/src/core/api/utils.ts +++ b/src/core/api/utils.ts @@ -14,58 +14,60 @@ * limitations under the License. */ -import { - defer as observableDefer, - EMPTY, - filter, - map, - merge as observableMerge, - Observable, - startWith, - switchMap, - take, -} from "rxjs"; import config from "../../config"; import { IPlayerState } from "../../public_types"; -import { IStallingSituation } from "../init"; -import { IPlaybackObservation } from "./playback_observer"; +import arrayIncludes from "../../utils/array_includes"; +import createSharedReference, { + IReadOnlySharedReference, +} from "../../utils/reference"; +import { CancellationSignal } from "../../utils/task_canceller"; +import { + ContentInitializer, + IStallingSituation, +} from "../init"; +import { + IPlaybackObservation, + IReadOnlyPlaybackObserver, +} from "./playback_observer"; /** - * Returns Observable which will emit: - * - `"seeking"` when we are seeking in the given mediaElement - * - `"seeked"` when a seek is considered as finished by the given observation$ - * Observable. * @param {HTMLMediaElement} mediaElement - * @param {Observable} observation$ - * @returns {Observable} + * @param {Object} playbackObserver - Observes playback conditions on + * `mediaElement`. + * @param {function} onSeeking - Callback called when a seeking operation starts + * on `mediaElement`. + * @param {function} onSeeked - Callback called when a seeking operation ends + * on `mediaElement`. + * @param {Object} cancelSignal - When triggered, stop calling callbacks and + * remove all listeners this function has registered. */ export function emitSeekEvents( mediaElement : HTMLMediaElement | null, - observation$ : Observable -) : Observable<"seeking" | "seeked"> { - return observableDefer(() => { - if (mediaElement === null) { - return EMPTY; - } - - let isSeeking$ = observation$.pipe( - filter((observation : IPlaybackObservation) => observation.event === "seeking"), - map(() => "seeking" as const) - ); + playbackObserver : IReadOnlyPlaybackObserver, + onSeeking: () => void, + onSeeked: () => void, + cancelSignal : CancellationSignal +) : void { + if (cancelSignal.isCancelled || mediaElement === null) { + return ; + } - if (mediaElement.seeking) { - isSeeking$ = isSeeking$.pipe(startWith("seeking" as const)); + let wasSeeking = playbackObserver.getReference().getValue().seeking; + if (wasSeeking) { + onSeeking(); + if (cancelSignal.isCancelled) { + return; } - - const hasSeeked$ = isSeeking$.pipe( - switchMap(() => - observation$.pipe( - filter((observation : IPlaybackObservation) => observation.event === "seeked"), - map(() => "seeked" as const), - take(1))) - ); - return observableMerge(isSeeking$, hasSeeked$); - }); + } + playbackObserver.listen((obs) => { + if (obs.event === "seeking") { + wasSeeking = true; + onSeeking(); + } else if (wasSeeking && obs.event === "seeked") { + wasSeeking = false; + onSeeked(); + } + }, { includeLastObservation: true, clearSignal: cancelSignal }); } /** Player state dictionnary. */ @@ -81,6 +83,67 @@ export const enum PLAYER_STATES { RELOADING = "RELOADING", } +export function constructPlayerStateReference( + initializer : ContentInitializer, + mediaElement : HTMLMediaElement, + playbackObserver : IReadOnlyPlaybackObserver, + cancelSignal : CancellationSignal +) : IReadOnlySharedReference { + const playerStateRef = createSharedReference(PLAYER_STATES.LOADING); + initializer.addEventListener("loaded", () => { + if (playerStateRef.getValue() === PLAYER_STATES.LOADING) { + playerStateRef.setValue(PLAYER_STATES.LOADED); + if (!cancelSignal.isCancelled) { + const newState = getLoadedContentState(mediaElement, null); + if (newState !== PLAYER_STATES.PAUSED) { + playerStateRef.setValue(newState); + } + } + } else { + playerStateRef.setValueIfChanged(getLoadedContentState(mediaElement, null)); + } + }, cancelSignal); + + initializer.addEventListener("reloadingMediaSource", () => { + if (isLoadedState(playerStateRef.getValue())) { + playerStateRef.setValueIfChanged(PLAYER_STATES.RELOADING); + } + }, cancelSignal); + + /** + * Keep track of the last known stalling situation. + * `null` if playback is not stalled. + */ + let prevStallReason : IStallingSituation | null = null; + initializer.addEventListener("stalled", (s) => { + if (s !== prevStallReason) { + if (isLoadedState(playerStateRef.getValue())) { + playerStateRef.setValueIfChanged(getLoadedContentState(mediaElement, s)); + } + prevStallReason = s; + } + }, cancelSignal); + initializer.addEventListener("unstalled", () => { + if (prevStallReason !== null) { + if (isLoadedState(playerStateRef.getValue())) { + playerStateRef.setValueIfChanged(getLoadedContentState(mediaElement, null)); + } + prevStallReason = null; + } + }, cancelSignal); + + playbackObserver.listen((observation) => { + if (isLoadedState(playerStateRef.getValue()) && + arrayIncludes(["seeking", "ended", "play", "pause"], observation.event)) + { + playerStateRef.setValueIfChanged( + getLoadedContentState(mediaElement, prevStallReason) + ); + } + }, { clearSignal: cancelSignal }); + return playerStateRef; +} + /** * Get state string for a _loaded_ content. * @param {HTMLMediaElement} mediaElement @@ -118,3 +181,9 @@ export function getLoadedContentState( return mediaElement.paused ? PLAYER_STATES.PAUSED : PLAYER_STATES.PLAYING; } + +export function isLoadedState(state : IPlayerState) : boolean { + return state !== PLAYER_STATES.LOADING && + state !== PLAYER_STATES.RELOADING && + state !== PLAYER_STATES.STOPPED; +} diff --git a/src/core/decrypt/attach_media_keys.ts b/src/core/decrypt/attach_media_keys.ts index 9a5d897e6c..42206874d3 100644 --- a/src/core/decrypt/attach_media_keys.ts +++ b/src/core/decrypt/attach_media_keys.ts @@ -39,9 +39,10 @@ export function disableMediaKeys(mediaElement : HTMLMediaElement): void { * Attach MediaKeys and its associated state to an HTMLMediaElement. * * /!\ Mutates heavily MediaKeysInfosStore - * @param {Object} mediaKeysInfos * @param {HTMLMediaElement} mediaElement - * @returns {Observable} + * @param {Object} mediaKeysInfos + * @param {Object} cancelSignal + * @returns {Promise} */ export default async function attachMediaKeys( mediaElement : HTMLMediaElement, diff --git a/src/core/fetchers/index.ts b/src/core/fetchers/index.ts index ab5097f9b4..e13d3b628c 100644 --- a/src/core/fetchers/index.ts +++ b/src/core/fetchers/index.ts @@ -17,7 +17,6 @@ import ManifestFetcher, { IManifestFetcherParsedResult, IManifestFetcherParserOptions, - IManifestFetcherWarningEvent, } from "./manifest"; import SegmentFetcherCreator, { IPrioritizedSegmentFetcher, @@ -30,7 +29,6 @@ export { IManifestFetcherParserOptions, IManifestFetcherParsedResult, - IManifestFetcherWarningEvent, IPrioritizedSegmentFetcher, diff --git a/src/core/fetchers/manifest/index.ts b/src/core/fetchers/manifest/index.ts index e7bd6693ae..d56abf35ce 100644 --- a/src/core/fetchers/manifest/index.ts +++ b/src/core/fetchers/manifest/index.ts @@ -17,12 +17,10 @@ import ManifestFetcher, { IManifestFetcherParsedResult, IManifestFetcherParserOptions, - IManifestFetcherWarningEvent, } from "./manifest_fetcher"; export default ManifestFetcher; export { IManifestFetcherParsedResult, IManifestFetcherParserOptions, - IManifestFetcherWarningEvent, }; diff --git a/src/core/fetchers/manifest/manifest_fetcher.ts b/src/core/fetchers/manifest/manifest_fetcher.ts index f0221e78b2..6eae3e07a1 100644 --- a/src/core/fetchers/manifest/manifest_fetcher.ts +++ b/src/core/fetchers/manifest/manifest_fetcher.ts @@ -14,9 +14,6 @@ * limitations under the License. */ -import { - Observable, -} from "rxjs"; import config from "../../../config"; import { formatError } from "../../../errors"; import log from "../../../log"; @@ -29,7 +26,9 @@ import { } from "../../../transports"; import assert from "../../../utils/assert"; import isNullOrUndefined from "../../../utils/is_null_or_undefined"; -import TaskCanceller from "../../../utils/task_canceller"; +import TaskCanceller, { + CancellationSignal, +} from "../../../utils/task_canceller"; import errorSelector from "../utils/error_selector"; import { IBackoffSettings, @@ -39,9 +38,6 @@ import { /** What will be sent once parsed. */ export interface IManifestFetcherParsedResult { - /** To differentiate it from a "warning" event. */ - type : "parsed"; - /** The resulting Manifest */ manifest : Manifest; /** @@ -55,24 +51,11 @@ export interface IManifestFetcherParsedResult { parsingTime? : number | undefined; } -/** Emitted when a fetching or parsing minor error happened. */ -export interface IManifestFetcherWarningEvent { - /** To differentiate it from other events. */ - type : "warning"; - - /** The error in question. */ - value : IPlayerError; -} - /** Response emitted by a Manifest fetcher. */ export interface IManifestFetcherResponse { - /** To differentiate it from a "warning" event. */ - type : "response"; - /** Allows to parse a fetched Manifest into a `Manifest` structure. */ parse(parserOptions : IManifestFetcherParserOptions) : - Observable; + Promise; } export interface IManifestFetcherParserOptions { @@ -116,20 +99,6 @@ export interface IManifestFetcherSettings { /** * Class allowing to facilitate the task of loading and parsing a Manifest. * @class ManifestFetcher - * @example - * ```js - * const manifestFetcher = new ManifestFetcher(manifestUrl, pipelines, options); - * manifestFetcher.fetch().pipe( - * // Filter only responses (might also receive warning events) - * filter((evt) => evt.type === "response"); - * // Parse the Manifest - * mergeMap(res => res.parse({ externalClockOffset })) - * // (again) - * filter((evt) => evt.type === "parsed"); - * ).subscribe(({ value }) => { - * console.log("Manifest:", value.manifest); - * }); - * ``` */ export default class ManifestFetcher { private _settings : IManifestFetcherSettings; @@ -165,94 +134,81 @@ export default class ManifestFetcher { * If not set, the regular Manifest url - defined on the `ManifestFetcher` * instanciation - will be used instead. * - * @param {string} [url] - * @returns {Observable} + * @param {string} url + * @param {Function} onWarning + * @param {Object} cancelSignal + * @returns {Promise} */ - public fetch(url? : string) : Observable - { - return new Observable((obs) => { - const settings = this._settings; - const pipelines = this._pipelines; - const requestUrl = url ?? this._manifestUrl; - - /** `true` if the loading pipeline is already completely executed. */ - let hasFinishedLoading = false; - - /** Allows to cancel the loading operation. */ - const canceller = new TaskCanceller(); - - const backoffSettings = this._getBackoffSetting((err) => { - obs.next({ type: "warning", value: errorSelector(err) }); - }); - - const loadingPromise = pipelines.resolveManifestUrl === undefined ? - callLoaderWithRetries(requestUrl) : - callResolverWithRetries(requestUrl).then(callLoaderWithRetries); - - loadingPromise - .then(response => { - hasFinishedLoading = true; - obs.next({ - type: "response", - parse: (parserOptions : IManifestFetcherParserOptions) => { - return this._parseLoadedManifest(response, parserOptions); - }, - }); - obs.complete(); - }) - .catch((err : unknown) => { - if (canceller.isUsed) { - // Cancellation has already been handled by RxJS - return; - } - hasFinishedLoading = true; - obs.error(errorSelector(err)); - }); + public async fetch( + url : string | undefined, + onWarning : (err : IPlayerError) => void, + cancelSignal : CancellationSignal + ) : Promise { + const settings = this._settings; + const pipelines = this._pipelines; + const requestUrl = url ?? this._manifestUrl; + + const backoffSettings = this._getBackoffSetting((err) => { + onWarning(errorSelector(err)); + }); - return () => { - if (!hasFinishedLoading) { - canceller.cancel(); - } + const loadingPromise = pipelines.resolveManifestUrl === undefined ? + callLoaderWithRetries(requestUrl) : + callResolverWithRetries(requestUrl).then(callLoaderWithRetries); + + try { + const response = await loadingPromise; + return { + parse: (parserOptions : IManifestFetcherParserOptions) => { + return this._parseLoadedManifest(response, + parserOptions, + onWarning, + cancelSignal); + }, }; - - /** - * Call the resolver part of the pipeline, retrying if it fails according - * to the current settings. - * Returns the Promise of the last attempt. - * /!\ This pipeline should have a `resolveManifestUrl` function defined. - * @param {string | undefined} resolverUrl - * @returns {Promise} - */ - function callResolverWithRetries(resolverUrl : string | undefined) { - const { resolveManifestUrl } = pipelines; - assert(resolveManifestUrl !== undefined); - const callResolver = () => resolveManifestUrl(resolverUrl, canceller.signal); - return scheduleRequestPromise(callResolver, backoffSettings, canceller.signal); + } catch (err) { + if (err instanceof CancellationSignal) { + throw err; } - - /** - * Call the loader part of the pipeline, retrying if it fails according - * to the current settings. - * Returns the Promise of the last attempt. - * @param {string | undefined} manifestUrl - * @returns {Promise} - */ - function callLoaderWithRetries(manifestUrl : string | undefined) { - const { loadManifest } = pipelines; - let requestTimeout : number | undefined = - isNullOrUndefined(settings.requestTimeout) ? - config.getCurrent().DEFAULT_REQUEST_TIMEOUT : - settings.requestTimeout; - if (requestTimeout < 0) { - requestTimeout = undefined; - } - const callLoader = () => loadManifest(manifestUrl, - { timeout: requestTimeout }, - canceller.signal); - return scheduleRequestPromise(callLoader, backoffSettings, canceller.signal); + throw errorSelector(err); + } + + /** + * Call the resolver part of the pipeline, retrying if it fails according + * to the current settings. + * Returns the Promise of the last attempt. + * /!\ This pipeline should have a `resolveManifestUrl` function defined. + * @param {string | undefined} resolverUrl + * @returns {Promise} + */ + function callResolverWithRetries(resolverUrl : string | undefined) { + const { resolveManifestUrl } = pipelines; + assert(resolveManifestUrl !== undefined); + const callResolver = () => resolveManifestUrl(resolverUrl, cancelSignal); + return scheduleRequestPromise(callResolver, backoffSettings, cancelSignal); + } + + /** + * Call the loader part of the pipeline, retrying if it fails according + * to the current settings. + * Returns the Promise of the last attempt. + * @param {string | undefined} manifestUrl + * @returns {Promise} + */ + function callLoaderWithRetries(manifestUrl : string | undefined) { + const { loadManifest } = pipelines; + let requestTimeout : number | undefined = + isNullOrUndefined(settings.requestTimeout) ? + config.getCurrent().DEFAULT_REQUEST_TIMEOUT : + settings.requestTimeout; + if (requestTimeout < 0) { + requestTimeout = undefined; } - }); + const callLoader = () => loadManifest(manifestUrl, + { timeout: requestTimeout }, + cancelSignal); + return scheduleRequestPromise(callLoader, backoffSettings, cancelSignal); + } } /** @@ -264,17 +220,22 @@ export default class ManifestFetcher { * information on the request can be used by the parsing process. * @param {*} manifest * @param {Object} parserOptions - * @returns {Observable} + * @param {Function} onWarning + * @param {Object} cancelSignal + * @returns {Promise} */ public parse( manifest : unknown, - parserOptions : IManifestFetcherParserOptions - ) : Observable { + parserOptions : IManifestFetcherParserOptions, + onWarning : (err : IPlayerError) => void, + cancelSignal : CancellationSignal + ) : Promise { return this._parseLoadedManifest({ responseData: manifest, size: undefined, requestDuration: undefined }, - parserOptions); + parserOptions, + onWarning, + cancelSignal); } @@ -284,127 +245,98 @@ export default class ManifestFetcher { * @param {Object} loaded - Information about the loaded Manifest as well as * about the corresponding request. * @param {Object} parserOptions - Options used when parsing the Manifest. - * @returns {Observable} + * @param {Function} onWarning + * @param {Object} cancelSignal + * @returns {Promise} */ - private _parseLoadedManifest( + private async _parseLoadedManifest( loaded : IRequestedData, - parserOptions : IManifestFetcherParserOptions - ) : Observable - { - return new Observable(obs => { - const parsingTimeStart = performance.now(); - const canceller = new TaskCanceller(); - const { sendingTime, receivedTime } = loaded; - const backoffSettings = this._getBackoffSetting((err) => { - obs.next({ type: "warning", value: errorSelector(err) }); - }); + parserOptions : IManifestFetcherParserOptions, + onWarning : (err : IPlayerError) => void, + cancelSignal : CancellationSignal + ) : Promise { + const parsingTimeStart = performance.now(); + const canceller = new TaskCanceller(); + const { sendingTime, receivedTime } = loaded; + const backoffSettings = this._getBackoffSetting((err) => { + onWarning(errorSelector(err)); + }); - const opts = { externalClockOffset: parserOptions.externalClockOffset, - unsafeMode: parserOptions.unsafeMode, - previousManifest: parserOptions.previousManifest, - originalUrl: this._manifestUrl }; + const opts = { externalClockOffset: parserOptions.externalClockOffset, + unsafeMode: parserOptions.unsafeMode, + previousManifest: parserOptions.previousManifest, + originalUrl: this._manifestUrl }; + try { + const res = this._pipelines.parseManifest(loaded, + opts, + onWarnings, + cancelSignal, + scheduleRequest); + if (!isPromise(res)) { + return finish(res.manifest); + } else { + const { manifest } = await res; + return finish(manifest); + } + } catch (err) { + const formattedError = formatError(err, { + defaultCode: "PIPELINE_PARSE_ERROR", + defaultReason: "Unknown error when parsing the Manifest", + }); + throw formattedError; + } + + /** + * Perform a request with the same retry mechanisms and error handling + * than for a Manifest loader. + * @param {Function} performRequest + * @returns {Function} + */ + async function scheduleRequest( + performRequest : () => Promise + ) : Promise { try { - const res = this._pipelines.parseManifest(loaded, - opts, - onWarnings, - canceller.signal, - scheduleRequest); - if (!isPromise(res)) { - emitManifestAndComplete(res.manifest); - } else { - res - .then(({ manifest }) => emitManifestAndComplete(manifest)) - .catch((err) => { - if (canceller.isUsed) { - // Cancellation is already handled by RxJS - return; - } - emitError(err, true); - }); - } + const data = await scheduleRequestPromise(performRequest, + backoffSettings, + cancelSignal); + return data; } catch (err) { - if (canceller.isUsed) { - // Cancellation is already handled by RxJS - return undefined; - } - emitError(err, true); - } - - return () => { - canceller.cancel(); - }; - - /** - * Perform a request with the same retry mechanisms and error handling - * than for a Manifest loader. - * @param {Function} performRequest - * @returns {Function} - */ - async function scheduleRequest( - performRequest : () => Promise - ) : Promise { - try { - const data = await scheduleRequestPromise(performRequest, - backoffSettings, - canceller.signal); - return data; - } catch (err) { - throw errorSelector(err); - } + throw errorSelector(err); } - - /** - * Handle minor errors encountered by a Manifest parser. - * @param {Array.} warnings - */ - function onWarnings(warnings : Error[]) : void { - for (const warning of warnings) { - if (canceller.isUsed) { - return; - } - emitError(warning, false); + } + + /** + * Handle minor errors encountered by a Manifest parser. + * @param {Array.} warnings + */ + function onWarnings(warnings : Error[]) : void { + for (const warning of warnings) { + if (canceller.isUsed) { + return; } - } - - /** - * Emit a formatted "parsed" event through `obs`. - * To call once the Manifest has been parsed. - * @param {Object} manifest - */ - function emitManifestAndComplete(manifest : Manifest) : void { - onWarnings(manifest.contentWarnings); - const parsingTime = performance.now() - parsingTimeStart; - log.info(`MF: Manifest parsed in ${parsingTime}ms`); - - obs.next({ type: "parsed" as const, - manifest, - sendingTime, - receivedTime, - parsingTime }); - obs.complete(); - } - - /** - * Format the given Error and emit it through `obs`. - * Either through a `"warning"` event, if `isFatal` is `false`, or through - * a fatal Observable error, if `isFatal` is set to `true`. - * @param {*} err - * @param {boolean} isFatal - */ - function emitError(err : unknown, isFatal : boolean) : void { - const formattedError = formatError(err, { + const formattedError = formatError(warning, { defaultCode: "PIPELINE_PARSE_ERROR", defaultReason: "Unknown error when parsing the Manifest", }); - if (isFatal) { - obs.error(formattedError); - } else { - obs.next({ type: "warning" as const, - value: formattedError }); - } + onWarning(formattedError); } - }); + } + + /** + * Emit a formatted "parsed" event through `obs`. + * To call once the Manifest has been parsed. + * @param {Object} manifest + */ + function finish(manifest : Manifest) : IManifestFetcherParsedResult { + onWarnings(manifest.contentWarnings); + const parsingTime = performance.now() - parsingTimeStart; + log.info(`MF: Manifest parsed in ${parsingTime}ms`); + + return { manifest, + sendingTime, + receivedTime, + parsingTime }; + } } /** diff --git a/src/core/init/README.md b/src/core/init/README.md index 85b9872dc2..489d4194ff 100644 --- a/src/core/init/README.md +++ b/src/core/init/README.md @@ -1,30 +1,33 @@ -# The `Init` ################################################################### +# The `ContentInitializer` ##################################################### -The Init is the part of the code starting the logic behind playing a content. +The ContentInitializer is the part of the code actually starting and running the +logic behind playing a content. -Its code is written in the ``src/core/init`` directory. +Its code is written in the `src/core/init` directory. -Every time you're calling the API to load a new video, the init is called by it -(with multiple options). +Every time you're calling the API to load a new video, a ContentInitializer is +called by it (with multiple options). -The Init then starts loading the content and communicate back its progress to +The ContentInitializer then starts loading the content and communicate back its progress to the API through events. ``` +-----------+ - 1. LOAD VIDEO | | 2. CALLS + 1. loadVideo | | 2. Instanciate ---------------> | API | -------------------+ | | | +-----------+ | ^ v - | +--------------+ - | 3. EMIT EVENTS | | - +------------------- | Init | - | | - +--------------+ + | +--------------------+ + | 3. Emit events | | + +------------------- | ContentInitializer | + | | + +--------------------+ ``` -During the various events happening on content playback, the Init will create / -destroy / update various player blocks. Example of such blocks are: +During the various events happening on content playback, the ContentInitializer will +create / destroy / update various player submodules. + +Example of such submodules are: - Adaptive streaming management - DRM handling - Manifest loading, parsing and refreshing @@ -35,56 +38,39 @@ destroy / update various player blocks. Example of such blocks are: ## Usage ####################################################################### -Concretely, the Init is a function which returns an Observable. -This Observable: - - - will automatically load the described content on subscription - - will automatically stop and clean-up infos related to the content on - unsubscription - - communicate on various streaming events through emitted notifications - - throw in the case of a fatal error (i.e. an error interrupting playback) - - -### Communication between the API and the Init ################################# +Concretely, the ContentInitializer is a class respecting a given interface, +allowing to: -Objects emitted by the Observable is the only way the Init should be able to -communicate with the API. + - prepare a future content for future play - without influencing a potentially + already-playing content (e.g. by pre-loading the next content's Manifest). -The API is then able to communicate back to the Init, either: - - by Observable provided by the API as arguments when the Init function was - called - - by emitting through Subject provided by the Init, as a payload of one of - its event + - start loading the content on a given media element -Thus, there is three ways the API and Init can communicate: - - API -> Init: When the Init function is called (so a single time) - - Init -> API: Through events emitted by the returned Observable - - API -> Init: Through Observables/Subjects the Init function is in possession - of. + - communicate about various playback events ### Emitted Events ############################################################# -Events allows the Init to reports milestones of the content playback, such as -when the content is ready to play. +Events allows the ContentInitializer to reports milestones of the content +playback, such as when the content is ready to play. -It's also a way for the Init to communicate information about the content and -give some controls to the user. +It's also a way for the `ContentInitializer` to communicate information about +the content and give some controls to the user. For example, as available audio languages are only known after the manifest has been downloaded and parsed, and as it is most of all a user preference, the -Init can emit to the API, RxJS Subjects allowing the API to "choose" at any -time the wanted language. +ContentInitializer can emit to the API, objects allowing the API to "choose" at +any time the wanted language. ### Playback rate management ################################################### -The playback rate (or speed) is updated by the Init. +The playback rate (or speed) is updated by the ContentInitializer. There can be three occasions for these updates: - - the API set a new Speed (``speed$`` Observable). + - the API set a new speed - the content needs to build its buffer. diff --git a/src/core/init/directfile_content_initializer.ts b/src/core/init/directfile_content_initializer.ts new file mode 100644 index 0000000000..d9938a8f1b --- /dev/null +++ b/src/core/init/directfile_content_initializer.ts @@ -0,0 +1,223 @@ +/** + * 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 feature-switchable. + * It always should be imported through the `features` object. + */ + +import { clearElementSrc } from "../../compat"; +import { MediaError } from "../../errors"; +import log from "../../log"; +import { + IKeySystemOption, + IPlayerError, +} from "../../public_types"; +import createSharedReference, { + IReadOnlySharedReference, +} from "../../utils/reference"; +import TaskCanceller from "../../utils/task_canceller"; +import { PlaybackObserver } from "../api"; +import { ContentInitializer } from "./types"; +import { IInitialTimeOptions } from "./utils/get_initial_time"; +import getLoadedReference from "./utils/get_loaded_reference"; +import performInitialSeekAndPlay from "./utils/initial_seek_and_play"; +import initializeContentDecryption from "./utils/initialize_content_decryption"; +import RebufferingController from "./utils/rebuffering_controller"; +import listenToMediaError from "./utils/throw_on_media_error"; + +export default class DirectFileContentInitializer extends ContentInitializer { + private _settings : IDirectFileOptions; + private _initCanceller : TaskCanceller; + + constructor(settings : IDirectFileOptions) { + super(); + this._settings = settings; + this._initCanceller = new TaskCanceller(); + } + + public prepare(): void { + return; // Directfile contents do not have any preparation + } + + public start( + mediaElement : HTMLMediaElement, + playbackObserver : PlaybackObserver + ): void { + const cancelSignal = this._initCanceller.signal; + const { keySystems, speed, url } = this._settings; + + clearElementSrc(mediaElement); + + if (url == null) { + throw new Error("No URL for a DirectFile content"); + } + + const drmInitRef = + initializeContentDecryption(mediaElement, keySystems, createSharedReference(null), { + onError: (err) => this._onFatalError(err), + onWarning: (err : IPlayerError) => this.trigger("warning", err), + }, cancelSignal); + + /** Translate errors coming from the media element into RxPlayer errors. */ + listenToMediaError(mediaElement, + (error : MediaError) => this._onFatalError(error), + cancelSignal); + + /** + * Class trying to avoid various stalling situations, emitting "stalled" + * events when it cannot, as well as "unstalled" events when it get out of one. + */ + const rebufferingController = new RebufferingController(playbackObserver, + null, + speed); + rebufferingController.addEventListener("stalled", (evt) => + this.trigger("stalled", evt)); + rebufferingController.addEventListener("unstalled", () => + this.trigger("unstalled", null)); + rebufferingController.addEventListener("warning", (err) => + this.trigger("warning", err)); + cancelSignal.register(() => { + rebufferingController.destroy(); + }); + rebufferingController.start(); + + drmInitRef.onUpdate((evt, stopListeningToDrmUpdates) => { + if (evt.initializationState.type === "uninitialized") { + return; + } + stopListeningToDrmUpdates(); + + // Start everything! (Just put the URL in the element's src). + log.info("Setting URL to HTMLMediaElement", url); + mediaElement.src = url; + cancelSignal.register(() => { + clearElementSrc(mediaElement); + }); + if (evt.initializationState.type === "awaiting-media-link") { + evt.initializationState.value.isMediaLinked.setValue(true); + drmInitRef.onUpdate((newDrmStatus, stopListeningToDrmUpdatesAgain) => { + if (newDrmStatus.initializationState.type === "initialized") { + stopListeningToDrmUpdatesAgain(); + this._seekAndPlay(mediaElement, playbackObserver); + return; + } + }, { emitCurrentValue: true, clearSignal: cancelSignal }); + } else { + this._seekAndPlay(mediaElement, playbackObserver); + return; + } + }, { emitCurrentValue: true, clearSignal: cancelSignal }); + } + + public dispose(): void { + this._initCanceller.cancel(); + } + + private _onFatalError(err : unknown) { + this._initCanceller.cancel(); + this.trigger("error", err); + } + + private _seekAndPlay( + mediaElement : HTMLMediaElement, + playbackObserver : PlaybackObserver + ) { + const cancelSignal = this._initCanceller.signal; + const { autoPlay, startAt } = this._settings; + const initialTime = () => { + log.debug("Init: Calculating initial time"); + const initTime = getDirectFileInitialTime(mediaElement, startAt); + log.debug("Init: Initial time calculated:", initTime); + return initTime; + }; + performInitialSeekAndPlay( + mediaElement, + playbackObserver, + initialTime, + autoPlay, + (err) => this.trigger("warning", err), + cancelSignal + ).autoPlayResult + .then(() => + getLoadedReference(playbackObserver, mediaElement, true, cancelSignal) + .onUpdate((isLoaded, stopListening) => { + if (isLoaded) { + stopListening(); + this.trigger("loaded", { segmentBuffersStore: null }); + } + }, { emitCurrentValue: true, clearSignal: cancelSignal })) + .catch((err) => { + if (!cancelSignal.isCancelled) { + this._onFatalError(err); + } + }); + } +} + +/** + * calculate initial time as a position in seconds. + * @param {HTMLMediaElement} mediaElement + * @param {Object|undefined} startAt + * @returns {number} + */ +function getDirectFileInitialTime( + mediaElement : HTMLMediaElement, + startAt? : IInitialTimeOptions +) : number { + if (startAt == null) { + return 0; + } + + if (startAt.position != null) { + return startAt.position; + } else if (startAt.wallClockTime != null) { + return startAt.wallClockTime; + } else if (startAt.fromFirstPosition != null) { + return startAt.fromFirstPosition; + } + + const duration = mediaElement.duration; + if (duration == null || !isFinite(duration)) { + log.warn("startAt.fromLastPosition set but no known duration, " + + "beginning at 0."); + return 0; + } + + if (typeof startAt.fromLastPosition === "number") { + return Math.max(0, duration + startAt.fromLastPosition); + } else if (startAt.percentage != null) { + const { percentage } = startAt; + if (percentage >= 100) { + return duration; + } else if (percentage <= 0) { + return 0; + } + const ratio = +percentage / 100; + return duration * ratio; + } + + return 0; +} + +// Argument used by `initializeDirectfileContent` +export interface IDirectFileOptions { + autoPlay : boolean; + keySystems : IKeySystemOption[]; + speed : IReadOnlySharedReference; + startAt? : IInitialTimeOptions | undefined; + url? : string | undefined; +} diff --git a/src/core/init/emit_loaded_event.ts b/src/core/init/emit_loaded_event.ts deleted file mode 100644 index 7fd115b59c..0000000000 --- a/src/core/init/emit_loaded_event.ts +++ /dev/null @@ -1,71 +0,0 @@ - -/** - * 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. - */ - -import { - Observable, - take, -} from "rxjs"; -import { - shouldValidateMetadata, - shouldWaitForDataBeforeLoaded, -} from "../../compat"; -import filterMap from "../../utils/filter_map"; -import { IPlaybackObservation } from "../api"; -import SegmentBuffersStore from "../segment_buffers"; -import EVENTS from "./events_generators"; -import { ILoadedEvent } from "./types"; - -/** - * Emit a `ILoadedEvent` once the content can be considered as loaded. - * @param {Observable} observation$ - * @param {HTMLMediaElement} mediaElement - * @param {Object|null} segmentBuffersStore - * @param {boolean} isDirectfile - `true` if this is a directfile content - * @returns {Observable} - */ -export default function emitLoadedEvent( - observation$ : Observable, - mediaElement : HTMLMediaElement, - segmentBuffersStore : SegmentBuffersStore | null, - isDirectfile : boolean -) : Observable { - return observation$.pipe( - filterMap((observation) => { - if (observation.rebuffering !== null || - observation.freezing !== null || - observation.readyState === 0) - { - return null; - } - - if (!shouldWaitForDataBeforeLoaded(isDirectfile, - mediaElement.hasAttribute("playsinline"))) - { - return mediaElement.duration > 0 ? EVENTS.loaded(segmentBuffersStore) : - null; - } - - if (observation.readyState >= 3 && observation.currentRange !== null) { - if (!shouldValidateMetadata() || mediaElement.duration > 0) { - return EVENTS.loaded(segmentBuffersStore); - } - return null; - } - return null; - }, null), - take(1)); -} diff --git a/src/core/init/end_of_stream.ts b/src/core/init/end_of_stream.ts deleted file mode 100644 index 4f0ac2fc0b..0000000000 --- a/src/core/init/end_of_stream.ts +++ /dev/null @@ -1,104 +0,0 @@ -/** - * 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. - */ - -import { - defer as observableDefer, - merge as observableMerge, - mergeMap, - Observable, - of as observableOf, - race as observableRace, - startWith, - switchMap, - take, - takeLast, -} from "rxjs"; -import { events } from "../../compat"; -import log from "../../log"; - -const { onRemoveSourceBuffers$, - onSourceOpen$, - onUpdate$ } = events; - -/** - * Get "updating" SourceBuffers from a SourceBufferList. - * @param {SourceBufferList} sourceBuffers - * @returns {Array.} - */ -function getUpdatingSourceBuffers(sourceBuffers : SourceBufferList) : SourceBuffer[] { - const updatingSourceBuffers : SourceBuffer[] = []; - for (let i = 0; i < sourceBuffers.length; i++) { - const SourceBuffer = sourceBuffers[i]; - if (SourceBuffer.updating) { - updatingSourceBuffers.push(SourceBuffer); - } - } - return updatingSourceBuffers; -} - -/** - * Trigger the `endOfStream` method of a MediaSource. - * - * If the MediaSource is ended/closed, do not call this method. - * If SourceBuffers are updating, wait for them to be updated before closing - * it. - * @param {MediaSource} mediaSource - * @returns {Observable} - */ -export default function triggerEndOfStream( - mediaSource : MediaSource -) : Observable { - return observableDefer(() => { - log.debug("Init: Trying to call endOfStream"); - if (mediaSource.readyState !== "open") { - log.debug("Init: MediaSource not open, cancel endOfStream"); - return observableOf(null); - } - - const { sourceBuffers } = mediaSource; - const updatingSourceBuffers = getUpdatingSourceBuffers(sourceBuffers); - - if (updatingSourceBuffers.length === 0) { - log.info("Init: Triggering end of stream"); - mediaSource.endOfStream(); - return observableOf(null); - } - - log.debug("Init: Waiting SourceBuffers to be updated before calling endOfStream."); - const updatedSourceBuffers$ = updatingSourceBuffers - .map((sourceBuffer) => onUpdate$(sourceBuffer).pipe(take(1))); - - return observableRace( - observableMerge(...updatedSourceBuffers$).pipe(takeLast(1)), - onRemoveSourceBuffers$(sourceBuffers).pipe(take(1)) - ).pipe(mergeMap(() => { - return triggerEndOfStream(mediaSource); - })); - }); -} - -/** - * Trigger the `endOfStream` method of a MediaSource each times it opens. - * @see triggerEndOfStream - * @param {MediaSource} mediaSource - * @returns {Observable} - */ -export function maintainEndOfStream(mediaSource : MediaSource) : Observable { - return onSourceOpen$(mediaSource).pipe( - startWith(null), - switchMap(() => triggerEndOfStream(mediaSource)) - ); -} diff --git a/src/core/init/events_generators.ts b/src/core/init/events_generators.ts deleted file mode 100644 index ad3da37448..0000000000 --- a/src/core/init/events_generators.ts +++ /dev/null @@ -1,140 +0,0 @@ -/** - * 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. - */ - -import Manifest, { - Adaptation, - Period, - Representation, -} from "../../manifest"; -import { IPlayerError } from "../../public_types"; -import SegmentBuffersStore, { - IBufferType, -} from "../segment_buffers"; -import { IRepresentationChangeEvent } from "../stream"; -import { - IDecipherabilityUpdateEvent, - ILoadedEvent, - IManifestReadyEvent, - IManifestUpdateEvent, - IReloadingMediaSourceEvent, - IStalledEvent, - IStallingSituation, - IUnstalledEvent, - IWarningEvent, -} from "./types"; - -/** - * Construct a "loaded" event. - * @returns {Object} - */ -function loaded(segmentBuffersStore : SegmentBuffersStore | null) : ILoadedEvent { - return { type: "loaded", value: { segmentBuffersStore } }; -} - -/** - * Construct a "stalled" event. - * @param {Object|null} rebuffering - * @returns {Object} - */ -function stalled(rebuffering : IStallingSituation) : IStalledEvent { - return { type: "stalled", value: rebuffering }; -} - -/** - * Construct a "stalled" event. - * @returns {Object} - */ -function unstalled() : IUnstalledEvent { - return { type: "unstalled", value: null }; -} - -/** - * Construct a "decipherabilityUpdate" event. - * @param {Array.} arg - * @returns {Object} - */ -function decipherabilityUpdate( - arg : Array<{ manifest : Manifest; - period : Period; - adaptation : Adaptation; - representation : Representation; }> -) : IDecipherabilityUpdateEvent { - return { type: "decipherabilityUpdate", value: arg }; -} - -/** - * Construct a "manifestReady" event. - * @param {Object} manifest - * @returns {Object} - */ -function manifestReady( - manifest : Manifest -) : IManifestReadyEvent { - return { type: "manifestReady", value: { manifest } }; -} - -/** - * Construct a "manifestUpdate" event. - * @returns {Object} - */ -function manifestUpdate() : IManifestUpdateEvent { - return { type: "manifestUpdate", value: null }; -} - -/** - * Construct a "representationChange" event. - * @param {string} type - * @param {Object} period - * @returns {Object} - */ -function nullRepresentation( - type : IBufferType, - period : Period -) : IRepresentationChangeEvent { - return { type: "representationChange", - value: { type, - representation: null, - period } }; -} - -/** - * construct a "warning" event. - * @param {error} value - * @returns {object} - */ -function warning(value : IPlayerError) : IWarningEvent { - return { type: "warning", value }; -} - -/** - * construct a "reloading-media-source" event. - * @returns {object} - */ -function reloadingMediaSource() : IReloadingMediaSourceEvent { - return { type: "reloading-media-source", value: undefined }; -} - -const INIT_EVENTS = { loaded, - decipherabilityUpdate, - manifestReady, - manifestUpdate, - nullRepresentation, - reloadingMediaSource, - stalled, - unstalled, - warning }; - -export default INIT_EVENTS; diff --git a/src/core/init/index.ts b/src/core/init/index.ts index b2964766d0..33479d4ac7 100644 --- a/src/core/init/index.ts +++ b/src/core/init/index.ts @@ -14,10 +14,10 @@ * limitations under the License. */ -import InitializeOnMediaSource, { +import MediaSourceContentInitializer, { IInitializeArguments, -} from "./initialize_media_source"; +} from "./media_source_content_initializer"; export * from "./types"; -export default InitializeOnMediaSource; +export default MediaSourceContentInitializer; export { IInitializeArguments }; diff --git a/src/core/init/initial_seek_and_play.ts b/src/core/init/initial_seek_and_play.ts deleted file mode 100644 index c0a1db538a..0000000000 --- a/src/core/init/initial_seek_and_play.ts +++ /dev/null @@ -1,208 +0,0 @@ -/** - * 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. - */ - -import { - catchError, - concat as observableConcat, - filter, - map, - mergeMap, - Observable, - of as observableOf, - shareReplay, - startWith, - take, - tap, -} from "rxjs"; -import { - play, - shouldValidateMetadata, - whenLoadedMetadata$, -} from "../../compat"; -import { MediaError } from "../../errors"; -import log from "../../log"; -import { - createSharedReference, - IReadOnlySharedReference, -} from "../../utils/reference"; -import { - IPlaybackObservation, - PlaybackObserver, -} from "../api"; -import EVENTS from "./events_generators"; -import { IWarningEvent } from "./types"; - -/** Event emitted when trying to perform the initial `play`. */ -export type IInitialPlayEvent = - /** Autoplay is not enabled, but all required steps to do so are there. */ - { type: "skipped" } | - /** - * Tried to play, but autoplay is blocked by the browser. - * A corresponding warning should have already been sent. - */ - { type: "autoplay-blocked" } | - /** Autoplay was done with success. */ - { type: "autoplay" } | - /** Warnings preventing the initial play from happening normally. */ - IWarningEvent; - -/** - * Emit once as soon as the playback observation announces that the content can - * begin to be played by calling the `play` method. - * - * This depends on browser-defined criteria (e.g. the readyState status) as well - * as RxPlayer-defined ones (e.g.) not rebuffering. - * - * @param {Observable} observation$ - * @returns {Observable.} - */ -export function waitUntilPlayable( - observation$ : Observable -) : Observable { - return observation$.pipe( - filter(({ seeking, rebuffering, readyState }) => !seeking && - rebuffering === null && - readyState >= 1), - take(1), - map(() => undefined) - ); -} - -/** - * Try to play content then handle autoplay errors. - * @param {HTMLMediaElement} mediaElement - * @returns {Observable} - */ -function autoPlay( - mediaElement: HTMLMediaElement -): Observable<"autoplay"|"autoplay-blocked"> { - return play(mediaElement).pipe( - map(() => "autoplay" as const), - catchError((error : unknown) => { - if (error instanceof Error && error.name === "NotAllowedError") { - // auto-play was probably prevented. - log.warn("Init: Media element can't play." + - " It may be due to browser auto-play policies."); - return observableOf("autoplay-blocked" as const); - } else { - throw error; - } - }) - ); -} - -/** Object returned by `initialSeekAndPlay`. */ -export interface IInitialSeekAndPlayObject { - /** - * Observable which, when subscribed, will try to seek at the initial position - * then play if needed as soon as the HTMLMediaElement's properties are right. - * - * Emits various events relative to the status of this operation. - */ - seekAndPlay$ : Observable; - - /** - * Shared reference whose value becomes `true` once the initial seek has - * been considered / has been done by `seekAndPlay$`. - */ - initialSeekPerformed : IReadOnlySharedReference; - - /** - * Shared reference whose value becomes `true` once the initial play has - * been considered / has been done by `seekAndPlay$`. - */ - initialPlayPerformed: IReadOnlySharedReference; -} - -/** - * Creates an Observable allowing to seek at the initially wanted position and - * to play if autoPlay is wanted. - * @param {Object} args - * @returns {Object} - */ -export default function initialSeekAndPlay( - { mediaElement, - playbackObserver, - startTime, - mustAutoPlay } : { playbackObserver : PlaybackObserver; - mediaElement : HTMLMediaElement; - mustAutoPlay : boolean; - startTime : number|(() => number); } -) : IInitialSeekAndPlayObject { - const initialSeekPerformed = createSharedReference(false); - const initialPlayPerformed = createSharedReference(false); - - const seek$ = whenLoadedMetadata$(mediaElement).pipe( - take(1), - tap(() => { - const initialTime = typeof startTime === "function" ? startTime() : - startTime; - log.info("Init: Set initial time", initialTime); - playbackObserver.setCurrentTime(initialTime); - initialSeekPerformed.setValue(true); - initialSeekPerformed.finish(); - }), - shareReplay({ refCount: true }) - ); - - const seekAndPlay$ = seek$.pipe( - mergeMap(() : Observable => { - if (!shouldValidateMetadata() || mediaElement.duration > 0) { - return waitUntilPlayable(playbackObserver.getReference().asObservable()); - } else { - const error = new MediaError("MEDIA_ERR_NOT_LOADED_METADATA", - "Cannot load automatically: your browser " + - "falsely announced having loaded the content."); - return waitUntilPlayable(playbackObserver.getReference().asObservable()) - .pipe(startWith(EVENTS.warning(error))); - } - }), - - mergeMap((evt) : Observable => { - if (evt !== undefined) { - return observableOf(evt); - } - log.info("Init: Can begin to play content"); - if (!mustAutoPlay) { - if (mediaElement.autoplay) { - log.warn("Init: autoplay is enabled on HTML media element. " + - "Media will play as soon as possible."); - } - initialPlayPerformed.setValue(true); - initialPlayPerformed.finish(); - return observableOf({ type: "skipped" as const }); - } - return autoPlay(mediaElement).pipe(mergeMap((autoplayEvt) => { - initialPlayPerformed.setValue(true); - initialPlayPerformed.finish(); - if (autoplayEvt === "autoplay") { - return observableOf({ type: "autoplay" as const }); - } else { - const error = new MediaError("MEDIA_ERR_BLOCKED_AUTOPLAY", - "Cannot trigger auto-play automatically: " + - "your browser does not allow it."); - return observableConcat( - observableOf(EVENTS.warning(error)), - observableOf({ type: "autoplay-blocked" as const }) - ); - } - })); - }), - shareReplay({ refCount: true }) - ); - - return { seekAndPlay$, initialPlayPerformed, initialSeekPerformed }; -} diff --git a/src/core/init/initialize_directfile.ts b/src/core/init/initialize_directfile.ts deleted file mode 100644 index cfd8da8694..0000000000 --- a/src/core/init/initialize_directfile.ts +++ /dev/null @@ -1,191 +0,0 @@ -/** - * 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 feature-switchable. - * It always should be imported through the `features` object. - */ - -import { - EMPTY, - filter, - ignoreElements, - merge as observableMerge, - mergeMap, - switchMap, - Observable, - of as observableOf, - share, - take, -} from "rxjs"; -import { - clearElementSrc, - setElementSrc$, -} from "../../compat"; -import log from "../../log"; -import { IKeySystemOption } from "../../public_types"; -import deferSubscriptions from "../../utils/defer_subscriptions"; -import { IReadOnlySharedReference } from "../../utils/reference"; -import { PlaybackObserver } from "../api"; -import emitLoadedEvent from "./emit_loaded_event"; -import { IInitialTimeOptions } from "./get_initial_time"; -import initialSeekAndPlay from "./initial_seek_and_play"; -import linkDrmAndContent from "./link_drm_and_content"; -import RebufferingController from "./rebuffering_controller"; -import throwOnMediaError from "./throw_on_media_error"; -import { IDirectfileEvent } from "./types"; - -// NOTE As of now (RxJS 7.4.0), RxJS defines `ignoreElements` default -// first type parameter as `any` instead of the perfectly fine `unknown`, -// leading to linter issues, as it forbids the usage of `any`. -// This is why we're disabling the eslint rule. -/* eslint-disable @typescript-eslint/no-unsafe-argument */ - -/** - * calculate initial time as a position in seconds. - * @param {HTMLMediaElement} mediaElement - * @param {Object|undefined} startAt - * @returns {number} - */ -function getDirectFileInitialTime( - mediaElement : HTMLMediaElement, - startAt? : IInitialTimeOptions -) : number { - if (startAt == null) { - return 0; - } - - if (startAt.position != null) { - return startAt.position; - } else if (startAt.wallClockTime != null) { - return startAt.wallClockTime; - } else if (startAt.fromFirstPosition != null) { - return startAt.fromFirstPosition; - } - - const duration = mediaElement.duration; - if (duration == null || !isFinite(duration)) { - log.warn("startAt.fromLastPosition set but no known duration, " + - "beginning at 0."); - return 0; - } - - if (typeof startAt.fromLastPosition === "number") { - return Math.max(0, duration + startAt.fromLastPosition); - } else if (startAt.percentage != null) { - const { percentage } = startAt; - if (percentage >= 100) { - return duration; - } else if (percentage <= 0) { - return 0; - } - const ratio = +percentage / 100; - return duration * ratio; - } - - return 0; -} - -// Argument used by `initializeDirectfileContent` -export interface IDirectFileOptions { autoPlay : boolean; - keySystems : IKeySystemOption[]; - mediaElement : HTMLMediaElement; - playbackObserver : PlaybackObserver; - speed : IReadOnlySharedReference; - startAt? : IInitialTimeOptions | undefined; - url? : string | undefined; } - -/** - * Launch a content in "Directfile mode". - * @param {Object} directfileOptions - * @returns {Observable} - */ -export default function initializeDirectfileContent({ - autoPlay, - keySystems, - mediaElement, - playbackObserver, - speed, - startAt, - url, -} : IDirectFileOptions) : Observable { - - clearElementSrc(mediaElement); - - if (url == null) { - throw new Error("No URL for a DirectFile content"); - } - - // Start everything! (Just put the URL in the element's src). - const linkURL$ = setElementSrc$(mediaElement, url); - - const initialTime = () => { - log.debug("Init: Calculating initial time"); - const initTime = getDirectFileInitialTime(mediaElement, startAt); - log.debug("Init: Initial time calculated:", initTime); - return initTime; - }; - - const { seekAndPlay$ } = initialSeekAndPlay({ mediaElement, - playbackObserver, - startTime: initialTime, - mustAutoPlay: autoPlay }); - - /** Initialize decryption capabilities and the HTMLMediaElement's src attribute. */ - const drmEvents$ = linkDrmAndContent(mediaElement, - keySystems, - EMPTY, - linkURL$).pipe( - deferSubscriptions(), - share() - ); - - // Translate errors coming from the media element into RxPlayer errors - // through a throwing Observable. - const mediaError$ = throwOnMediaError(mediaElement); - - const observation$ = playbackObserver.getReference().asObservable(); - - /** - * Observable trying to avoid various stalling situations, emitting "stalled" - * events when it cannot, as well as "unstalled" events when it get out of one. - */ - const rebuffer$ = RebufferingController(playbackObserver, null, speed, EMPTY, EMPTY); - - /** - * Emit a "loaded" events once the initial play has been performed and the - * media can begin playback. - * Also emits warning events if issues arise when doing so. - */ - const loadingEvts$ = drmEvents$.pipe( - filter((evt) => evt.type === "decryption-ready" || - evt.type === "decryption-disabled"), - take(1), - mergeMap(() => seekAndPlay$), - switchMap((evt) => { - if (evt.type === "warning") { - return observableOf(evt); - } - return emitLoadedEvent(observation$, mediaElement, null, true); - })); - - return observableMerge(loadingEvts$, - drmEvents$.pipe(ignoreElements()), - mediaError$, - rebuffer$); -} - -export { IDirectfileEvent }; diff --git a/src/core/init/initialize_media_source.ts b/src/core/init/initialize_media_source.ts deleted file mode 100644 index e159f334c8..0000000000 --- a/src/core/init/initialize_media_source.ts +++ /dev/null @@ -1,389 +0,0 @@ -/** - * 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. - */ - -import { - combineLatest as observableCombineLatest, - filter, - finalize, - ignoreElements, - map, - merge as observableMerge, - mergeMap, - Observable, - of as observableOf, - share, - shareReplay, - startWith, - Subject, - switchMap, - take, - takeUntil, -} from "rxjs"; -import { shouldReloadMediaSourceOnDecipherabilityUpdate } from "../../compat"; -import config from "../../config"; -import log from "../../log"; -import { IKeySystemOption } from "../../public_types"; -import { ITransportPipelines } from "../../transports"; -import deferSubscriptions from "../../utils/defer_subscriptions"; -import { fromEvent } from "../../utils/event_emitter"; -import filterMap from "../../utils/filter_map"; -import objectAssign from "../../utils/object_assign"; -import { IReadOnlySharedReference } from "../../utils/reference"; -import TaskCanceller from "../../utils/task_canceller"; -import AdaptiveRepresentationSelector, { - IAdaptiveRepresentationSelectorArguments, -} from "../adaptive"; -import { PlaybackObserver } from "../api"; -import { - getCurrentKeySystem, - IContentProtection, -} from "../decrypt"; -import { - IManifestFetcherParsedResult, - IManifestFetcherWarningEvent, - ManifestFetcher, - SegmentFetcherCreator, -} from "../fetchers"; -import { ITextTrackSegmentBufferOptions } from "../segment_buffers"; -import { IAudioTrackSwitchingMode } from "../stream"; -import openMediaSource from "./create_media_source"; -import EVENTS from "./events_generators"; -import getInitialTime, { - IInitialTimeOptions, -} from "./get_initial_time"; -import linkDrmAndContent, { - IDecryptionDisabledEvent, - IDecryptionReadyEvent, -} from "./link_drm_and_content"; -import createMediaSourceLoader from "./load_on_media_source"; -import manifestUpdateScheduler, { - IManifestRefreshSchedulerEvent, -} from "./manifest_update_scheduler"; -import throwOnMediaError from "./throw_on_media_error"; -import { - IInitEvent, - IMediaSourceLoaderEvent, -} from "./types"; - -// NOTE As of now (RxJS 7.4.0), RxJS defines `ignoreElements` default -// first type parameter as `any` instead of the perfectly fine `unknown`, -// leading to linter issues, as it forbids the usage of `any`. -// This is why we're disabling the eslint rule. -/* eslint-disable @typescript-eslint/no-unsafe-argument */ - -/** Arguments to give to the `InitializeOnMediaSource` function. */ -export interface IInitializeArguments { - /** Options concerning the ABR logic. */ - adaptiveOptions: IAdaptiveRepresentationSelectorArguments; - /** `true` if we should play when loaded. */ - autoPlay : boolean; - /** Options concerning the media buffers. */ - bufferOptions : { - /** Buffer "goal" at which we stop downloading new segments. */ - wantedBufferAhead : IReadOnlySharedReference; - /** Buffer maximum size in kiloBytes at which we stop downloading */ - maxVideoBufferSize : IReadOnlySharedReference; - /** Max buffer size after the current position, in seconds (we GC further up). */ - maxBufferAhead : IReadOnlySharedReference; - /** Max buffer size before the current position, in seconds (we GC further down). */ - maxBufferBehind : IReadOnlySharedReference; - /** Strategy when switching the current bitrate manually (smooth vs reload). */ - manualBitrateSwitchingMode : "seamless" | "direct"; - /** - * Enable/Disable fastSwitching: allow to replace lower-quality segments by - * higher-quality ones to have a faster transition. - */ - enableFastSwitching : boolean; - /** Strategy when switching of audio track. */ - audioTrackSwitchingMode : IAudioTrackSwitchingMode; - /** Behavior when a new video and/or audio codec is encountered. */ - onCodecSwitch : "continue" | "reload"; - }; - /** Regularly emit current playback conditions. */ - playbackObserver : PlaybackObserver; - /** Every encryption configuration set. */ - keySystems : IKeySystemOption[]; - /** `true` to play low-latency contents optimally. */ - lowLatencyMode : boolean; - /** Initial Manifest value. */ - manifest$ : Observable; - /** Interface allowing to load and refresh the Manifest */ - manifestFetcher : ManifestFetcher; -/** The HTMLMediaElement on which we will play. */ - mediaElement : HTMLMediaElement; - /** Limit the frequency of Manifest updates. */ - minimumManifestUpdateInterval : number; - /** Interface allowing to interact with the transport protocol */ - transport : ITransportPipelines; - /** Configuration for the segment requesting logic. */ - segmentRequestOptions : { - /** Maximum number of time a request on error will be retried. */ - regularError : number | undefined; - /** Maximum number of time a request be retried when the user is offline. */ - offlineError : number | undefined; - /** - * Amount of time after which a request should be aborted. - * `undefined` indicates that a default value is wanted. - * `-1` indicates no timeout. - */ - requestTimeout : number | undefined; - }; - /** Emit the playback rate (speed) set by the user. */ - speed : IReadOnlySharedReference; - /** The configured starting position. */ - startAt? : IInitialTimeOptions | undefined; - /** Configuration specific to the text track. */ - textTrackOptions : ITextTrackSegmentBufferOptions; -} - -/** - * Begin content playback. - * - * Returns an Observable emitting notifications about the content lifecycle. - * On subscription, it will perform every necessary tasks so the content can - * play. Among them: - * - * - Creates a MediaSource on the given `mediaElement` and attach to it the - * necessary SourceBuffer instances. - * - * - download the content's Manifest and handle its refresh logic - * - * - Perform decryption if needed - * - * - ask for the choice of the wanted Adaptation through events (e.g. to - * choose a language) - * - * - requests and push the right segments (according to the Adaptation choice, - * the current position, the network conditions etc.) - * - * This Observable will throw in the case where a fatal error (i.e. which has - * stopped content playback) is encountered, with the corresponding error as a - * payload. - * - * This Observable will never complete, it will always run until it is - * unsubscribed from. - * Unsubscription will stop playback and reset the corresponding state. - * - * @param {Object} args - * @returns {Observable} - */ -export default function InitializeOnMediaSource( - { adaptiveOptions, - autoPlay, - bufferOptions, - keySystems, - lowLatencyMode, - manifest$, - manifestFetcher, - mediaElement, - minimumManifestUpdateInterval, - playbackObserver, - segmentRequestOptions, - speed, - startAt, - transport, - textTrackOptions } : IInitializeArguments -) : Observable { - /** Choose the right "Representation" for a given "Adaptation". */ - const representationEstimator = AdaptiveRepresentationSelector(adaptiveOptions); - - const playbackCanceller = new TaskCanceller(); - - /** - * Create and open a new MediaSource object on the given media element on - * subscription. - * Multiple concurrent subscriptions on this Observable will obtain the same - * created MediaSource. - * The MediaSource will be closed when subscriptions are down to 0. - */ - const openMediaSource$ = openMediaSource(mediaElement).pipe( - shareReplay({ refCount: true }) - ); - - /** Send content protection initialization data. */ - const protectedSegments$ = new Subject(); - - /** Initialize decryption capabilities and MediaSource. */ - const drmEvents$ = linkDrmAndContent(mediaElement, - keySystems, - protectedSegments$, - openMediaSource$) - .pipe( - // Because multiple Observables here depend on this Observable as a source, - // we prefer deferring Subscription until those Observables are themselves - // all subscribed to. - // This is needed because `drmEvents$` might send events synchronously - // on subscription. In that case, it might communicate those events directly - // after the first Subscription is done, making the next subscription miss - // out on those events, even if that second subscription is done - // synchronously after the first one. - // By calling `deferSubscriptions`, we ensure that subscription to - // `drmEvents$` effectively starts after a very short delay, thus - // ensuring that no such race condition can occur. - deferSubscriptions(), - share()); - - /** - * Translate errors coming from the media element into RxPlayer errors - * through a throwing Observable. - */ - const mediaError$ = throwOnMediaError(mediaElement); - - const mediaSourceReady$ = drmEvents$.pipe( - filter((evt) : evt is IDecryptionReadyEvent | - IDecryptionDisabledEvent => - evt.type === "decryption-ready" || evt.type === "decryption-disabled"), - map(e => e.value), - take(1)); - - /** Load and play the content asked. */ - const loadContent$ = observableCombineLatest([manifest$, mediaSourceReady$]).pipe( - mergeMap(([manifestEvt, { drmSystemId, mediaSource: initialMediaSource } ]) => { - if (manifestEvt.type === "warning") { - return observableOf(manifestEvt); - } - const { manifest } = manifestEvt; - - log.debug("Init: Calculating initial time"); - const initialTime = getInitialTime(manifest, lowLatencyMode, startAt); - log.debug("Init: Initial time calculated:", initialTime); - - const requestOptions = { lowLatencyMode, - requestTimeout: segmentRequestOptions.requestTimeout, - maxRetryRegular: segmentRequestOptions.regularError, - maxRetryOffline: segmentRequestOptions.offlineError }; - const segmentFetcherCreator = new SegmentFetcherCreator(transport, - requestOptions, - playbackCanceller.signal); - - const mediaSourceLoader = createMediaSourceLoader({ - bufferOptions: objectAssign({ textTrackOptions, drmSystemId }, - bufferOptions), - manifest, - mediaElement, - playbackObserver, - representationEstimator, - segmentFetcherCreator, - speed, - }); - - // handle initial load and reloads - const recursiveLoad$ = recursivelyLoadOnMediaSource(initialMediaSource, - initialTime, - autoPlay); - - // Emit when we want to manually update the manifest. - const scheduleRefresh$ = new Subject(); - - const manifestUpdate$ = manifestUpdateScheduler({ initialManifest: manifestEvt, - manifestFetcher, - minimumManifestUpdateInterval, - scheduleRefresh$ }); - - const manifestEvents$ = observableMerge( - fromEvent(manifest, "manifestUpdate") - .pipe(map(() => EVENTS.manifestUpdate())), - fromEvent(manifest, "decipherabilityUpdate") - .pipe(map(EVENTS.decipherabilityUpdate))); - - return observableMerge(manifestEvents$, - manifestUpdate$, - recursiveLoad$) - .pipe(startWith(EVENTS.manifestReady(manifest)), - finalize(() => { scheduleRefresh$.complete(); })); - - /** - * Load the content defined by the Manifest in the mediaSource given at the - * given position and playing status. - * This function recursively re-call itself when a MediaSource reload is - * wanted. - * @param {MediaSource} mediaSource - * @param {number} startingPos - * @param {boolean} shouldPlay - * @returns {Observable} - */ - function recursivelyLoadOnMediaSource( - mediaSource : MediaSource, - startingPos : number, - shouldPlay : boolean - ) : Observable { - const reloadMediaSource$ = new Subject<{ position : number; - autoPlay : boolean; }>(); - const mediaSourceLoader$ = mediaSourceLoader(mediaSource, startingPos, shouldPlay) - .pipe(filterMap((evt) => { - switch (evt.type) { - case "needs-manifest-refresh": - scheduleRefresh$.next({ completeRefresh: false, - canUseUnsafeMode: true }); - return null; - case "manifest-might-be-out-of-sync": - const { OUT_OF_SYNC_MANIFEST_REFRESH_DELAY } = config.getCurrent(); - scheduleRefresh$.next({ - completeRefresh: true, - canUseUnsafeMode: false, - delay: OUT_OF_SYNC_MANIFEST_REFRESH_DELAY, - }); - return null; - case "needs-media-source-reload": - reloadMediaSource$.next(evt.value); - return null; - case "needs-decipherability-flush": - const keySystem = getCurrentKeySystem(mediaElement); - if (shouldReloadMediaSourceOnDecipherabilityUpdate(keySystem)) { - reloadMediaSource$.next(evt.value); - return null; - } - - // simple seek close to the current position - // to flush the buffers - const { position } = evt.value; - if (position + 0.001 < evt.value.duration) { - playbackObserver.setCurrentTime(mediaElement.currentTime + 0.001); - } else { - playbackObserver.setCurrentTime(position); - } - return null; - case "encryption-data-encountered": - protectedSegments$.next(evt.value); - return null; - case "needs-buffer-flush": - playbackObserver.setCurrentTime(mediaElement.currentTime + 0.001); - return null; - } - return evt; - }, null)); - - const currentLoad$ = - mediaSourceLoader$.pipe(takeUntil(reloadMediaSource$)); - - const handleReloads$ = reloadMediaSource$.pipe( - switchMap((reloadOrder) => { - return openMediaSource(mediaElement).pipe( - mergeMap(newMS => recursivelyLoadOnMediaSource(newMS, - reloadOrder.position, - reloadOrder.autoPlay)), - startWith(EVENTS.reloadingMediaSource()) - ); - })); - - return observableMerge(handleReloads$, currentLoad$); - } - })); - - return observableMerge(loadContent$, mediaError$, drmEvents$.pipe(ignoreElements())) - .pipe(finalize(() => { playbackCanceller.cancel(); })); -} diff --git a/src/core/init/link_drm_and_content.ts b/src/core/init/link_drm_and_content.ts deleted file mode 100644 index 9105d41119..0000000000 --- a/src/core/init/link_drm_and_content.ts +++ /dev/null @@ -1,176 +0,0 @@ -/** - * 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. - */ - -import { - map, - merge as observableMerge, - Observable, - Subscription, -} from "rxjs"; -import { - events, - hasEMEAPIs, -} from "../../compat/"; -import { EncryptedMediaError } from "../../errors"; -import features from "../../features"; -import log from "../../log"; -import { IKeySystemOption } from "../../public_types"; -import { - IContentProtection, - ContentDecryptorState, -} from "../decrypt"; -import { IWarningEvent } from "./types"; - -const { onEncrypted$ } = events; - -/** - * @param {HTMLMediaElement} mediaElement - * @param {Array.} keySystems - * @param {Observable} contentProtections$ - * @param {Promise} linkingMedia$ - * @returns {Observable} - */ -export default function linkDrmAndContent( - mediaElement : HTMLMediaElement, - keySystems : IKeySystemOption[], - contentProtections$ : Observable, - linkingMedia$ : Observable -) : Observable> { - const encryptedEvents$ = observableMerge(onEncrypted$(mediaElement), - contentProtections$); - if (features.ContentDecryptor == null) { - return observableMerge( - encryptedEvents$.pipe(map(() => { - log.error("Init: Encrypted event but EME feature not activated"); - throw new EncryptedMediaError("MEDIA_IS_ENCRYPTED_ERROR", - "EME feature not activated."); - })), - linkingMedia$.pipe(map(mediaSource => ({ - type: "decryption-disabled" as const, - value: { drmSystemId: undefined, mediaSource }, - })))); - } - - if (keySystems.length === 0) { - return observableMerge( - encryptedEvents$.pipe(map(() => { - log.error("Init: Ciphered media and no keySystem passed"); - throw new EncryptedMediaError("MEDIA_IS_ENCRYPTED_ERROR", - "Media is encrypted and no `keySystems` given"); - })), - linkingMedia$.pipe(map(mediaSource => ({ - type: "decryption-disabled" as const, - value: { drmSystemId: undefined, mediaSource }, - })))); - } - - if (!hasEMEAPIs()) { - return observableMerge( - encryptedEvents$.pipe(map(() => { - log.error("Init: Encrypted event but no EME API available"); - throw new EncryptedMediaError("MEDIA_IS_ENCRYPTED_ERROR", - "Encryption APIs not found."); - })), - linkingMedia$.pipe(map(mediaSource => ({ - type: "decryption-disabled" as const, - value: { drmSystemId: undefined, mediaSource }, - })))); - } - - log.debug("Init: Creating ContentDecryptor"); - const ContentDecryptor = features.ContentDecryptor; - return new Observable((obs) => { - const contentDecryptor = new ContentDecryptor(mediaElement, keySystems); - - let mediaSub : Subscription | undefined; - contentDecryptor.addEventListener("stateChange", (state) => { - if (state === ContentDecryptorState.WaitingForAttachment) { - contentDecryptor.removeEventListener("stateChange"); - - mediaSub = linkingMedia$.subscribe(mediaSource => { - contentDecryptor.addEventListener("stateChange", (newState) => { - if (newState === ContentDecryptorState.ReadyForContent) { - obs.next({ type: "decryption-ready", - value: { drmSystemId: contentDecryptor.systemId, - mediaSource } }); - contentDecryptor.removeEventListener("stateChange"); - } - }); - - contentDecryptor.attach(); - }); - } - }); - - contentDecryptor.addEventListener("error", (e) => { - obs.error(e); - }); - - contentDecryptor.addEventListener("warning", (w) => { - obs.next({ type: "warning", value: w }); - }); - - const protectionDataSub = contentProtections$.subscribe(data => { - contentDecryptor.onInitializationData(data); - }); - - return () => { - protectionDataSub.unsubscribe(); - mediaSub?.unsubscribe(); - contentDecryptor.dispose(); - }; - }); -} - -export type IContentDecryptorInitEvent = IDecryptionDisabledEvent | - IDecryptionReadyEvent | - IWarningEvent; - -/** - * Event emitted after deciding that no decryption logic will be launched for - * the current content. - */ -export interface IDecryptionDisabledEvent { - type: "decryption-disabled"; - value: { - /** - * Identify the current DRM's system ID. - * Here `undefined` as no decryption capability has been added. - */ - drmSystemId: undefined; - /** The value outputed by the `linkingMedia$` Observable. */ - mediaSource: T; - }; -} - -/** - * Event emitted when decryption capabilities have started and content can - * begin to be pushed on the HTMLMediaElement. - */ -export interface IDecryptionReadyEvent { - type: "decryption-ready"; - value: { - /** - * Identify the current DRM's systemId as an hexadecimal string, so the - * RxPlayer may be able to (optionally) only send the corresponding - * encryption initialization data. - * `undefined` if unknown. - */ - drmSystemId: string | undefined; - /** The value outputed by the `linkingMedia$` Observable. */ - mediaSource: T; - }; -} diff --git a/src/core/init/load_on_media_source.ts b/src/core/init/load_on_media_source.ts deleted file mode 100644 index 8f852bf474..0000000000 --- a/src/core/init/load_on_media_source.ts +++ /dev/null @@ -1,237 +0,0 @@ -/** - * 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. - */ - -import { - EMPTY, - filter, - finalize, - ignoreElements, - merge as observableMerge, - mergeMap, - Observable, - of as observableOf, - Subject, - switchMap, - takeUntil, - throwError, -} from "rxjs"; -import { MediaError } from "../../errors"; -import log from "../../log"; -import Manifest from "../../manifest"; -import createSharedReference, { IReadOnlySharedReference } from "../../utils/reference"; -import { IRepresentationEstimator } from "../adaptive"; -import { PlaybackObserver } from "../api"; -import { SegmentFetcherCreator } from "../fetchers"; -import SegmentBuffersStore from "../segment_buffers"; -import StreamOrchestrator, { - IAdaptationChangeEvent, - IStreamOrchestratorOptions, -} from "../stream"; -import ContentTimeBoundariesObserver from "./content_time_boundaries_observer"; -import createStreamPlaybackObserver from "./create_stream_playback_observer"; -import emitLoadedEvent from "./emit_loaded_event"; -import { maintainEndOfStream } from "./end_of_stream"; -import initialSeekAndPlay from "./initial_seek_and_play"; -import MediaDurationUpdater from "./media_duration_updater"; -import RebufferingController, { - IDiscontinuityEvent, - ILockedStreamEvent, -} from "./rebuffering_controller"; -import streamEventsEmitter from "./stream_events_emitter"; -import { IMediaSourceLoaderEvent } from "./types"; - -// NOTE As of now (RxJS 7.4.0), RxJS defines `ignoreElements` default -// first type parameter as `any` instead of the perfectly fine `unknown`, -// leading to linter issues, as it forbids the usage of `any`. -// This is why we're disabling the eslint rule. -/* eslint-disable @typescript-eslint/no-unsafe-argument */ - -/** Arguments needed by `createMediaSourceLoader`. */ -export interface IMediaSourceLoaderArguments { - /** Various stream-related options. */ - bufferOptions : IStreamOrchestratorOptions; - /* Manifest of the content we want to play. */ - manifest : Manifest; - /** Media Element on which the content will be played. */ - mediaElement : HTMLMediaElement; - /** Emit playback conditions regularly. */ - playbackObserver : PlaybackObserver; - /** Estimate the right Representation. */ - representationEstimator : IRepresentationEstimator; - /** Module to facilitate segment fetching. */ - segmentFetcherCreator : SegmentFetcherCreator; - /** Last wanted playback rate. */ - speed : IReadOnlySharedReference; -} - -/** - * Returns a function allowing to load or reload the content in arguments into - * a single or multiple MediaSources. - * @param {Object} args - * @returns {Function} - */ -export default function createMediaSourceLoader( - { mediaElement, - manifest, - speed, - bufferOptions, - representationEstimator, - playbackObserver, - segmentFetcherCreator } : IMediaSourceLoaderArguments -) : (mediaSource : MediaSource, initialTime : number, autoPlay : boolean) => - Observable { - /** - * Load the content on the given MediaSource. - * @param {MediaSource} mediaSource - * @param {number} initialTime - * @param {boolean} autoPlay - */ - return function loadContentOnMediaSource( - mediaSource : MediaSource, - initialTime : number, - autoPlay : boolean - ) : Observable { - /** Maintains the MediaSource's duration up-to-date with the Manifest */ - const mediaDurationUpdater = new MediaDurationUpdater(manifest, mediaSource); - - const initialPeriod = manifest.getPeriodForTime(initialTime) ?? - manifest.getNextPeriod(initialTime); - if (initialPeriod === undefined) { - const error = new MediaError("MEDIA_STARTING_TIME_NOT_FOUND", - "Wanted starting time not found in the Manifest."); - return throwError(() => error); - } - - /** Interface to create media buffers. */ - const segmentBuffersStore = new SegmentBuffersStore(mediaElement, mediaSource); - - const { seekAndPlay$, - initialPlayPerformed, - initialSeekPerformed } = initialSeekAndPlay({ mediaElement, - playbackObserver, - startTime: initialTime, - mustAutoPlay: autoPlay }); - - const observation$ = playbackObserver.getReference().asObservable(); - const streamEvents$ = initialPlayPerformed.asObservable().pipe( - filter((hasPlayed) => hasPlayed), - mergeMap(() => streamEventsEmitter(manifest, mediaElement, observation$))); - - const streamObserver = createStreamPlaybackObserver(manifest, - playbackObserver, - { autoPlay, - initialPlayPerformed, - initialSeekPerformed, - speed, - startTime: initialTime }); - - /** Cancel endOfStream calls when streams become active again. */ - const cancelEndOfStream$ = new Subject(); - - /** Emits discontinuities detected by the StreamOrchestrator. */ - const discontinuityUpdate$ = new Subject(); - - /** Emits event when streams are "locked", meaning they cannot load segments. */ - const lockedStream$ = new Subject(); - - /** Emit each time a new Adaptation is considered by the `StreamOrchestrator`. */ - const lastAdaptationChange = createSharedReference< - IAdaptationChangeEvent | null - >(null); - - // Creates Observable which will manage every Stream for the given Content. - const streams$ = StreamOrchestrator({ manifest, initialPeriod }, - streamObserver, - representationEstimator, - segmentBuffersStore, - segmentFetcherCreator, - bufferOptions - ).pipe( - mergeMap((evt) => { - switch (evt.type) { - case "end-of-stream": - log.debug("Init: end-of-stream order received."); - return maintainEndOfStream(mediaSource).pipe( - ignoreElements(), - takeUntil(cancelEndOfStream$)); - case "resume-stream": - log.debug("Init: resume-stream order received."); - cancelEndOfStream$.next(null); - return EMPTY; - case "stream-status": - const { period, bufferType, imminentDiscontinuity, position } = evt.value; - discontinuityUpdate$.next({ period, - bufferType, - discontinuity: imminentDiscontinuity, - position }); - return EMPTY; - case "locked-stream": - lockedStream$.next(evt.value); - return EMPTY; - case "adaptationChange": - lastAdaptationChange.setValue(evt); - return observableOf(evt); - default: - return observableOf(evt); - } - }) - ); - - const contentTimeObserver = ContentTimeBoundariesObserver(manifest, - lastAdaptationChange, - streamObserver) - .pipe( - mergeMap((evt) => { - if (evt.type === "contentDurationUpdate") { - log.debug("Init: Duration has to be updated.", evt.value); - mediaDurationUpdater.updateKnownDuration(evt.value); - return EMPTY; - } - return observableOf(evt); - })); - - /** - * Observable trying to avoid various stalling situations, emitting "stalled" - * events when it cannot, as well as "unstalled" events when it get out of one. - */ - const rebuffer$ = RebufferingController(playbackObserver, - manifest, - speed, - lockedStream$, - discontinuityUpdate$); - - /** - * Emit a "loaded" events once the initial play has been performed and the - * media can begin playback. - * Also emits warning events if issues arise when doing so. - */ - const loadingEvts$ = seekAndPlay$.pipe(switchMap((evt) => - evt.type === "warning" ? - observableOf(evt) : - emitLoadedEvent(observation$, mediaElement, segmentBuffersStore, false))); - - return observableMerge(loadingEvts$, - rebuffer$, - streams$, - contentTimeObserver, - streamEvents$ - ).pipe(finalize(() => { - mediaDurationUpdater.stop(); - // clean-up every created SegmentBuffers - segmentBuffersStore.disposeAll(); - })); - }; -} diff --git a/src/core/init/manifest_update_scheduler.ts b/src/core/init/manifest_update_scheduler.ts deleted file mode 100644 index 04b091617c..0000000000 --- a/src/core/init/manifest_update_scheduler.ts +++ /dev/null @@ -1,362 +0,0 @@ -/** - * 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. - */ - -import { - defer as observableDefer, - EMPTY, - from as observableFrom, - map, - merge as observableMerge, - mergeMap, - Observable, - of as observableOf, - share, - take, - timer as observableTimer, -} from "rxjs"; -import config from "../../config"; -import log from "../../log"; -import Manifest from "../../manifest"; -import throttle from "../../utils/rx-throttle"; -import { - IManifestFetcherParsedResult, - IManifestFetcherParserOptions, - ManifestFetcher, -} from "../fetchers"; -import { IWarningEvent } from "./types"; - - -/** Arguments to give to the `manifestUpdateScheduler` */ -export interface IManifestUpdateSchedulerArguments { - /** Interface allowing to refresh the Manifest */ - manifestFetcher : ManifestFetcher; - /** Information about the initial load of the manifest */ - initialManifest : { manifest : Manifest; - sendingTime? : number | undefined; - receivedTime? : number | undefined; - parsingTime? : number | undefined; }; - /** Minimum interval to keep between Manifest updates */ - minimumManifestUpdateInterval : number; - /** Allows the rest of the code to ask for a Manifest refresh */ - scheduleRefresh$ : IManifestRefreshScheduler; -} - -/** Function defined to refresh the Manifest */ -export type IManifestFetcher = - (manifestURL : string | undefined, options : IManifestFetcherParserOptions) => - Observable; - -/** Events sent by the `IManifestRefreshScheduler` Observable */ -export interface IManifestRefreshSchedulerEvent { - /** - * if `true`, the Manifest should be fully updated. - * if `false`, a shorter version with just the added information can be loaded - * instead. - */ - completeRefresh : boolean; - /** - * Optional wanted refresh delay, which is the minimum time you want to wait - * before updating the Manifest - */ - delay? : number | undefined; - /** - * Whether the parsing can be done in the more efficient "unsafeMode". - * This mode is extremely fast but can lead to de-synchronisation with the - * server. - */ - canUseUnsafeMode : boolean; -} - -/** Observable to send events related to refresh requests coming from the Player. */ -export type IManifestRefreshScheduler = Observable; - -/** - * Refresh the Manifest at the right time. - * @param {Object} manifestUpdateSchedulerArguments - * @returns {Observable} - */ -export default function manifestUpdateScheduler({ - initialManifest, - manifestFetcher, - minimumManifestUpdateInterval, - scheduleRefresh$, -} : IManifestUpdateSchedulerArguments) : Observable { - /** - * Fetch and parse the manifest from the URL given. - * Throttled to avoid doing multiple simultaneous requests. - */ - const fetchManifest = throttle( - (manifestURL : string | undefined, options : IManifestFetcherParserOptions) - : Observable => - manifestFetcher.fetch(manifestURL).pipe( - mergeMap((response) => response.type === "warning" ? - observableOf(response) : // bubble-up warnings - response.parse(options)), - share())); - - // The Manifest always keeps the same reference - const { manifest } = initialManifest; - - /** Number of consecutive times the parsing has been done in `unsafeMode`. */ - let consecutiveUnsafeMode = 0; - - return observableDefer(() => handleManifestRefresh$(initialManifest)); - - /** - * Performs Manifest refresh (recursively) when it judges it is time to do so. - * @param {Object} manifestRequestInfos - Various information linked to the - * Manifest loading and parsing operations. - * @returns {Observable} - Observable which will automatically refresh the - * Manifest on subscription. Can also emit warnings when minor errors are - * encountered. - */ - function handleManifestRefresh$( - { sendingTime, parsingTime, updatingTime } : { sendingTime?: number | undefined; - parsingTime? : number | undefined; - updatingTime? : number | undefined; } - ) : Observable { - /** - * Total time taken to fully update the last Manifest, in milliseconds. - * Note: this time also includes possible requests done by the parsers. - */ - const totalUpdateTime = parsingTime !== undefined ? - parsingTime + (updatingTime ?? 0) : - undefined; - - const { MAX_CONSECUTIVE_MANIFEST_PARSING_IN_UNSAFE_MODE, - MIN_MANIFEST_PARSING_TIME_TO_ENTER_UNSAFE_MODE } = config.getCurrent(); - - /** - * "unsafeMode" is a mode where we unlock advanced Manifest parsing - * optimizations with the added risk to lose some information. - * `unsafeModeEnabled` is set to `true` when the `unsafeMode` is enabled. - * - * Only perform parsing in `unsafeMode` when the last full parsing took a - * lot of time and do not go higher than the maximum consecutive time. - */ - - const unsafeModeEnabled = consecutiveUnsafeMode > 0 ? - consecutiveUnsafeMode < MAX_CONSECUTIVE_MANIFEST_PARSING_IN_UNSAFE_MODE : - totalUpdateTime !== undefined ? - (totalUpdateTime >= MIN_MANIFEST_PARSING_TIME_TO_ENTER_UNSAFE_MODE) : - false; - - /** Time elapsed since the beginning of the Manifest request, in milliseconds. */ - const timeSinceRequest = sendingTime === undefined ? 0 : - performance.now() - sendingTime; - - /** Minimum update delay we should not go below, in milliseconds. */ - const minInterval = Math.max(minimumManifestUpdateInterval - timeSinceRequest, 0); - - /** Emit when the RxPlayer determined that a refresh should be done. */ - const internalRefresh$ = scheduleRefresh$ - .pipe(mergeMap(({ completeRefresh, delay, canUseUnsafeMode }) => { - const unsafeMode = canUseUnsafeMode && unsafeModeEnabled; - return startManualRefreshTimer(delay ?? 0, - minimumManifestUpdateInterval, - sendingTime) - .pipe(map(() => ({ completeRefresh, unsafeMode }))); - })); - - /** Emit when the Manifest tells us that it has "expired". */ - const expired$ = manifest.expired === null ? - EMPTY : - observableTimer(minInterval).pipe( - mergeMap(() => - manifest.expired === null ? EMPTY : - observableFrom(manifest.expired)), - map(() => ({ completeRefresh: true, unsafeMode: unsafeModeEnabled }))); - - /** Emit when the Manifest should normally be refreshed. */ - const autoRefresh$ = createAutoRefreshObservable(); - - return observableMerge(autoRefresh$, internalRefresh$, expired$).pipe( - take(1), - mergeMap(({ completeRefresh, - unsafeMode }) => refreshManifest({ completeRefresh, - unsafeMode })), - mergeMap(evt => { - if (evt.type === "warning") { - return observableOf(evt); - } - return handleManifestRefresh$(evt); - })); - - /** - * Create an Observable that will emit when the Manifest needs to be - * refreshed according to the Manifest's internal properties (parsing - * time is also taken into account in this operation to avoid refreshing too - * often). - * @returns {Observable} - */ - function createAutoRefreshObservable() : Observable<{ - completeRefresh: boolean; - unsafeMode: boolean; - }> { - if (manifest.lifetime === undefined || manifest.lifetime < 0) { - return EMPTY; - } - - /** Regular refresh delay as asked by the Manifest. */ - const regularRefreshDelay = manifest.lifetime * 1000 - timeSinceRequest; - - /** Actually choosen delay to refresh the Manifest. */ - let actualRefreshInterval : number; - - if (totalUpdateTime === undefined) { - actualRefreshInterval = regularRefreshDelay; - } else if (manifest.lifetime < 3 && totalUpdateTime >= 100) { - // If Manifest update is very frequent and we take time to update it, - // postpone it. - actualRefreshInterval = Math.min( - Math.max( - // Take 3 seconds as a default safe value for a base interval. - 3000 - timeSinceRequest, - // Add update time to the original interval. - Math.max(regularRefreshDelay, 0) + totalUpdateTime - ), - - // Limit the postponment's higher bound to a very high value relative - // to `regularRefreshDelay`. - // This avoid perpetually postponing a Manifest update when - // performance seems to have been abysmal one time. - regularRefreshDelay * 6 - ); - log.info("MUS: Manifest update rythm is too frequent. Postponing next request.", - regularRefreshDelay, - actualRefreshInterval); - } else if (totalUpdateTime >= (manifest.lifetime * 1000) / 10) { - // If Manifest updating time is very long relative to its lifetime, - // postpone it: - actualRefreshInterval = Math.min( - // Just add the update time to the original waiting time - Math.max(regularRefreshDelay, 0) + totalUpdateTime, - - // Limit the postponment's higher bound to a very high value relative - // to `regularRefreshDelay`. - // This avoid perpetually postponing a Manifest update when - // performance seems to have been abysmal one time. - regularRefreshDelay * 6); - log.info("MUS: Manifest took too long to parse. Postponing next request", - actualRefreshInterval, - actualRefreshInterval); - } else { - actualRefreshInterval = regularRefreshDelay; - } - return observableTimer(Math.max(actualRefreshInterval, minInterval)) - .pipe(map(() => ({ completeRefresh: false, unsafeMode: unsafeModeEnabled }))); - } - } - - /** - * Refresh the Manifest. - * Perform a full update if a partial update failed. - * @param {boolean} completeRefresh - * @returns {Observable} - */ - function refreshManifest( - { completeRefresh, - unsafeMode } : { completeRefresh : boolean; - unsafeMode : boolean; } - ) : Observable { - const manifestUpdateUrl = manifest.updateUrl; - const fullRefresh = completeRefresh || manifestUpdateUrl === undefined; - const refreshURL = fullRefresh ? manifest.getUrl() : - manifestUpdateUrl; - const externalClockOffset = manifest.clockOffset; - - if (unsafeMode) { - consecutiveUnsafeMode += 1; - log.info("Init: Refreshing the Manifest in \"unsafeMode\" for the " + - String(consecutiveUnsafeMode) + " consecutive time."); - } else if (consecutiveUnsafeMode > 0) { - log.info("Init: Not parsing the Manifest in \"unsafeMode\" anymore after " + - String(consecutiveUnsafeMode) + " consecutive times."); - consecutiveUnsafeMode = 0; - } - return fetchManifest(refreshURL, { externalClockOffset, - previousManifest: manifest, - unsafeMode }) - .pipe(mergeMap((value) => { - if (value.type === "warning") { - return observableOf(value); - } - const { manifest: newManifest, - sendingTime: newSendingTime, - receivedTime, - parsingTime } = value; - const updateTimeStart = performance.now(); - - if (fullRefresh) { - manifest.replace(newManifest); - } else { - try { - manifest.update(newManifest); - } catch (e) { - const message = e instanceof Error ? e.message : - "unknown error"; - log.warn(`MUS: Attempt to update Manifest failed: ${message}`, - "Re-downloading the Manifest fully"); - const { FAILED_PARTIAL_UPDATE_MANIFEST_REFRESH_DELAY } = config.getCurrent(); - return startManualRefreshTimer(FAILED_PARTIAL_UPDATE_MANIFEST_REFRESH_DELAY, - minimumManifestUpdateInterval, - newSendingTime) - .pipe(mergeMap(() => - refreshManifest({ completeRefresh: true, unsafeMode: false }))); - } - } - return observableOf({ type: "parsed" as const, - manifest, - sendingTime: newSendingTime, - receivedTime, - parsingTime, - updatingTime: performance.now() - updateTimeStart }); - })); - } -} - -/** - * Launch a timer Observable which will emit when it is time to refresh the - * Manifest. - * The timer's delay is calculated from: - * - a target delay (`wantedDelay`), which is the minimum time we want to wait - * in the best scenario - * - the minimum set possible interval between manifest updates - * (`minimumManifestUpdateInterval`) - * - the time at which was done the last Manifest refresh - * (`lastManifestRequestTime`) - * @param {number} wantedDelay - * @param {number} minimumManifestUpdateInterval - * @param {number|undefined} lastManifestRequestTime - * @returns {Observable} - */ -function startManualRefreshTimer( - wantedDelay : number, - minimumManifestUpdateInterval : number, - lastManifestRequestTime : number | undefined -) : Observable { - return observableDefer(() => { - // The value allows to set a delay relatively to the last Manifest refresh - // (to avoid asking for it too often). - const timeSinceLastRefresh = lastManifestRequestTime === undefined ? - 0 : - performance.now() - lastManifestRequestTime; - const _minInterval = Math.max(minimumManifestUpdateInterval - timeSinceLastRefresh, - 0); - return observableTimer(Math.max(wantedDelay - timeSinceLastRefresh, - _minInterval)); - }); -} diff --git a/src/core/init/media_source_content_initializer.ts b/src/core/init/media_source_content_initializer.ts new file mode 100644 index 0000000000..0fe8d8b922 --- /dev/null +++ b/src/core/init/media_source_content_initializer.ts @@ -0,0 +1,716 @@ +/** + * 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. + */ + +import { shouldReloadMediaSourceOnDecipherabilityUpdate } from "../../compat"; +import config from "../../config"; +import { MediaError } from "../../errors"; +import log from "../../log"; +import Manifest from "../../manifest"; +import { + IKeySystemOption, + ILoadedManifestFormat, + IPlayerError, +} from "../../public_types"; +import { + IManifestParserResult, + ITransportPipelines, +} from "../../transports"; +import assert from "../../utils/assert"; +import assertUnreachable from "../../utils/assert_unreachable"; +import objectAssign from "../../utils/object_assign"; +import createSharedReference, { + IReadOnlySharedReference, + ISharedReference, +} from "../../utils/reference"; +import TaskCanceller, { + CancellationError, CancellationSignal, +} from "../../utils/task_canceller"; +import AdaptiveRepresentationSelector, { + IAdaptiveRepresentationSelectorArguments, + IRepresentationEstimator, +} from "../adaptive"; +import { PlaybackObserver } from "../api"; +import { + getCurrentKeySystem, + IContentProtection, +} from "../decrypt"; +import { + ManifestFetcher, + SegmentFetcherCreator, +} from "../fetchers"; +import { IManifestFetcherSettings } from "../fetchers/manifest/manifest_fetcher"; +import SegmentBuffersStore, { + ITextTrackSegmentBufferOptions, +} from "../segment_buffers"; +import StreamOrchestrator, { + IAdaptationChangeEvent, + IAudioTrackSwitchingMode, + IStreamOrchestratorOptions, +} from "../stream"; +import { ContentInitializer } from "./types"; +import ContentTimeBoundariesObserver from "./utils/content_time_boundaries_observer"; +import openMediaSource from "./utils/create_media_source"; +import createStreamPlaybackObserver from "./utils/create_stream_playback_observer"; +import { maintainEndOfStream } from "./utils/end_of_stream"; +import getInitialTime, { + IInitialTimeOptions, +} from "./utils/get_initial_time"; +import getLoadedReference from "./utils/get_loaded_reference"; +import performInitialSeekAndPlay from "./utils/initial_seek_and_play"; +import initializeContentDecryption from "./utils/initialize_content_decryption"; +import manifestUpdateScheduler, { + IManifestUpdateScheduler, +} from "./utils/manifest_update_scheduler"; +import MediaDurationUpdater from "./utils/media_duration_updater"; +import RebufferingController from "./utils/rebuffering_controller"; +import streamEventsEmitter from "./utils/stream_events_emitter"; +import listenToMediaError from "./utils/throw_on_media_error"; + +/** + * Allows to load a new content thanks to the MediaSource Extensions (a.k.a. MSE) + * Web APIs. + * + * Through this `ContentInitializer`, a Manifest will be fetched (and depending + * on the situation, refreshed), a `MediaSource` instance will be linked to the + * wanted `HTMLMediaElement` and chunks of media data, called segments, will be + * pushed on buffers associated to this `MediaSource` instance. + * + * @class MediaSourceContentInitializer + */ +export default class MediaSourceContentInitializer extends ContentInitializer { + /** Constructor settings associated to this `MediaSourceContentInitializer`. */ + private _settings : IInitializeArguments; + /** + * `TaskCanceller` allowing to abort everything that the + * `MediaSourceContentInitializer` is doing. + */ + private _initCanceller : TaskCanceller; + /** Interface allowing to fetch and refresh the Manifest. */ + private _manifestFetcher : ManifestFetcher; + /** + * Promise resolving with the Manifest once it has been initially loaded. + * `null` if the load task has not started yet. + */ + private _initialManifestProm : Promise | null; + + /** + * Create a new `MediaSourceContentInitializer`, associated to the given + * settings. + * @param {Object} settings + */ + constructor(settings : IInitializeArguments) { + super(); + this._settings = settings; + this._initCanceller = new TaskCanceller(); + this._initialManifestProm = null; + this._manifestFetcher = new ManifestFetcher(settings.url, + settings.transport, + settings.manifestRequestSettings); + } + + /** + * Perform non-destructive preparation steps, to prepare a future content. + * For now, this mainly mean loading the Manifest document. + */ + public prepare(): void { + if (this._initialManifestProm !== null) { + return; + } + const initialManifest = this._settings.initialManifest; + this._settings.initialManifest = undefined; // Reset to free resources + if (initialManifest instanceof Manifest) { + this._initialManifestProm = Promise.resolve({ manifest: initialManifest }); + } else if (initialManifest !== undefined) { + this._initialManifestProm = this._manifestFetcher + .parse(initialManifest, + { previousManifest: null, + unsafeMode: false }, + (err : IPlayerError) => + this.trigger("warning", err), + this._initCanceller.signal); + } else { + this._initialManifestProm = this._manifestFetcher.fetch(undefined, (err) => { + this.trigger("warning", err); + }, this._initCanceller.signal) + .then((res) => res.parse({ previousManifest: null, + unsafeMode: false })); + } + } + + public start( + mediaElement : HTMLMediaElement, + playbackObserver : PlaybackObserver + ): void { + this.prepare(); // Load Manifest if not already done + + /** Translate errors coming from the media element into RxPlayer errors. */ + listenToMediaError(mediaElement, + (error : MediaError) => this._onFatalError(error), + this._initCanceller.signal); + + /** Send content protection initialization data to the decryption logic. */ + const protectionRef = createSharedReference(null); + + this._initializeMediaSourceAndDecryption(mediaElement, protectionRef) + .then(initResult => this._onInitialMediaSourceReady(mediaElement, + initResult.mediaSource, + playbackObserver, + initResult.drmSystemId, + protectionRef, + initResult.unlinkMediaSource)) + .catch((err) => { + if (err instanceof CancellationError) { + return; + } + this._onFatalError(err); + }); + } + + public dispose(): void { + this._initCanceller.cancel(); + } + + private _onFatalError(err : unknown) { + this._initCanceller.cancel(); + this.trigger("error", err); + } + + private _initializeMediaSourceAndDecryption( + mediaElement : HTMLMediaElement, + protectionRef : IReadOnlySharedReference + ) : Promise<{ mediaSource : MediaSource; + drmSystemId : string | undefined; + unlinkMediaSource : TaskCanceller; }> + { + return new Promise((resolve, reject) => { + const { keySystems } = this._settings; + const initCanceller = this._initCanceller; + + const unregisterReject = initCanceller.signal.register((err) => { + reject(err); + }); + + /** Initialize decryption capabilities. */ + const drmInitRef = + initializeContentDecryption(mediaElement, keySystems, protectionRef, { + onWarning: (err : IPlayerError) => this.trigger("warning", err), + onError: (err : Error) => this._onFatalError(err), + }, initCanceller.signal); + + drmInitRef.onUpdate((drmStatus, stopListeningToDrmUpdates) => { + if (drmStatus.initializationState.type === "uninitialized") { + return; + } + stopListeningToDrmUpdates(); + + const mediaSourceCanceller = new TaskCanceller({ + cancelOn: initCanceller.signal, + }); + openMediaSource(mediaElement, mediaSourceCanceller.signal) + .then((mediaSource) => { + const lastDrmStatus = drmInitRef.getValue(); + if (lastDrmStatus.initializationState.type === "awaiting-media-link") { + lastDrmStatus.initializationState.value.isMediaLinked.setValue(true); + drmInitRef.onUpdate((newDrmStatus, stopListeningToDrmUpdatesAgain) => { + if (newDrmStatus.initializationState.type === "initialized") { + stopListeningToDrmUpdatesAgain(); + unregisterReject(); + resolve({ mediaSource, + drmSystemId: newDrmStatus.drmSystemId, + unlinkMediaSource: mediaSourceCanceller }); + return; + } + }, { emitCurrentValue: true, clearSignal: initCanceller.signal }); + } else if (drmStatus.initializationState.type === "initialized") { + unregisterReject(); + resolve({ mediaSource, + drmSystemId: drmStatus.drmSystemId, + unlinkMediaSource: mediaSourceCanceller }); + return; + } + }) + .catch((err) => { + if (mediaSourceCanceller.isUsed) { + return; + } + this._onFatalError(err); + }); + }, { emitCurrentValue: true, clearSignal: initCanceller.signal }); + }); + } + + private async _onInitialMediaSourceReady( + mediaElement : HTMLMediaElement, + initialMediaSource : MediaSource, + playbackObserver : PlaybackObserver, + drmSystemId : string | undefined, + protectionRef : ISharedReference, + initialMediaSourceCanceller : TaskCanceller + ) : Promise { + const { adaptiveOptions, + autoPlay, + bufferOptions, + lowLatencyMode, + minimumManifestUpdateInterval, + segmentRequestOptions, + speed, + startAt, + textTrackOptions, + transport } = this._settings; + const initCanceller = this._initCanceller; + assert(this._initialManifestProm !== null); + const manifestProm = this._initialManifestProm; + const manifestResponse = await manifestProm; + const { manifest } = manifestResponse; + + manifest.addEventListener("manifestUpdate", () => { + this.trigger("manifestUpdate", null); + }, initCanceller.signal); + manifest.addEventListener("decipherabilityUpdate", (args) => { + this.trigger("decipherabilityUpdate", args); + }, initCanceller.signal); + + log.debug("Init: Calculating initial time"); + const initialTime = getInitialTime(manifest, lowLatencyMode, startAt); + log.debug("Init: Initial time calculated:", initialTime); + + /** Choose the right "Representation" for a given "Adaptation". */ + const representationEstimator = AdaptiveRepresentationSelector(adaptiveOptions); + const subBufferOptions = objectAssign({ textTrackOptions, drmSystemId }, + bufferOptions); + const manifestUpdater = manifestUpdateScheduler(manifestResponse, + this._manifestFetcher, + minimumManifestUpdateInterval, + (err : IPlayerError) => + this.trigger("warning", err), + (err : unknown) => { + initCanceller.cancel(); + this.trigger("error", err); + }); + initCanceller.signal.register(() => { + manifestUpdater.stop(); + }); + + const segmentFetcherCreator = new SegmentFetcherCreator(transport, + segmentRequestOptions, + initCanceller.signal); + + this.trigger("manifestReady", manifest); + if (initCanceller.isUsed) { + return undefined; + } + + const bufferOnMediaSource = this._startBufferingOnMediaSource.bind(this); + const triggerEvent = this.trigger.bind(this); + const onFatalError = this._onFatalError.bind(this); + + // handle initial load and reloads + recursivelyLoadOnMediaSource(initialMediaSource, + initialTime, + autoPlay, + initialMediaSourceCanceller); + + /** + * Load the content defined by the Manifest in the mediaSource given at the + * given position and playing status. + * This function recursively re-call itself when a MediaSource reload is + * wanted. + * @param {MediaSource} mediaSource + * @param {number} startingPos + * @param {Object} currentCanceller + * @param {boolean} shouldPlay + */ + function recursivelyLoadOnMediaSource( + mediaSource : MediaSource, + startingPos : number, + shouldPlay : boolean, + currentCanceller : TaskCanceller + ) : void { + const opts = { mediaElement, + playbackObserver, + mediaSource, + manifestUpdater, + initialTime: startingPos, + autoPlay: shouldPlay, + manifest, + representationEstimator, + segmentFetcherCreator, + speed, + protectionRef, + bufferOptions: subBufferOptions }; + bufferOnMediaSource(opts, onReloadMediaSource, currentCanceller.signal); + + function onReloadMediaSource( + reloadOrder : { position : number; + autoPlay : boolean; } + ) : void { + currentCanceller.cancel(); + triggerEvent("reloadingMediaSource", null); + if (initCanceller.isUsed) { + return; + } + + const newCanceller = new TaskCanceller({ cancelOn: initCanceller.signal }); + openMediaSource(mediaElement, newCanceller.signal) + .then(newMediaSource => { + recursivelyLoadOnMediaSource(newMediaSource, + reloadOrder.position, + reloadOrder.autoPlay, + newCanceller); + }) + .catch((err) => { + if (newCanceller.isUsed) { + return; + } + onFatalError(err); + }); + } + } + } + + /** + * Buffer the content on the given MediaSource. + * @param {Object} args + * @param {function} onReloadOrder + * @param {Object} cancelSignal + */ + private _startBufferingOnMediaSource( + args : IBufferingMediaSettings, + onReloadOrder: (reloadOrder : { position: number; + autoPlay : boolean; }) => void, + cancelSignal : CancellationSignal + ) : void { + const { autoPlay, + bufferOptions, + initialTime, + manifest, + manifestUpdater, + mediaElement, + mediaSource, + playbackObserver, + protectionRef, + representationEstimator, + segmentFetcherCreator, + speed } = args; + + /** Maintains the MediaSource's duration up-to-date with the Manifest */ + const mediaDurationUpdater = new MediaDurationUpdater(manifest, mediaSource); + cancelSignal.register(() => { + mediaDurationUpdater.stop(); + }); + + const initialPeriod = manifest.getPeriodForTime(initialTime) ?? + manifest.getNextPeriod(initialTime); + if (initialPeriod === undefined) { + const error = new MediaError("MEDIA_STARTING_TIME_NOT_FOUND", + "Wanted starting time not found in the Manifest."); + return this._onFatalError(error); + } + + /** Interface to create media buffers. */ + const segmentBuffersStore = new SegmentBuffersStore(mediaElement, mediaSource); + cancelSignal.register(() => { + segmentBuffersStore.disposeAll(); + }); + + const { autoPlayResult, initialPlayPerformed, initialSeekPerformed } = + performInitialSeekAndPlay(mediaElement, + playbackObserver, + initialTime, + autoPlay, + (err) => this.trigger("warning", err), + cancelSignal); + + if (cancelSignal.isCancelled) { + return; + } + + /** + * Class trying to avoid various stalling situations, emitting "stalled" + * events when it cannot, as well as "unstalled" events when it get out of one. + */ + const rebufferingController = new RebufferingController(playbackObserver, + manifest, + speed); + rebufferingController.addEventListener("stalled", (evt) => + this.trigger("stalled", evt)); + rebufferingController.addEventListener("unstalled", () => + this.trigger("unstalled", null)); + rebufferingController.addEventListener("warning", (err) => + this.trigger("warning", err)); + cancelSignal.register(() => { + rebufferingController.destroy(); + }); + rebufferingController.start(); + + initialPlayPerformed.onUpdate((isPerformed, stopListening) => { + if (isPerformed) { + stopListening(); + streamEventsEmitter(manifest, + mediaElement, + playbackObserver, + (evt) => this.trigger("streamEvent", evt), + (evt) => this.trigger("streamEventSkip", evt), + cancelSignal); + } + }, { clearSignal: cancelSignal, emitCurrentValue: true }); + + const streamObserver = createStreamPlaybackObserver(manifest, + playbackObserver, + { autoPlay, + initialPlayPerformed, + initialSeekPerformed, + speed, + startTime: initialTime }); + + /** Emit each time a new Adaptation is considered by the `StreamOrchestrator`. */ + const lastAdaptationChange = createSharedReference< + IAdaptationChangeEvent | null + >(null); + + const durationRef = ContentTimeBoundariesObserver(manifest, + lastAdaptationChange, + streamObserver, + (err) => + this.trigger("warning", err), + cancelSignal); + durationRef.onUpdate((newDuration) => { + log.debug("Init: Duration has to be updated.", newDuration); + mediaDurationUpdater.updateKnownDuration(newDuration); + }, { emitCurrentValue: true, clearSignal: cancelSignal }); + + /** + * Emit a "loaded" events once the initial play has been performed and the + * media can begin playback. + * Also emits warning events if issues arise when doing so. + */ + autoPlayResult + .then(() => { + getLoadedReference(playbackObserver, mediaElement, false, cancelSignal) + .onUpdate((isLoaded, stopListening) => { + if (isLoaded) { + stopListening(); + this.trigger("loaded", { segmentBuffersStore }); + } + }, { emitCurrentValue: true, clearSignal: cancelSignal }); + }) + .catch((err) => { + if (cancelSignal.isCancelled) { + return; + } + this._onFatalError(err); + }); + + let endOfStreamCanceller : TaskCanceller | null = null; + + // Creates Observable which will manage every Stream for the given Content. + const streamSub = StreamOrchestrator({ manifest, initialPeriod }, + streamObserver, + representationEstimator, + segmentBuffersStore, + segmentFetcherCreator, + bufferOptions + ).subscribe({ + next: (evt) => { + switch (evt.type) { + case "needs-buffer-flush": + playbackObserver.setCurrentTime(mediaElement.currentTime + 0.001); + break; + case "end-of-stream": + if (endOfStreamCanceller === null) { + endOfStreamCanceller = new TaskCanceller({ cancelOn: cancelSignal }); + log.debug("Init: end-of-stream order received."); + maintainEndOfStream(mediaSource, endOfStreamCanceller.signal); + } + break; + case "resume-stream": + if (endOfStreamCanceller !== null) { + log.debug("Init: resume-stream order received."); + endOfStreamCanceller.cancel(); + } + break; + case "stream-status": + const { period, bufferType, imminentDiscontinuity, position } = evt.value; + rebufferingController.updateDiscontinuityInfo({ + period, + bufferType, + discontinuity: imminentDiscontinuity, + position, + }); + break; + case "needs-manifest-refresh": + return manifestUpdater.forceRefresh({ completeRefresh: false, + canUseUnsafeMode: true }); + case "manifest-might-be-out-of-sync": + const { OUT_OF_SYNC_MANIFEST_REFRESH_DELAY } = config.getCurrent(); + manifestUpdater.forceRefresh({ + completeRefresh: true, + canUseUnsafeMode: false, + delay: OUT_OF_SYNC_MANIFEST_REFRESH_DELAY, + }); + return ; + case "locked-stream": + rebufferingController.onLockedStream(evt.value.bufferType, evt.value.period); + break; + case "adaptationChange": + lastAdaptationChange.setValue(evt); + this.trigger("adaptationChange", evt.value); + break; + case "inband-events": + return this.trigger("inbandEvents", evt.value); + case "warning": + return this.trigger("warning", evt.value); + case "periodStreamReady": + return this.trigger("periodStreamReady", evt.value); + case "activePeriodChanged": + return this.trigger("activePeriodChanged", evt.value); + case "periodStreamCleared": + return this.trigger("periodStreamCleared", evt.value); + case "representationChange": + return this.trigger("representationChange", evt.value); + case "complete-stream": + return this.trigger("completeStream", evt.value); + case "bitrateEstimationChange": + return this.trigger("bitrateEstimationChange", evt.value); + case "added-segment": + return this.trigger("addedSegment", evt.value); + case "needs-media-source-reload": + onReloadOrder(evt.value); + break; + case "needs-decipherability-flush": + const keySystem = getCurrentKeySystem(mediaElement); + if (shouldReloadMediaSourceOnDecipherabilityUpdate(keySystem)) { + onReloadOrder(evt.value); + } else { + // simple seek close to the current position + // to flush the buffers + if (evt.value.position + 0.001 < evt.value.duration) { + playbackObserver.setCurrentTime(mediaElement.currentTime + 0.001); + } else { + playbackObserver.setCurrentTime(evt.value.position); + } + } + break; + case "encryption-data-encountered": + protectionRef.setValue(evt.value); + break; + default: + assertUnreachable(evt); + } + }, + error: (err) => this._onFatalError(err), + }); + cancelSignal.register(() => { + streamSub.unsubscribe(); + }); + } +} + +/** Arguments to give to the `InitializeOnMediaSource` function. */ +export interface IInitializeArguments { + /** Options concerning the ABR logic. */ + adaptiveOptions: IAdaptiveRepresentationSelectorArguments; + /** `true` if we should play when loaded. */ + autoPlay : boolean; + /** Options concerning the media buffers. */ + bufferOptions : { + /** Buffer "goal" at which we stop downloading new segments. */ + wantedBufferAhead : IReadOnlySharedReference; + /** Buffer maximum size in kiloBytes at which we stop downloading */ + maxVideoBufferSize : IReadOnlySharedReference; + /** Max buffer size after the current position, in seconds (we GC further up). */ + maxBufferAhead : IReadOnlySharedReference; + /** Max buffer size before the current position, in seconds (we GC further down). */ + maxBufferBehind : IReadOnlySharedReference; + /** Strategy when switching the current bitrate manually (smooth vs reload). */ + manualBitrateSwitchingMode : "seamless" | "direct"; + /** + * Enable/Disable fastSwitching: allow to replace lower-quality segments by + * higher-quality ones to have a faster transition. + */ + enableFastSwitching : boolean; + /** Strategy when switching of audio track. */ + audioTrackSwitchingMode : IAudioTrackSwitchingMode; + /** Behavior when a new video and/or audio codec is encountered. */ + onCodecSwitch : "continue" | "reload"; + }; + /** + * Potential first Manifest to rely on, allowing to skip the initial Manifest + * fetching. + */ + initialManifest : ILoadedManifestFormat | undefined; + /** Every encryption configuration set. */ + keySystems : IKeySystemOption[]; + /** `true` to play low-latency contents optimally. */ + lowLatencyMode : boolean; + /** Settings linked to Manifest requests. */ + manifestRequestSettings : IManifestFetcherSettings; + /** Limit the frequency of Manifest updates. */ + minimumManifestUpdateInterval : number; + /** Logic linked Manifest and segment loading and parsing. */ + transport : ITransportPipelines; + /** Configuration for the segment requesting logic. */ + segmentRequestOptions : { + lowLatencyMode : boolean; + /** + * Amount of time after which a request should be aborted. + * `undefined` indicates that a default value is wanted. + * `-1` indicates no timeout. + */ + requestTimeout : number | undefined; + /** Maximum number of time a request on error will be retried. */ + maxRetryRegular : number | undefined; + /** Maximum number of time a request be retried when the user is offline. */ + maxRetryOffline : number | undefined; + }; + /** Emit the playback rate (speed) set by the user. */ + speed : IReadOnlySharedReference; + /** The configured starting position. */ + startAt? : IInitialTimeOptions | undefined; + /** Configuration specific to the text track. */ + textTrackOptions : ITextTrackSegmentBufferOptions; + /** URL of the Manifest. `undefined` if unknown or not pertinent. */ + url : string | undefined; +} + +/** Arguments needed when starting to buffer media on a specific MediaSource. */ +interface IBufferingMediaSettings { + /** Various stream-related options. */ + bufferOptions : IStreamOrchestratorOptions; + /* Manifest of the content we want to play. */ + manifest : Manifest; + /** Media Element on which the content will be played. */ + mediaElement : HTMLMediaElement; + /** Emit playback conditions regularly. */ + playbackObserver : PlaybackObserver; + /** Estimate the right Representation. */ + representationEstimator : IRepresentationEstimator; + /** Module to facilitate segment fetching. */ + segmentFetcherCreator : SegmentFetcherCreator; + /** Last wanted playback rate. */ + speed : IReadOnlySharedReference; + /** + * Reference through which decryption initialization information can be + * communicated. + */ + protectionRef : ISharedReference; + /** `MediaSource` element on which the media will be buffered. */ + mediaSource : MediaSource; + /** Interface allowing to refresh the Manifest. */ + manifestUpdater : IManifestUpdateScheduler; + initialTime : number; + autoPlay : boolean; +} diff --git a/src/core/init/stream_events_emitter/stream_events_emitter.ts b/src/core/init/stream_events_emitter/stream_events_emitter.ts deleted file mode 100644 index d55a351913..0000000000 --- a/src/core/init/stream_events_emitter/stream_events_emitter.ts +++ /dev/null @@ -1,182 +0,0 @@ -/** - * 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. - */ - -import { - combineLatest as observableCombineLatest, - concat as observableConcat, - distinctUntilChanged, - EMPTY, - ignoreElements, - interval, - map, - mergeMap, - Observable, - pairwise, - scan, - startWith, - switchMap, - tap, - of as observableOf, -} from "rxjs"; -import config from "../../../config"; -import Manifest from "../../../manifest"; -import { fromEvent } from "../../../utils/event_emitter"; -import { IPlaybackObservation } from "../../api"; -import refreshScheduledEventsList from "./refresh_scheduled_events_list"; -import { - INonFiniteStreamEventPayload, - IPublicStreamEvent, - IStreamEvent, - IStreamEventPayload, -} from "./types"; - - -/** - * Tells if a stream event has a duration - * @param {Object} evt - * @returns {Boolean} - */ -function isFiniteStreamEvent( - evt: IStreamEventPayload|INonFiniteStreamEventPayload -): evt is IStreamEventPayload { - return (evt as IStreamEventPayload).end !== undefined; -} - -/** - * Get events from manifest and emit each time an event has to be emitted - * @param {Object} manifest - * @param {HTMLMediaElement} mediaElement - * @returns {Observable} - */ -function streamEventsEmitter(manifest: Manifest, - mediaElement: HTMLMediaElement, - observation$: Observable -): Observable { - const eventsBeingPlayed = - new WeakMap(); - let lastScheduledEvents : Array = []; - const scheduledEvents$ = fromEvent(manifest, "manifestUpdate").pipe( - startWith(null), - scan((oldScheduledEvents) => { - return refreshScheduledEventsList(oldScheduledEvents, manifest); - }, [] as Array) - ); - - /** - * Examine playback situation from playback observations to emit stream events and - * prepare set onExit callbacks if needed. - * @param {Array.} scheduledEvents - * @param {Object} oldObservation - * @param {Object} newObservation - * @returns {Observable} - */ - function emitStreamEvents$( - scheduledEvents: Array, - oldObservation: { currentTime: number; isSeeking: boolean }, - newObservation: { currentTime: number; isSeeking: boolean } - ): Observable { - const { currentTime: previousTime } = oldObservation; - const { isSeeking, currentTime } = newObservation; - const eventsToSend: IStreamEvent[] = []; - const eventsToExit: IPublicStreamEvent[] = []; - - for (let i = 0; i < scheduledEvents.length; i++) { - const event = scheduledEvents[i]; - const start = event.start; - const end = isFiniteStreamEvent(event) ? event.end : - undefined; - const isBeingPlayed = eventsBeingPlayed.has(event); - if (isBeingPlayed) { - if (start > currentTime || - (end !== undefined && currentTime >= end) - ) { - if (isFiniteStreamEvent(event)) { - eventsToExit.push(event.publicEvent); - } - eventsBeingPlayed.delete(event); - } - } else if (start <= currentTime && - end !== undefined && - currentTime < end) { - eventsToSend.push({ type: "stream-event", - value: event.publicEvent }); - eventsBeingPlayed.set(event, true); - } else if (previousTime < start && - currentTime >= (end ?? start)) { - if (isSeeking) { - eventsToSend.push({ type: "stream-event-skip", - value: event.publicEvent }); - } else { - eventsToSend.push({ type: "stream-event", - value: event.publicEvent }); - if (isFiniteStreamEvent(event)) { - eventsToExit.push(event.publicEvent); - } - } - } - } - - return observableConcat( - eventsToSend.length > 0 ? observableOf(...eventsToSend) : - EMPTY, - eventsToExit.length > 0 ? observableOf(...eventsToExit).pipe( - tap((evt) => { - if (typeof evt.onExit === "function") { - evt.onExit(); - } - }), - // NOTE As of now (RxJS 7.4.0), RxJS defines `ignoreElements` default - // first type parameter as `any` instead of the perfectly fine `unknown`, - // leading to linter issues, as it forbids the usage of `any`. - // This is why we're disabling the eslint rule. - /* eslint-disable-next-line @typescript-eslint/no-unsafe-argument */ - ignoreElements() - ) : EMPTY - ); - } - - /** - * This pipe allows to control wether the polling should occur, if there - * are scheduledEvents, or not. - */ - return scheduledEvents$.pipe( - tap((scheduledEvents) => lastScheduledEvents = scheduledEvents), - map((evt): boolean => evt.length > 0), - distinctUntilChanged(), - switchMap((hasEvents: boolean) => { - if (!hasEvents) { - return EMPTY; - } - - const { STREAM_EVENT_EMITTER_POLL_INTERVAL } = config.getCurrent(); - return observableCombineLatest([ - interval(STREAM_EVENT_EMITTER_POLL_INTERVAL).pipe(startWith(null)), - observation$, - ]).pipe( - map(([_, observation]) => { - const { seeking } = observation; - return { isSeeking: seeking, - currentTime: mediaElement.currentTime }; - }), - pairwise(), - mergeMap(([oldObservation, newObservation]) => - emitStreamEvents$(lastScheduledEvents, oldObservation, newObservation)) - ); - }) - ); -} - -export default streamEventsEmitter; diff --git a/src/core/init/throw_on_media_error.ts b/src/core/init/throw_on_media_error.ts deleted file mode 100644 index 0c789fd8f2..0000000000 --- a/src/core/init/throw_on_media_error.ts +++ /dev/null @@ -1,68 +0,0 @@ -/** - * 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. - */ - -import { - fromEvent as observableFromEvent, - mergeMap, - Observable, -} from "rxjs"; -import { MediaError } from "../../errors"; -import isNullOrUndefined from "../../utils/is_null_or_undefined"; - -/** - * Returns an observable which throws the right MediaError as soon an "error" - * event is received through the media element. - * @param {HTMLMediaElement} mediaElement - * @returns {Observable} - */ -export default function throwOnMediaError( - mediaElement : HTMLMediaElement -) : Observable { - return observableFromEvent(mediaElement, "error") - .pipe(mergeMap(() => { - const mediaError = mediaElement.error; - let errorCode : number | undefined; - let errorMessage : string | undefined; - if (!isNullOrUndefined(mediaError)) { - errorCode = mediaError.code; - errorMessage = mediaError.message; - } - - switch (errorCode) { - case 1: - errorMessage = errorMessage ?? - "The fetching of the associated resource was aborted by the user's request."; - throw new MediaError("MEDIA_ERR_ABORTED", errorMessage); - case 2: - errorMessage = errorMessage ?? - "A network error occurred which prevented the media from being " + - "successfully fetched"; - throw new MediaError("MEDIA_ERR_NETWORK", errorMessage); - case 3: - errorMessage = errorMessage ?? - "An error occurred while trying to decode the media resource"; - throw new MediaError("MEDIA_ERR_DECODE", errorMessage); - case 4: - errorMessage = errorMessage ?? - "The media resource has been found to be unsuitable."; - throw new MediaError("MEDIA_ERR_SRC_NOT_SUPPORTED", errorMessage); - default: - errorMessage = errorMessage ?? - "The HTMLMediaElement errored due to an unknown reason."; - throw new MediaError("MEDIA_ERR_UNKNOWN", errorMessage); - } - })); -} diff --git a/src/core/init/types.ts b/src/core/init/types.ts index 7a6a58f2e6..665fb4eb9d 100644 --- a/src/core/init/types.ts +++ b/src/core/init/types.ts @@ -14,77 +14,228 @@ * limitations under the License. */ +import { Subject } from "rxjs"; import Manifest, { Adaptation, + ISegment, Period, Representation, } from "../../manifest"; import { IPlayerError } from "../../public_types"; -import SegmentBuffersStore from "../segment_buffers"; +import EventEmitter from "../../utils/event_emitter"; +import { PlaybackObserver } from "../api"; +import SegmentBuffersStore, { + IBufferType, +} from "../segment_buffers"; +import { IInbandEvent } from "../stream"; import { - IActivePeriodChangedEvent, - IAdaptationChangeEvent, - IBitrateEstimationChangeEvent, - ICompletedStreamEvent, - IEncryptionDataEncounteredEvent, - IInbandEventsEvent, - INeedsBufferFlushEvent, - INeedsDecipherabilityFlush, - INeedsMediaSourceReload, - IPeriodStreamClearedEvent, - IPeriodStreamReadyEvent, - IRepresentationChangeEvent, - IStreamEventAddedSegment, - IStreamManifestMightBeOutOfSync, - IStreamNeedsManifestRefresh, -} from "../stream"; -import { - IStreamEventEvent, - IStreamEventSkipEvent, -} from "./stream_events_emitter"; - -/** Event sent after the Manifest has been loaded and parsed for the first time. */ -export interface IManifestReadyEvent { - type : "manifestReady"; - value : { - /** The Manifest we just parsed. */ - manifest : Manifest; - }; -} - -/** Event sent after the Manifest has been updated. */ -export interface IManifestUpdateEvent { type: "manifestUpdate"; - value: null; } - -/** - * Event sent after updating the decipherability status of at least one - * Manifest's Representation. - * This generally means that some Representation(s) were detected to be - * undecipherable on the current device. - */ -export interface IDecipherabilityUpdateEvent { - type: "decipherabilityUpdate"; - value: Array<{ manifest : Manifest; - period : Period; - adaptation : Adaptation; - representation : Representation; }>; } - -/** Event sent when a minor happened. */ -export interface IWarningEvent { type : "warning"; - value : IPlayerError; } + IPublicNonFiniteStreamEvent, + IPublicStreamEvent, +} from "./utils/stream_events_emitter"; /** - * Event sent when we're starting attach a new MediaSource to the media element - * (after removing the previous one). + * Class allowing to start playing a content on an `HTMLMediaElement`. + * + * The actual constructor arguments depend on the `ContentInitializer` defined, + * but should reflect all potential configuration wanted relative to this + * content's playback. + * + * Various events may be emitted by a `ContentInitializer`. However, no event + * should be emitted before `prepare` or `start` is called and no event should + * be emitted after `dispose` is called. */ -export interface IReloadingMediaSourceEvent { type: "reloading-media-source"; - value: undefined; } +export abstract class ContentInitializer extends EventEmitter { + /** + * Prepare the content linked to this `ContentInitializer` in the background, + * without actually trying to play it. + * + * This method may be used for optimization reasons, for example to prepare a + * future content without interrupting the previous one playing on a given + * `HTMLMediaElement`. + */ + public abstract prepare() : void; + + /** + * Actually starts playing the content linked to this `ContentInitializer` on + * the given `mediaElement`. + * + * Only a single call to `start` is expected to be performed on each + * `ContentInitializer`. + * + * A call to `prepare` may or may not have been performed before calling + * `start`. If it was, it may or may not have yet finished the preparation + * phase before `start` is called (the `ContentInitializer` should stay + * resilient in both scenarios). + * + * @param {HTMLMediaElement} mediaElement - `HTMLMediaElement` on which the + * content will play. This is given to `start` (and not sooner) to ensure + * that no prior step influence the `HTMLMediaElement`, on which a previous + * content could have been playing until then. + * + * If a content was already playing on that `HTMLMediaElement`, it will be + * stopped. + * @param {Object} playbackObserver - Interface allowing to poll playback + * information on what's playing on the `HTMLMediaElement` at regular + * intervals. + */ + public abstract start( + mediaElement : HTMLMediaElement, + playbackObserver : PlaybackObserver + ) : void; + + /** + * Stop playing the content linked to this `ContentInitializer` on the + * `HTMLMediaElement` linked to it and dispose of every resources taken while + * trying to do so. + */ + public abstract dispose() : void; +} -/** Event sent after the player stalled. */ -export interface IStalledEvent { - type : "stalled"; - /** The reason behind the stall */ - value : IStallingSituation; +/** Every events emitted by a `ContentInitializer`. */ +export interface IContentInitializerEvents { + /** Event sent when a minor happened. */ + warning : IPlayerError; + /** A fatal error occured, leading to the current content being stopped. */ + error : unknown; + /** Event sent after the Manifest has been loaded and parsed for the first time. */ + manifestReady : Manifest; + /** Event sent after the Manifest has been updated. */ + manifestUpdate: null; + /** + * Event sent when we're starting attach a new MediaSource to the media element + * (after removing the previous one). + */ + reloadingMediaSource: null; + /** Event sent after the player stalled. */ + stalled : IStallingSituation; + /** Event sent when the player goes out of a stalling situation. */ + unstalled : null; + /** + * Event sent just as the content is considered as "loaded". + * From this point on, the user can reliably play/pause/resume the stream. + */ + loaded : { segmentBuffersStore: SegmentBuffersStore | null }; + /** + * Event sent after updating the decipherability status of at least one + * Manifest's Representation. + * This generally means that some Representation(s) were detected to be + * undecipherable on the current device. + */ + decipherabilityUpdate: Array<{ manifest : Manifest; + period : Period; + adaptation : Adaptation; + representation : Representation; }>; + /** Event emitted when a stream event is encountered. */ + streamEvent: IPublicStreamEvent | + IPublicNonFiniteStreamEvent; + streamEventSkip: IPublicStreamEvent | + IPublicNonFiniteStreamEvent; + /** Emitted when a new `Period` is currently playing. */ + activePeriodChanged: { + /** The Period we're now playing. */ + period: Period; + }; + /** + * A new `PeriodStream` is ready to start but needs an Adaptation (i.e. track) + * to be chosen first. + */ + periodStreamReady: { + /** The type of buffer linked to the `PeriodStream` we want to create. */ + type : IBufferType; + /** The `Period` linked to the `PeriodStream` we have created. */ + period : Period; + /** + * The subject through which any Adaptation (i.e. track) choice should be + * emitted for that `PeriodStream`. + * + * The `PeriodStream` will not do anything until this subject has emitted + * at least one to give its initial choice. + * You can send `null` through it to tell this `PeriodStream` that you don't + * want any `Adaptation`. + */ + adaptation$ : Subject; + }; + /** + * A `PeriodStream` has been removed. + * This event can be used for clean-up purposes. For example, you are free to + * remove from scope the subject that you used to choose a track for that + * `PeriodStream`. + */ + periodStreamCleared: { + /** + * The type of buffer linked to the `PeriodStream` we just removed. + * + * The combination of this and `Period` should give you enough information + * about which `PeriodStream` has been removed. + */ + type : IBufferType; + /** + * The `Period` linked to the `PeriodStream` we just removed. + * + * The combination of this and `Period` should give you enough information + * about which `PeriodStream` has been removed. + */ + period : Period; + }; + /** + * The last (chronologically) `Period` for a given type has pushed all + * the segments it needs until the end. + */ + completeStream: { type: IBufferType }; + /** Emitted when a new `Adaptation` is being considered. */ + adaptationChange: { + /** The type of buffer for which the Representation is changing. */ + type : IBufferType; + /** The `Period` linked to the `RepresentationStream` we're creating. */ + period : Period; + /** + * The `Adaptation` linked to the `AdaptationStream` we're creating. + * `null` when we're choosing no Adaptation at all. + */ + adaptation : Adaptation | + null; + }; + /** Emitted as new bitrate estimates are done. */ + bitrateEstimationChange: { + /** The type of buffer for which the estimation is done. */ + type : IBufferType; + /** + * The bitrate estimate, in bits per seconds. `undefined` when no bitrate + * estimate is currently available. + */ + bitrate : number|undefined; + }; + /** Emitted when a new `Representation` is being considered. */ + representationChange: { + /** The type of buffer linked to that `RepresentationStream`. */ + type : IBufferType; + /** The `Period` linked to the `RepresentationStream` we're creating. */ + period : Period; + /** + * The `Representation` linked to the `RepresentationStream` we're creating. + * `null` when we're choosing no Representation at all. + */ + representation : Representation | + null; + }; + /** Emitted after a new segment has been succesfully added to the SegmentBuffer */ + addedSegment: { + /** Context about the content that has been added. */ + content: { period : Period; + adaptation : Adaptation; + representation : Representation; }; + /** The concerned Segment. */ + segment : ISegment; + /** TimeRanges of the concerned SegmentBuffer after the segment was pushed. */ + buffered : TimeRanges; + /* The data pushed */ + segmentData : unknown; + }; + /** + * Event emitted when one or multiple inband events (i.e. events inside a + * given segment) have been encountered. + */ + inbandEvents : IInbandEvent[]; } export type IStallingSituation = @@ -94,77 +245,3 @@ export type IStallingSituation = "buffering" | // Other rebuffering cases "freezing"; // stalled for an unknown reason (might be waiting for // a decryption key) - -/** Event sent when the player goes out of a stalling situation. */ -export interface IUnstalledEvent { type : "unstalled"; - value : null; } - -/** - * Event sent just as the content is considered as "loaded". - * From this point on, the user can reliably play/pause/resume the stream. - */ -export interface ILoadedEvent { type : "loaded"; - value : { - segmentBuffersStore: SegmentBuffersStore | null; - }; } - -export { IRepresentationChangeEvent }; - -/** Events emitted by a `MediaSourceLoader`. */ -export type IMediaSourceLoaderEvent = IStalledEvent | - IUnstalledEvent | - ILoadedEvent | - IWarningEvent | - IStreamEventEvent | - IStreamEventSkipEvent | - - // Coming from the StreamOrchestrator - - IActivePeriodChangedEvent | - IPeriodStreamClearedEvent | - ICompletedStreamEvent | - IPeriodStreamReadyEvent | - INeedsMediaSourceReload | - INeedsBufferFlushEvent | - IAdaptationChangeEvent | - IBitrateEstimationChangeEvent | - INeedsDecipherabilityFlush | - IRepresentationChangeEvent | - IStreamEventAddedSegment | - IEncryptionDataEncounteredEvent | - IStreamManifestMightBeOutOfSync | - IStreamNeedsManifestRefresh | - IInbandEventsEvent; - -/** Every events emitted by the `Init` module. */ -export type IInitEvent = IManifestReadyEvent | - IManifestUpdateEvent | - IReloadingMediaSourceEvent | - IDecipherabilityUpdateEvent | - IWarningEvent | - - // Coming from the `MediaSourceLoader` - - IStalledEvent | - IUnstalledEvent | - ILoadedEvent | - IStreamEventEvent | - IStreamEventSkipEvent | - - // Coming from the `StreamOrchestrator` - - IActivePeriodChangedEvent | - IPeriodStreamClearedEvent | - ICompletedStreamEvent | - IPeriodStreamReadyEvent | - IAdaptationChangeEvent | - IBitrateEstimationChangeEvent | - IRepresentationChangeEvent | - IStreamEventAddedSegment | - IInbandEventsEvent; - -/** Events emitted by the `Init` module for directfile contents. */ -export type IDirectfileEvent = IStalledEvent | - IUnstalledEvent | - ILoadedEvent | - IWarningEvent; diff --git a/src/core/init/update_playback_rate.ts b/src/core/init/update_playback_rate.ts deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/src/core/init/__tests__/are_same_stream_events.test.ts b/src/core/init/utils/__tests__/are_same_stream_events.test.ts similarity index 100% rename from src/core/init/__tests__/are_same_stream_events.test.ts rename to src/core/init/utils/__tests__/are_same_stream_events.test.ts diff --git a/src/core/init/__tests__/refresh_scheduled_events_list.test.ts b/src/core/init/utils/__tests__/refresh_scheduled_events_list.test.ts similarity index 100% rename from src/core/init/__tests__/refresh_scheduled_events_list.test.ts rename to src/core/init/utils/__tests__/refresh_scheduled_events_list.test.ts diff --git a/src/core/init/content_time_boundaries_observer.ts b/src/core/init/utils/content_time_boundaries_observer.ts similarity index 67% rename from src/core/init/content_time_boundaries_observer.ts rename to src/core/init/utils/content_time_boundaries_observer.ts index d7264e6811..7ba93032b0 100644 --- a/src/core/init/content_time_boundaries_observer.ts +++ b/src/core/init/utils/content_time_boundaries_observer.ts @@ -14,143 +14,109 @@ * limitations under the License. */ -import { - distinctUntilChanged, - ignoreElements, - map, - merge as observableMerge, - Observable, - skipWhile, - startWith, - tap, -} from "rxjs"; -import { MediaError } from "../../errors"; +import { MediaError } from "../../../errors"; import Manifest, { Adaptation, IRepresentationIndex, -} from "../../manifest"; -import { fromEvent } from "../../utils/event_emitter"; -import filterMap from "../../utils/filter_map"; -import isNullOrUndefined from "../../utils/is_null_or_undefined"; +} from "../../../manifest"; +import { IPlayerError } from "../../../public_types"; +import isNullOrUndefined from "../../../utils/is_null_or_undefined"; import createSharedReference, { IReadOnlySharedReference, -} from "../../utils/reference"; -import { IReadOnlyPlaybackObserver } from "../api"; +} from "../../../utils/reference"; +import { CancellationSignal } from "../../../utils/task_canceller"; +import { IReadOnlyPlaybackObserver } from "../../api"; import { IAdaptationChangeEvent, IStreamOrchestratorPlaybackObservation, -} from "../stream"; -import EVENTS from "./events_generators"; -import { IWarningEvent } from "./types"; - -// NOTE As of now (RxJS 7.4.0), RxJS defines `ignoreElements` default -// first type parameter as `any` instead of the perfectly fine `unknown`, -// leading to linter issues, as it forbids the usage of `any`. -// This is why we're disabling the eslint rule. -/* eslint-disable @typescript-eslint/no-unsafe-argument */ +} from "../../stream"; /** - * Observes the position and Adaptations being played and deduce various events - * related to the available time boundaries: - * - Emit when the theoretical duration of the content becomes known or when it - * changes. - * - Emit warnings when the duration goes out of what is currently - * theoretically playable. + * Observes the position and Adaptations being played and: + * - emit warnings through the `onWarning` callback when what is being played + * is outside of the Manifest range. + * - Returns a shared reference indicating the theoretical duration of the + * content, and `undefined` if unknown. * * @param {Object} manifest * @param {Object} lastAdaptationChange * @param {Object} playbackObserver - * @returns {Observable} + * @param {Function} onWarning + * @param {Object} cancelSignal + * @returns {Object} */ export default function ContentTimeBoundariesObserver( manifest : Manifest, lastAdaptationChange : IReadOnlySharedReference, - playbackObserver : IReadOnlyPlaybackObserver - -) : Observable { + playbackObserver : IReadOnlyPlaybackObserver, + onWarning : (err : IPlayerError) => void, + cancelSignal : CancellationSignal +) : IReadOnlySharedReference { /** * Allows to calculate the minimum and maximum playable position on the * whole content. */ const maximumPositionCalculator = new MaximumPositionCalculator(manifest); - // trigger warnings when the wanted time is before or after the manifest's - // segments - const outOfManifest$ = playbackObserver.getReference().asObservable().pipe( - filterMap(( - { position } - ) => { - const wantedPosition = position.pending ?? position.last; - if ( - wantedPosition < manifest.getMinimumSafePosition() - ) { - const warning = new MediaError("MEDIA_TIME_BEFORE_MANIFEST", - "The current position is behind the " + - "earliest time announced in the Manifest."); - return EVENTS.warning(warning); - } else if ( - wantedPosition > maximumPositionCalculator.getMaximumAvailablePosition() - ) { - const warning = new MediaError("MEDIA_TIME_AFTER_MANIFEST", - "The current position is after the latest " + - "time announced in the Manifest."); - return EVENTS.warning(warning); - } - return null; - }, null)); + playbackObserver.listen(({ position } : IContentTimeObserverPlaybackObservation) => { + const wantedPosition = position.pending ?? position.last; + if (wantedPosition < manifest.getMinimumSafePosition()) { + const warning = new MediaError("MEDIA_TIME_BEFORE_MANIFEST", + "The current position is behind the " + + "earliest time announced in the Manifest."); + onWarning(warning); + } else if ( + wantedPosition > maximumPositionCalculator.getMaximumAvailablePosition() + ) { + const warning = new MediaError("MEDIA_TIME_AFTER_MANIFEST", + "The current position is after the latest " + + "time announced in the Manifest."); + onWarning(warning); + } + }, { includeLastObservation: true, clearSignal: cancelSignal }); /** * Contains the content duration according to the last audio and video * Adaptation chosen for the last Period. * `undefined` if unknown yet. */ - const contentDuration = createSharedReference(undefined); - - const updateDurationOnManifestUpdate$ = fromEvent(manifest, "manifestUpdate").pipe( - startWith(null), - tap(() => { - const duration = manifest.isDynamic ? - maximumPositionCalculator.getEndingPosition() : - maximumPositionCalculator.getMaximumAvailablePosition(); - contentDuration.setValue(duration); - }), - ignoreElements() + const contentDuration = createSharedReference( + getManifestDuration() ); - const updateDurationAndTimeBoundsOnTrackChange$ = lastAdaptationChange - .asObservable().pipe( - tap((message) => { - if (message === null || !manifest.isLastPeriodKnown) { - return; - } - const lastPeriod = manifest.periods[manifest.periods.length - 1]; - if (message.value.period.id === lastPeriod?.id) { - if (message.value.type === "audio" || message.value.type === "video") { - if (message.value.type === "audio") { - maximumPositionCalculator - .updateLastAudioAdaptation(message.value.adaptation); - } else { - maximumPositionCalculator - .updateLastVideoAdaptation(message.value.adaptation); - } - const newDuration = manifest.isDynamic ? - maximumPositionCalculator.getMaximumAvailablePosition() : - maximumPositionCalculator.getEndingPosition(); - contentDuration.setValue(newDuration); - } + manifest.addEventListener("manifestUpdate", () => { + contentDuration.setValue(getManifestDuration()); + }, cancelSignal); + + lastAdaptationChange.onUpdate((message) => { + if (message === null || !manifest.isLastPeriodKnown) { + return; + } + const lastPeriod = manifest.periods[manifest.periods.length - 1]; + if (message.value.period.id === lastPeriod?.id) { + if (message.value.type === "audio" || message.value.type === "video") { + if (message.value.type === "audio") { + maximumPositionCalculator + .updateLastAudioAdaptation(message.value.adaptation); + } else { + maximumPositionCalculator + .updateLastVideoAdaptation(message.value.adaptation); } - }), - ignoreElements()); + const newDuration = manifest.isDynamic ? + maximumPositionCalculator.getMaximumAvailablePosition() : + maximumPositionCalculator.getEndingPosition(); + contentDuration.setValue(newDuration); + } + } + }, { emitCurrentValue: true, clearSignal: cancelSignal }); - return observableMerge( - updateDurationOnManifestUpdate$, - updateDurationAndTimeBoundsOnTrackChange$, - outOfManifest$, - contentDuration.asObservable().pipe( - skipWhile((val) => val === undefined), - distinctUntilChanged(), - map(value => ({ type: "contentDurationUpdate" as const, value })) - )); + return contentDuration; + + function getManifestDuration() : number | undefined { + return manifest.isDynamic ? + maximumPositionCalculator.getEndingPosition() : + maximumPositionCalculator.getMaximumAvailablePosition(); + } } /** @@ -368,15 +334,5 @@ function getEndingPositionFromAdaptation( return min; } -/** - * Emitted when the duration of the full content (== the last playable position) - * has changed. - */ -export interface IContentDurationUpdateEvent { - type: "contentDurationUpdate"; - /** The new theoretical duration, `undefined` if unknown, */ - value : number | undefined; -} - export type IContentTimeObserverPlaybackObservation = Pick; diff --git a/src/core/init/create_media_source.ts b/src/core/init/utils/create_media_source.ts similarity index 52% rename from src/core/init/create_media_source.ts rename to src/core/init/utils/create_media_source.ts index 554fe9b132..919c7f15d2 100644 --- a/src/core/init/create_media_source.ts +++ b/src/core/init/utils/create_media_source.ts @@ -14,23 +14,17 @@ * limitations under the License. */ -import { - map, - mergeMap, - Observable, - Observer, - take, -} from "rxjs"; import { clearElementSrc, events, MediaSource_, -} from "../../compat"; -import { MediaError } from "../../errors"; -import log from "../../log"; -import isNonEmptyString from "../../utils/is_non_empty_string"; - -const { onSourceOpen$ } = events; +} from "../../../compat"; +import { MediaError } from "../../../errors"; +import log from "../../../log"; +import isNonEmptyString from "../../../utils/is_non_empty_string"; +import TaskCanceller, { + CancellationSignal, +} from "../../../utils/task_canceller"; /** * Dispose of ressources taken by the MediaSource: @@ -84,60 +78,71 @@ export function resetMediaSource( * Create, on subscription, a MediaSource instance and attach it to the given * mediaElement element's src attribute. * - * Returns an Observable which emits the MediaSource when created and attached - * to the mediaElement element. - * This Observable never completes. It can throw if MediaSource is not - * available in the current environment. - * - * On unsubscription, the mediaElement.src is cleaned, MediaSource SourceBuffers - * are aborted and some minor cleaning is done. + * Returns a Promise which resolves with the MediaSource when created and attached + * to the `mediaElement` element. * + * When the given `unlinkSignal` emits, mediaElement.src is cleaned, MediaSource + * SourceBuffers are aborted and some minor cleaning is done. * @param {HTMLMediaElement} mediaElement - * @returns {Observable} + * @param {Object} unlinkSignal + * @returns {MediaSource} */ function createMediaSource( - mediaElement : HTMLMediaElement -) : Observable { - return new Observable((observer : Observer) => { - if (MediaSource_ == null) { - throw new MediaError("MEDIA_SOURCE_NOT_SUPPORTED", - "No MediaSource Object was found in the current browser."); - } + mediaElement : HTMLMediaElement, + unlinkSignal : CancellationSignal +) : MediaSource { + if (MediaSource_ == null) { + throw new MediaError("MEDIA_SOURCE_NOT_SUPPORTED", + "No MediaSource Object was found in the current browser."); + } - // make sure the media has been correctly reset - const oldSrc = isNonEmptyString(mediaElement.src) ? mediaElement.src : - null; - resetMediaSource(mediaElement, null, oldSrc); + // make sure the media has been correctly reset + const oldSrc = isNonEmptyString(mediaElement.src) ? mediaElement.src : + null; + resetMediaSource(mediaElement, null, oldSrc); - log.info("Init: Creating MediaSource"); - const mediaSource = new MediaSource_(); - const objectURL = URL.createObjectURL(mediaSource); + log.info("Init: Creating MediaSource"); + const mediaSource = new MediaSource_(); + const objectURL = URL.createObjectURL(mediaSource); - log.info("Init: Attaching MediaSource URL to the media element", objectURL); - mediaElement.src = objectURL; + log.info("Init: Attaching MediaSource URL to the media element", objectURL); + mediaElement.src = objectURL; - observer.next(mediaSource); - return () => { - resetMediaSource(mediaElement, mediaSource, objectURL); - }; + unlinkSignal.register(() => { + resetMediaSource(mediaElement, mediaSource, objectURL); }); + return mediaSource; } /** * Create and open a new MediaSource object on the given media element. - * Emit the MediaSource when done. + * Resolves with the MediaSource when done. + * + * When the given `unlinkSignal` emits, mediaElement.src is cleaned, MediaSource + * SourceBuffers are aborted and some minor cleaning is done. * @param {HTMLMediaElement} mediaElement - * @returns {Observable} + * @param {Object} unlinkSignal + * @returns {Promise} */ export default function openMediaSource( - mediaElement : HTMLMediaElement -) : Observable { - return createMediaSource(mediaElement).pipe( - mergeMap(mediaSource => { - return onSourceOpen$(mediaSource).pipe( - take(1), - map(() => mediaSource) - ); - }) - ); + mediaElement : HTMLMediaElement, + unlinkSignal : CancellationSignal +) : Promise { + return new Promise((resolve, reject) => { + let hasResolved = false; + const mediaSource = createMediaSource(mediaElement, unlinkSignal); + const eventListenerCanceller = new TaskCanceller({ cancelOn: unlinkSignal }); + + events.onSourceOpen(mediaSource, () => { + eventListenerCanceller.cancel(); + hasResolved = true; + resolve(mediaSource); + }, eventListenerCanceller.signal); + + unlinkSignal.register((error) => { + if (!hasResolved) { + reject(error); + } + }); + }); } diff --git a/src/core/init/create_stream_playback_observer.ts b/src/core/init/utils/create_stream_playback_observer.ts similarity index 94% rename from src/core/init/create_stream_playback_observer.ts rename to src/core/init/utils/create_stream_playback_observer.ts index 225e668f05..647ef012e0 100644 --- a/src/core/init/create_stream_playback_observer.ts +++ b/src/core/init/utils/create_stream_playback_observer.ts @@ -14,17 +14,17 @@ * limitations under the License. */ -import Manifest from "../../manifest"; +import Manifest from "../../../manifest"; import createSharedReference, { IReadOnlySharedReference, -} from "../../utils/reference"; -import { CancellationSignal } from "../../utils/task_canceller"; +} from "../../../utils/reference"; +import { CancellationSignal } from "../../../utils/task_canceller"; import { IPlaybackObservation, IReadOnlyPlaybackObserver, PlaybackObserver, -} from "../api"; -import { IStreamOrchestratorPlaybackObservation } from "../stream"; +} from "../../api"; +import { IStreamOrchestratorPlaybackObservation } from "../../stream"; /** Arguments needed to create the Stream's version of the PlaybackObserver. */ export interface IStreamPlaybackObserverArguments { diff --git a/src/core/init/utils/end_of_stream.ts b/src/core/init/utils/end_of_stream.ts new file mode 100644 index 0000000000..2baacbd7cd --- /dev/null +++ b/src/core/init/utils/end_of_stream.ts @@ -0,0 +1,104 @@ +/** + * 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. + */ + +import { events } from "../../../compat"; +import log from "../../../log"; +import TaskCanceller, { + CancellationSignal, +} from "../../../utils/task_canceller"; + +const { onRemoveSourceBuffers, + onSourceOpen, + onSourceBufferUpdate } = events; + +/** + * Get "updating" SourceBuffers from a SourceBufferList. + * @param {SourceBufferList} sourceBuffers + * @returns {Array.} + */ +function getUpdatingSourceBuffers(sourceBuffers : SourceBufferList) : SourceBuffer[] { + const updatingSourceBuffers : SourceBuffer[] = []; + for (let i = 0; i < sourceBuffers.length; i++) { + const SourceBuffer = sourceBuffers[i]; + if (SourceBuffer.updating) { + updatingSourceBuffers.push(SourceBuffer); + } + } + return updatingSourceBuffers; +} + +/** + * Trigger the `endOfStream` method of a MediaSource. + * + * If the MediaSource is ended/closed, do not call this method. + * If SourceBuffers are updating, wait for them to be updated before closing + * it. + * @param {MediaSource} mediaSource + * @param {Object} cancelSignal + */ +export default function triggerEndOfStream( + mediaSource : MediaSource, + cancelSignal : CancellationSignal +) : void { + log.debug("Init: Trying to call endOfStream"); + if (mediaSource.readyState !== "open") { + log.debug("Init: MediaSource not open, cancel endOfStream"); + return ; + } + + const { sourceBuffers } = mediaSource; + const updatingSourceBuffers = getUpdatingSourceBuffers(sourceBuffers); + + if (updatingSourceBuffers.length === 0) { + log.info("Init: Triggering end of stream"); + mediaSource.endOfStream(); + return ; + } + + log.debug("Init: Waiting SourceBuffers to be updated before calling endOfStream."); + + const innerCanceller = new TaskCanceller({ cancelOn: cancelSignal }); + for (const sourceBuffer of updatingSourceBuffers) { + onSourceBufferUpdate(sourceBuffer, () => { + innerCanceller.cancel(); + triggerEndOfStream(mediaSource, cancelSignal); + }, innerCanceller.signal); + } + + onRemoveSourceBuffers(sourceBuffers, () => { + innerCanceller.cancel(); + triggerEndOfStream(mediaSource, cancelSignal); + }, innerCanceller.signal); +} + +/** + * Trigger the `endOfStream` method of a MediaSource each times it opens. + * @see triggerEndOfStream + * @param {MediaSource} mediaSource + * @param {Object} cancelSignal + */ +export function maintainEndOfStream( + mediaSource : MediaSource, + cancelSignal : CancellationSignal +) : void { + let endOfStreamCanceller = new TaskCanceller({ cancelOn: cancelSignal }); + onSourceOpen(mediaSource, () => { + endOfStreamCanceller.cancel(); + endOfStreamCanceller = new TaskCanceller({ cancelOn: cancelSignal }); + triggerEndOfStream(mediaSource, endOfStreamCanceller.signal); + }, cancelSignal); + triggerEndOfStream(mediaSource, endOfStreamCanceller.signal); +} diff --git a/src/core/init/get_initial_time.ts b/src/core/init/utils/get_initial_time.ts similarity index 96% rename from src/core/init/get_initial_time.ts rename to src/core/init/utils/get_initial_time.ts index f5274793c7..1c682937c6 100644 --- a/src/core/init/get_initial_time.ts +++ b/src/core/init/utils/get_initial_time.ts @@ -14,10 +14,10 @@ * limitations under the License. */ -import config from "../../config"; -import log from "../../log"; -import Manifest from "../../manifest"; -import isNullOrUndefined from "../../utils/is_null_or_undefined"; +import config from "../../../config"; +import log from "../../../log"; +import Manifest from "../../../manifest"; +import isNullOrUndefined from "../../../utils/is_null_or_undefined"; /** * All possible initial time options that can be set. diff --git a/src/core/init/utils/get_loaded_reference.ts b/src/core/init/utils/get_loaded_reference.ts new file mode 100644 index 0000000000..a8ed228894 --- /dev/null +++ b/src/core/init/utils/get_loaded_reference.ts @@ -0,0 +1,78 @@ + +/** + * 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. + */ + +import { + shouldValidateMetadata, + shouldWaitForDataBeforeLoaded, +} from "../../../compat"; +import createSharedReference, { + IReadOnlySharedReference, +} from "../../../utils/reference"; +import TaskCanceller, { CancellationSignal } from "../../../utils/task_canceller"; +import { + IPlaybackObservation, + IReadOnlyPlaybackObserver, +} from "../../api"; + +/** + * Returns an `IReadOnlySharedReference` that switches to `true` once the + * content is considered loaded (i.e. once it can begin to be played). + * @param {Object} playbackObserver + * @param {HTMLMediaElement} mediaElement + * @param {boolean} isDirectfile - `true` if this is a directfile content + * @param {Object} cancelSignal + * @returns {Object} + */ +export default function getLoadedReference( + playbackObserver : IReadOnlyPlaybackObserver, + mediaElement : HTMLMediaElement, + isDirectfile : boolean, + cancelSignal : CancellationSignal +) : IReadOnlySharedReference { + const isLoaded = createSharedReference(false); + + const listenCanceller = new TaskCanceller({ cancelOn: cancelSignal }); + listenCanceller.signal.register(() => isLoaded.finish()); + playbackObserver.listen((observation) => { + if (observation.rebuffering !== null || + observation.freezing !== null || + observation.readyState === 0) + { + return ; + } + + if (!shouldWaitForDataBeforeLoaded(isDirectfile, + mediaElement.hasAttribute("playsinline"))) + { + if (mediaElement.duration > 0) { + isLoaded.setValue(true); + listenCanceller.cancel(); + return; + } + } + + if (observation.readyState >= 3 && observation.currentRange !== null) { + if (!shouldValidateMetadata() || mediaElement.duration > 0) { + isLoaded.setValue(true); + listenCanceller.cancel(); + return; + } + } + }, { includeLastObservation: true, clearSignal: listenCanceller.signal }); + + return isLoaded; +} diff --git a/src/core/init/utils/initial_seek_and_play.ts b/src/core/init/utils/initial_seek_and_play.ts new file mode 100644 index 0000000000..524e8bc340 --- /dev/null +++ b/src/core/init/utils/initial_seek_and_play.ts @@ -0,0 +1,179 @@ +/** + * 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. + */ + +import { shouldValidateMetadata } from "../../../compat"; +import { READY_STATES } from "../../../compat/browser_compatibility_types"; +import { MediaError } from "../../../errors"; +import log from "../../../log"; +import { IPlayerError } from "../../../public_types"; +import { + createSharedReference, + IReadOnlySharedReference, +} from "../../../utils/reference"; +import { CancellationError, CancellationSignal } from "../../../utils/task_canceller"; +import { PlaybackObserver } from "../../api"; + +/** Event emitted when trying to perform the initial `play`. */ +export type IInitialPlayEvent = + /** Autoplay is not enabled, but all required steps to do so are there. */ + { type: "skipped" } | + /** + * Tried to play, but autoplay is blocked by the browser. + * A corresponding warning should have already been sent. + */ + { type: "autoplay-blocked" } | + /** Autoplay was done with success. */ + { type: "autoplay" }; + +/** Object returned by `initialSeekAndPlay`. */ +export interface IInitialSeekAndPlayObject { + /** Emit the result of the auto-play operation, once performed. */ + autoPlayResult : Promise; + + /** + * Shared reference whose value becomes `true` once the initial seek has + * been considered / has been done by `performInitialSeekAndPlay`. + */ + initialSeekPerformed : IReadOnlySharedReference; + + /** + * Shared reference whose value becomes `true` once the initial play has + * been considered / has been done by `performInitialSeekAndPlay`. + */ + initialPlayPerformed: IReadOnlySharedReference; +} + +/** + * Seek as soon as possible at the initially wanted position and play if + * autoPlay is wanted. + * @param {HTMLMediaElement} mediaElement + * @param {Object} playbackObserver + * @param {number|Function} startTime + * @param {boolean} mustAutoPlay + * @param {Function} onWarning + * @param {Object} cancelSignal + * @returns {Object} + */ +export default function performInitialSeekAndPlay( + mediaElement : HTMLMediaElement, + playbackObserver : PlaybackObserver, + startTime : number|(() => number), + mustAutoPlay : boolean, + onWarning : (err : IPlayerError) => void, + cancelSignal : CancellationSignal +) : IInitialSeekAndPlayObject { + let resolveAutoPlay : (x : IInitialPlayEvent) => void; + let rejectAutoPlay : (x : unknown) => void; + const autoPlayResult = new Promise((res, rej) => { + resolveAutoPlay = res; + rejectAutoPlay = rej; + }); + + const initialSeekPerformed = createSharedReference(false); + const initialPlayPerformed = createSharedReference(false); + + mediaElement.addEventListener("loadedmetadata", onLoadedMetadata); + if (mediaElement.readyState >= READY_STATES.HAVE_METADATA) { + onLoadedMetadata(); + } + + cancelSignal.register((err : CancellationError) => { + mediaElement.removeEventListener("loadedmetadata", onLoadedMetadata); + rejectAutoPlay(err); + }); + + return { autoPlayResult, initialPlayPerformed, initialSeekPerformed }; + + function onLoadedMetadata() { + mediaElement.removeEventListener("loadedmetadata", onLoadedMetadata); + const initialTime = typeof startTime === "function" ? startTime() : + startTime; + log.info("Init: Set initial time", initialTime); + playbackObserver.setCurrentTime(initialTime); + initialSeekPerformed.setValue(true); + initialSeekPerformed.finish(); + + if (shouldValidateMetadata() && mediaElement.duration === 0) { + const error = new MediaError("MEDIA_ERR_NOT_LOADED_METADATA", + "Cannot load automatically: your browser " + + "falsely announced having loaded the content."); + onWarning(error); + } + if (cancelSignal.isCancelled) { + return ; + } + + playbackObserver.listen((observation, stopListening) => { + if (!observation.seeking && + observation.rebuffering === null && + observation.readyState >= 1) + { + stopListening(); + onPlayable(); + } + }, { includeLastObservation: true, clearSignal: cancelSignal }); + } + + function onPlayable() { + log.info("Init: Can begin to play content"); + if (!mustAutoPlay) { + if (mediaElement.autoplay) { + log.warn("Init: autoplay is enabled on HTML media element. " + + "Media will play as soon as possible."); + } + initialPlayPerformed.setValue(true); + initialPlayPerformed.finish(); + return resolveAutoPlay({ type: "skipped" as const }); + } + + let playResult : Promise; + try { + playResult = mediaElement.play() ?? Promise.resolve(); + } catch (playError) { + return rejectAutoPlay(playError); + } + playResult + .then(() => { + if (cancelSignal.isCancelled) { + return; + } + initialPlayPerformed.setValue(true); + initialPlayPerformed.finish(); + return resolveAutoPlay({ type: "autoplay" as const }); + }) + .catch((playError : unknown) => { + if (cancelSignal.isCancelled) { + return; + } + if (playError instanceof Error && playError.name === "NotAllowedError") { + // auto-play was probably prevented. + log.warn("Init: Media element can't play." + + " It may be due to browser auto-play policies."); + + const error = new MediaError("MEDIA_ERR_BLOCKED_AUTOPLAY", + "Cannot trigger auto-play automatically: " + + "your browser does not allow it."); + onWarning(error); + if (cancelSignal.isCancelled) { + return; + } + return resolveAutoPlay({ type: "autoplay-blocked" as const }); + } else { + rejectAutoPlay(playError); + } + }); + } +} diff --git a/src/core/init/utils/initialize_content_decryption.ts b/src/core/init/utils/initialize_content_decryption.ts new file mode 100644 index 0000000000..6ce3181aa2 --- /dev/null +++ b/src/core/init/utils/initialize_content_decryption.ts @@ -0,0 +1,174 @@ +import { hasEMEAPIs } from "../../../compat"; +import { EncryptedMediaError } from "../../../errors"; +import log from "../../../log"; +import { + IKeySystemOption, + IPlayerError, +} from "../../../public_types"; +import createSharedReference, { + IReadOnlySharedReference, + ISharedReference, +} from "../../../utils/reference"; +import TaskCanceller, { + CancellationSignal, +} from "../../../utils/task_canceller"; +import ContentDecryptor, { + ContentDecryptorState, + IContentProtection, +} from "../../decrypt"; + +/** + * Initialize content decryption capabilities on the given `HTMLMediaElement`. + * + * You can call this function even if you don't want decrytpion capabilities, in + * which case you can just set the `keySystems` option as an empty array. + * In this situation, the returned object will directly correspond to an + * "`initialized`" state and the `onError` callback will be triggered as soon + * as protection information is received. + * + * @param {HTMLMediaElement} mediaElement - `HTMLMediaElement` on which content + * decryption may be wanted. + * @param {Array.} keySystems - Key system configuration(s) wanted + * Empty array if no content decryption capability is wanted. + * @param {Object} protectionRef - Reference through which content + * protection initialization data will be sent through. + * @param {Object} callbacks - Callbacks called at various decryption-related + * events. + * @param {Object} cancelSignal - When that signal emits, this function will + * stop listening to various events as well as items sent through the + * `protectionRef` parameter. + * @returns {Object} - Reference emitting the current status regarding DRM + * initialization. + */ +export default function initializeContentDecryption( + mediaElement : HTMLMediaElement, + keySystems : IKeySystemOption[], + protectionRef : IReadOnlySharedReference, + callbacks : { onWarning : (err : IPlayerError) => void; + onError : (err : Error) => void; }, + cancelSignal : CancellationSignal +) : IReadOnlySharedReference { + if (keySystems.length === 0) { + protectionRef.onUpdate((data, stopListening) => { + if (data === null) { // initial value + return; + } + stopListening(); + log.error("Init: Encrypted event but EME feature not activated"); + const err = new EncryptedMediaError("MEDIA_IS_ENCRYPTED_ERROR", + "EME feature not activated."); + callbacks.onError(err); + }, { clearSignal: cancelSignal }); + return createSharedReference({ initializationState: { type: "initialized", + value: null }, + drmSystemId: undefined }); + } else if (!hasEMEAPIs()) { + protectionRef.onUpdate((data, stopListening) => { + if (data === null) { // initial value + return; + } + stopListening(); + log.error("Init: Encrypted event but no EME API available"); + const err = new EncryptedMediaError("MEDIA_IS_ENCRYPTED_ERROR", + "Encryption APIs not found."); + callbacks.onError(err); + }, { clearSignal: cancelSignal }); + return createSharedReference({ initializationState: { type: "initialized", + value: null }, + drmSystemId: undefined }); + } + + const decryptorCanceller = new TaskCanceller({ cancelOn: cancelSignal }); + const drmStatusRef = createSharedReference({ + initializationState: { type: "uninitialized", value: null }, + drmSystemId: undefined, + }); + + log.debug("Init: Creating ContentDecryptor"); + const contentDecryptor = new ContentDecryptor(mediaElement, keySystems); + + contentDecryptor.addEventListener("stateChange", (state) => { + if (state === ContentDecryptorState.WaitingForAttachment) { + + const isMediaLinked = createSharedReference(false); + isMediaLinked.onUpdate((isAttached, stopListening) => { + if (isAttached) { + stopListening(); + if (state === ContentDecryptorState.WaitingForAttachment) { + contentDecryptor.attach(); + } + } + }, { clearSignal: decryptorCanceller.signal }); + drmStatusRef.setValue({ initializationState: { type: "awaiting-media-link", + value: { isMediaLinked } }, + drmSystemId: contentDecryptor.systemId }); + } else if (state === ContentDecryptorState.ReadyForContent) { + drmStatusRef.setValue({ initializationState: { type: "initialized", + value: null }, + drmSystemId: contentDecryptor.systemId }); + contentDecryptor.removeEventListener("stateChange"); + } + }); + + contentDecryptor.addEventListener("error", (error) => { + decryptorCanceller.cancel(); + callbacks.onError(error); + }); + + contentDecryptor.addEventListener("warning", (error) => { + callbacks.onWarning(error); + }); + + protectionRef.onUpdate((data) => { + if (data === null) { + return; + } + contentDecryptor.onInitializationData(data); + }, { clearSignal: decryptorCanceller.signal }); + + decryptorCanceller.signal.register(() => { + contentDecryptor.dispose(); + }); + + return drmStatusRef; +} + +/** Status of content decryption initialization. */ +interface IDrmInitializationStatus { + /** Current initialization state the decryption logic is in. */ + initializationState : IDecryptionInitializationState; + /** + * If set, corresponds to the hex string describing the current key system + * used. + * `undefined` if unknown or if it does not apply. + */ + drmSystemId : string | undefined; +} + +/** Initialization steps to add decryption capabilities to an `HTMLMediaElement`. */ +type IDecryptionInitializationState = + /** + * Decryption capabilities have not been initialized yet. + * You should wait before performing any action on the concerned + * `HTMLMediaElement` (such as linking a content / `MediaSource` to it). + */ + { type: "uninitialized"; value: null } | + /** + * The `MediaSource` or media url has to be linked to the `HTMLMediaElement` + * before continuing. + * Once it has been linked with success (e.g. the `MediaSource` has "opened"), + * the `isMediaLinked` `ISharedReference` should be set to `true`. + * + * In the `MediaSource` case, you should wait until the `"initialized"` + * state before pushing segment. + * + * Note that the `"awaiting-media-link"` is an optional state. It can be + * skipped to directly `"initialized"` instead. + */ + { type: "awaiting-media-link"; + value: { isMediaLinked : ISharedReference }; } | + /** + * The `MediaSource` or media url can be linked AND segments can be pushed to + * the `HTMLMediaElement` on which decryption capabilities were wanted. + */ + { type: "initialized"; value: null }; diff --git a/src/core/init/utils/manifest_update_scheduler.ts b/src/core/init/utils/manifest_update_scheduler.ts new file mode 100644 index 0000000000..de855a102a --- /dev/null +++ b/src/core/init/utils/manifest_update_scheduler.ts @@ -0,0 +1,341 @@ +/** + * 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. + */ + +import config from "../../../config"; +import log from "../../../log"; +import Manifest from "../../../manifest"; +import { IPlayerError } from "../../../public_types"; +import noop from "../../../utils/noop"; +import TaskCanceller from "../../../utils/task_canceller"; +import { ManifestFetcher } from "../../fetchers"; + +/** + * Refresh the Manifest at the right time. + * @param {Object} lastManifestResponse - Information about the last loading + * operation of the manifest. + * @param {Object} manifestFetcher - Interface allowing to refresh the Manifest. + * @param {number} minimumManifestUpdateInterval - Minimum interval to keep + * between Manifest updates. + * @param {Function} onWarning - Callback called when a minor error occurs. + * @param {Function} onError - Callback called when a major error occured, + * leading to a complete stop of Manifest refresh. + * @returns {Object} - Manifest Update Scheduler Interface allowing to manually + * schedule Manifest refresh and to stop them at any time. + */ +export default function createManifestUpdateScheduler( + lastManifestResponse : { manifest : Manifest; + sendingTime? : number | undefined; + receivedTime? : number | undefined; + parsingTime? : number | undefined; }, + manifestFetcher : ManifestFetcher, + minimumManifestUpdateInterval : number, + onWarning : (err : IPlayerError) => void, + onError : (err : unknown) => void +) : IManifestUpdateScheduler { + /** + * `TaskCanceller` allowing to cancel the refresh operation from ever + * happening. + * Used to dispose of this `IManifestUpdateScheduler`. + */ + const canceller = new TaskCanceller(); + + /** Function used to manually schedule a Manifest refresh. */ + let scheduleManualRefresh : (settings : IManifestRefreshSettings) => void = noop; + + /** + * Set to `true` when a Manifest refresh is currently pending. + * Allows to avoid doing multiple concurrent Manifest refresh, as this is + * most of the time unnecessary. + */ + let isRefreshAlreadyPending = false; + + // The Manifest always keeps the same reference + const { manifest } = lastManifestResponse; + + /** Number of consecutive times the parsing has been done in `unsafeMode`. */ + let consecutiveUnsafeMode = 0; + + /* Start-up the logic now. */ + recursivelyRefreshManifest(lastManifestResponse); + + return { + forceRefresh(settings : IManifestRefreshSettings) : void { + scheduleManualRefresh(settings); + }, + stop() : void { + scheduleManualRefresh = noop; + canceller.cancel(); + }, + }; + + /** + * Performs Manifest refresh (recursively) when it judges it is time to do so. + * @param {Object} manifestRequestInfos - Various information linked to the + * last Manifest loading and parsing operations. + */ + function recursivelyRefreshManifest( + { sendingTime, parsingTime, updatingTime } : { sendingTime?: number | undefined; + parsingTime? : number | undefined; + updatingTime? : number | undefined; } + ) : void { + const { MAX_CONSECUTIVE_MANIFEST_PARSING_IN_UNSAFE_MODE, + MIN_MANIFEST_PARSING_TIME_TO_ENTER_UNSAFE_MODE } = config.getCurrent(); + + /** + * Total time taken to fully update the last Manifest, in milliseconds. + * Note: this time also includes possible requests done by the parsers. + */ + const totalUpdateTime = parsingTime !== undefined ? + parsingTime + (updatingTime ?? 0) : + undefined; + + /** + * "unsafeMode" is a mode where we unlock advanced Manifest parsing + * optimizations with the added risk to lose some information. + * `unsafeModeEnabled` is set to `true` when the `unsafeMode` is enabled. + * + * Only perform parsing in `unsafeMode` when the last full parsing took a + * lot of time and do not go higher than the maximum consecutive time. + */ + + const unsafeModeEnabled = consecutiveUnsafeMode > 0 ? + consecutiveUnsafeMode < MAX_CONSECUTIVE_MANIFEST_PARSING_IN_UNSAFE_MODE : + totalUpdateTime !== undefined ? + (totalUpdateTime >= MIN_MANIFEST_PARSING_TIME_TO_ENTER_UNSAFE_MODE) : + false; + + /** Time elapsed since the beginning of the Manifest request, in milliseconds. */ + const timeSinceRequest = sendingTime === undefined ? 0 : + performance.now() - sendingTime; + + /** Minimum update delay we should not go below, in milliseconds. */ + const minInterval = Math.max(minimumManifestUpdateInterval - timeSinceRequest, 0); + + /** + * Multiple refresh trigger are scheduled here, but only the first one should + * be effectively considered. + * `nextRefreshCanceller` will allow to cancel every other when one is triggered. + */ + const nextRefreshCanceller = new TaskCanceller({ cancelOn: canceller.signal }); + + /* Function to manually schedule a Manifest refresh */ + scheduleManualRefresh = (settings : IManifestRefreshSettings) => { + const { completeRefresh, delay, canUseUnsafeMode } = settings; + const unsafeMode = canUseUnsafeMode && unsafeModeEnabled; + // The value allows to set a delay relatively to the last Manifest refresh + // (to avoid asking for it too often). + const timeSinceLastRefresh = sendingTime === undefined ? + 0 : + performance.now() - sendingTime; + const _minInterval = Math.max(minimumManifestUpdateInterval - timeSinceLastRefresh, + 0); + const timeoutId = setTimeout(() => { + nextRefreshCanceller.cancel(); + triggerNextManifestRefresh({ completeRefresh, unsafeMode }); + }, Math.max((delay ?? 0) - timeSinceLastRefresh, _minInterval)); + nextRefreshCanceller.signal.register(() => { + clearTimeout(timeoutId); + }); + }; + + /* Handle Manifest expiration. */ + if (manifest.expired !== null) { + const timeoutId = setTimeout(() => { + manifest.expired?.then(() => { + nextRefreshCanceller.cancel(); + triggerNextManifestRefresh({ completeRefresh: true, + unsafeMode: unsafeModeEnabled }); + }, noop /* `expired` should not reject */); + }, minInterval); + nextRefreshCanceller.signal.register(() => { + clearTimeout(timeoutId); + }); + } + + /* + * Trigger Manifest refresh when the Manifest needs to be refreshed + * according to the Manifest's internal properties (parsing time is also + * taken into account in this operation to avoid refreshing too often). + */ + if (manifest.lifetime !== undefined && manifest.lifetime >= 0) { + /** Regular refresh delay as asked by the Manifest. */ + const regularRefreshDelay = manifest.lifetime * 1000 - timeSinceRequest; + + /** Actually choosen delay to refresh the Manifest. */ + let actualRefreshInterval : number; + + if (totalUpdateTime === undefined) { + actualRefreshInterval = regularRefreshDelay; + } else if (manifest.lifetime < 3 && totalUpdateTime >= 100) { + // If Manifest update is very frequent and we take time to update it, + // postpone it. + actualRefreshInterval = Math.min( + Math.max( + // Take 3 seconds as a default safe value for a base interval. + 3000 - timeSinceRequest, + // Add update time to the original interval. + Math.max(regularRefreshDelay, 0) + totalUpdateTime + ), + + // Limit the postponment's higher bound to a very high value relative + // to `regularRefreshDelay`. + // This avoid perpetually postponing a Manifest update when + // performance seems to have been abysmal one time. + regularRefreshDelay * 6 + ); + log.info("MUS: Manifest update rythm is too frequent. Postponing next request.", + regularRefreshDelay, + actualRefreshInterval); + } else if (totalUpdateTime >= (manifest.lifetime * 1000) / 10) { + // If Manifest updating time is very long relative to its lifetime, + // postpone it: + actualRefreshInterval = Math.min( + // Just add the update time to the original waiting time + Math.max(regularRefreshDelay, 0) + totalUpdateTime, + + // Limit the postponment's higher bound to a very high value relative + // to `regularRefreshDelay`. + // This avoid perpetually postponing a Manifest update when + // performance seems to have been abysmal one time. + regularRefreshDelay * 6); + log.info("MUS: Manifest took too long to parse. Postponing next request", + actualRefreshInterval, + actualRefreshInterval); + } else { + actualRefreshInterval = regularRefreshDelay; + } + const timeoutId = setTimeout(() => { + nextRefreshCanceller.cancel(); + triggerNextManifestRefresh({ completeRefresh: false, + unsafeMode: unsafeModeEnabled }); + }, Math.max(actualRefreshInterval, minInterval)); + nextRefreshCanceller.signal.register(() => { + clearTimeout(timeoutId); + }); + } + } + + /** + * Refresh the Manifest, performing a full update if a partial update failed. + * Also re-call `recursivelyRefreshManifest` to schedule the next refresh + * trigger. + * @param {Object} refreshInformation + */ + function triggerNextManifestRefresh( + { completeRefresh, + unsafeMode } : { completeRefresh : boolean; + unsafeMode : boolean; } + ) { + const manifestUpdateUrl = manifest.updateUrl; + const fullRefresh = completeRefresh || manifestUpdateUrl === undefined; + const refreshURL = fullRefresh ? manifest.getUrl() : + manifestUpdateUrl; + const externalClockOffset = manifest.clockOffset; + + if (unsafeMode) { + consecutiveUnsafeMode += 1; + log.info("Init: Refreshing the Manifest in \"unsafeMode\" for the " + + String(consecutiveUnsafeMode) + " consecutive time."); + } else if (consecutiveUnsafeMode > 0) { + log.info("Init: Not parsing the Manifest in \"unsafeMode\" anymore after " + + String(consecutiveUnsafeMode) + " consecutive times."); + consecutiveUnsafeMode = 0; + } + + if (isRefreshAlreadyPending) { + return; + } + isRefreshAlreadyPending = true; + manifestFetcher.fetch(refreshURL, onWarning, canceller.signal) + .then(res => res.parse({ externalClockOffset, + previousManifest: manifest, + unsafeMode })) + .then(res => { + isRefreshAlreadyPending = false; + const { manifest: newManifest, + sendingTime: newSendingTime, + parsingTime } = res; + const updateTimeStart = performance.now(); + + if (fullRefresh) { + manifest.replace(newManifest); + } else { + try { + manifest.update(newManifest); + } catch (e) { + const message = e instanceof Error ? e.message : + "unknown error"; + log.warn(`MUS: Attempt to update Manifest failed: ${message}`, + "Re-downloading the Manifest fully"); + const { FAILED_PARTIAL_UPDATE_MANIFEST_REFRESH_DELAY } = config.getCurrent(); + + // The value allows to set a delay relatively to the last Manifest refresh + // (to avoid asking for it too often). + const timeSinceLastRefresh = newSendingTime === undefined ? + 0 : + performance.now() - newSendingTime; + const _minInterval = Math.max(minimumManifestUpdateInterval - + timeSinceLastRefresh, + 0); + let unregisterCanceller = noop; + const timeoutId = setTimeout(() => { + unregisterCanceller(); + triggerNextManifestRefresh({ completeRefresh: true, unsafeMode: false }); + }, Math.max(FAILED_PARTIAL_UPDATE_MANIFEST_REFRESH_DELAY - + timeSinceLastRefresh, + _minInterval)); + unregisterCanceller = canceller.signal.register(() => { + clearTimeout(timeoutId); + }); + return; + } + } + const updatingTime = performance.now() - updateTimeStart; + recursivelyRefreshManifest({ sendingTime: newSendingTime, + parsingTime, + updatingTime }); + }) + .catch((err) => { + isRefreshAlreadyPending = false; + onError(err); + }); + } +} + +export interface IManifestRefreshSettings { + /** + * if `true`, the Manifest should be fully updated. + * if `false`, a shorter version with just the added information can be loaded + * instead. + */ + completeRefresh : boolean; + /** + * Optional wanted refresh delay, which is the minimum time you want to wait + * before updating the Manifest + */ + delay? : number | undefined; + /** + * Whether the parsing can be done in the more efficient "unsafeMode". + * This mode is extremely fast but can lead to de-synchronisation with the + * server. + */ + canUseUnsafeMode : boolean; +} + +export interface IManifestUpdateScheduler { + forceRefresh(settings : IManifestRefreshSettings) : void; + stop() : void; +} diff --git a/src/core/init/media_duration_updater.ts b/src/core/init/utils/media_duration_updater.ts similarity index 51% rename from src/core/init/media_duration_updater.ts rename to src/core/init/utils/media_duration_updater.ts index 1217a19c0c..4c2f2467ed 100644 --- a/src/core/init/media_duration_updater.ts +++ b/src/core/init/utils/media_duration_updater.ts @@ -15,32 +15,19 @@ */ import { - combineLatest as observableCombineLatest, - distinctUntilChanged, - EMPTY, - fromEvent as observableFromEvent, - interval as observableInterval, - map, - merge as observableMerge, - mergeMap, - Observable, - of as observableOf, - startWith, - Subscription, - switchMap, - timer, -} from "rxjs"; -import { - onSourceOpen$, - onSourceClose$, - onSourceEnded$, -} from "../../compat/event_listeners"; -import log from "../../log"; -import Manifest from "../../manifest"; -import { fromEvent } from "../../utils/event_emitter"; + onSourceOpen, + onSourceEnded, + onSourceClose, +} from "../../../compat/event_listeners"; +import log from "../../../log"; +import Manifest from "../../../manifest"; import createSharedReference, { + IReadOnlySharedReference, ISharedReference, -} from "../../utils/reference"; +} from "../../../utils/reference"; +import TaskCanceller, { + CancellationSignal, +} from "../../../utils/task_canceller"; /** Number of seconds in a regular year. */ const YEAR_IN_SECONDS = 365 * 24 * 3600; @@ -50,14 +37,15 @@ const YEAR_IN_SECONDS = 365 * 24 * 3600; * @class MediaDurationUpdater */ export default class MediaDurationUpdater { - private _subscription : Subscription; + private _canceller : TaskCanceller; + /** * The last known audio Adaptation (i.e. track) chosen for the last Period. * Useful to determinate the duration of the current content. * `undefined` if the audio track for the last Period has never been known yet. * `null` if there are no chosen audio Adaptation. */ - private _lastKnownDuration : ISharedReference; + private _currentKnownDuration : ISharedReference; /** * Create a new `MediaDurationUpdater` that will keep the given MediaSource's @@ -69,30 +57,74 @@ export default class MediaDurationUpdater { * pushed. */ constructor(manifest : Manifest, mediaSource : MediaSource) { - this._lastKnownDuration = createSharedReference(undefined); - this._subscription = isMediaSourceOpened$(mediaSource).pipe( - switchMap((canUpdate) => - canUpdate ? observableCombineLatest([this._lastKnownDuration.asObservable(), - fromEvent(manifest, "manifestUpdate") - .pipe(startWith(null))]) : - EMPTY - ), - switchMap(([lastKnownDuration]) => - areSourceBuffersUpdating$(mediaSource.sourceBuffers).pipe( - switchMap((areSBUpdating) => { - return areSBUpdating ? EMPTY : - recursivelyTryUpdatingDuration(); - function recursivelyTryUpdatingDuration() : Observable { - const res = setMediaSourceDuration(mediaSource, - manifest, - lastKnownDuration); - if (res === MediaSourceDurationUpdateStatus.Success) { - return EMPTY; - } - return timer(2000) - .pipe(mergeMap(() => recursivelyTryUpdatingDuration())); - } - })))).subscribe(); + const canceller = new TaskCanceller(); + const currentKnownDuration = createSharedReference(undefined); + + this._canceller = canceller; + this._currentKnownDuration = currentKnownDuration; + + const isMediaSourceOpened = createMediaSourceOpenReference(mediaSource, + this._canceller.signal); + + /** TaskCanceller triggered each time the MediaSource open status changes. */ + let msUpdateCanceller = new TaskCanceller({ cancelOn: this._canceller.signal }); + + isMediaSourceOpened.onUpdate(onMediaSourceOpenedStatusChanged, + { emitCurrentValue: true, + clearSignal: this._canceller.signal }); + + + function onMediaSourceOpenedStatusChanged() { + msUpdateCanceller.cancel(); + if (!isMediaSourceOpened.getValue()) { + return; + } + msUpdateCanceller = new TaskCanceller({ cancelOn: canceller.signal }); + + /** TaskCanceller triggered each time the content's duration may have change */ + let durationChangeCanceller = new TaskCanceller({ + cancelOn: msUpdateCanceller.signal, + }); + + const reSetDuration = () => { + durationChangeCanceller.cancel(); + durationChangeCanceller = new TaskCanceller({ + cancelOn: msUpdateCanceller.signal, + }); + onDurationMayHaveChanged(durationChangeCanceller.signal); + }; + + currentKnownDuration.onUpdate(reSetDuration, + { emitCurrentValue: false, + clearSignal: msUpdateCanceller.signal }); + + manifest.addEventListener("manifestUpdate", + reSetDuration, + msUpdateCanceller.signal); + + onDurationMayHaveChanged(durationChangeCanceller.signal); + } + + function onDurationMayHaveChanged(cancelSignal : CancellationSignal) { + const areSourceBuffersUpdating = createSourceBuffersUpdatingReference( + mediaSource.sourceBuffers, + cancelSignal + ); + + /** TaskCanceller triggered each time SourceBuffers' updating status changes */ + let sourceBuffersUpdatingCanceller = new TaskCanceller({ cancelOn: cancelSignal }); + return areSourceBuffersUpdating.onUpdate((areUpdating) => { + sourceBuffersUpdatingCanceller.cancel(); + sourceBuffersUpdatingCanceller = new TaskCanceller({ cancelOn: cancelSignal }); + if (areUpdating) { + return; + } + recursivelyForceDurationUpdate(mediaSource, + manifest, + currentKnownDuration.getValue(), + cancelSignal); + }, { clearSignal: cancelSignal, emitCurrentValue: true }); + } } /** @@ -107,7 +139,7 @@ export default class MediaDurationUpdater { public updateKnownDuration( newDuration : number | undefined ) : void { - this._lastKnownDuration.setValue(newDuration); + this._currentKnownDuration.setValueIfChanged(newDuration); } /** @@ -116,7 +148,7 @@ export default class MediaDurationUpdater { * `MediaDurationUpdater`. */ public stop() { - this._subscription.unsubscribe(); + this._canceller.cancel(); } } @@ -221,50 +253,103 @@ const enum MediaSourceDurationUpdateStatus { } /** - * Returns an Observable which will emit only when all the SourceBuffers ended - * all pending updates. + * Returns an `ISharedReference` wrapping a boolean that tells if all the + * SourceBuffers ended all pending updates. * @param {SourceBufferList} sourceBuffers - * @returns {Observable} + * @param {Object} cancelSignal + * @returns {Object} */ -function areSourceBuffersUpdating$( - sourceBuffers: SourceBufferList -) : Observable { +function createSourceBuffersUpdatingReference( + sourceBuffers : SourceBufferList, + cancelSignal : CancellationSignal +) : IReadOnlySharedReference { + // const areUpdating = createSharedReference( if (sourceBuffers.length === 0) { - return observableOf(false); + const notOpenedRef = createSharedReference(false); + notOpenedRef.finish(); + return notOpenedRef; } - const sourceBufferUpdatingStatuses : Array> = []; + + const areUpdatingRef = createSharedReference(false); + reCheck(); for (let i = 0; i < sourceBuffers.length; i++) { const sourceBuffer = sourceBuffers[i]; - sourceBufferUpdatingStatuses.push( - observableMerge( - observableFromEvent(sourceBuffer, "updatestart").pipe(map(() => true)), - observableFromEvent(sourceBuffer, "update").pipe(map(() => false)), - observableInterval(500).pipe(map(() => sourceBuffer.updating)) - ).pipe( - startWith(sourceBuffer.updating), - distinctUntilChanged() - ) - ); + sourceBuffer.addEventListener("updatestart", reCheck); + sourceBuffer.addEventListener("update", reCheck); + cancelSignal.register(() => { + sourceBuffer.removeEventListener("updatestart", reCheck); + sourceBuffer.removeEventListener("update", reCheck); + }); + } + + return areUpdatingRef; + + function reCheck() { + for (let i = 0; i < sourceBuffers.length; i++) { + const sourceBuffer = sourceBuffers[i]; + if (sourceBuffer.updating) { + areUpdatingRef.setValueIfChanged(true); + return; + } + } + areUpdatingRef.setValueIfChanged(false); } - return observableCombineLatest(sourceBufferUpdatingStatuses).pipe( - map((areUpdating) => { - return areUpdating.some((isUpdating) => isUpdating); - }), - distinctUntilChanged()); } /** - * Emit a boolean that tells if the media source is opened or not. + * Returns an `ISharedReference` wrapping a boolean that tells if the media + * source is opened or not. * @param {MediaSource} mediaSource + * @param {Object} cancelSignal * @returns {Object} */ -function isMediaSourceOpened$(mediaSource: MediaSource): Observable { - return observableMerge(onSourceOpen$(mediaSource).pipe(map(() => true)), - onSourceEnded$(mediaSource).pipe(map(() => false)), - onSourceClose$(mediaSource).pipe(map(() => false)) - ).pipe( - startWith(mediaSource.readyState === "open"), - distinctUntilChanged() - ); +function createMediaSourceOpenReference( + mediaSource : MediaSource, + cancelSignal : CancellationSignal +): IReadOnlySharedReference { + const isMediaSourceOpen = createSharedReference(mediaSource.readyState === "open"); + onSourceOpen(mediaSource, () => { + isMediaSourceOpen.setValueIfChanged(true); + }, cancelSignal); + onSourceEnded(mediaSource, () => { + isMediaSourceOpen.setValueIfChanged(false); + }, cancelSignal); + onSourceClose(mediaSource, () => { + isMediaSourceOpen.setValueIfChanged(false); + }, cancelSignal); + cancelSignal.register(() => { + isMediaSourceOpen.finish(); + }); + return isMediaSourceOpen; +} + +/** + * Immediately tries to set the MediaSource's duration to the most appropriate + * one according to the Manifest and duration given. + * + * If it fails, wait 2 seconds and retries. + * + * @param {MediaSource} mediaSource + * @param {Object} manifest + * @param {number|undefined} duration + * @param {Object} cancelSignal + */ +function recursivelyForceDurationUpdate( + mediaSource : MediaSource, + manifest : Manifest, + duration : number | undefined, + cancelSignal : CancellationSignal +) : void { + const res = setMediaSourceDuration(mediaSource, manifest, duration); + if (res === MediaSourceDurationUpdateStatus.Success) { + return ; + } + const timeoutId = setTimeout(() => { + unregisterClear(); + recursivelyForceDurationUpdate(mediaSource, manifest, duration, cancelSignal); + }, 2000); + const unregisterClear = cancelSignal.register(() => { + clearTimeout(timeoutId); + }); } diff --git a/src/core/init/rebuffering_controller.ts b/src/core/init/utils/rebuffering_controller.ts similarity index 66% rename from src/core/init/rebuffering_controller.ts rename to src/core/init/utils/rebuffering_controller.ts index d91cf1cba6..efa1b6824c 100644 --- a/src/core/init/rebuffering_controller.ts +++ b/src/core/init/utils/rebuffering_controller.ts @@ -14,38 +14,24 @@ * limitations under the License. */ -import { - finalize, - ignoreElements, - map, - merge as observableMerge, - Observable, - scan, - tap, - withLatestFrom, -} from "rxjs"; -import isSeekingApproximate from "../../compat/is_seeking_approximate"; -import config from "../../config"; -import { MediaError } from "../../errors"; -import log from "../../log"; +import isSeekingApproximate from "../../../compat/is_seeking_approximate"; +import config from "../../../config"; +import { MediaError } from "../../../errors"; +import log from "../../../log"; import Manifest, { Period, -} from "../../manifest"; -import { getNextRangeGap } from "../../utils/ranges"; -import { IReadOnlySharedReference } from "../../utils/reference"; -import TaskCanceller from "../../utils/task_canceller"; +} from "../../../manifest"; +import { IPlayerError } from "../../../public_types"; +import EventEmitter from "../../../utils/event_emitter"; +import { getNextRangeGap } from "../../../utils/ranges"; +import { IReadOnlySharedReference } from "../../../utils/reference"; +import TaskCanceller from "../../../utils/task_canceller"; import { IPlaybackObservation, PlaybackObserver, -} from "../api"; -import { IBufferType } from "../segment_buffers"; -import EVENTS from "../stream/events_generators"; -import { - IStalledEvent, - IStallingSituation, - IUnstalledEvent, - IWarningEvent, -} from "./types"; +} from "../../api"; +import { IBufferType } from "../../segment_buffers"; +import { IStallingSituation } from "../types"; /** @@ -54,70 +40,6 @@ import { */ const EPSILON = 1 / 60; -export interface ILockedStreamEvent { - /** Buffer type for which no segment will currently load. */ - bufferType : IBufferType; - /** Period for which no segment will currently load. */ - period : Period; -} - -/** - * Event indicating that a discontinuity has been found. - * Each event for a `bufferType` and `period` combination replaces the previous - * one. - */ -export interface IDiscontinuityEvent { - /** Buffer type concerned by the discontinuity. */ - bufferType : IBufferType; - /** Period concerned by the discontinuity. */ - period : Period; - /** - * Close discontinuity time information. - * `null` if no discontinuity has been detected currently for that buffer - * type and Period. - */ - discontinuity : IDiscontinuityTimeInfo | null; - /** - * Position at which the discontinuity was found. - * Can be important for when a current discontinuity's start is unknown. - */ - position : number; -} - -/** Information on a found discontinuity. */ -export interface IDiscontinuityTimeInfo { - /** - * Start time of the discontinuity. - * `undefined` for when the start is unknown but the discontinuity was - * currently encountered at the position we were in when this event was - * created. - */ - start : number | undefined; - /** - * End time of the discontinuity, in seconds. - * If `null`, no further segment can be loaded for the corresponding Period. - */ - end : number | null; -} - -/** - * Internally stored information about a known discontinuity in the audio or - * video buffer. - */ -interface IDiscontinuityStoredInfo { - /** Buffer type concerned by the discontinuity. */ - bufferType : IBufferType; - /** Period concerned by the discontinuity. */ - period : Period; - /** Discontinuity time information. */ - discontinuity : IDiscontinuityTimeInfo; - /** - * Position at which the discontinuity was found. - * Can be important for when a current discontinuity's start is unknown. - */ - position : number; -} - /** * Monitor playback, trying to avoid stalling situation. * If stopping the player to build buffer is needed, temporarily set the @@ -125,105 +47,86 @@ interface IDiscontinuityStoredInfo { * * Emit "stalled" then "unstalled" respectively when an unavoidable stall is * encountered and exited. - * @param {object} playbackObserver - emit the current playback conditions. - * @param {Object} manifest - The Manifest of the currently-played content. - * @param {Object} speed - The last speed set by the user - * @param {Observable} lockedStream$ - Emit information on currently "locked" - * streams. - * @param {Observable} discontinuityUpdate$ - Observable emitting encountered - * discontinuities for loaded Period and buffer types. - * @returns {Observable} */ -export default function RebufferingController( - playbackObserver : PlaybackObserver, - manifest: Manifest | null, - speed : IReadOnlySharedReference, - lockedStream$ : Observable, - discontinuityUpdate$: Observable -) : Observable { - const initialDiscontinuitiesStore : IDiscontinuityStoredInfo[] = []; +export default class RebufferingController + extends EventEmitter { + + /** Emit the current playback conditions */ + private _playbackObserver : PlaybackObserver; + private _manifest : Manifest | null; + private _speed : IReadOnlySharedReference; + private _isStarted : boolean; /** - * Emit every known audio and video buffer discontinuities in chronological + * Every known audio and video buffer discontinuities in chronological * order (first ordered by Period's start, then by bufferType in any order. */ - const discontinuitiesStore$ = discontinuityUpdate$.pipe( - withLatestFrom(playbackObserver.getReference().asObservable()), - scan( - (discontinuitiesStore, [evt, observation]) => - updateDiscontinuitiesStore(discontinuitiesStore, evt, observation), - initialDiscontinuitiesStore)); + private _discontinuitiesStore : IDiscontinuityStoredInfo[]; - /** - * On some devices (right now only seen on Tizen), seeking through the - * `currentTime` property can lead to the browser re-seeking once the - * segments have been loaded to improve seeking performances (for - * example, by seeking right to an intra video frame). - * In that case, we risk being in a conflict with that behavior: if for - * example we encounter a small discontinuity at the position the browser - * seeks to, we will seek over it, the browser would seek back and so on. - * - * This variable allows to store the last known position we were seeking to - * so we can detect when the browser seeked back (to avoid performing another - * seek after that). When browsers seek back to a position behind a - * discontinuity, they are usually able to skip them without our help. - */ - let lastSeekingPosition : number | null = null; + private _canceller : TaskCanceller; /** - * In some conditions (see `lastSeekingPosition`), we might want to not - * automatically seek over discontinuities because the browser might do it - * itself instead. - * In that case, we still want to perform the seek ourselves if the browser - * doesn't do it after sufficient time. - * This variable allows to store the timestamp at which a discontinuity began - * to be ignored. + * @param {object} playbackObserver - emit the current playback conditions. + * @param {Object} manifest - The Manifest of the currently-played content. + * @param {Object} speed - The last speed set by the user */ - let ignoredStallTimeStamp : number | null = null; + constructor( + playbackObserver : PlaybackObserver, + manifest: Manifest | null, + speed : IReadOnlySharedReference + ) { + super(); + this._playbackObserver = playbackObserver; + this._manifest = manifest; + this._speed = speed; + this._discontinuitiesStore = []; + this._isStarted = false; + this._canceller = new TaskCanceller(); + } - let prevFreezingState : { attemptTimestamp : number } | null; + public start() : void { + if (this._isStarted) { + return; + } + this._isStarted = true; + + /** + * On some devices (right now only seen on Tizen), seeking through the + * `currentTime` property can lead to the browser re-seeking once the + * segments have been loaded to improve seeking performances (for + * example, by seeking right to an intra video frame). + * In that case, we risk being in a conflict with that behavior: if for + * example we encounter a small discontinuity at the position the browser + * seeks to, we will seek over it, the browser would seek back and so on. + * + * This variable allows to store the last known position we were seeking to + * so we can detect when the browser seeked back (to avoid performing another + * seek after that). When browsers seek back to a position behind a + * discontinuity, they are usually able to skip them without our help. + */ + let lastSeekingPosition : number | null; + + /** + * In some conditions (see `lastSeekingPosition`), we might want to not + * automatically seek over discontinuities because the browser might do it + * itself instead. + * In that case, we still want to perform the seek ourselves if the browser + * doesn't do it after sufficient time. + * This variable allows to store the timestamp at which a discontinuity began + * to be ignored. + */ + let ignoredStallTimeStamp : number | null = null; + + const playbackRateUpdater = new PlaybackRateUpdater(this._playbackObserver, + this._speed); + this._canceller.signal.register(() => { + playbackRateUpdater.dispose(); + }); - /** - * If we're rebuffering waiting on data of a "locked stream", seek into the - * Period handled by that stream to unlock the situation. - */ - const unlock$ = lockedStream$.pipe( - withLatestFrom(playbackObserver.getReference().asObservable()), - tap(([lockedStreamEvt, observation]) => { - if ( - !observation.rebuffering || - observation.paused || - speed.getValue() <= 0 || ( - lockedStreamEvt.bufferType !== "audio" && - lockedStreamEvt.bufferType !== "video" - ) - ) { - return; - } - const currPos = observation.position; - const rebufferingPos = observation.rebuffering.position ?? currPos; - const lockedPeriodStart = lockedStreamEvt.period.start; - if (currPos < lockedPeriodStart && - Math.abs(rebufferingPos - lockedPeriodStart) < 1) - { - log.warn("Init: rebuffering because of a future locked stream.\n" + - "Trying to unlock by seeking to the next Period"); - playbackObserver.setCurrentTime(lockedPeriodStart + 0.001); - } - }), - // NOTE As of now (RxJS 7.4.0), RxJS defines `ignoreElements` default - // first type parameter as `any` instead of the perfectly fine `unknown`, - // leading to linter issues, as it forbids the usage of `any`. - // This is why we're disabling the eslint rule. - /* eslint-disable-next-line @typescript-eslint/no-unsafe-argument */ - ignoreElements() - ); - - const playbackRateUpdater = new PlaybackRateUpdater(playbackObserver, speed); - - const stall$ = playbackObserver.getReference().asObservable().pipe( - withLatestFrom(discontinuitiesStore$), - map(([observation, discontinuitiesStore]) => { + let prevFreezingState : { attemptTimestamp : number } | null; + + this._playbackObserver.listen((observation) => { + const discontinuitiesStore = this._discontinuitiesStore; const { buffered, position, readyState, @@ -240,9 +143,8 @@ export default function RebufferingController( !observation.seeking && isSeekingApproximate && ignoredStallTimeStamp === null && - lastSeekingPosition !== null && - observation.position < lastSeekingPosition - ) { + lastSeekingPosition !== null && observation.position < lastSeekingPosition) + { log.debug("Init: the device appeared to have seeked back by itself."); const now = performance.now(); ignoredStallTimeStamp = now; @@ -261,8 +163,8 @@ export default function RebufferingController( if (now - referenceTimestamp > UNFREEZING_SEEK_DELAY) { log.warn("Init: trying to seek to un-freeze player"); - playbackObserver.setCurrentTime( - playbackObserver.getCurrentTime() + UNFREEZING_DELTA_POSITION); + this._playbackObserver.setCurrentTime( + this._playbackObserver.getCurrentTime() + UNFREEZING_DELTA_POSITION); prevFreezingState = { attemptTimestamp: now }; } @@ -272,8 +174,8 @@ export default function RebufferingController( } else { playbackRateUpdater.startRebuffering(); } - return { type: "stalled" as const, - value: "freezing" as const }; + this.trigger("stalled", "freezing"); + return; } } else { prevFreezingState = null; @@ -291,11 +193,11 @@ export default function RebufferingController( } else { reason = "not-ready"; } - return { type: "stalled" as const, - value: reason }; + this.trigger("stalled", reason); + return ; } - return { type: "unstalled" as const, - value: null }; + this.trigger("unstalled", null); + return ; } // We want to separate a stall situation when a seek is due to a seek done @@ -310,8 +212,8 @@ export default function RebufferingController( if (now - ignoredStallTimeStamp < FORCE_DISCONTINUITY_SEEK_DELAY) { playbackRateUpdater.stopRebuffering(); log.debug("Init: letting the device get out of a stall by itself"); - return { type: "stalled" as const, - value: stalledReason }; + this.trigger("stalled", stalledReason); + return ; } else { log.warn("Init: ignored stall for too long, checking discontinuity", now - ignoredStallTimeStamp); @@ -321,9 +223,9 @@ export default function RebufferingController( ignoredStallTimeStamp = null; playbackRateUpdater.startRebuffering(); - if (manifest === null) { - return { type: "stalled" as const, - value: stalledReason }; + if (this._manifest === null) { + this.trigger("stalled", stalledReason); + return ; } /** Position at which data is awaited. */ @@ -331,22 +233,23 @@ export default function RebufferingController( if (stalledPosition !== null && stalledPosition !== undefined && - speed.getValue() > 0) + this._speed.getValue() > 0) { const skippableDiscontinuity = findSeekableDiscontinuity(discontinuitiesStore, - manifest, + this._manifest, stalledPosition); if (skippableDiscontinuity !== null) { const realSeekTime = skippableDiscontinuity + 0.001; - if (realSeekTime <= playbackObserver.getCurrentTime()) { + if (realSeekTime <= this._playbackObserver.getCurrentTime()) { log.info("Init: position to seek already reached, no seeking", - playbackObserver.getCurrentTime(), realSeekTime); + this._playbackObserver.getCurrentTime(), realSeekTime); } else { log.warn("SA: skippable discontinuity found in the stream", position, realSeekTime); - playbackObserver.setCurrentTime(realSeekTime); - return EVENTS.warning(generateDiscontinuityError(stalledPosition, - realSeekTime)); + this._playbackObserver.setCurrentTime(realSeekTime); + this.trigger("warning", generateDiscontinuityError(stalledPosition, + realSeekTime)); + return; } } } @@ -362,44 +265,89 @@ export default function RebufferingController( // case of small discontinuity in the content. const nextBufferRangeGap = getNextRangeGap(buffered, freezePosition); if ( - speed.getValue() > 0 && + this._speed.getValue() > 0 && nextBufferRangeGap < BUFFER_DISCONTINUITY_THRESHOLD ) { const seekTo = (freezePosition + nextBufferRangeGap + EPSILON); - if (playbackObserver.getCurrentTime() < seekTo) { + if (this._playbackObserver.getCurrentTime() < seekTo) { log.warn("Init: discontinuity encountered inferior to the threshold", freezePosition, seekTo, BUFFER_DISCONTINUITY_THRESHOLD); - playbackObserver.setCurrentTime(seekTo); - return EVENTS.warning(generateDiscontinuityError(freezePosition, seekTo)); + this._playbackObserver.setCurrentTime(seekTo); + this.trigger("warning", generateDiscontinuityError(freezePosition, seekTo)); + return; } } // Are we in a discontinuity between periods ? -> Seek at the beginning of the // next period - for (let i = manifest.periods.length - 2; i >= 0; i--) { - const period = manifest.periods[i]; + for (let i = this._manifest.periods.length - 2; i >= 0; i--) { + const period = this._manifest.periods[i]; if (period.end !== undefined && period.end <= freezePosition) { - if (manifest.periods[i + 1].start > freezePosition && - manifest.periods[i + 1].start > playbackObserver.getCurrentTime()) + if (this._manifest.periods[i + 1].start > freezePosition && + this._manifest.periods[i + 1].start > + this._playbackObserver.getCurrentTime()) { - const nextPeriod = manifest.periods[i + 1]; - playbackObserver.setCurrentTime(nextPeriod.start); - return EVENTS.warning(generateDiscontinuityError(freezePosition, - nextPeriod.start)); + const nextPeriod = this._manifest.periods[i + 1]; + this._playbackObserver.setCurrentTime(nextPeriod.start); + this.trigger("warning", generateDiscontinuityError(freezePosition, + nextPeriod.start)); + return; } break; } } - return { type: "stalled" as const, - value: stalledReason }; - })); + this.trigger("stalled", stalledReason); + }, { includeLastObservation: true, clearSignal: this._canceller.signal }); + } - return observableMerge(unlock$, stall$) - .pipe(finalize(() => { - playbackRateUpdater.dispose(); - })); + public updateDiscontinuityInfo(evt: IDiscontinuityEvent) : void { + if (!this._isStarted) { + this.start(); + } + const lastObservation = this._playbackObserver.getReference().getValue(); + updateDiscontinuitiesStore(this._discontinuitiesStore, evt, lastObservation); + } + + /** + * Function to call when a Stream is currently locked, i.e. we cannot load + * segments for the corresponding Period and buffer type until it is seeked + * to. + * @param {string} bufferType - Buffer type for which no segment will + * currently load. + * @param {Object} period - Period for which no segment will currently load. + */ + public onLockedStream(bufferType : IBufferType, period : Period) : void { + if (!this._isStarted) { + this.start(); + } + const observation = this._playbackObserver.getReference().getValue(); + if ( + !observation.rebuffering || + observation.paused || + this._speed.getValue() <= 0 || ( + bufferType !== "audio" && + bufferType !== "video" + ) + ) { + return; + } + const currPos = observation.position; + const rebufferingPos = observation.rebuffering.position ?? currPos; + const lockedPeriodStart = period.start; + if (currPos < lockedPeriodStart && + Math.abs(rebufferingPos - lockedPeriodStart) < 1) + { + log.warn("Init: rebuffering because of a future locked stream.\n" + + "Trying to unlock by seeking to the next Period"); + this._playbackObserver.setCurrentTime(lockedPeriodStart + 0.001); + } + } + + public destroy() : void { + this._canceller.cancel(); + } } /** @@ -483,7 +431,7 @@ function updateDiscontinuitiesStore( discontinuitiesStore : IDiscontinuityStoredInfo[], evt : IDiscontinuityEvent, observation : IPlaybackObservation -) : IDiscontinuityStoredInfo[] { +) : void { // First, perform clean-up of old discontinuities while (discontinuitiesStore.length > 0 && discontinuitiesStore[0].period.end !== undefined && @@ -494,7 +442,7 @@ function updateDiscontinuitiesStore( const { period, bufferType } = evt; if (bufferType !== "audio" && bufferType !== "video") { - return discontinuitiesStore; + return ; } for (let i = 0; i < discontinuitiesStore.length; i++) { @@ -505,19 +453,19 @@ function updateDiscontinuitiesStore( } else { discontinuitiesStore[i] = evt; } - return discontinuitiesStore; + return ; } } else if (discontinuitiesStore[i].period.start > period.start) { if (eventContainsDiscontinuity(evt)) { discontinuitiesStore.splice(i, 0, evt); } - return discontinuitiesStore; + return ; } } if (eventContainsDiscontinuity(evt)) { discontinuitiesStore.push(evt); } - return discontinuitiesStore; + return ; } /** @@ -619,3 +567,66 @@ class PlaybackRateUpdater { }, { clearSignal: this._speedUpdateCanceller.signal, emitCurrentValue: true }); } } + +export interface IRebufferingControllerEvent { + stalled : IStallingSituation; + unstalled : null; + warning : IPlayerError; +} + +/** + * Event indicating that a discontinuity has been found. + * Each event for a `bufferType` and `period` combination replaces the previous + * one. + */ +export interface IDiscontinuityEvent { + /** Buffer type concerned by the discontinuity. */ + bufferType : IBufferType; + /** Period concerned by the discontinuity. */ + period : Period; + /** + * Close discontinuity time information. + * `null` if no discontinuity has been detected currently for that buffer + * type and Period. + */ + discontinuity : IDiscontinuityTimeInfo | null; + /** + * Position at which the discontinuity was found. + * Can be important for when a current discontinuity's start is unknown. + */ + position : number; +} + +/** Information on a found discontinuity. */ +export interface IDiscontinuityTimeInfo { + /** + * Start time of the discontinuity. + * `undefined` for when the start is unknown but the discontinuity was + * currently encountered at the position we were in when this event was + * created. + */ + start : number | undefined; + /** + * End time of the discontinuity, in seconds. + * If `null`, no further segment can be loaded for the corresponding Period. + */ + end : number | null; +} + +/** + * Internally stored information about a known discontinuity in the audio or + * video buffer. + */ +interface IDiscontinuityStoredInfo { + /** Buffer type concerned by the discontinuity. */ + bufferType : IBufferType; + /** Period concerned by the discontinuity. */ + period : Period; + /** Discontinuity time information. */ + discontinuity : IDiscontinuityTimeInfo; + /** + * Position at which the discontinuity was found. + * Can be important for when a current discontinuity's start is unknown. + */ + position : number; +} diff --git a/src/core/init/stream_events_emitter/are_same_stream_events.ts b/src/core/init/utils/stream_events_emitter/are_same_stream_events.ts similarity index 100% rename from src/core/init/stream_events_emitter/are_same_stream_events.ts rename to src/core/init/utils/stream_events_emitter/are_same_stream_events.ts diff --git a/src/core/init/stream_events_emitter/index.ts b/src/core/init/utils/stream_events_emitter/index.ts similarity index 80% rename from src/core/init/stream_events_emitter/index.ts rename to src/core/init/utils/stream_events_emitter/index.ts index b51f44a6ed..9f92f7e510 100644 --- a/src/core/init/stream_events_emitter/index.ts +++ b/src/core/init/utils/stream_events_emitter/index.ts @@ -15,20 +15,8 @@ */ import streamEventsEmitter from "./stream_events_emitter"; -import { - IPublicNonFiniteStreamEvent, - IPublicStreamEvent, - IStreamEvent, - IStreamEventEvent, - IStreamEventSkipEvent, -} from "./types"; - export { - IStreamEvent, IPublicNonFiniteStreamEvent, IPublicStreamEvent, - - IStreamEventEvent, - IStreamEventSkipEvent, -}; +} from "./types"; export default streamEventsEmitter; diff --git a/src/core/init/stream_events_emitter/refresh_scheduled_events_list.ts b/src/core/init/utils/stream_events_emitter/refresh_scheduled_events_list.ts similarity index 98% rename from src/core/init/stream_events_emitter/refresh_scheduled_events_list.ts rename to src/core/init/utils/stream_events_emitter/refresh_scheduled_events_list.ts index e1f0474c57..5c3969cb7b 100644 --- a/src/core/init/stream_events_emitter/refresh_scheduled_events_list.ts +++ b/src/core/init/utils/stream_events_emitter/refresh_scheduled_events_list.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import Manifest from "../../../manifest"; +import Manifest from "../../../../manifest"; import areSameStreamEvents from "./are_same_stream_events"; import { INonFiniteStreamEventPayload, diff --git a/src/core/init/utils/stream_events_emitter/stream_events_emitter.ts b/src/core/init/utils/stream_events_emitter/stream_events_emitter.ts new file mode 100644 index 0000000000..75c8cc8848 --- /dev/null +++ b/src/core/init/utils/stream_events_emitter/stream_events_emitter.ts @@ -0,0 +1,209 @@ +/** + * 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. + */ + +import config from "../../../../config"; +import Manifest from "../../../../manifest"; +import createSharedReference from "../../../../utils/reference"; +import TaskCanceller, { CancellationSignal } from "../../../../utils/task_canceller"; +import { IPlaybackObservation, IReadOnlyPlaybackObserver } from "../../../api"; +import refreshScheduledEventsList from "./refresh_scheduled_events_list"; +import { + INonFiniteStreamEventPayload, + IPublicNonFiniteStreamEvent, + IPublicStreamEvent, + IStreamEventPayload, +} from "./types"; + +/** + * Get events from manifest and emit each time an event has to be emitted + * @param {Object} manifest + * @param {HTMLMediaElement} mediaElement + * @param {Object} playbackObserver + * @param {Function} onEvent + * @param {Function} onEventSkip + * @param {Object} cancelSignal + * @returns {Object} + */ +export default function streamEventsEmitter( + manifest : Manifest, + mediaElement : HTMLMediaElement, + playbackObserver : IReadOnlyPlaybackObserver, + onEvent : (evt : IPublicStreamEvent | IPublicNonFiniteStreamEvent) => void, + onEventSkip : (evt : IPublicStreamEvent | IPublicNonFiniteStreamEvent) => void, + cancelSignal : CancellationSignal +) : void { + const eventsBeingPlayed = + new WeakMap(); + const scheduledEventsRef = createSharedReference(refreshScheduledEventsList([], + manifest)); + manifest.addEventListener("manifestUpdate", () => { + const prev = scheduledEventsRef.getValue(); + scheduledEventsRef.setValue(refreshScheduledEventsList(prev, manifest)); + }, cancelSignal); + + let isPollingEvents = false; + let cancelCurrentPolling = new TaskCanceller({ cancelOn: cancelSignal }); + + scheduledEventsRef.onUpdate(({ length: scheduledEventsLength }) => { + if (scheduledEventsLength === 0) { + if (isPollingEvents) { + cancelCurrentPolling.cancel(); + cancelCurrentPolling = new TaskCanceller({ cancelOn: cancelSignal }); + isPollingEvents = false; + } + return; + } else if (isPollingEvents) { + return; + } + isPollingEvents = true; + let oldObservation = constructObservation(); + + const { STREAM_EVENT_EMITTER_POLL_INTERVAL } = config.getCurrent(); + const intervalId = setInterval(checkStreamEvents, + STREAM_EVENT_EMITTER_POLL_INTERVAL); + playbackObserver.listen(checkStreamEvents, + { includeLastObservation: false, + clearSignal: cancelCurrentPolling.signal }); + + cancelCurrentPolling.signal.register(() => { + clearInterval(intervalId); + }); + + function checkStreamEvents() { + const newObservation = constructObservation(); + emitStreamEvents(scheduledEventsRef.getValue(), + oldObservation, + newObservation, + cancelCurrentPolling.signal); + oldObservation = newObservation; + } + + function constructObservation() { + const isSeeking = playbackObserver.getReference().getValue().seeking; + return { currentTime: mediaElement.currentTime, + isSeeking }; + } + }, { emitCurrentValue: true, clearSignal: cancelSignal }); + + /** + * Examine playback situation from playback observations to emit stream events and + * prepare set onExit callbacks if needed. + * @param {Array.} scheduledEvents + * @param {Object} oldObservation + * @param {Object} newObservation + * @param {Object} stopSignal + */ + function emitStreamEvents( + scheduledEvents : Array, + oldObservation : { currentTime: number; isSeeking: boolean }, + newObservation : { currentTime: number; isSeeking: boolean }, + stopSignal : CancellationSignal + ) : void { + const { currentTime: previousTime } = oldObservation; + const { isSeeking, currentTime } = newObservation; + const eventsToSend: IStreamEvent[] = []; + const eventsToExit: IPublicStreamEvent[] = []; + + for (let i = 0; i < scheduledEvents.length; i++) { + const event = scheduledEvents[i]; + const start = event.start; + const end = isFiniteStreamEvent(event) ? event.end : + undefined; + const isBeingPlayed = eventsBeingPlayed.has(event); + if (isBeingPlayed) { + if (start > currentTime || + (end !== undefined && currentTime >= end) + ) { + if (isFiniteStreamEvent(event)) { + eventsToExit.push(event.publicEvent); + } + eventsBeingPlayed.delete(event); + } + } else if (start <= currentTime && + end !== undefined && + currentTime < end) { + eventsToSend.push({ type: "stream-event", + value: event.publicEvent }); + eventsBeingPlayed.set(event, true); + } else if (previousTime < start && + currentTime >= (end ?? start)) { + if (isSeeking) { + eventsToSend.push({ type: "stream-event-skip", + value: event.publicEvent }); + } else { + eventsToSend.push({ type: "stream-event", + value: event.publicEvent }); + if (isFiniteStreamEvent(event)) { + eventsToExit.push(event.publicEvent); + } + } + } + } + + if (eventsToSend.length > 0) { + for (const event of eventsToSend) { + if (event.type === "stream-event") { + onEvent(event.value); + } else { + onEventSkip(event.value); + } + if (stopSignal.isCancelled) { + return; + } + } + } + + if (eventsToExit.length > 0) { + for (const event of eventsToExit) { + if (typeof event.onExit === "function") { + event.onExit(); + } + if (stopSignal.isCancelled) { + return; + } + } + } + } +} + +/** + * Tells if a stream event has a duration + * @param {Object} evt + * @returns {Boolean} + */ +function isFiniteStreamEvent( + evt: IStreamEventPayload|INonFiniteStreamEventPayload +): evt is IStreamEventPayload { + return (evt as IStreamEventPayload).end !== undefined; +} + +/** Event emitted when a stream event is encountered. */ +interface IStreamEventEvent { + type: "stream-event"; + value: IPublicStreamEvent | + IPublicNonFiniteStreamEvent; +} + +/** Event emitted when a stream event has just been skipped. */ +interface IStreamEventSkipEvent { + type: "stream-event-skip"; + value: IPublicStreamEvent | + IPublicNonFiniteStreamEvent; +} + +/** Events sent by the `streamEventsEmitter`. */ +type IStreamEvent = IStreamEventEvent | + IStreamEventSkipEvent; diff --git a/src/core/init/stream_events_emitter/types.ts b/src/core/init/utils/stream_events_emitter/types.ts similarity index 68% rename from src/core/init/stream_events_emitter/types.ts rename to src/core/init/utils/stream_events_emitter/types.ts index bb689699d6..29467a5631 100644 --- a/src/core/init/stream_events_emitter/types.ts +++ b/src/core/init/utils/stream_events_emitter/types.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { IStreamEventData } from "../../../public_types"; +import { IStreamEventData } from "../../../../public_types"; export interface IStreamEventPayload { id?: string | undefined; @@ -44,21 +44,3 @@ export interface IPublicStreamEvent { end: number; onExit?: () => void; } - -/** Event emitted when a stream event is encountered. */ -export interface IStreamEventEvent { - type: "stream-event"; - value: IPublicStreamEvent | - IPublicNonFiniteStreamEvent; -} - -/** Event emitted when a stream event has just been skipped. */ -export interface IStreamEventSkipEvent { - type: "stream-event-skip"; - value: IPublicStreamEvent | - IPublicNonFiniteStreamEvent; -} - -/** Events sent by the `streamEventsEmitter`. */ -export type IStreamEvent = IStreamEventEvent | - IStreamEventSkipEvent; diff --git a/src/core/init/utils/throw_on_media_error.ts b/src/core/init/utils/throw_on_media_error.ts new file mode 100644 index 0000000000..291e5d9086 --- /dev/null +++ b/src/core/init/utils/throw_on_media_error.ts @@ -0,0 +1,74 @@ +/** + * 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. + */ + +import { MediaError } from "../../../errors"; +import isNullOrUndefined from "../../../utils/is_null_or_undefined"; +import { CancellationSignal } from "../../../utils/task_canceller"; + +/** + * @param {HTMLMediaElement} mediaElement + * @param {Function} onError + * @param {Object} cancelSignal + */ +export default function listenToMediaError( + mediaElement : HTMLMediaElement, + onError : (error : MediaError) => void, + cancelSignal : CancellationSignal +) : void { + if (cancelSignal.isCancelled) { + return; + } + + mediaElement.addEventListener("error", onMediaError); + + cancelSignal.register(() => { + mediaElement.removeEventListener("error", onMediaError); + }); + + function onMediaError() : void { + const mediaError = mediaElement.error; + let errorCode : number | undefined; + let errorMessage : string | undefined; + if (!isNullOrUndefined(mediaError)) { + errorCode = mediaError.code; + errorMessage = mediaError.message; + } + + switch (errorCode) { + case 1: + errorMessage = errorMessage ?? + "The fetching of the associated resource was aborted by the user's request."; + return onError(new MediaError("MEDIA_ERR_ABORTED", errorMessage)); + case 2: + errorMessage = errorMessage ?? + "A network error occurred which prevented the media from being " + + "successfully fetched"; + return onError(new MediaError("MEDIA_ERR_NETWORK", errorMessage)); + case 3: + errorMessage = errorMessage ?? + "An error occurred while trying to decode the media resource"; + return onError(new MediaError("MEDIA_ERR_DECODE", errorMessage)); + case 4: + errorMessage = errorMessage ?? + "The media resource has been found to be unsuitable."; + return onError(new MediaError("MEDIA_ERR_SRC_NOT_SUPPORTED", errorMessage)); + default: + errorMessage = errorMessage ?? + "The HTMLMediaElement errored due to an unknown reason."; + return onError(new MediaError("MEDIA_ERR_UNKNOWN", errorMessage)); + } + } +} diff --git a/src/experimental/tools/VideoThumbnailLoader/prepare_source_buffer.ts b/src/experimental/tools/VideoThumbnailLoader/prepare_source_buffer.ts index 712ed7bc35..997d415f46 100644 --- a/src/experimental/tools/VideoThumbnailLoader/prepare_source_buffer.ts +++ b/src/experimental/tools/VideoThumbnailLoader/prepare_source_buffer.ts @@ -15,7 +15,7 @@ */ import { MediaSource_ } from "../../../compat"; -import { resetMediaSource } from "../../../core/init/create_media_source"; +import { resetMediaSource } from "../../../core/init/utils/create_media_source"; import { AudioVideoSegmentBuffer } from "../../../core/segment_buffers/implementations"; import log from "../../../log"; import isNonEmptyString from "../../../utils/is_non_empty_string"; diff --git a/src/features/__tests__/initialize_features.test.ts b/src/features/__tests__/initialize_features.test.ts index c95b361908..aa2fb2c738 100644 --- a/src/features/__tests__/initialize_features.test.ts +++ b/src/features/__tests__/initialize_features.test.ts @@ -115,7 +115,7 @@ describe("Features - initializeFeaturesObject", () => { }, ContentDecryptor: jest.requireActual("../../core/decrypt/index").default, directfile: { - initDirectFile: jest.requireActual("../../core/init/initialize_directfile").default, + initDirectFile: jest.requireActual("../../core/init/directfile_content_initializer").default, mediaElementTrackChoiceManager: jest.requireActual( "../../core/api/tracks_management/media_element_track_choice_manager" diff --git a/src/features/initialize_features.ts b/src/features/initialize_features.ts index 645ae350f8..2b3ef308c2 100644 --- a/src/features/initialize_features.ts +++ b/src/features/initialize_features.ts @@ -113,7 +113,8 @@ export default function initializeFeaturesObject() : void { } if (__FEATURES__.DIRECTFILE === __FEATURES__.IS_ENABLED as number) { - const initDirectFile = require("../core/init/initialize_directfile.ts").default; + const initDirectFile = + require("../core/init/directfile_content_initializer.ts").default; const mediaElementTrackChoiceManager = require("../core/api/tracks_management/media_element_track_choice_manager.ts") .default; diff --git a/src/features/list/__tests__/directfile.test.ts b/src/features/list/__tests__/directfile.test.ts index 9b010bae51..a8305a6afb 100644 --- a/src/features/list/__tests__/directfile.test.ts +++ b/src/features/list/__tests__/directfile.test.ts @@ -19,10 +19,11 @@ // eslint-disable-next-line max-len import mediaElementTrackChoiceManager from "../../../core/api/tracks_management/media_element_track_choice_manager"; -import initDirectFile from "../../../core/init/initialize_directfile"; +import initDirectFile from "../../../core/init/directfile_content_initializer"; import addDirectfileFeature from "../directfile"; -jest.mock("../../../core/init/initialize_directfile", () => ({ +// eslint-disable-next-line @typescript-eslint/no-extraneous-class +jest.mock("../../../core/init/directfile_content_initializer", () => ({ __esModule: true as const, default: jest.fn(), })); diff --git a/src/features/list/directfile.ts b/src/features/list/directfile.ts index a868a42e34..2ae2adc4c6 100644 --- a/src/features/list/directfile.ts +++ b/src/features/list/directfile.ts @@ -16,7 +16,7 @@ // eslint-disable-next-line max-len import mediaElementTrackChoiceManager from "../../core/api/tracks_management/media_element_track_choice_manager"; -import directfile from "../../core/init/initialize_directfile"; +import directfile from "../../core/init/directfile_content_initializer"; import { IFeaturesObject } from "../types"; /** diff --git a/src/features/types.ts b/src/features/types.ts index a614763c0e..efd62bda19 100644 --- a/src/features/types.ts +++ b/src/features/types.ts @@ -14,14 +14,10 @@ * limitations under the License. */ -import { Observable } from "rxjs"; // eslint-disable-next-line max-len import MediaElementTrackChoiceManager from "../core/api/tracks_management/media_element_track_choice_manager"; import type ContentDecryptor from "../core/decrypt"; -import { - IDirectfileEvent, - IDirectFileOptions, -} from "../core/init/initialize_directfile"; +import DirectFileContentInitializer from "../core/init/directfile_content_initializer"; import { SegmentBuffer } from "../core/segment_buffers"; import { IDashParserResponse, @@ -34,8 +30,7 @@ import { } from "../parsers/texttracks"; import { ITransportFunction } from "../transports"; -export type IDirectFileInit = (args : IDirectFileOptions) => - Observable; +export type IDirectFileInit = typeof DirectFileContentInitializer; export type IContentDecryptorClass = typeof ContentDecryptor; diff --git a/src/parsers/manifest/dash/wasm-parser/ts/dash-wasm-parser.ts b/src/parsers/manifest/dash/wasm-parser/ts/dash-wasm-parser.ts index 4e309d0791..5a9135cdb4 100644 --- a/src/parsers/manifest/dash/wasm-parser/ts/dash-wasm-parser.ts +++ b/src/parsers/manifest/dash/wasm-parser/ts/dash-wasm-parser.ts @@ -107,9 +107,9 @@ export default class DashWasmParser { * future improvements. */ private _isParsing : boolean; + /** * Create a new `DashWasmParser`. - * @param {object} opts */ constructor() { this._parsersStack = new ParsersStack(); diff --git a/src/utils/reference.ts b/src/utils/reference.ts index b85ccc4ba3..62ccd0a580 100644 --- a/src/utils/reference.ts +++ b/src/utils/reference.ts @@ -18,41 +18,39 @@ import { Observable, Subscriber, } from "rxjs"; -import TaskCanceller, { - CancellationSignal, -} from "./task_canceller"; +import { CancellationSignal } from "./task_canceller"; /** * A value behind a shared reference, meaning that any update to its value from * anywhere can be retrieved from any other parts of the code in posession of - * the "same" (i.e. not cloned) `ISharedReference`. + * the same `ISharedReference`. * * @example * ```ts * const myVal = 1; * const myRef : ISharedReference = createSharedReference(1); * - * function DoThingsWithVal(num : number) { + * function setValTo2(num : number) { * num = 2; * } * - * function DoThingsWithRef(num : ISharedReference) { + * function setRefTo2(num : ISharedReference) { * num.setValue(2); * } * - * myRef.asObservable().subscribe((val) => { - * console.log(val); // outputs first `1`, then `2` - * }); - * - * DoThingsWithVal(myVal); + * setValTo2(myVal); * console.log(myVal); // output: 1 * - * DoThingsWithRef(myRef); + * myRef.onUpdate((val) => { + * console.log(val); // outputs first synchronously `1`, then `2` + * }, { emitCurrentValue: true }); + * + * setRefTo2(myRef); * console.log(myRef.getValue()); // output: 2 * - * myRef.asObservable().subscribe((val) => { + * myRef.listen((val) => { * console.log(val); // outputs only `2` - * }); + * }, { emitCurrentValue: true }); * ``` * * This type was added because we found that the usage of an explicit type for @@ -93,7 +91,8 @@ export interface ISharedReference { * Allows to register a callback to be called each time the value inside the * reference is updated. * @param {Function} cb - Callback to be called each time the reference is - * updated. Takes the new value im argument. + * updated. Takes as first argument its new value and in second argument a + * callback allowing to unregister the callback. * @param {Object} [options] * @param {Object} [options.clearSignal] - Allows to provide a * CancellationSignal which will unregister the callback when it emits. @@ -101,7 +100,7 @@ export interface ISharedReference { * also be immediately called with the current value. */ onUpdate( - cb : (val : T) => void, + cb : (val : T, stopListening : () => void) => void, options? : { clearSignal?: CancellationSignal | undefined; emitCurrentValue?: boolean | undefined; @@ -117,7 +116,7 @@ export interface ISharedReference { * This method can be used as a lighter weight alternative to `onUpdate` when * just waiting that the stored value becomes defined. * @param {Function} cb - Callback to be called each time the reference is - * updated. Takes the new value im argument. + * updated. Takes the new value in argument. * @param {Object} [options] * @param {Object} [options.clearSignal] - Allows to provide a * CancellationSignal which will unregister the callback when it emits. @@ -127,10 +126,11 @@ export interface ISharedReference { options? : { clearSignal?: CancellationSignal | undefined } | undefined, ) : void; + /** * Indicate that no new values will be emitted. - * Allows to automatically close all Observables generated from this shared - * reference. + * Allows to automatically close all Observables and listeners subscribed to + * this `ISharedReference`. */ finish() : void; } @@ -157,43 +157,12 @@ export interface ISharedReference { * be upcasted into a `IReadOnlySharedReference` at any time to make it clear in * the code that some logic is not supposed to update the referenced value. */ -export interface IReadOnlySharedReference { - /** Get the last value set on that reference. */ - getValue() : T; - /** - * Returns an Observable notifying this reference's value each time it is - * updated. - * - * Also emit its current value on subscription unless its argument is set to - * `true`. - */ - asObservable(skipCurrentValue? : boolean) : Observable; - - /** - * Triggers a callback each time this reference's value is updated. - * - * Can be given several options as argument: - * - clearSignal: When the attach `CancellationSignal` emits, the given - * callback will not be called anymore on reference updates. - * - emitCurrentValue: If `true`, the callback will be called directly and - * synchronously on this call with its current value. - * @param {Function} cb - * @param {Object} [options] - */ - onUpdate( - cb : (val : T) => void, - options? : { - clearSignal?: CancellationSignal | undefined; - emitCurrentValue?: boolean | undefined; - } | undefined, - ) : void; - - waitUntilDefined( - cb : (val : Exclude) => void, - options? : { clearSignal?: CancellationSignal | undefined } | - undefined, - ) : void; -} +export type IReadOnlySharedReference = + Pick, + "getValue" | + "asObservable" | + "onUpdate" | + "waitUntilDefined">; /** * Create an `ISharedReference` object encapsulating the mutable `initialValue` @@ -223,7 +192,7 @@ export default function createSharedReference(initialValue : T) : ISharedRefe * function call. * - `complete`: Callback to call when the current Reference is "finished". */ - const cbs : Array<{ trigger : (a: T) => void; + const cbs : Array<{ trigger : (a: T, stopListening: () => void) => void; complete : () => void; hasBeenCleared : boolean; }> = []; @@ -259,7 +228,7 @@ export default function createSharedReference(initialValue : T) : ISharedRefe for (const cbObj of clonedCbs) { try { if (!cbObj.hasBeenCleared) { - cbObj.trigger(newVal); + cbObj.trigger(newVal, cbObj.complete); } } catch (_) { /* nothing */ @@ -298,6 +267,9 @@ export default function createSharedReference(initialValue : T) : ISharedRefe hasBeenCleared: false }; cbs.push(cbObj); return () => { + if (cbObj.hasBeenCleared) { + return; + } /** * Code in here can still be running while this is happening. * Set `hasBeenCleared` to `true` to avoid still using the @@ -316,7 +288,8 @@ export default function createSharedReference(initialValue : T) : ISharedRefe * Allows to register a callback to be called each time the value inside the * reference is updated. * @param {Function} cb - Callback to be called each time the reference is - * updated. Takes the new value im argument. + * updated. Takes as first argument its new value and in second argument a + * callback allowing to unregister the callback. * @param {Object|undefined} [options] * @param {Object|undefined} [options.clearSignal] - Allows to provide a * CancellationSignal which will unregister the callback when it emits. @@ -324,28 +297,34 @@ export default function createSharedReference(initialValue : T) : ISharedRefe * callback will also be immediately called with the current value. */ onUpdate( - cb : (val : T) => void, + cb : (val : T, stopListening : () => void) => void, options? : { clearSignal?: CancellationSignal | undefined; emitCurrentValue?: boolean | undefined; } | undefined ) : void { - if (options?.emitCurrentValue === true) { - cb(value); - } - if (isFinished) { - return ; - } const cbObj = { trigger: cb, complete: unlisten, hasBeenCleared: false }; cbs.push(cbObj); + + if (options?.emitCurrentValue === true) { + cb(value, unlisten); + } + + if (isFinished || cbObj.hasBeenCleared) { + unlisten(); + return ; + } if (options?.clearSignal === undefined) { return; } options.clearSignal.register(unlisten); function unlisten() : void { + if (cbObj.hasBeenCleared) { + return; + } /** * Code in here can still be running while this is happening. * Set `hasBeenCleared` to `true` to avoid still using the @@ -359,6 +338,20 @@ export default function createSharedReference(initialValue : T) : ISharedRefe } }, + /** + * Variant of `onUpdate` which will only call the callback once, once the + * value inside the reference is different from `undefined`. + * The callback is called synchronously if the value already isn't set to + * `undefined`. + * + * This method can be used as a lighter weight alternative to `onUpdate` + * when just waiting that the stored value becomes defined. + * @param {Function} cb - Callback to be called each time the reference is + * updated. Takes the new value in argument. + * @param {Object} [options] + * @param {Object} [options.clearSignal] - Allows to provide a + * CancellationSignal which will unregister the callback when it emits. + */ waitUntilDefined( cb : (val : Exclude) => void, options? : { clearSignal?: CancellationSignal | undefined } | @@ -366,23 +359,14 @@ export default function createSharedReference(initialValue : T) : ISharedRefe ) : void { if (value !== undefined) { cb(value as Exclude); - return; - } - if (isFinished) { - return ; - } - - const childCanceller = new TaskCanceller(); - if (options?.clearSignal !== undefined) { - options.clearSignal.register(() => childCanceller.cancel()); + } else if (!isFinished) { + this.onUpdate((val : T, stopListening) => { + if (val !== undefined) { + stopListening(); + cb(value as Exclude); + } + }, { clearSignal: options?.clearSignal }); } - this.onUpdate((val : T) => { - if (val !== undefined) { - childCanceller.cancel(); - cb(value as Exclude); - return; - } - }, { clearSignal: childCanceller.signal }); }, /** @@ -397,8 +381,8 @@ export default function createSharedReference(initialValue : T) : ISharedRefe try { if (!cbObj.hasBeenCleared) { cbObj.complete(); + cbObj.hasBeenCleared = true; } - cbObj.hasBeenCleared = true; } catch (_) { /* nothing */ } From 95fe95d605975b12669f1483634548fcfad9d914 Mon Sep 17 00:00:00 2001 From: Paul Berberian Date: Thu, 24 Nov 2022 17:17:24 +0100 Subject: [PATCH 02/86] DASH: Now handle `endNumber` DASH attribute This commit adds handling of the `endNumber` attribute found on some DASH contents, which is the number the last segment should have. We decided to add it after seeing it used in the wild, most notably by some of Canal+ partners. This attribute is only present in newer 2019+ DASH specifications we funnily enough did not have access to until now. It was not in the latest DASH-IF specification neither. --- src/parsers/manifest/dash/common/indexes/base.ts | 4 ++++ .../dash/common/indexes/get_segments_from_timeline.ts | 9 ++++++++- src/parsers/manifest/dash/common/indexes/template.ts | 10 +++++++++- .../indexes/timeline/timeline_representation_index.ts | 7 +++++++ .../dash/js-parser/node_parsers/SegmentBase.ts | 6 ++++++ src/parsers/manifest/dash/node_parser_types.ts | 3 +++ src/parsers/manifest/dash/wasm-parser/rs/events.rs | 9 +++++++++ .../dash/wasm-parser/rs/processor/attributes.rs | 4 ++++ .../dash/wasm-parser/ts/generators/SegmentBase.ts | 6 ++++++ .../dash/wasm-parser/ts/generators/SegmentTemplate.ts | 7 +++++++ src/parsers/manifest/dash/wasm-parser/ts/types.ts | 3 +++ 11 files changed, 66 insertions(+), 2 deletions(-) diff --git a/src/parsers/manifest/dash/common/indexes/base.ts b/src/parsers/manifest/dash/common/indexes/base.ts index 3d40040d15..405bd533cc 100644 --- a/src/parsers/manifest/dash/common/indexes/base.ts +++ b/src/parsers/manifest/dash/common/indexes/base.ts @@ -69,6 +69,8 @@ export interface IBaseIndex { segmentUrlTemplate : string | null; /** Number from which the first segments in this index starts with. */ startNumber? : number | undefined; + /** Number associated to the last segment in this index. */ + endNumber? : number | undefined; /** Every segments defined in this index. */ timeline : IIndexSegment[]; /** @@ -90,6 +92,7 @@ export interface IBaseIndexIndexArgument { indexRange?: [number, number]; initialization?: { media?: string; range?: [number, number] }; startNumber? : number; + endNumber? : number; /** * Offset present in the index to convert from the mediaTime (time declared in * the media segments and in this index) to the presentationTime (time wanted @@ -222,6 +225,7 @@ export default class BaseRepresentationIndex implements IRepresentationIndex { initialization: { url: initializationUrl, range }, segmentUrlTemplate, startNumber: index.startNumber, + endNumber: index.endNumber, timeline: index.timeline ?? [], timescale }; this._scaledPeriodStart = toIndexTime(periodStart, this._index); diff --git a/src/parsers/manifest/dash/common/indexes/get_segments_from_timeline.ts b/src/parsers/manifest/dash/common/indexes/get_segments_from_timeline.ts index bb5c87872a..f0839e7ac4 100644 --- a/src/parsers/manifest/dash/common/indexes/get_segments_from_timeline.ts +++ b/src/parsers/manifest/dash/common/indexes/get_segments_from_timeline.ts @@ -55,6 +55,7 @@ export default function getSegmentsFromTimeline( index : { availabilityTimeComplete? : boolean | undefined; segmentUrlTemplate : string | null; startNumber? : number | undefined; + endNumber? : number | undefined; timeline : IIndexSegment[]; timescale : number; indexTimeOffset : number; }, @@ -65,7 +66,7 @@ export default function getSegmentsFromTimeline( ) : ISegment[] { const scaledUp = toIndexTime(from, index); const scaledTo = toIndexTime(from + durationWanted, index); - const { timeline, timescale, segmentUrlTemplate, startNumber } = index; + const { timeline, timescale, segmentUrlTemplate, startNumber, endNumber } = index; let currentNumber = startNumber ?? 1; const segments : ISegment[] = []; @@ -84,6 +85,9 @@ export default function getSegmentsFromTimeline( let segmentTime = start + segmentNumberInCurrentRange * duration; while (segmentTime < scaledTo && segmentNumberInCurrentRange <= repeat) { const segmentNumber = currentNumber + segmentNumberInCurrentRange; + if (endNumber !== undefined && segmentNumber > endNumber) { + break; + } const detokenizedURL = segmentUrlTemplate === null ? null : @@ -121,6 +125,9 @@ export default function getSegmentsFromTimeline( } currentNumber += repeat + 1; + if (endNumber !== undefined && currentNumber > endNumber) { + return segments; + } } return segments; diff --git a/src/parsers/manifest/dash/common/indexes/template.ts b/src/parsers/manifest/dash/common/indexes/template.ts index 8bf3541cc3..657c23cd71 100644 --- a/src/parsers/manifest/dash/common/indexes/template.ts +++ b/src/parsers/manifest/dash/common/indexes/template.ts @@ -94,6 +94,8 @@ export interface ITemplateIndex { presentationTimeOffset : number; /** Number from which the first segments in this index starts with. */ startNumber? : number | undefined; + /** Number associated to the last segment in this index. */ + endNumber? : number | undefined; } /** @@ -109,6 +111,7 @@ export interface ITemplateIndexIndexArgument { media? : string | undefined; presentationTimeOffset? : number | undefined; startNumber? : number | undefined; + endNumber? : number | undefined; timescale? : number | undefined; } @@ -213,7 +216,8 @@ export default class TemplateRepresentationIndex implements IRepresentationIndex range: index.initialization.range }, url: segmentUrlTemplate, presentationTimeOffset, - startNumber: index.startNumber }; + startNumber: index.startNumber, + endNumber: index.endNumber }; this._isDynamic = isDynamic; this._periodStart = periodStart; this._scaledRelativePeriodEnd = periodEnd === undefined ? @@ -239,6 +243,7 @@ export default class TemplateRepresentationIndex implements IRepresentationIndex const index = this._index; const { duration, startNumber, + endNumber, timescale, url } = index; @@ -276,6 +281,9 @@ export default class TemplateRepresentationIndex implements IRepresentationIndex ) { // To obtain the real number, adds the real number from the Period's start const realNumber = numberIndexedToZero + numberOffset; + if (endNumber !== undefined && realNumber > endNumber) { + return segments; + } const realDuration = scaledEnd != null && timeFromPeriodStart + duration > scaledEnd ? diff --git a/src/parsers/manifest/dash/common/indexes/timeline/timeline_representation_index.ts b/src/parsers/manifest/dash/common/indexes/timeline/timeline_representation_index.ts index 32162aeb8a..dd9a73056f 100644 --- a/src/parsers/manifest/dash/common/indexes/timeline/timeline_representation_index.ts +++ b/src/parsers/manifest/dash/common/indexes/timeline/timeline_representation_index.ts @@ -88,6 +88,8 @@ export interface ITimelineIndex { segmentUrlTemplate : string | null ; /** Number from which the first segments in this index starts with. */ startNumber? : number | undefined; + /** Number associated to the last segment in this index. */ + endNumber? : number | undefined; /** * Every segments defined in this index. * `null` at the beginning as this property is parsed lazily (only when first @@ -125,6 +127,7 @@ export interface ITimelineIndexIndexArgument { undefined; media? : string | undefined; startNumber? : number | undefined; + endNumber? : number | undefined; timescale? : number | undefined; /** * Offset present in the index to convert from the mediaTime (time declared in @@ -306,6 +309,7 @@ export default class TimelineRepresentationIndex implements IRepresentationIndex }, segmentUrlTemplate, startNumber: index.startNumber, + endNumber: index.endNumber, timeline: index.timeline ?? null, timescale }; this._scaledPeriodStart = toIndexTime(periodStart, this._index); @@ -336,11 +340,13 @@ export default class TimelineRepresentationIndex implements IRepresentationIndex // destructuring to please TypeScript const { segmentUrlTemplate, startNumber, + endNumber, timeline, timescale, indexTimeOffset } = this._index; return getSegmentsFromTimeline({ segmentUrlTemplate, startNumber, + endNumber, timeline, timescale, indexTimeOffset }, @@ -536,6 +542,7 @@ export default class TimelineRepresentationIndex implements IRepresentationIndex if (hasReplaced) { this._index.startNumber = newIndex._index.startNumber; } + this._index.endNumber = newIndex._index.endNumber; this._isDynamic = newIndex._isDynamic; this._scaledPeriodStart = newIndex._scaledPeriodStart; this._scaledPeriodEnd = newIndex._scaledPeriodEnd; diff --git a/src/parsers/manifest/dash/js-parser/node_parsers/SegmentBase.ts b/src/parsers/manifest/dash/js-parser/node_parsers/SegmentBase.ts index 99daed51aa..c0f2cd3863 100644 --- a/src/parsers/manifest/dash/js-parser/node_parsers/SegmentBase.ts +++ b/src/parsers/manifest/dash/js-parser/node_parsers/SegmentBase.ts @@ -99,6 +99,12 @@ export default function parseSegmentBase( parser: parseMPDInteger, dashName: "startNumber" }); break; + + case "endNumber": + parseValue(attr.value, { asKey: "endNumber", + parser: parseMPDInteger, + dashName: "endNumber" }); + break; } } diff --git a/src/parsers/manifest/dash/node_parser_types.ts b/src/parsers/manifest/dash/node_parser_types.ts index 7b32016b84..dec2ec3cb7 100644 --- a/src/parsers/manifest/dash/node_parser_types.ts +++ b/src/parsers/manifest/dash/node_parser_types.ts @@ -285,6 +285,7 @@ export interface ISegmentBaseIntermediateRepresentation { media?: string; presentationTimeOffset?: number; startNumber? : number; + endNumber? : number; timescale?: number; } @@ -299,6 +300,7 @@ export interface ISegmentListIntermediateRepresentation { media?: string; presentationTimeOffset?: number; startNumber? : number; + endNumber? : number; timescale?: number; } @@ -348,6 +350,7 @@ export interface ISegmentTemplateIntermediateRepresentation { media? : string | undefined; presentationTimeOffset? : number | undefined; startNumber? : number | undefined; + endNumber? : number | undefined; timescale? : number | undefined; initialization? : { media?: string } | undefined; timeline? : ISegmentTimelineElement[] | undefined; diff --git a/src/parsers/manifest/dash/wasm-parser/rs/events.rs b/src/parsers/manifest/dash/wasm-parser/rs/events.rs index 075470bf9f..9348a64ef2 100644 --- a/src/parsers/manifest/dash/wasm-parser/rs/events.rs +++ b/src/parsers/manifest/dash/wasm-parser/rs/events.rs @@ -282,6 +282,15 @@ pub enum AttributeName { Label = 71, // String ServiceLocation = 72, // String + + QueryBeforeStart = 73, // Boolean + + ProxyServerUrl = 74, // String + + DefaultServiceLocation = 75, + + // SegmentTemplate + EndNumber = 76, // f64 } impl TagName { diff --git a/src/parsers/manifest/dash/wasm-parser/rs/processor/attributes.rs b/src/parsers/manifest/dash/wasm-parser/rs/processor/attributes.rs index 886c80c538..00cd0c0385 100644 --- a/src/parsers/manifest/dash/wasm-parser/rs/processor/attributes.rs +++ b/src/parsers/manifest/dash/wasm-parser/rs/processor/attributes.rs @@ -173,6 +173,8 @@ pub fn report_segment_template_attrs(tag_bs : &quick_xml::events::BytesStart) { Duration.try_report_as_u64(&attr), b"startNumber" => StartNumber.try_report_as_u64(&attr), + b"endNumber" => + EndNumber.try_report_as_u64(&attr), b"media" => Media.try_report_as_string(&attr), b"bitstreamSwitching" => BitstreamSwitching.try_report_as_bool(&attr), _ => {}, @@ -201,6 +203,8 @@ pub fn report_segment_base_attrs(tag_bs : &quick_xml::events::BytesStart) { Duration.try_report_as_u64(&attr), b"startNumber" => StartNumber.try_report_as_u64(&attr), + b"endNumber" => + EndNumber.try_report_as_u64(&attr), _ => {}, }, Err(err) => ParsingError::from(err).report_err(), diff --git a/src/parsers/manifest/dash/wasm-parser/ts/generators/SegmentBase.ts b/src/parsers/manifest/dash/wasm-parser/ts/generators/SegmentBase.ts index 65dddb2aa3..ea662fd52e 100644 --- a/src/parsers/manifest/dash/wasm-parser/ts/generators/SegmentBase.ts +++ b/src/parsers/manifest/dash/wasm-parser/ts/generators/SegmentBase.ts @@ -104,6 +104,12 @@ export function generateSegmentBaseAttrParser( break; } + case AttributeName.EndNumber: { + const dataView = new DataView(linearMemory.buffer); + segmentBaseAttrs.endNumber = dataView.getFloat64(ptr, true); + break; + } + } }; } diff --git a/src/parsers/manifest/dash/wasm-parser/ts/generators/SegmentTemplate.ts b/src/parsers/manifest/dash/wasm-parser/ts/generators/SegmentTemplate.ts index de084949d0..1aea7deff0 100644 --- a/src/parsers/manifest/dash/wasm-parser/ts/generators/SegmentTemplate.ts +++ b/src/parsers/manifest/dash/wasm-parser/ts/generators/SegmentTemplate.ts @@ -121,6 +121,13 @@ export function generateSegmentTemplateAttrParser( break; } + case AttributeName.EndNumber: { + const dataView = new DataView(linearMemory.buffer); + segmentTemplateAttrs.endNumber = + dataView.getFloat64(ptr, true); + break; + } + } }; } diff --git a/src/parsers/manifest/dash/wasm-parser/ts/types.ts b/src/parsers/manifest/dash/wasm-parser/ts/types.ts index 571bb9a15a..ef0733f47a 100644 --- a/src/parsers/manifest/dash/wasm-parser/ts/types.ts +++ b/src/parsers/manifest/dash/wasm-parser/ts/types.ts @@ -290,4 +290,7 @@ export const enum AttributeName { ProxyServerUrl = 74, // String DefaultServiceLocation = 75, + + // SegmentTemplate + EndNumber = 76, // f64 } From 2f399faa85e58bdce2c255bd180fc7cfb702cd8e Mon Sep 17 00:00:00 2001 From: Paul Berberian Date: Mon, 5 Dec 2022 21:00:20 +0100 Subject: [PATCH 03/86] Add integration tests for endNumber attributes and fix remaining issues --- manifest.mpd | 149 ++++++++ .../period/create_empty_adaptation_stream.ts | 18 +- src/core/stream/period/period_stream.ts | 4 +- .../manifest/dash/common/indexes/template.ts | 91 +++-- .../timeline/timeline_representation_index.ts | 57 ++- .../end_number.js | 354 ++++++++++++++++++ .../index.js | 2 + .../media/end_number.mpd | 33 ++ .../urls.js | 5 + .../DASH_static_SegmentTimeline/index.js | 2 + .../media/segment_timeline_end_number.mpd | 146 ++++++++ .../segment_timeline_end_number.js | 17 + .../DASH_static_SegmentTimeline/urls.js | 5 + .../end_number.js | 15 + .../index.js | 6 +- .../media/end_number.mpd | 24 ++ .../urls.js | 5 + tests/integration/scenarios/end_number.js | 102 +++++ tests/utils/request_mock/xhr_mock.js | 2 +- 19 files changed, 991 insertions(+), 46 deletions(-) create mode 100644 manifest.mpd create mode 100644 tests/contents/DASH_static_SegmentTemplate_Multi_Periods/end_number.js create mode 100644 tests/contents/DASH_static_SegmentTemplate_Multi_Periods/media/end_number.mpd create mode 100644 tests/contents/DASH_static_SegmentTimeline/media/segment_timeline_end_number.mpd create mode 100644 tests/contents/DASH_static_SegmentTimeline/segment_timeline_end_number.js create mode 100644 tests/contents/DASH_static_number_based_SegmentTimeline/end_number.js create mode 100644 tests/contents/DASH_static_number_based_SegmentTimeline/media/end_number.mpd create mode 100644 tests/integration/scenarios/end_number.js diff --git a/manifest.mpd b/manifest.mpd new file mode 100644 index 0000000000..aacc1cd6d3 --- /dev/null +++ b/manifest.mpd @@ -0,0 +1,149 @@ + + + + + + VAMAAAEAAQBKAzwAVwBSAE0ASABFAEEARABFAFIAIAB4AG0AbABuAHMAPQAiAGgAdAB0AHAAOgAvAC8AcwBjAGgAZQBtAGEAcwAuAG0AaQBjAHIAbwBzAG8AZgB0AC4AYwBvAG0ALwBEAFIATQAvADIAMAAwADcALwAwADMALwBQAGwAYQB5AFIAZQBhAGQAeQBIAGUAYQBkAGUAcgAiACAAdgBlAHIAcwBpAG8AbgA9ACIANAAuADAALgAwAC4AMAAiAD4APABEAEEAVABBAD4APABQAFIATwBUAEUAQwBUAEkATgBGAE8APgA8AEsARQBZAEwARQBOAD4AMQA2ADwALwBLAEUAWQBMAEUATgA+ADwAQQBMAEcASQBEAD4AQQBFAFMAQwBUAFIAPAAvAEEATABHAEkARAA+ADwALwBQAFIATwBUAEUAQwBUAEkATgBGAE8APgA8AEsASQBEAD4ARgAyAEsAcwBzAGgARgA2AGMAawBDAFEAYwBpAE0AQwA0AGUATwBqAEwAQQA9AD0APAAvAEsASQBEAD4APABDAEgARQBDAEsAUwBVAE0APgBEAGsARQBYAHQAYwBsAFMAWABrAFUAPQA8AC8AQwBIAEUAQwBLAFMAVQBNAD4APABMAEEAXwBVAFIATAA+AGgAdAB0AHAAcwA6AC8ALwBsAGkAYwBlAG4AcwBlAC4AYwB1AGIAbwB2AGkAcwBpAG8AbgAuAGkAdAAvAEwAaQBjAGUAbgBzAGUALwByAGkAZwBoAHQAcwBtAGEAbgBhAGcAZQByAC4AYQBzAG0AeAA8AC8ATABBAF8AVQBSAEwAPgA8AEwAVQBJAF8AVQBSAEwAPgBoAHQAdABwADoALwAvAGMAbwBuAHQAbwBzAG8ALgBtAGkAYwByAG8AcwBvAGYAdAAuAGMAbwBtAC8APAAvAEwAVQBJAF8AVQBSAEwAPgA8AEMAVQBTAFQATwBNAEEAVABUAFIASQBCAFUAVABFAFMAIAB4AG0AbABuAHMAPQAiACIAPgA8AC8AQwBVAFMAVABPAE0AQQBUAFQAUgBJAEIAVQBUAEUAUwA+ADwALwBEAEEAVABBAD4APAAvAFcAUgBNAEgARQBBAEQARQBSAD4AAAADdHBzc2gAAAAAmgTweZhAQoarkuZb4IhflQAAA1RUAwAAAQABAEoDPABXAFIATQBIAEUAQQBEAEUAUgAgAHgAbQBsAG4AcwA9ACIAaAB0AHQAcAA6AC8ALwBzAGMAaABlAG0AYQBzAC4AbQBpAGMAcgBvAHMAbwBmAHQALgBjAG8AbQAvAEQAUgBNAC8AMgAwADAANwAvADAAMwAvAFAAbABhAHkAUgBlAGEAZAB5AEgAZQBhAGQAZQByACIAIAB2AGUAcgBzAGkAbwBuAD0AIgA0AC4AMAAuADAALgAwACIAPgA8AEQAQQBUAEEAPgA8AFAAUgBPAFQARQBDAFQASQBOAEYATwA+ADwASwBFAFkATABFAE4APgAxADYAPAAvAEsARQBZAEwARQBOAD4APABBAEwARwBJAEQAPgBBAEUAUwBDAFQAUgA8AC8AQQBMAEcASQBEAD4APAAvAFAAUgBPAFQARQBDAFQASQBOAEYATwA+ADwASwBJAEQAPgBGADIASwBzAHMAaABGADYAYwBrAEMAUQBjAGkATQBDADQAZQBPAGoATABBAD0APQA8AC8ASwBJAEQAPgA8AEMASABFAEMASwBTAFUATQA+AEQAawBFAFgAdABjAGwAUwBYAGsAVQA9ADwALwBDAEgARQBDAEsAUwBVAE0APgA8AEwAQQBfAFUAUgBMAD4AaAB0AHQAcABzADoALwAvAGwAaQBjAGUAbgBzAGUALgBjAHUAYgBvAHYAaQBzAGkAbwBuAC4AaQB0AC8ATABpAGMAZQBuAHMAZQAvAHIAaQBnAGgAdABzAG0AYQBuAGEAZwBlAHIALgBhAHMAbQB4ADwALwBMAEEAXwBVAFIATAA+ADwATABVAEkAXwBVAFIATAA+AGgAdAB0AHAAOgAvAC8AYwBvAG4AdABvAHMAbwAuAG0AaQBjAHIAbwBzAG8AZgB0AC4AYwBvAG0ALwA8AC8ATABVAEkAXwBVAFIATAA+ADwAQwBVAFMAVABPAE0AQQBUAFQAUgBJAEIAVQBUAEUAUwAgAHgAbQBsAG4AcwA9ACIAIgA+ADwALwBDAFUAUwBUAE8ATQBBAFQAVABSAEkAQgBVAFQARQBTAD4APAAvAEQAQQBUAEEAPgA8AC8AVwBSAE0ASABFAEEARABFAFIAPgA= + AAAAYnBzc2gAAAAA7e+LqXnWSs6jyCfc1R0h7QAAAEIIARIQsqxiF3oRQHKQciMC4eOjLBoAIh45MDIwMDEzMV8yMDIyMDIwMTAwMDBfTElWRUNFTkMqAkhEMgBI49yVmwY= + + + + + + + + + + + + + + + + + VAMAAAEAAQBKAzwAVwBSAE0ASABFAEEARABFAFIAIAB4AG0AbABuAHMAPQAiAGgAdAB0AHAAOgAvAC8AcwBjAGgAZQBtAGEAcwAuAG0AaQBjAHIAbwBzAG8AZgB0AC4AYwBvAG0ALwBEAFIATQAvADIAMAAwADcALwAwADMALwBQAGwAYQB5AFIAZQBhAGQAeQBIAGUAYQBkAGUAcgAiACAAdgBlAHIAcwBpAG8AbgA9ACIANAAuADAALgAwAC4AMAAiAD4APABEAEEAVABBAD4APABQAFIATwBUAEUAQwBUAEkATgBGAE8APgA8AEsARQBZAEwARQBOAD4AMQA2ADwALwBLAEUAWQBMAEUATgA+ADwAQQBMAEcASQBEAD4AQQBFAFMAQwBUAFIAPAAvAEEATABHAEkARAA+ADwALwBQAFIATwBUAEUAQwBUAEkATgBGAE8APgA8AEsASQBEAD4ARgAyAEsAcwBzAGgARgA2AGMAawBDAFEAYwBpAE0AQwA0AGUATwBqAEwAQQA9AD0APAAvAEsASQBEAD4APABDAEgARQBDAEsAUwBVAE0APgBEAGsARQBYAHQAYwBsAFMAWABrAFUAPQA8AC8AQwBIAEUAQwBLAFMAVQBNAD4APABMAEEAXwBVAFIATAA+AGgAdAB0AHAAcwA6AC8ALwBsAGkAYwBlAG4AcwBlAC4AYwB1AGIAbwB2AGkAcwBpAG8AbgAuAGkAdAAvAEwAaQBjAGUAbgBzAGUALwByAGkAZwBoAHQAcwBtAGEAbgBhAGcAZQByAC4AYQBzAG0AeAA8AC8ATABBAF8AVQBSAEwAPgA8AEwAVQBJAF8AVQBSAEwAPgBoAHQAdABwADoALwAvAGMAbwBuAHQAbwBzAG8ALgBtAGkAYwByAG8AcwBvAGYAdAAuAGMAbwBtAC8APAAvAEwAVQBJAF8AVQBSAEwAPgA8AEMAVQBTAFQATwBNAEEAVABUAFIASQBCAFUAVABFAFMAIAB4AG0AbABuAHMAPQAiACIAPgA8AC8AQwBVAFMAVABPAE0AQQBUAFQAUgBJAEIAVQBUAEUAUwA+ADwALwBEAEEAVABBAD4APAAvAFcAUgBNAEgARQBBAEQARQBSAD4AAAADdHBzc2gAAAAAmgTweZhAQoarkuZb4IhflQAAA1RUAwAAAQABAEoDPABXAFIATQBIAEUAQQBEAEUAUgAgAHgAbQBsAG4AcwA9ACIAaAB0AHQAcAA6AC8ALwBzAGMAaABlAG0AYQBzAC4AbQBpAGMAcgBvAHMAbwBmAHQALgBjAG8AbQAvAEQAUgBNAC8AMgAwADAANwAvADAAMwAvAFAAbABhAHkAUgBlAGEAZAB5AEgAZQBhAGQAZQByACIAIAB2AGUAcgBzAGkAbwBuAD0AIgA0AC4AMAAuADAALgAwACIAPgA8AEQAQQBUAEEAPgA8AFAAUgBPAFQARQBDAFQASQBOAEYATwA+ADwASwBFAFkATABFAE4APgAxADYAPAAvAEsARQBZAEwARQBOAD4APABBAEwARwBJAEQAPgBBAEUAUwBDAFQAUgA8AC8AQQBMAEcASQBEAD4APAAvAFAAUgBPAFQARQBDAFQASQBOAEYATwA+ADwASwBJAEQAPgBGADIASwBzAHMAaABGADYAYwBrAEMAUQBjAGkATQBDADQAZQBPAGoATABBAD0APQA8AC8ASwBJAEQAPgA8AEMASABFAEMASwBTAFUATQA+AEQAawBFAFgAdABjAGwAUwBYAGsAVQA9ADwALwBDAEgARQBDAEsAUwBVAE0APgA8AEwAQQBfAFUAUgBMAD4AaAB0AHQAcABzADoALwAvAGwAaQBjAGUAbgBzAGUALgBjAHUAYgBvAHYAaQBzAGkAbwBuAC4AaQB0AC8ATABpAGMAZQBuAHMAZQAvAHIAaQBnAGgAdABzAG0AYQBuAGEAZwBlAHIALgBhAHMAbQB4ADwALwBMAEEAXwBVAFIATAA+ADwATABVAEkAXwBVAFIATAA+AGgAdAB0AHAAOgAvAC8AYwBvAG4AdABvAHMAbwAuAG0AaQBjAHIAbwBzAG8AZgB0AC4AYwBvAG0ALwA8AC8ATABVAEkAXwBVAFIATAA+ADwAQwBVAFMAVABPAE0AQQBUAFQAUgBJAEIAVQBUAEUAUwAgAHgAbQBsAG4AcwA9ACIAIgA+ADwALwBDAFUAUwBUAE8ATQBBAFQAVABSAEkAQgBVAFQARQBTAD4APAAvAEQAQQBUAEEAPgA8AC8AVwBSAE0ASABFAEEARABFAFIAPgA= + AAAAYnBzc2gAAAAA7e+LqXnWSs6jyCfc1R0h7QAAAEIIARIQsqxiF3oRQHKQciMC4eOjLBoAIh45MDIwMDEzMV8yMDIyMDIwMTAwMDBfTElWRUNFTkMqAkhEMgBI49yVmwY= + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/core/stream/period/create_empty_adaptation_stream.ts b/src/core/stream/period/create_empty_adaptation_stream.ts index 5b874de3b5..d4bcb2b096 100644 --- a/src/core/stream/period/create_empty_adaptation_stream.ts +++ b/src/core/stream/period/create_empty_adaptation_stream.ts @@ -15,14 +15,11 @@ */ import { - combineLatest as observableCombineLatest, mergeMap, Observable, of as observableOf, } from "rxjs"; -import log from "../../../log"; import { Period } from "../../../manifest"; -import { IReadOnlySharedReference } from "../../../utils/reference"; import { IReadOnlyPlaybackObserver } from "../../api"; import { IBufferType } from "../../segment_buffers"; import { IStreamStatusEvent } from "../types"; @@ -34,35 +31,26 @@ import { IPeriodStreamPlaybackObservation } from "./period_stream"; * This observable will never download any segment and just emit a "full" * event when reaching the end. * @param {Observable} playbackObserver - * @param {Object} wantedBufferAhead * @param {string} bufferType * @param {Object} content * @returns {Observable} */ export default function createEmptyAdaptationStream( playbackObserver : IReadOnlyPlaybackObserver, - wantedBufferAhead : IReadOnlySharedReference, bufferType : IBufferType, content : { period : Period } ) : Observable { const { period } = content; - let hasFinishedLoading = false; - const wantedBufferAhead$ = wantedBufferAhead.asObservable(); const observation$ = playbackObserver.getReference().asObservable(); - return observableCombineLatest([observation$, - wantedBufferAhead$]).pipe( - mergeMap(([observation, wba]) => { + return observation$.pipe( + mergeMap((observation) => { const position = observation.position.last; - if (period.end !== undefined && position + wba >= period.end) { - log.debug("Stream: full \"empty\" AdaptationStream", bufferType); - hasFinishedLoading = true; - } return observableOf({ type: "stream-status" as const, value: { period, bufferType, position, imminentDiscontinuity: null, - hasFinishedLoading, + hasFinishedLoading: true, neededSegments: [], shouldRefreshManifest: false } }); }) diff --git a/src/core/stream/period/period_stream.ts b/src/core/stream/period/period_stream.ts index f4f65e062f..e46b7da69a 100644 --- a/src/core/stream/period/period_stream.ts +++ b/src/core/stream/period/period_stream.ts @@ -202,7 +202,7 @@ export default function PeriodStream({ return observableConcat( cleanBuffer$.pipe(map(() => EVENTS.adaptationChange(bufferType, null, period))), - createEmptyStream(playbackObserver, wantedBufferAhead, bufferType, { period }) + createEmptyStream(playbackObserver, bufferType, { period }) ); } @@ -313,7 +313,7 @@ export default function PeriodStream({ }); return observableConcat( observableOf(EVENTS.warning(formattedError)), - createEmptyStream(playbackObserver, wantedBufferAhead, bufferType, { period }) + createEmptyStream(playbackObserver, bufferType, { period }) ); } log.error(`Stream: ${bufferType} Stream crashed. Stopping playback.`, diff --git a/src/parsers/manifest/dash/common/indexes/template.ts b/src/parsers/manifest/dash/common/indexes/template.ts index 657c23cd71..191935e22f 100644 --- a/src/parsers/manifest/dash/common/indexes/template.ts +++ b/src/parsers/manifest/dash/common/indexes/template.ts @@ -20,6 +20,7 @@ import { ISegment, } from "../../../../../manifest"; import assert from "../../../../../utils/assert"; +import isNullOrUndefined from "../../../../../utils/is_null_or_undefined"; import { IEMSG } from "../../../../containers/isobmff"; import ManifestBoundsCalculator from "../manifest_bounds_calculator"; import getInitSegment from "./get_init_segment"; @@ -335,15 +336,16 @@ export default class TemplateRepresentationIndex implements IRepresentationIndex */ getLastAvailablePosition() : number|null|undefined { const lastSegmentStart = this._getLastSegmentStart(); - if (lastSegmentStart == null) { + if (isNullOrUndefined(lastSegmentStart)) { // In that case (null or undefined), getLastAvailablePosition should reflect // the result of getLastSegmentStart, as the meaning is the same for // the two functions. So, we return the result of the latter. return lastSegmentStart; } + const scaledRelativeIndexEnd = this._estimateRelativeScaledEnd(); const lastSegmentEnd = Math.min(lastSegmentStart + this._index.duration, - this._scaledRelativePeriodEnd ?? Infinity); + scaledRelativeIndexEnd ?? Infinity); return (lastSegmentEnd / this._index.timescale) + this._periodStart; } @@ -356,13 +358,14 @@ export default class TemplateRepresentationIndex implements IRepresentationIndex if (!this._isDynamic) { return this.getLastAvailablePosition(); } - if (this._scaledRelativePeriodEnd === undefined) { + const scaledRelativeIndexEnd = this._estimateRelativeScaledEnd(); + if (scaledRelativeIndexEnd === undefined) { return undefined; } const { timescale } = this._index; - const absoluteScaledPeriodEnd = (this._scaledRelativePeriodEnd + + const absoluteScaledIndexEnd = (scaledRelativeIndexEnd + this._periodStart * timescale); - return absoluteScaledPeriodEnd / this._index.timescale; + return absoluteScaledIndexEnd / timescale; } /** @@ -387,14 +390,13 @@ export default class TemplateRepresentationIndex implements IRepresentationIndex const segmentTimeRounding = getSegmentTimeRoundingError(timescale); const scaledPeriodStart = this._periodStart * timescale; const scaledRelativeEnd = end * timescale - scaledPeriodStart; - if (this._scaledRelativePeriodEnd === undefined) { + + const relativeScaledIndexEnd = this._estimateRelativeScaledEnd(); + if (relativeScaledIndexEnd === undefined) { return (scaledRelativeEnd + segmentTimeRounding) >= 0; } - - const scaledRelativePeriodEnd = this._scaledRelativePeriodEnd; const scaledRelativeStart = start * timescale - scaledPeriodStart; - return (scaledRelativeStart - segmentTimeRounding) < scaledRelativePeriodEnd && - (scaledRelativeEnd + segmentTimeRounding) >= 0; + return (scaledRelativeStart - segmentTimeRounding) < relativeScaledIndexEnd; } /** @@ -444,13 +446,19 @@ export default class TemplateRepresentationIndex implements IRepresentationIndex } /** + * Returns `true` if the last segments in this index have already been + * generated so that we can freely go to the next period. + * Returns `false` if the index is still waiting on future segments to be + * generated. * @returns {Boolean} */ isFinished() : boolean { if (!this._isDynamic) { return true; } - if (this._scaledRelativePeriodEnd === undefined) { + + const scaledRelativeIndexEnd = this._estimateRelativeScaledEnd(); + if (scaledRelativeIndexEnd === undefined) { return false; } @@ -459,12 +467,12 @@ export default class TemplateRepresentationIndex implements IRepresentationIndex // As last segment start is null if live time is before // current period, consider the index not to be finished. - if (lastSegmentStart == null) { + if (isNullOrUndefined(lastSegmentStart)) { return false; } const lastSegmentEnd = lastSegmentStart + this._index.duration; const segmentTimeRounding = getSegmentTimeRoundingError(timescale); - return (lastSegmentEnd + segmentTimeRounding) >= this._scaledRelativePeriodEnd; + return (lastSegmentEnd + segmentTimeRounding) >= scaledRelativeIndexEnd; } /** @@ -540,7 +548,7 @@ export default class TemplateRepresentationIndex implements IRepresentationIndex * @returns {number|null|undefined} */ private _getLastSegmentStart() : number | null | undefined { - const { duration, timescale } = this._index; + const { duration, timescale, endNumber, startNumber = 1 } = this._index; if (this._isDynamic) { const lastPos = this._manifestBoundsCalculator.estimateMaximumBound(); @@ -549,13 +557,15 @@ export default class TemplateRepresentationIndex implements IRepresentationIndex } const agressiveModeOffset = this._aggressiveMode ? (duration / timescale) : 0; - if (this._scaledRelativePeriodEnd != null && + if (this._scaledRelativePeriodEnd !== undefined && this._scaledRelativePeriodEnd < (lastPos + agressiveModeOffset - this._periodStart) * this._index.timescale) { - if (this._scaledRelativePeriodEnd < duration) { - return null; + + let numberOfSegments = Math.ceil(this._scaledRelativePeriodEnd / duration); + if (endNumber !== undefined && (endNumber - startNumber + 1) < numberOfSegments) { + numberOfSegments = endNumber - startNumber + 1; } - return (Math.floor(this._scaledRelativePeriodEnd / duration) - 1) * duration; + return (numberOfSegments - 1) * duration; } // /!\ The scaled last position augments continuously and might not // reflect exactly the real server-side value. As segments are @@ -572,28 +582,61 @@ export default class TemplateRepresentationIndex implements IRepresentationIndex ((this._availabilityTimeOffset !== undefined ? this._availabilityTimeOffset : 0) + agressiveModeOffset) * timescale; - const numberOfSegmentsAvailable = + let numberOfSegmentsAvailable = Math.floor((scaledLastPosition + availabilityTimeOffset) / duration); + if (endNumber !== undefined && + (endNumber - startNumber + 1) < numberOfSegmentsAvailable) { + numberOfSegmentsAvailable = endNumber - startNumber + 1; + } return numberOfSegmentsAvailable <= 0 ? null : (numberOfSegmentsAvailable - 1) * duration; } else { const maximumTime = this._scaledRelativePeriodEnd ?? 0; - const numberIndexedToZero = Math.ceil(maximumTime / duration) - 1; - const regularLastSegmentStart = numberIndexedToZero * duration; + let numberOfSegments = Math.ceil(maximumTime / duration); + if (endNumber !== undefined && (endNumber - startNumber + 1) < numberOfSegments) { + numberOfSegments = endNumber - startNumber + 1; + } + + const regularLastSegmentStart = (numberOfSegments - 1) * duration; // In some SegmentTemplate, we could think that there is one more // segment that there actually is due to a very little difference between // the period's duration and a multiple of a segment's duration. // Check that we're within a good margin const minimumDuration = config.getCurrent().MINIMUM_SEGMENT_SIZE * timescale; - if (maximumTime - regularLastSegmentStart > minimumDuration || - numberIndexedToZero === 0) + if (endNumber !== undefined || + maximumTime - regularLastSegmentStart > minimumDuration || + numberOfSegments < 2) { return regularLastSegmentStart; } - return (numberIndexedToZero - 1) * duration; + return (numberOfSegments - 2) * duration; } } + + /** + * Returns an estimate of the last available position in this + * `RepresentationIndex` based on attributes such as the Period's end and + * the `endNumber` attribute. + * If the estimate cannot be made (e.g. this Period's segments are still being + * generated and its end is yet unknown), returns `undefined`. + * @returns {number|undefined} + */ + private _estimateRelativeScaledEnd() : number | undefined { + if (this._index.endNumber !== undefined) { + const numberOfSegments = + this._index.endNumber - (this._index.startNumber ?? 1) + 1; + return Math.max(Math.min(numberOfSegments * this._index.duration, + this._scaledRelativePeriodEnd ?? Infinity), + 0); + } + + if (this._scaledRelativePeriodEnd === undefined) { + return undefined; + } + + return Math.max(this._scaledRelativePeriodEnd, 0); + } } diff --git a/src/parsers/manifest/dash/common/indexes/timeline/timeline_representation_index.ts b/src/parsers/manifest/dash/common/indexes/timeline/timeline_representation_index.ts index dd9a73056f..0d105ee865 100644 --- a/src/parsers/manifest/dash/common/indexes/timeline/timeline_representation_index.ts +++ b/src/parsers/manifest/dash/common/indexes/timeline/timeline_representation_index.ts @@ -310,7 +310,11 @@ export default class TimelineRepresentationIndex implements IRepresentationIndex segmentUrlTemplate, startNumber: index.startNumber, endNumber: index.endNumber, - timeline: index.timeline ?? null, + timeline: index.timeline === undefined ? + null : + updateTimelineFromEndNumber(index.timeline, + index.startNumber, + index.endNumber), timescale }; this._scaledPeriodStart = toIndexTime(periodStart, this._index); this._scaledPeriodEnd = periodEnd === undefined ? undefined : @@ -620,6 +624,8 @@ export default class TimelineRepresentationIndex implements IRepresentationIndex scaledFirstPosition); if (this._index.startNumber !== undefined) { this._index.startNumber += nbEltsRemoved; + } else if (this._index.endNumber !== undefined) { + this._index.startNumber = nbEltsRemoved + 1; } } @@ -673,7 +679,9 @@ export default class TimelineRepresentationIndex implements IRepresentationIndex newElements.length < MIN_DASH_S_ELEMENTS_TO_PARSE_UNSAFELY) { // Just completely parse the current timeline - return constructTimelineFromElements(newElements); + return updateTimelineFromEndNumber(constructTimelineFromElements(newElements), + this._index.startNumber, + this._index.endNumber); } // Construct previously parsed timeline if not already done @@ -686,7 +694,50 @@ export default class TimelineRepresentationIndex implements IRepresentationIndex } this._unsafelyBaseOnPreviousIndex = null; // Free memory - return constructTimelineFromPreviousTimeline(newElements, prevTimeline); + return updateTimelineFromEndNumber( + constructTimelineFromPreviousTimeline(newElements, prevTimeline), + this._index.startNumber, + this._index.endNumber); } } + +/** + * Take the original SegmentTimeline's parsed timeline and, if an `endNumber` is + * specified, filter segments which possess a number superior to that number. + * + * This should only be useful in only rare and broken MPDs, but we aim to + * respect the specification even in those cases. + * + * @param {Array.} timeline + * @param {number|undefined} startNumber + * @param {Array.} endNumber + * @returns {number|undefined} + */ +function updateTimelineFromEndNumber( + timeline : IIndexSegment[], + startNumber : number | undefined, + endNumber : number | undefined +) : IIndexSegment[] { + if (endNumber === undefined) { + return timeline; + } + let currNumber = startNumber ?? 1; + for (let idx = 0; idx < timeline.length; idx++) { + const seg = timeline[idx]; + currNumber += seg.repeatCount + 1; + if (currNumber > endNumber) { + if (currNumber === endNumber + 1) { + return timeline.slice(0, idx + 1); + } else { + const newTimeline = timeline.slice(0, idx); + const lastElt = { ...seg }; + const beginningNumber = currNumber - seg.repeatCount - 1; + lastElt.repeatCount = Math.max(0, endNumber - beginningNumber); + newTimeline.push(lastElt); + return newTimeline; + } + } + } + return timeline; +} diff --git a/tests/contents/DASH_static_SegmentTemplate_Multi_Periods/end_number.js b/tests/contents/DASH_static_SegmentTemplate_Multi_Periods/end_number.js new file mode 100644 index 0000000000..b4240ca5cf --- /dev/null +++ b/tests/contents/DASH_static_SegmentTemplate_Multi_Periods/end_number.js @@ -0,0 +1,354 @@ +const BASE_URL = "http://" + + /* eslint-disable no-undef */ + __TEST_CONTENT_SERVER__.URL + ":" + + __TEST_CONTENT_SERVER__.PORT + + /* eslint-enable no-undef */ + "/DASH_static_SegmentTemplate_Multi_Periods/media/"; + +export default { + url: BASE_URL + "end_number.mpd", + transport: "dash", + isDynamic: false, + isLive: false, + duration: 240, + minimumPosition: 0, + maximumPosition: 240, + availabilityStartTime: 0, + periods: [ + { + start: 0, + duration: 120, + adaptations: { + audio: [ + { + id: "audio", + isAudioDescription: undefined, + language: undefined, + normalizedLanguage: undefined, + representations: [ + { + id: "aaclc", + bitrate: 66295, + codec: "mp4a.40.2", + mimeType: "audio/mp4", + index: { + init: { + url: "mp4-live-periods-aaclc-.mp4", + }, + segments: [ + { + time: 0, + duration: 440029 / 44100, + timescale: 1, + url: "mp4-live-periods-aaclc-1.m4s", + }, + { + time: 440029 / 44100, + duration: 440029 / 44100, + timescale: 1, + url: "mp4-live-periods-aaclc-2.m4s", + }, + ], + // ... + }, + }, + ], + }, + ], + video: [ + { + id: "video", + representations: [ + { + id: "h264bl_low", + bitrate: 50842, + height: 180, + width: 320, + frameRate: "25", + codec: "avc1.42c00d", + mimeType: "video/mp4", + index: { + init: { + url: "mp4-live-periods-h264bl_low-.mp4", + }, + segments: [ + { + time: 0, + duration: 250000 / 25000, + timescale: 1, + url: "mp4-live-periods-h264bl_low-1.m4s", + }, + { + time: 250000 / 25000, + duration: 250000 / 25000, + timescale: 1, + url: "mp4-live-periods-h264bl_low-2.m4s", + }, + // ... + ], + }, + }, + + { + id: "h264bl_mid", + bitrate: 194834, + height: 360, + width: 640, + frameRate: "25", + codec: "avc1.42c01e", + mimeType: "video/mp4", + index: { + init: { + url: "mp4-live-periods-h264bl_mid-.mp4", + }, + segments: [ + { + time: 0, + duration: 250000 / 25000, + timescale: 1, + url: "mp4-live-periods-h264bl_mid-1.m4s", + }, + { + time: 250000 / 25000, + duration: 250000 / 25000, + timescale: 1, + url: "mp4-live-periods-h264bl_mid-2.m4s", + }, + // ... + ], + }, + }, + + { + id: "h264bl_hd", + bitrate: 514793, + height: 720, + width: 1280, + frameRate: "25", + codec: "avc1.42c01f", + mimeType: "video/mp4", + index: { + init: { + url: "mp4-live-periods-h264bl_hd-.mp4", + }, + segments: [ + { + time: 0, + duration: 250000 / 25000, + timescale: 1, + url: "mp4-live-periods-h264bl_hd-1.m4s", + }, + { + time: 250000 / 25000, + duration: 250000 / 25000, + timescale: 1, + url: "mp4-live-periods-h264bl_hd-2.m4s", + }, + // ... + ], + }, + }, + + { + id: "h264bl_full", + bitrate: 770663, + height: 1080, + width: 1920, + frameRate: "25", + codec: "avc1.42c028", + mimeType: "video/mp4", + index: { + init: { + url: "mp4-live-periods-h264bl_full-.mp4", + }, + segments: [ + { + time: 0, + duration: 250000 / 25000, + timescale: 1, + url: "mp4-live-periods-h264bl_full-1.m4s", + }, + { + time: 250000 / 25000, + duration: 250000 / 25000, + timescale: 1, + url: "mp4-live-periods-h264bl_full-2.m4s", + }, + // ... + ], + }, + }, + ], + }, + ], + }, + }, { + start: 120, + duration: 120, + adaptations: { + audio: [ + { + id: "audio", + isAudioDescription: undefined, + language: undefined, + normalizedLanguage: undefined, + representations: [ + { + id: "aaclc", + bitrate: 66295, + codec: "mp4a.40.2", + mimeType: "audio/mp4", + index: { + init: { + url: "mp4-live-periods-aaclc-.mp4", + }, + segments: [ + { + time: 120, + duration: 440029 / 44100, + timescale: 1, + url: "mp4-live-periods-aaclc-13.m4s", + }, + { + time: 120 + (440029 / 44100), + duration: 440029 / 44100, + timescale: 1, + url: "mp4-live-periods-aaclc-14.m4s", + }, + ], + // ... + }, + }, + ], + }, + ], + video: [ + { + id: "video", + representations: [ + { + id: "h264bl_low", + bitrate: 50842, + height: 180, + width: 320, + frameRate: "25", + codec: "avc1.42c00d", + mimeType: "video/mp4", + index: { + init: { + url: "mp4-live-periods-h264bl_low-.mp4", + }, + segments: [ + { + time: 12 * (250000 / 25000), + duration: 250000 / 25000, + timescale: 1, + url: "mp4-live-periods-h264bl_low-13.m4s", + }, + { + time: 13 * (250000 / 25000), + duration: 250000 / 25000, + timescale: 1, + url: "mp4-live-periods-h264bl_low-14.m4s", + }, + // ... + ], + }, + }, + + { + id: "h264bl_mid", + bitrate: 194834, + height: 360, + width: 640, + frameRate: "25", + codec: "avc1.42c01e", + mimeType: "video/mp4", + index: { + init: { + url: "mp4-live-periods-h264bl_mid-.mp4", + }, + segments: [ + { + time: 12 * (250000 / 25000), + duration: 250000 / 25000, + timescale: 1, + url: "mp4-live-periods-h264bl_mid-13.m4s", + }, + { + time: 13 * (250000 / 25000), + duration: 250000 / 25000, + timescale: 1, + url: "mp4-live-periods-h264bl_mid-14.m4s", + }, + // ... + ], + }, + }, + + { + id: "h264bl_hd", + bitrate: 514793, + height: 720, + width: 1280, + frameRate: "25", + codec: "avc1.42c01f", + mimeType: "video/mp4", + index: { + init: { + url: "mp4-live-periods-h264bl_hd-.mp4", + }, + segments: [ + { + time: 12 * (250000 / 25000), + duration: 250000 / 25000, + timescale: 1, + url: "mp4-live-periods-h264bl_hd-13.m4s", + }, + { + time: 13 * (250000 / 25000), + duration: 250000 / 25000, + timescale: 1, + url: "mp4-live-periods-h264bl_hd-14.m4s", + }, + // ... + ], + }, + }, + + { + id: "h264bl_full", + bitrate: 770663, + height: 1080, + width: 1920, + frameRate: "25", + codec: "avc1.42c028", + mimeType: "video/mp4", + index: { + init: { + url: "mp4-live-periods-h264bl_full-.mp4", + }, + segments: [ + { + time: 12 * (250000 / 25000), + duration: 250000 / 25000, + timescale: 1, + url: "mp4-live-periods-h264bl_full-13.m4s", + }, + { + time: 13 * (250000 / 25000), + duration: 250000 / 25000, + timescale: 1, + url: "mp4-live-periods-h264bl_full-14.m4s", + }, + // ... + ], + }, + }, + ], + }, + ], + }, + }, + ], +}; diff --git a/tests/contents/DASH_static_SegmentTemplate_Multi_Periods/index.js b/tests/contents/DASH_static_SegmentTemplate_Multi_Periods/index.js index 445cecfaa6..e9879630cf 100644 --- a/tests/contents/DASH_static_SegmentTemplate_Multi_Periods/index.js +++ b/tests/contents/DASH_static_SegmentTemplate_Multi_Periods/index.js @@ -1,9 +1,11 @@ import manifestInfos from "./infos.js"; import discontinuitiesBetweenPeriodsInfos from "./discontinuity_between_periods_infos"; import differentTypesDiscontinuitiesInfos from "./different_types_infos"; +import endNumberManifestInfos from "./end_number"; export { differentTypesDiscontinuitiesInfos, discontinuitiesBetweenPeriodsInfos, manifestInfos, + endNumberManifestInfos, }; diff --git a/tests/contents/DASH_static_SegmentTemplate_Multi_Periods/media/end_number.mpd b/tests/contents/DASH_static_SegmentTemplate_Multi_Periods/media/end_number.mpd new file mode 100644 index 0000000000..a3a330b103 --- /dev/null +++ b/tests/contents/DASH_static_SegmentTemplate_Multi_Periods/media/end_number.mpd @@ -0,0 +1,33 @@ + + + + + mp4-live-periods-mpd.mpd generated by GPAC + TelecomParisTech(c)2012 + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/contents/DASH_static_SegmentTemplate_Multi_Periods/urls.js b/tests/contents/DASH_static_SegmentTemplate_Multi_Periods/urls.js index 438e53b1d8..5c7ab15b9a 100644 --- a/tests/contents/DASH_static_SegmentTemplate_Multi_Periods/urls.js +++ b/tests/contents/DASH_static_SegmentTemplate_Multi_Periods/urls.js @@ -72,6 +72,11 @@ module.exports = [ path: path.join(__dirname, "./media/different_types_discontinuity.mpd"), contentType: "application/dash+xml", }, + { + url: baseURL + "end_number.mpd", + path: path.join(__dirname, "./media/end_number.mpd"), + contentType: "application/dash+xml", + }, ...audioSegments, // remaining audio segments ...videoQualities, // every video segments ]; diff --git a/tests/contents/DASH_static_SegmentTimeline/index.js b/tests/contents/DASH_static_SegmentTimeline/index.js index faa2e768fa..f581990211 100644 --- a/tests/contents/DASH_static_SegmentTimeline/index.js +++ b/tests/contents/DASH_static_SegmentTimeline/index.js @@ -8,6 +8,7 @@ import notStartingAt0ManifestInfos from "./not_starting_at_0.js"; import streamEventsInfos from "./event-stream"; import segmentTemplateInheritanceASRep from "./segment_template_inheritance_as_rep"; import segmentTemplateInheritancePeriodAS from "./segment_template_inheritance_period_as"; +import segmentTimelineEndNumber from "./segment_timeline_end_number"; export { manifestInfos, @@ -19,5 +20,6 @@ export { notStartingAt0ManifestInfos, segmentTemplateInheritanceASRep, segmentTemplateInheritancePeriodAS, + segmentTimelineEndNumber, streamEventsInfos, }; diff --git a/tests/contents/DASH_static_SegmentTimeline/media/segment_timeline_end_number.mpd b/tests/contents/DASH_static_SegmentTimeline/media/segment_timeline_end_number.mpd new file mode 100644 index 0000000000..cd57925c9b --- /dev/null +++ b/tests/contents/DASH_static_SegmentTimeline/media/segment_timeline_end_number.mpd @@ -0,0 +1,146 @@ + + + + + dash/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/contents/DASH_static_SegmentTimeline/segment_timeline_end_number.js b/tests/contents/DASH_static_SegmentTimeline/segment_timeline_end_number.js new file mode 100644 index 0000000000..a9f5d94356 --- /dev/null +++ b/tests/contents/DASH_static_SegmentTimeline/segment_timeline_end_number.js @@ -0,0 +1,17 @@ +const BASE_URL = "http://" + + /* eslint-disable no-undef */ + __TEST_CONTENT_SERVER__.URL + ":" + + __TEST_CONTENT_SERVER__.PORT + + /* eslint-enable no-undef */ + "/DASH_static_SegmentTimeline/media/"; +export default { + url: BASE_URL + "./segment_timeline_end_number.mpd", + transport: "dash", + isDynamic: false, + isLive: false, + duration: 101.476, + minimumPosition: 0, + maximumPosition: 101.476, + availabilityStartTime: 0, + periods: [], +}; diff --git a/tests/contents/DASH_static_SegmentTimeline/urls.js b/tests/contents/DASH_static_SegmentTimeline/urls.js index cd48ad5f60..b65753d789 100644 --- a/tests/contents/DASH_static_SegmentTimeline/urls.js +++ b/tests/contents/DASH_static_SegmentTimeline/urls.js @@ -106,6 +106,11 @@ module.exports = [ path: path.join(__dirname, "media/segment_template_inheritance_as_rep.mpd"), contentType: "application/dash+xml", }, + { + url: BASE_URL + "segment_timeline_end_number.mpd", + path: path.join(__dirname, "media/segment_timeline_end_number.mpd"), + contentType: "application/dash+xml", + }, { url: BASE_URL + "discontinuity.mpd", path: path.join(__dirname, "media/discontinuity.mpd"), diff --git a/tests/contents/DASH_static_number_based_SegmentTimeline/end_number.js b/tests/contents/DASH_static_number_based_SegmentTimeline/end_number.js new file mode 100644 index 0000000000..30ff26aed9 --- /dev/null +++ b/tests/contents/DASH_static_number_based_SegmentTimeline/end_number.js @@ -0,0 +1,15 @@ +const BASE_URL = "http://" + + /* eslint-disable no-undef */ + __TEST_CONTENT_SERVER__.URL + ":" + + __TEST_CONTENT_SERVER__.PORT + + /* eslint-enable no-undef */ + "/DASH_static_number_based_SegmentTimeline/media/"; + +export default { + url: BASE_URL + "end_number.mpd", + transport: "dash", + isDynamic: false, + isLive: false, + + // TODO ... +}; diff --git a/tests/contents/DASH_static_number_based_SegmentTimeline/index.js b/tests/contents/DASH_static_number_based_SegmentTimeline/index.js index 2fddc33e84..4bd96969f2 100644 --- a/tests/contents/DASH_static_number_based_SegmentTimeline/index.js +++ b/tests/contents/DASH_static_number_based_SegmentTimeline/index.js @@ -1,4 +1,8 @@ import manifestInfos from "./infos.js"; +import manifestInfosEndNumber from "./end_number"; -export { manifestInfos }; +export { + manifestInfos, + manifestInfosEndNumber, +}; diff --git a/tests/contents/DASH_static_number_based_SegmentTimeline/media/end_number.mpd b/tests/contents/DASH_static_number_based_SegmentTimeline/media/end_number.mpd new file mode 100644 index 0000000000..18c35096e8 --- /dev/null +++ b/tests/contents/DASH_static_number_based_SegmentTimeline/media/end_number.mpd @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/contents/DASH_static_number_based_SegmentTimeline/urls.js b/tests/contents/DASH_static_number_based_SegmentTimeline/urls.js index 6211e84506..4f38bbb37d 100644 --- a/tests/contents/DASH_static_number_based_SegmentTimeline/urls.js +++ b/tests/contents/DASH_static_number_based_SegmentTimeline/urls.js @@ -18,5 +18,10 @@ module.exports = [ path: path.join(__dirname, "./media/manifest.mpd"), contentType: "application/dash+xml", }, + { + url: baseURL + "end_number.mpd", + path: path.join(__dirname, "./media/end_number.mpd"), + contentType: "application/dash+xml", + }, ]; diff --git a/tests/integration/scenarios/end_number.js b/tests/integration/scenarios/end_number.js new file mode 100644 index 0000000000..5bdca99f7d --- /dev/null +++ b/tests/integration/scenarios/end_number.js @@ -0,0 +1,102 @@ +import { expect } from "chai"; +import XHRMock from "../../utils/request_mock"; +import { + manifestInfosEndNumber as numberBasedManifestInfos, +} from "../../contents/DASH_static_number_based_SegmentTimeline"; +import { + endNumberManifestInfos as templateManifestinfos, +} from "../../contents/DASH_static_SegmentTemplate_Multi_Periods"; +import { + segmentTimelineEndNumber as timeBasedManifestInfos, +} from "../../contents/DASH_static_SegmentTimeline"; +import RxPlayer from "../../../src"; +import sleep from "../../utils/sleep.js"; +import { waitForLoadedStateAfterLoadVideo } from "../../utils/waitForPlayerState"; + +let player; + +describe("end number", function () { + let xhrMock; + beforeEach(() => { + player = new RxPlayer({ stopAtEnd: false }); + xhrMock = new XHRMock(); + }); + + afterEach(() => { + player.dispose(); + xhrMock.restore(); + }); + + it("should calculate the right duration according to endNumber on a number-based SegmentTemplate", async function () { + this.timeout(3000); + xhrMock.lock(); + player.setVideoBitrate(0); + player.setWantedBufferAhead(15); + const { url, transport } = templateManifestinfos; + + player.loadVideo({ + url, + transport, + autoPlay: false, + }); + await sleep(50); + expect(xhrMock.getLockedXHR().length).to.equal(1); + await xhrMock.flush(); + await sleep(500); + expect(player.getMinimumPosition()).to.eql(0); + expect(player.getMaximumPosition()).to.eql(120 + 30); + }); + + it("should not load segment later than the end number on a time-based SegmentTimeline", async function () { + this.timeout(10000); + xhrMock.lock(); + player.setVideoBitrate(0); + player.setWantedBufferAhead(15); + const { url, transport } = timeBasedManifestInfos; + + player.loadVideo({ + url, + transport, + autoPlay: true, + }); + await sleep(50); + expect(xhrMock.getLockedXHR().length).to.equal(1); + await xhrMock.flush(); + await sleep(50); + expect(player.getMaximumPosition()).to.be.closeTo(20, 1); + expect(xhrMock.getLockedXHR().length).to.equal(4); + await xhrMock.flush(); + await waitForLoadedStateAfterLoadVideo(player); + + await sleep(500); + expect(xhrMock.getLockedXHR().length).to.equal(2); + xhrMock.flush(); + player.seekTo(19); + await sleep(50); + expect(xhrMock.getLockedXHR().length).to.equal(2); + xhrMock.flush(); + await sleep(3000); + expect(xhrMock.getLockedXHR().length).to.equal(0); + expect(player.getPlayerState()).to.eql("ENDED"); + expect(player.getPosition()).to.be.closeTo(20, 1); + }); + + it("should calculate the right duration on a number-based SegmentTimeline", async function () { + this.timeout(10000); + xhrMock.lock(); + player.setVideoBitrate(0); + player.setWantedBufferAhead(15); + const { url, transport } = numberBasedManifestInfos; + + player.loadVideo({ + url, + transport, + autoPlay: true, + }); + await sleep(50); + expect(xhrMock.getLockedXHR().length).to.equal(1); + await xhrMock.flush(); + await sleep(50); + expect(player.getMaximumPosition()).to.be.closeTo(20, 1); + }); +}); diff --git a/tests/utils/request_mock/xhr_mock.js b/tests/utils/request_mock/xhr_mock.js index ee8415194d..a4f7a0eff6 100644 --- a/tests/utils/request_mock/xhr_mock.js +++ b/tests/utils/request_mock/xhr_mock.js @@ -164,7 +164,7 @@ export default class XHRMock { Math.min(len, nbrOfRequests) : len; const nbrOfRequestThatStays = len - nbrOfRequestsToFlush; while (this._sendingQueue.length > nbrOfRequestThatStays) { - const { xhr, data } = this._sendingQueue.pop(); + const { xhr, data } = this._sendingQueue.shift(); this.__xhrSend(xhr, data); proms.push(xhr._finished); } From 547e377fe04f96e7ad78e4d81dd5fc620de83413 Mon Sep 17 00:00:00 2001 From: Paul Berberian Date: Fri, 18 Nov 2022 16:54:16 +0100 Subject: [PATCH 04/86] API: ensure that only the right `contentInfos` object is used in event handlers --- src/core/api/public_api.ts | 734 +++++++++++++++++++------------------ 1 file changed, 373 insertions(+), 361 deletions(-) diff --git a/src/core/api/public_api.ts b/src/core/api/public_api.ts index 6bea32a71c..b00993bfeb 100644 --- a/src/core/api/public_api.ts +++ b/src/core/api/public_api.ts @@ -79,6 +79,7 @@ import assert from "../../utils/assert"; import EventEmitter, { IListener, } from "../../utils/event_emitter"; +import idGenerator from "../../utils/id_generator"; import isNullOrUndefined from "../../utils/is_null_or_undefined"; import Logger from "../../utils/logger"; import objectAssign from "../../utils/object_assign"; @@ -130,6 +131,7 @@ import { /* eslint-disable @typescript-eslint/naming-convention */ +const generateContentId = idGenerator(); const { getPageActivityRef, getPictureOnPictureStateRef, @@ -231,67 +233,7 @@ class Player extends EventEmitter { * Information about the current content being played. * `null` when no content is currently loading or loaded. */ - private _priv_contentInfos : null | { - /** - * URL of the Manifest (or just of the content for DirectFile contents) - * currently being played. - */ - url : string | undefined; - - /** TaskCanceller triggered when it's time to stop the current content. */ - currentContentCanceller : TaskCanceller; - - /** - * `true` if the current content is in DirectFile mode. - * `false` is the current content has a transport protocol (Smooth/DASH...). - */ - isDirectFile : boolean; - - /** - * Current Image Track Data associated to the content. - * `null` if the current content has no image playlist linked to it. - * @deprecated - */ - thumbnails : IBifThumbnail[]|null; - - /** - * Manifest linked to the current content. - * `null` if the current content loaded has no manifest or if the content is - * not yet loaded. - */ - manifest : Manifest|null; - - /** - * Current Period being played. - * `null` if no Period is being played. - */ - currentPeriod : Period|null; - - /** - * Store currently considered adaptations, per active period. - * `null` if no Adaptation is active - */ - activeAdaptations : { - [periodId : string] : Partial>; - } | null; - - /** - * Store currently considered representations, per active period. - * `null` if no Representation is active - */ - activeRepresentations : { - [periodId : string] : Partial>; - } | null; - - /** Store starting audio track if one. */ - initialAudioTrack : undefined|IAudioTrackPreference; - - /** Store starting text track if one. */ - initialTextTrack : undefined|ITextTrackPreference; - - /** Keep information on the active SegmentBuffers. */ - segmentBuffersStore : SegmentBuffersStore | null; - }; + private _priv_contentInfos : IPublicApiContentInfos | null; /** List of favorite audio tracks, in preference order. */ private _priv_preferredAudioTracks : IAudioTrackPreference[]; @@ -305,20 +247,6 @@ class Player extends EventEmitter { /** If `true` trickMode video tracks will be chosen if available. */ private _priv_preferTrickModeTracks : boolean; - /** - * TrackChoiceManager instance linked to the current content. - * `null` if no content has been loaded or if the current content loaded - * has no TrackChoiceManager. - */ - private _priv_trackChoiceManager : TrackChoiceManager|null; - - /** - * MediaElementTrackChoiceManager instance linked to the current content. - * `null` if no content has been loaded or if the current content loaded - * has no MediaElementTrackChoiceManager. - */ - private _priv_mediaElementTrackChoiceManager : MediaElementTrackChoiceManager|null; - /** Refer to last picture in picture event received. */ private _priv_pictureInPictureRef : IReadOnlySharedReference< events.IPictureInPictureEvent @@ -517,8 +445,6 @@ class Player extends EventEmitter { this._priv_limitVideoWidth = limitVideoWidth; this._priv_mutedMemory = DEFAULT_UNMUTED_VOLUME; - this._priv_trackChoiceManager = null; - this._priv_mediaElementTrackChoiceManager = null; this._priv_currentError = null; this._priv_contentInfos = null; @@ -701,19 +627,6 @@ class Player extends EventEmitter { /** Subject which will emit to stop the current content. */ const currentContentCanceller = new TaskCanceller(); - /** Future `this._priv_contentInfos` related to this content. */ - const contentInfos = { url, - currentContentCanceller, - isDirectFile, - segmentBuffersStore: null, - thumbnails: null, - manifest: null, - currentPeriod: null, - activeAdaptations: null, - activeRepresentations: null, - initialAudioTrack: defaultAudioTrack, - initialTextTrack: defaultTextTrack }; - const videoElement = this.videoElement; @@ -729,6 +642,8 @@ class Player extends EventEmitter { let initializer : ContentInitializer; + let mediaElementTrackChoiceManager : MediaElementTrackChoiceManager | null = + null; if (!isDirectFile) { const transportFn = features.transports[transport]; if (typeof transportFn !== "function") { @@ -847,8 +762,9 @@ class Player extends EventEmitter { this._priv_currentError = null; throw new Error("DirectFile feature not activated in your build."); } - this._priv_initializeMediaElementTrackChoiceManager(defaultAudioTrack, - defaultTextTrack); + mediaElementTrackChoiceManager = + this._priv_initializeMediaElementTrackChoiceManager(defaultAudioTrack, + defaultTextTrack); initializer = new features.directfile.initDirectFile({ autoPlay, keySystems, speed: this._priv_speed, @@ -856,9 +772,61 @@ class Player extends EventEmitter { url }); } + /** Future `this._priv_contentInfos` related to this content. */ + const contentInfos : IPublicApiContentInfos = { + contentId: generateContentId(), + originalUrl: url, + currentContentCanceller, + isDirectFile, + segmentBuffersStore: null, + thumbnails: null, + manifest: null, + currentPeriod: null, + activeAdaptations: null, + activeRepresentations: null, + initialAudioTrack: defaultAudioTrack, + initialTextTrack: defaultTextTrack, + trackChoiceManager: null, + mediaElementTrackChoiceManager, + }; + // Bind events - initializer.addEventListener("error", (err) => - this._priv_onPlaybackError(err)); + initializer.addEventListener("error", (error) => { + const formattedError = formatError(error, { + defaultCode: "NONE", + defaultReason: "An unknown error stopped content playback.", + }); + formattedError.fatal = true; + + contentInfos.currentContentCanceller.cancel(); + this._priv_cleanUpCurrentContentState(); + this._priv_currentError = formattedError; + log.error("API: The player stopped because of an error", + error instanceof Error ? error : ""); + this._priv_setPlayerState(PLAYER_STATES.STOPPED); + + // TODO This condition is here because the eventual callback called when the + // player state is updated can launch a new content, thus the error will not + // be here anymore, in which case triggering the "error" event is unwanted. + // This is very ugly though, and we should probable have a better solution + if (this._priv_currentError === formattedError) { + this.trigger("error", formattedError); + } + }); + initializer.addEventListener("warning", (error) => { + const formattedError = formatError(error, { + defaultCode: "NONE", + defaultReason: "An unknown error happened.", + }); + log.warn("API: Sending warning:", formattedError); + this.trigger("warning", formattedError); + }); + initializer.addEventListener("reloadingMediaSource", () => { + contentInfos.segmentBuffersStore = null; + if (contentInfos.trackChoiceManager !== null) { + contentInfos.trackChoiceManager.resetPeriods(); + } + }); initializer.addEventListener("inbandEvents", (inbandEvents) => this.trigger("inbandEvents", inbandEvents)); initializer.addEventListener("streamEvent", (streamEvent) => @@ -868,36 +836,23 @@ class Player extends EventEmitter { initializer.addEventListener("decipherabilityUpdate", (decipherabilityUpdate) => this.trigger("decipherabilityUpdate", decipherabilityUpdate)); initializer.addEventListener("activePeriodChanged", (periodInfo) => - this._priv_onActivePeriodChanged(periodInfo)); + this._priv_onActivePeriodChanged(contentInfos, periodInfo)); initializer.addEventListener("periodStreamReady", (periodReadyInfo) => - this._priv_onPeriodStreamReady(periodReadyInfo)); + this._priv_onPeriodStreamReady(contentInfos, periodReadyInfo)); initializer.addEventListener("periodStreamCleared", (periodClearedInfo) => - this._priv_onPeriodStreamCleared(periodClearedInfo)); - initializer.addEventListener("reloadingMediaSource", () => - this._priv_onReloadingMediaSource()); + this._priv_onPeriodStreamCleared(contentInfos, periodClearedInfo)); initializer.addEventListener("representationChange", (representationInfo) => - this._priv_onRepresentationChange(representationInfo)); + this._priv_onRepresentationChange(contentInfos, representationInfo)); initializer.addEventListener("adaptationChange", (adaptationInfo) => - this._priv_onAdaptationChange(adaptationInfo)); + this._priv_onAdaptationChange(contentInfos, adaptationInfo)); initializer.addEventListener("bitrateEstimationChange", (bitrateEstimationInfo) => this._priv_onBitrateEstimationChange(bitrateEstimationInfo)); initializer.addEventListener("manifestReady", (manifest) => - this._priv_onManifestReady(manifest)); - initializer.addEventListener("warning", (err) => - this._priv_onPlaybackWarning(err)); + this._priv_onManifestReady(contentInfos, manifest)); initializer.addEventListener("loaded", (evt) => { - if (this._priv_contentInfos === null) { - log.error("API: Loaded event while no content is loaded"); - return; - } - this._priv_contentInfos.segmentBuffersStore = evt.segmentBuffersStore; + contentInfos.segmentBuffersStore = evt.segmentBuffersStore; }); initializer.addEventListener("addedSegment", (evt) => { - if (this._priv_contentInfos === null) { - log.error("API: Added segment while no content is loaded"); - return; - } - // Manage image tracks // @deprecated const { content, segmentData } = evt; @@ -907,9 +862,9 @@ class Player extends EventEmitter { { const imageData = (segmentData as { data : IBifThumbnail[] }).data; /* eslint-disable import/no-deprecated */ - this._priv_contentInfos.thumbnails = imageData; + contentInfos.thumbnails = imageData; this.trigger("imageTrackUpdate", - { data: this._priv_contentInfos.thumbnails }); + { data: contentInfos.thumbnails }); /* eslint-enable import/no-deprecated */ } } @@ -1005,7 +960,7 @@ class Player extends EventEmitter { // React to playback conditions change playbackObserver.listen((observation) => { updateReloadingMetadata(this.state); - this._priv_triggerPositionUpdate(observation); + this._priv_triggerPositionUpdate(contentInfos, observation); }, { clearSignal: currentContentCanceller.signal }); this._priv_currentError = null; @@ -1155,7 +1110,8 @@ class Player extends EventEmitter { } /** - * Returns the url of the content's manifest + * Returns the url of the currently considered Manifest, or of the content for + * directfile content. * @returns {string|undefined} - Current URL. `undefined` if not known or no * URL yet. */ @@ -1163,9 +1119,9 @@ class Player extends EventEmitter { if (this._priv_contentInfos === null) { return undefined; } - const { isDirectFile, manifest, url } = this._priv_contentInfos; + const { isDirectFile, manifest, originalUrl } = this._priv_contentInfos; if (isDirectFile) { - return url; + return originalUrl; } if (manifest !== null) { return manifest.getUrl(); @@ -1359,15 +1315,12 @@ class Player extends EventEmitter { return; } this._priv_preferTrickModeTracks = preferTrickModeTracks; - if (this._priv_trackChoiceManager !== null) { - if (preferTrickModeTracks && - !this._priv_trackChoiceManager.isTrickModeEnabled()) - { - this._priv_trackChoiceManager.enableVideoTrickModeTracks(); - } else if (!preferTrickModeTracks && - this._priv_trackChoiceManager.isTrickModeEnabled()) - { - this._priv_trackChoiceManager.disableVideoTrickModeTracks(); + const trackChoiceManager = this._priv_contentInfos?.trackChoiceManager; + if (!isNullOrUndefined(trackChoiceManager)) { + if (preferTrickModeTracks && !trackChoiceManager.isTrickModeEnabled()) { + trackChoiceManager.enableVideoTrickModeTracks(); + } else if (!preferTrickModeTracks && trackChoiceManager.isTrickModeEnabled()) { + trackChoiceManager.disableVideoTrickModeTracks(); } } } @@ -1841,14 +1794,17 @@ class Player extends EventEmitter { if (this._priv_contentInfos === null) { return []; } - const { currentPeriod, isDirectFile } = this._priv_contentInfos; + const { currentPeriod, + isDirectFile, + trackChoiceManager, + mediaElementTrackChoiceManager } = this._priv_contentInfos; if (isDirectFile) { - return this._priv_mediaElementTrackChoiceManager?.getAvailableAudioTracks() ?? []; + return mediaElementTrackChoiceManager?.getAvailableAudioTracks() ?? []; } - if (this._priv_trackChoiceManager === null || currentPeriod === null) { + if (trackChoiceManager === null || currentPeriod === null) { return []; } - return this._priv_trackChoiceManager.getAvailableAudioTracks(currentPeriod); + return trackChoiceManager.getAvailableAudioTracks(currentPeriod); } /** @@ -1859,14 +1815,17 @@ class Player extends EventEmitter { if (this._priv_contentInfos === null) { return []; } - const { currentPeriod, isDirectFile } = this._priv_contentInfos; + const { currentPeriod, + isDirectFile, + trackChoiceManager, + mediaElementTrackChoiceManager } = this._priv_contentInfos; if (isDirectFile) { - return this._priv_mediaElementTrackChoiceManager?.getAvailableTextTracks() ?? []; + return mediaElementTrackChoiceManager?.getAvailableTextTracks() ?? []; } - if (this._priv_trackChoiceManager === null || currentPeriod === null) { + if (trackChoiceManager === null || currentPeriod === null) { return []; } - return this._priv_trackChoiceManager.getAvailableTextTracks(currentPeriod); + return trackChoiceManager.getAvailableTextTracks(currentPeriod); } /** @@ -1877,14 +1836,17 @@ class Player extends EventEmitter { if (this._priv_contentInfos === null) { return []; } - const { currentPeriod, isDirectFile } = this._priv_contentInfos; + const { currentPeriod, + isDirectFile, + trackChoiceManager, + mediaElementTrackChoiceManager } = this._priv_contentInfos; if (isDirectFile) { - return this._priv_mediaElementTrackChoiceManager?.getAvailableVideoTracks() ?? []; + return mediaElementTrackChoiceManager?.getAvailableVideoTracks() ?? []; } - if (this._priv_trackChoiceManager === null || currentPeriod === null) { + if (trackChoiceManager === null || currentPeriod === null) { return []; } - return this._priv_trackChoiceManager.getAvailableVideoTracks(currentPeriod); + return trackChoiceManager.getAvailableVideoTracks(currentPeriod); } /** @@ -1895,17 +1857,20 @@ class Player extends EventEmitter { if (this._priv_contentInfos === null) { return undefined; } - const { currentPeriod, isDirectFile } = this._priv_contentInfos; + const { currentPeriod, + isDirectFile, + trackChoiceManager, + mediaElementTrackChoiceManager } = this._priv_contentInfos; if (isDirectFile) { - if (this._priv_mediaElementTrackChoiceManager === null) { + if (mediaElementTrackChoiceManager === null) { return undefined; } - return this._priv_mediaElementTrackChoiceManager.getChosenAudioTrack(); + return mediaElementTrackChoiceManager.getChosenAudioTrack(); } - if (this._priv_trackChoiceManager === null || currentPeriod === null) { + if (trackChoiceManager === null || currentPeriod === null) { return undefined; } - return this._priv_trackChoiceManager.getChosenAudioTrack(currentPeriod); + return trackChoiceManager.getChosenAudioTrack(currentPeriod); } /** @@ -1916,17 +1881,20 @@ class Player extends EventEmitter { if (this._priv_contentInfos === null) { return undefined; } - const { currentPeriod, isDirectFile } = this._priv_contentInfos; + const { currentPeriod, + isDirectFile, + trackChoiceManager, + mediaElementTrackChoiceManager } = this._priv_contentInfos; if (isDirectFile) { - if (this._priv_mediaElementTrackChoiceManager === null) { + if (mediaElementTrackChoiceManager === null) { return undefined; } - return this._priv_mediaElementTrackChoiceManager.getChosenTextTrack(); + return mediaElementTrackChoiceManager.getChosenTextTrack(); } - if (this._priv_trackChoiceManager === null || currentPeriod === null) { + if (trackChoiceManager === null || currentPeriod === null) { return undefined; } - return this._priv_trackChoiceManager.getChosenTextTrack(currentPeriod); + return trackChoiceManager.getChosenTextTrack(currentPeriod); } /** @@ -1937,17 +1905,20 @@ class Player extends EventEmitter { if (this._priv_contentInfos === null) { return undefined; } - const { currentPeriod, isDirectFile } = this._priv_contentInfos; + const { currentPeriod, + isDirectFile, + trackChoiceManager, + mediaElementTrackChoiceManager } = this._priv_contentInfos; if (isDirectFile) { - if (this._priv_mediaElementTrackChoiceManager === null) { + if (mediaElementTrackChoiceManager === null) { return undefined; } - return this._priv_mediaElementTrackChoiceManager.getChosenVideoTrack(); + return mediaElementTrackChoiceManager.getChosenVideoTrack(); } - if (this._priv_trackChoiceManager === null || currentPeriod === null) { + if (trackChoiceManager === null || currentPeriod === null) { return undefined; } - return this._priv_trackChoiceManager.getChosenVideoTrack(currentPeriod); + return trackChoiceManager.getChosenVideoTrack(currentPeriod); } /** @@ -1960,20 +1931,23 @@ class Player extends EventEmitter { if (this._priv_contentInfos === null) { throw new Error("No content loaded"); } - const { currentPeriod, isDirectFile } = this._priv_contentInfos; + const { currentPeriod, + isDirectFile, + trackChoiceManager, + mediaElementTrackChoiceManager } = this._priv_contentInfos; if (isDirectFile) { try { - this._priv_mediaElementTrackChoiceManager?.setAudioTrackById(audioId); + mediaElementTrackChoiceManager?.setAudioTrackById(audioId); return; } catch (e) { throw new Error("player: unknown audio track"); } } - if (this._priv_trackChoiceManager === null || currentPeriod === null) { + if (trackChoiceManager === null || currentPeriod === null) { throw new Error("No compatible content launched."); } try { - this._priv_trackChoiceManager.setAudioTrackByID(currentPeriod, audioId); + trackChoiceManager.setAudioTrackByID(currentPeriod, audioId); } catch (e) { throw new Error("player: unknown audio track"); @@ -1990,20 +1964,23 @@ class Player extends EventEmitter { if (this._priv_contentInfos === null) { throw new Error("No content loaded"); } - const { currentPeriod, isDirectFile } = this._priv_contentInfos; + const { currentPeriod, + isDirectFile, + trackChoiceManager, + mediaElementTrackChoiceManager } = this._priv_contentInfos; if (isDirectFile) { try { - this._priv_mediaElementTrackChoiceManager?.setTextTrackById(textId); + mediaElementTrackChoiceManager?.setTextTrackById(textId); return; } catch (e) { throw new Error("player: unknown text track"); } } - if (this._priv_trackChoiceManager === null || currentPeriod === null) { + if (trackChoiceManager === null || currentPeriod === null) { throw new Error("No compatible content launched."); } try { - this._priv_trackChoiceManager.setTextTrackByID(currentPeriod, textId); + trackChoiceManager.setTextTrackByID(currentPeriod, textId); } catch (e) { throw new Error("player: unknown text track"); @@ -2017,15 +1994,18 @@ class Player extends EventEmitter { if (this._priv_contentInfos === null) { return; } - const { currentPeriod, isDirectFile } = this._priv_contentInfos; + const { currentPeriod, + isDirectFile, + trackChoiceManager, + mediaElementTrackChoiceManager } = this._priv_contentInfos; if (isDirectFile) { - this._priv_mediaElementTrackChoiceManager?.disableTextTrack(); + mediaElementTrackChoiceManager?.disableTextTrack(); return; } - if (this._priv_trackChoiceManager === null || currentPeriod === null) { + if (trackChoiceManager === null || currentPeriod === null) { return; } - return this._priv_trackChoiceManager.disableTextTrack(currentPeriod); + return trackChoiceManager.disableTextTrack(currentPeriod); } /** @@ -2038,20 +2018,23 @@ class Player extends EventEmitter { if (this._priv_contentInfos === null) { throw new Error("No content loaded"); } - const { currentPeriod, isDirectFile } = this._priv_contentInfos; + const { currentPeriod, + isDirectFile, + trackChoiceManager, + mediaElementTrackChoiceManager } = this._priv_contentInfos; if (isDirectFile) { try { - this._priv_mediaElementTrackChoiceManager?.setVideoTrackById(videoId); + mediaElementTrackChoiceManager?.setVideoTrackById(videoId); return; } catch (e) { throw new Error("player: unknown video track"); } } - if (this._priv_trackChoiceManager === null || currentPeriod === null) { + if (trackChoiceManager === null || currentPeriod === null) { throw new Error("No compatible content launched."); } try { - this._priv_trackChoiceManager.setVideoTrackByID(currentPeriod, videoId); + trackChoiceManager.setVideoTrackByID(currentPeriod, videoId); } catch (e) { throw new Error("player: unknown video track"); @@ -2065,14 +2048,17 @@ class Player extends EventEmitter { if (this._priv_contentInfos === null) { return; } - const { currentPeriod, isDirectFile } = this._priv_contentInfos; - if (isDirectFile && this._priv_mediaElementTrackChoiceManager !== null) { - return this._priv_mediaElementTrackChoiceManager.disableVideoTrack(); + const { currentPeriod, + isDirectFile, + trackChoiceManager, + mediaElementTrackChoiceManager } = this._priv_contentInfos; + if (isDirectFile && mediaElementTrackChoiceManager !== null) { + return mediaElementTrackChoiceManager.disableVideoTrack(); } - if (this._priv_trackChoiceManager === null || currentPeriod === null) { + if (trackChoiceManager === null || currentPeriod === null) { return; } - return this._priv_trackChoiceManager.disableVideoTrack(currentPeriod); + return trackChoiceManager.disableVideoTrack(currentPeriod); } /** @@ -2115,11 +2101,12 @@ class Player extends EventEmitter { "Should have been an Array."); } this._priv_preferredAudioTracks = tracks; - if (this._priv_trackChoiceManager !== null) { - this._priv_trackChoiceManager.setPreferredAudioTracks(tracks, shouldApply); - } else if (this._priv_mediaElementTrackChoiceManager !== null) { - this._priv_mediaElementTrackChoiceManager.setPreferredAudioTracks(tracks, - shouldApply); + const contentInfos = this._priv_contentInfos; + if (!isNullOrUndefined(contentInfos?.trackChoiceManager)) { + contentInfos?.trackChoiceManager.setPreferredAudioTracks(tracks, shouldApply); + } else if (!isNullOrUndefined(contentInfos?.mediaElementTrackChoiceManager)) { + contentInfos?.mediaElementTrackChoiceManager.setPreferredAudioTracks(tracks, + shouldApply); } } @@ -2139,11 +2126,12 @@ class Player extends EventEmitter { "Should have been an Array."); } this._priv_preferredTextTracks = tracks; - if (this._priv_trackChoiceManager !== null) { - this._priv_trackChoiceManager.setPreferredTextTracks(tracks, shouldApply); - } else if (this._priv_mediaElementTrackChoiceManager !== null) { - this._priv_mediaElementTrackChoiceManager.setPreferredTextTracks(tracks, - shouldApply); + const contentInfos = this._priv_contentInfos; + if (!isNullOrUndefined(contentInfos?.trackChoiceManager)) { + contentInfos?.trackChoiceManager.setPreferredTextTracks(tracks, shouldApply); + } else if (!isNullOrUndefined(contentInfos?.mediaElementTrackChoiceManager)) { + contentInfos?.mediaElementTrackChoiceManager.setPreferredTextTracks(tracks, + shouldApply); } } @@ -2163,11 +2151,12 @@ class Player extends EventEmitter { "Should have been an Array."); } this._priv_preferredVideoTracks = tracks; - if (this._priv_trackChoiceManager !== null) { - this._priv_trackChoiceManager.setPreferredVideoTracks(tracks, shouldApply); - } else if (this._priv_mediaElementTrackChoiceManager !== null) { - this._priv_mediaElementTrackChoiceManager.setPreferredVideoTracks(tracks, - shouldApply); + const contentInfos = this._priv_contentInfos; + if (!isNullOrUndefined(contentInfos?.trackChoiceManager)) { + contentInfos?.trackChoiceManager.setPreferredVideoTracks(tracks, shouldApply); + } else if (!isNullOrUndefined(contentInfos?.mediaElementTrackChoiceManager)) { + contentInfos?.mediaElementTrackChoiceManager.setPreferredVideoTracks(tracks, + shouldApply); } } @@ -2264,10 +2253,8 @@ class Player extends EventEmitter { // lock playback of new contents while cleaning up is pending this._priv_contentLock.setValue(true); + this._priv_contentInfos?.mediaElementTrackChoiceManager?.dispose(); this._priv_contentInfos = null; - this._priv_trackChoiceManager = null; - this._priv_mediaElementTrackChoiceManager?.dispose(); - this._priv_mediaElementTrackChoiceManager = null; this._priv_contentEventsMemory = {}; @@ -2296,85 +2283,44 @@ class Player extends EventEmitter { } } - /** - * Triggered when we received a fatal error. - * Clean-up ressources and signal that the content has stopped on error. - * @param {Error} error - */ - private _priv_onPlaybackError(error : unknown) : void { - const formattedError = formatError(error, { - defaultCode: "NONE", - defaultReason: "An unknown error stopped content playback.", - }); - formattedError.fatal = true; - - if (this._priv_contentInfos !== null) { - this._priv_contentInfos.currentContentCanceller.cancel(); - } - this._priv_cleanUpCurrentContentState(); - this._priv_currentError = formattedError; - log.error("API: The player stopped because of an error", - error instanceof Error ? error : ""); - this._priv_setPlayerState(PLAYER_STATES.STOPPED); - - // TODO This condition is here because the eventual callback called when the - // player state is updated can launch a new content, thus the error will not - // be here anymore, in which case triggering the "error" event is unwanted. - // This is very ugly though, and we should probable have a better solution - if (this._priv_currentError === formattedError) { - this.trigger("error", formattedError); - } - } - - /** - * Triggered when we received a warning event during playback. - * Trigger the right API event. - * @param {Error} error - */ - private _priv_onPlaybackWarning(error : IPlayerError) : void { - const formattedError = formatError(error, { - defaultCode: "NONE", - defaultReason: "An unknown error happened.", - }); - log.warn("API: Sending warning:", formattedError); - this.trigger("warning", formattedError); - } - /** * Triggered when the Manifest has been loaded for the current content. * Initialize various private properties and emit initial event. - * @param {Object} value + * @param {Object} contentInfos + * @param {Object} manifest */ - private _priv_onManifestReady(manifest : Manifest) : void { - const contentInfos = this._priv_contentInfos; - if (contentInfos === null) { - log.error("API: The manifest is loaded but no content is."); - return; + private _priv_onManifestReady( + contentInfos : IPublicApiContentInfos, + manifest : Manifest + ) : void { + if (contentInfos.contentId !== this._priv_contentInfos?.contentId) { + return; // Event for another content } contentInfos.manifest = manifest; this._priv_reloadingMetadata.manifest = manifest; const { initialAudioTrack, initialTextTrack } = contentInfos; - this._priv_trackChoiceManager = new TrackChoiceManager({ + contentInfos.trackChoiceManager = new TrackChoiceManager({ preferTrickModeTracks: this._priv_preferTrickModeTracks, }); const preferredAudioTracks = initialAudioTrack === undefined ? this._priv_preferredAudioTracks : [initialAudioTrack]; - this._priv_trackChoiceManager.setPreferredAudioTracks(preferredAudioTracks, true); + contentInfos.trackChoiceManager.setPreferredAudioTracks(preferredAudioTracks, true); const preferredTextTracks = initialTextTrack === undefined ? this._priv_preferredTextTracks : [initialTextTrack]; - this._priv_trackChoiceManager.setPreferredTextTracks(preferredTextTracks, true); + contentInfos.trackChoiceManager.setPreferredTextTracks(preferredTextTracks, true); - this._priv_trackChoiceManager.setPreferredVideoTracks(this._priv_preferredVideoTracks, - true); + contentInfos.trackChoiceManager + .setPreferredVideoTracks(this._priv_preferredVideoTracks, + true); manifest.addEventListener("manifestUpdate", () => { // Update the tracks chosen if it changed - if (this._priv_trackChoiceManager !== null) { - this._priv_trackChoiceManager.update(); + if (contentInfos.trackChoiceManager !== null) { + contentInfos.trackChoiceManager.update(); } }, contentInfos.currentContentCanceller.signal); } @@ -2383,14 +2329,17 @@ class Player extends EventEmitter { * Triggered each times the current Period Changed. * Store and emit initial state for the Period. * - * @param {Object} value + * @param {Object} contentInfos + * @param {Object} periodInfo */ - private _priv_onActivePeriodChanged({ period } : { period : Period }) : void { - if (this._priv_contentInfos === null) { - log.error("API: The active period changed but no content is loaded"); - return; + private _priv_onActivePeriodChanged( + contentInfos : IPublicApiContentInfos, + { period } : { period : Period } + ) : void { + if (contentInfos.contentId !== this._priv_contentInfos?.contentId) { + return; // Event for another content } - this._priv_contentInfos.currentPeriod = period; + contentInfos.currentPeriod = period; if (this._priv_contentEventsMemory.periodChange !== period) { this._priv_contentEventsMemory.periodChange = period; @@ -2401,11 +2350,13 @@ class Player extends EventEmitter { this.trigger("availableTextTracksChange", this.getAvailableTextTracks()); this.trigger("availableVideoTracksChange", this.getAvailableVideoTracks()); + const trackChoiceManager = this._priv_contentInfos?.trackChoiceManager; + // Emit intial events for the Period - if (this._priv_trackChoiceManager !== null) { - const audioTrack = this._priv_trackChoiceManager.getChosenAudioTrack(period); - const textTrack = this._priv_trackChoiceManager.getChosenTextTrack(period); - const videoTrack = this._priv_trackChoiceManager.getChosenVideoTrack(period); + if (!isNullOrUndefined(trackChoiceManager)) { + const audioTrack = trackChoiceManager.getChosenAudioTrack(period); + const textTrack = trackChoiceManager.getChosenTextTrack(period); + const videoTrack = trackChoiceManager.getChosenVideoTrack(period); this.trigger("audioTrackChange", audioTrack); this.trigger("textTrackChange", textTrack); @@ -2431,44 +2382,52 @@ class Player extends EventEmitter { /** * Triggered each times a new "PeriodStream" is ready. * Choose the right Adaptation for the Period and emit it. + * @param {Object} contentInfos * @param {Object} value */ - private _priv_onPeriodStreamReady(value : { - type : IBufferType; - period : Period; - adaptation$ : Subject; - }) : void { + private _priv_onPeriodStreamReady( + contentInfos : IPublicApiContentInfos, + value : { + type : IBufferType; + period : Period; + adaptation$ : Subject; + } + ) : void { + if (contentInfos.contentId !== this._priv_contentInfos?.contentId) { + return; // Event for another content + } const { type, period, adaptation$ } = value; + const trackChoiceManager = contentInfos.trackChoiceManager; switch (type) { case "video": - if (this._priv_trackChoiceManager === null) { + if (isNullOrUndefined(trackChoiceManager)) { log.error("API: TrackChoiceManager not instanciated for a new video period"); adaptation$.next(null); } else { - this._priv_trackChoiceManager.addPeriod(type, period, adaptation$); - this._priv_trackChoiceManager.setInitialVideoTrack(period); + trackChoiceManager.addPeriod(type, period, adaptation$); + trackChoiceManager.setInitialVideoTrack(period); } break; case "audio": - if (this._priv_trackChoiceManager === null) { + if (isNullOrUndefined(trackChoiceManager)) { log.error(`API: TrackChoiceManager not instanciated for a new ${type} period`); adaptation$.next(null); } else { - this._priv_trackChoiceManager.addPeriod(type, period, adaptation$); - this._priv_trackChoiceManager.setInitialAudioTrack(period); + trackChoiceManager.addPeriod(type, period, adaptation$); + trackChoiceManager.setInitialAudioTrack(period); } break; case "text": - if (this._priv_trackChoiceManager === null) { + if (isNullOrUndefined(trackChoiceManager)) { log.error(`API: TrackChoiceManager not instanciated for a new ${type} period`); adaptation$.next(null); } else { - this._priv_trackChoiceManager.addPeriod(type, period, adaptation$); - this._priv_trackChoiceManager.setInitialTextTrack(period); + trackChoiceManager.addPeriod(type, period, adaptation$); + trackChoiceManager.setInitialTextTrack(period); } break; @@ -2485,30 +2444,32 @@ class Player extends EventEmitter { /** * Triggered each times we "remove" a PeriodStream. + * @param {Object} contentInfos * @param {Object} value */ - private _priv_onPeriodStreamCleared(value : { - type : IBufferType; - period : Period; - }) : void { + private _priv_onPeriodStreamCleared( + contentInfos : IPublicApiContentInfos, + value : { type : IBufferType; period : Period } + ) : void { + if (contentInfos.contentId !== this._priv_contentInfos?.contentId) { + return; // Event for another content + } const { type, period } = value; + const trackChoiceManager = contentInfos.trackChoiceManager; // Clean-up track choice from TrackChoiceManager switch (type) { case "audio": case "text": case "video": - if (this._priv_trackChoiceManager !== null) { - this._priv_trackChoiceManager.removePeriod(type, period); + if (!isNullOrUndefined(trackChoiceManager)) { + trackChoiceManager.removePeriod(type, period); } break; } // Clean-up stored Representation and Adaptation information - if (this._priv_contentInfos === null) { - return ; - } - const { activeAdaptations, activeRepresentations } = this._priv_contentInfos; + const { activeAdaptations, activeRepresentations } = contentInfos; if (!isNullOrUndefined(activeAdaptations) && !isNullOrUndefined(activeAdaptations[period.id])) { @@ -2530,44 +2491,29 @@ class Player extends EventEmitter { } } - /** - * Triggered each time the content is re-loaded on the MediaSource. - */ - private _priv_onReloadingMediaSource() { - if (this._priv_contentInfos !== null) { - this._priv_contentInfos.segmentBuffersStore = null; - } - if (this._priv_trackChoiceManager !== null) { - this._priv_trackChoiceManager.resetPeriods(); - } - } - /** * Triggered each times a new Adaptation is considered for the current * content. * Store given Adaptation and emit it if from the current Period. + * @param {Object} contentInfos * @param {Object} value */ - private _priv_onAdaptationChange({ - type, - adaptation, - period, - } : { - type : IBufferType; - adaptation : Adaptation|null; - period : Period; - }) : void { - if (this._priv_contentInfos === null) { - log.error("API: The adaptations changed but no content is loaded"); - return; + private _priv_onAdaptationChange( + contentInfos : IPublicApiContentInfos, + { type, adaptation, period } : { type : IBufferType; + adaptation : Adaptation|null; + period : Period; } + ) : void { + if (contentInfos.contentId !== this._priv_contentInfos?.contentId) { + return; // Event for another content } - // lazily create this._priv_contentInfos.activeAdaptations - if (this._priv_contentInfos.activeAdaptations === null) { - this._priv_contentInfos.activeAdaptations = {}; + // lazily create contentInfos.activeAdaptations + if (contentInfos.activeAdaptations === null) { + contentInfos.activeAdaptations = {}; } - const { activeAdaptations, currentPeriod } = this._priv_contentInfos; + const { activeAdaptations, currentPeriod } = contentInfos; const activePeriodAdaptations = activeAdaptations[period.id]; if (isNullOrUndefined(activePeriodAdaptations)) { activeAdaptations[period.id] = { [type]: adaptation }; @@ -2575,14 +2521,14 @@ class Player extends EventEmitter { activePeriodAdaptations[type] = adaptation; } - if (this._priv_trackChoiceManager !== null && + const { trackChoiceManager } = contentInfos; + if (trackChoiceManager !== null && currentPeriod !== null && !isNullOrUndefined(period) && period.id === currentPeriod.id) { switch (type) { case "audio": - const audioTrack = this._priv_trackChoiceManager - .getChosenAudioTrack(currentPeriod); + const audioTrack = trackChoiceManager.getChosenAudioTrack(currentPeriod); this.trigger("audioTrackChange", audioTrack); const availableAudioBitrates = this.getAvailableAudioBitrates(); @@ -2590,13 +2536,11 @@ class Player extends EventEmitter { availableAudioBitrates); break; case "text": - const textTrack = this._priv_trackChoiceManager - .getChosenTextTrack(currentPeriod); + const textTrack = trackChoiceManager.getChosenTextTrack(currentPeriod); this.trigger("textTrackChange", textTrack); break; case "video": - const videoTrack = this._priv_trackChoiceManager - .getChosenVideoTrack(currentPeriod); + const videoTrack = trackChoiceManager.getChosenVideoTrack(currentPeriod); this.trigger("videoTrackChange", videoTrack); const availableVideoBitrates = this.getAvailableVideoBitrates(); @@ -2612,28 +2556,25 @@ class Player extends EventEmitter { * * Store given Representation and emit it if from the current Period. * + * @param {Object} contentInfos * @param {Object} obj */ - private _priv_onRepresentationChange({ - type, - period, - representation, - }: { - type : IBufferType; - period : Period; - representation : Representation|null; - }) : void { - if (this._priv_contentInfos === null) { - log.error("API: The representations changed but no content is loaded"); - return; + private _priv_onRepresentationChange( + contentInfos : IPublicApiContentInfos, + { type, period, representation }: { type : IBufferType; + period : Period; + representation : Representation|null; } + ) : void { + if (contentInfos.contentId !== this._priv_contentInfos?.contentId) { + return; // Event for another content } - // lazily create this._priv_contentInfos.activeRepresentations - if (this._priv_contentInfos.activeRepresentations === null) { - this._priv_contentInfos.activeRepresentations = {}; + // lazily create contentInfos.activeRepresentations + if (contentInfos.activeRepresentations === null) { + contentInfos.activeRepresentations = {}; } - const { activeRepresentations, currentPeriod } = this._priv_contentInfos; + const { activeRepresentations, currentPeriod } = contentInfos; const activePeriodRepresentations = activeRepresentations[period.id]; if (isNullOrUndefined(activePeriodRepresentations)) { @@ -2705,15 +2646,18 @@ class Player extends EventEmitter { * * Trigger the right Player Event * + * @param {Object} contentInfos * @param {Object} observation */ - private _priv_triggerPositionUpdate(observation : IPlaybackObservation) : void { - if (this._priv_contentInfos === null) { - log.warn("API: Cannot perform time update: no content loaded."); - return; + private _priv_triggerPositionUpdate( + contentInfos : IPublicApiContentInfos, + observation : IPlaybackObservation + ) : void { + if (contentInfos.contentId !== this._priv_contentInfos?.contentId) { + return; // Event for another content } - const { isDirectFile, manifest } = this._priv_contentInfos; + const { isDirectFile, manifest } = contentInfos; if ((!isDirectFile && manifest === null) || isNullOrUndefined(observation)) { return; } @@ -2803,66 +2747,66 @@ class Player extends EventEmitter { private _priv_initializeMediaElementTrackChoiceManager( defaultAudioTrack : IAudioTrackPreference | null | undefined, defaultTextTrack : ITextTrackPreference | null | undefined - ) : void { + ) : MediaElementTrackChoiceManager { assert(features.directfile !== null, "Initializing `MediaElementTrackChoiceManager` without Directfile feature"); assert(this.videoElement !== null, "Initializing `MediaElementTrackChoiceManager` on a disposed RxPlayer"); - this._priv_mediaElementTrackChoiceManager = + const mediaElementTrackChoiceManager = new features.directfile.mediaElementTrackChoiceManager(this.videoElement); const preferredAudioTracks = defaultAudioTrack === undefined ? this._priv_preferredAudioTracks : [defaultAudioTrack]; - this._priv_mediaElementTrackChoiceManager - .setPreferredAudioTracks(preferredAudioTracks, true); + mediaElementTrackChoiceManager.setPreferredAudioTracks(preferredAudioTracks, true); const preferredTextTracks = defaultTextTrack === undefined ? this._priv_preferredTextTracks : [defaultTextTrack]; - this._priv_mediaElementTrackChoiceManager - .setPreferredTextTracks(preferredTextTracks, true); + mediaElementTrackChoiceManager.setPreferredTextTracks(preferredTextTracks, true); - this._priv_mediaElementTrackChoiceManager + mediaElementTrackChoiceManager .setPreferredVideoTracks(this._priv_preferredVideoTracks, true); this.trigger("availableAudioTracksChange", - this._priv_mediaElementTrackChoiceManager.getAvailableAudioTracks()); + mediaElementTrackChoiceManager.getAvailableAudioTracks()); this.trigger("availableVideoTracksChange", - this._priv_mediaElementTrackChoiceManager.getAvailableVideoTracks()); + mediaElementTrackChoiceManager.getAvailableVideoTracks()); this.trigger("availableTextTracksChange", - this._priv_mediaElementTrackChoiceManager.getAvailableTextTracks()); + mediaElementTrackChoiceManager.getAvailableTextTracks()); this.trigger("audioTrackChange", - this._priv_mediaElementTrackChoiceManager.getChosenAudioTrack() + mediaElementTrackChoiceManager.getChosenAudioTrack() ?? null); this.trigger("textTrackChange", - this._priv_mediaElementTrackChoiceManager.getChosenTextTrack() + mediaElementTrackChoiceManager.getChosenTextTrack() ?? null); this.trigger("videoTrackChange", - this._priv_mediaElementTrackChoiceManager.getChosenVideoTrack() + mediaElementTrackChoiceManager.getChosenVideoTrack() ?? null); - this._priv_mediaElementTrackChoiceManager + mediaElementTrackChoiceManager .addEventListener("availableVideoTracksChange", (val) => this.trigger("availableVideoTracksChange", val)); - this._priv_mediaElementTrackChoiceManager + mediaElementTrackChoiceManager .addEventListener("availableAudioTracksChange", (val) => this.trigger("availableAudioTracksChange", val)); - this._priv_mediaElementTrackChoiceManager + mediaElementTrackChoiceManager .addEventListener("availableTextTracksChange", (val) => this.trigger("availableTextTracksChange", val)); - this._priv_mediaElementTrackChoiceManager + mediaElementTrackChoiceManager .addEventListener("audioTrackChange", (val) => this.trigger("audioTrackChange", val)); - this._priv_mediaElementTrackChoiceManager + mediaElementTrackChoiceManager .addEventListener("videoTrackChange", (val) => this.trigger("videoTrackChange", val)); - this._priv_mediaElementTrackChoiceManager + mediaElementTrackChoiceManager .addEventListener("textTrackChange", (val) => this.trigger("textTrackChange", val)); + + return mediaElementTrackChoiceManager; } } Player.version = /* PLAYER_VERSION */"3.29.0"; @@ -2897,4 +2841,72 @@ interface IPublicAPIEvent { inbandEvents : IInbandEvent[]; } +/** State linked to a particular contents loaded by the public API. */ +interface IPublicApiContentInfos { + /** + * Unique identifier for this `IPublicApiContentInfos` object. + * Allows to identify and thus compare this `contentInfos` object with another + * one. + */ + contentId : string; + /** Original URL set to load the content. */ + originalUrl : string | undefined; + /** TaskCanceller triggered when it's time to stop the current content. */ + currentContentCanceller : TaskCanceller; + /** + * `true` if the current content is in DirectFile mode. + * `false` is the current content has a transport protocol (Smooth/DASH...). + */ + isDirectFile : boolean; + /** + * Current Image Track Data associated to the content. + * `null` if the current content has no image playlist linked to it. + * @deprecated + */ + thumbnails : IBifThumbnail[]|null; + /** + * Manifest linked to the current content. + * `null` if the current content loaded has no manifest or if the content is + * not yet loaded. + */ + manifest : Manifest|null; + /** + * Current Period being played. + * `null` if no Period is being played. + */ + currentPeriod : Period|null; + /** + * Store currently considered adaptations, per active period. + * `null` if no Adaptation is active + */ + activeAdaptations : { + [periodId : string] : Partial>; + } | null; + /** + * Store currently considered representations, per active period. + * `null` if no Representation is active + */ + activeRepresentations : { + [periodId : string] : Partial>; + } | null; + /** Store starting audio track if one. */ + initialAudioTrack : undefined|IAudioTrackPreference; + /** Store starting text track if one. */ + initialTextTrack : undefined|ITextTrackPreference; + /** Keep information on the active SegmentBuffers. */ + segmentBuffersStore : SegmentBuffersStore | null; + /** + * TrackChoiceManager instance linked to the current content. + * `null` if no content has been loaded or if the current content loaded + * has no TrackChoiceManager. + */ + trackChoiceManager : TrackChoiceManager|null; + /** + * MediaElementTrackChoiceManager instance linked to the current content. + * `null` if no content has been loaded or if the current content loaded + * has no MediaElementTrackChoiceManager. + */ + mediaElementTrackChoiceManager : MediaElementTrackChoiceManager|null; +} + export default Player; From f19131c322609ba0a3c006aa134d42912f4c4171 Mon Sep 17 00:00:00 2001 From: Paul Berberian Date: Mon, 5 Dec 2022 21:28:05 +0100 Subject: [PATCH 05/86] Preload next Period if current AdaptationStream is empty --- tests/integration/scenarios/dash_multi-track.js | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/tests/integration/scenarios/dash_multi-track.js b/tests/integration/scenarios/dash_multi-track.js index 6015c85784..5eee3902c1 100644 --- a/tests/integration/scenarios/dash_multi-track.js +++ b/tests/integration/scenarios/dash_multi-track.js @@ -367,22 +367,23 @@ describe("DASH multi-track content (SegmentTimeline)", function () { }); it("should not update the current tracks for non-applied preferences", async () => { + player.setPreferredTextTracks([ { language: "de", + closedCaption: false } ], undefined); await loadContent(); player.setPreferredAudioTracks([ { language: "be", audioDescription: true } ]); player.setPreferredVideoTracks([ { codec: { all: false, test: /avc1\.640028/}, signInterpreted: true }], false); - player.setPreferredTextTracks([ { language: "de", - closedCaption: false } ], undefined); + player.setPreferredTextTracks([ null ], undefined); await sleep(100); checkAudioTrack("de", "deu", false); - checkNoTextTrack(); + checkTextTrack("de", "deu", false); checkVideoTrack({ all: true, test: /avc1\.42C014/ }, true); await goToSecondPeriod(); checkAudioTrack("be", "bel", true); - checkTextTrack("de", "deu", false); + checkNoTextTrack(); checkVideoTrack({ all: false, test: /avc1\.640028/ }, true); }); From 0ef907dc20c408a008f25a08e23bf93f6206d2d4 Mon Sep 17 00:00:00 2001 From: Paul Berberian Date: Fri, 18 Nov 2022 18:08:27 +0100 Subject: [PATCH 06/86] utils: update documentation and simplify some logic of ISharedReference --- src/utils/reference.ts | 42 ++++++++++++++++++++++++------------------ 1 file changed, 24 insertions(+), 18 deletions(-) diff --git a/src/utils/reference.ts b/src/utils/reference.ts index 62ccd0a580..f9bf03db73 100644 --- a/src/utils/reference.ts +++ b/src/utils/reference.ts @@ -22,7 +22,7 @@ import { CancellationSignal } from "./task_canceller"; /** * A value behind a shared reference, meaning that any update to its value from - * anywhere can be retrieved from any other parts of the code in posession of + * anywhere can be retrieved from any other parts of the code in possession of * the same `ISharedReference`. * * @example @@ -92,12 +92,12 @@ export interface ISharedReference { * reference is updated. * @param {Function} cb - Callback to be called each time the reference is * updated. Takes as first argument its new value and in second argument a - * callback allowing to unregister the callback. + * function allowing to unregister the update listening callback. * @param {Object} [options] * @param {Object} [options.clearSignal] - Allows to provide a * CancellationSignal which will unregister the callback when it emits. * @param {boolean} [options.emitCurrentValue] - If `true`, the callback will - * also be immediately called with the current value. + * also be immediately (synchronously) called with the current value. */ onUpdate( cb : (val : T, stopListening : () => void) => void, @@ -115,6 +115,16 @@ export interface ISharedReference { * * This method can be used as a lighter weight alternative to `onUpdate` when * just waiting that the stored value becomes defined. + * As such, it is an explicit equivalent to something like: + * ```js + * myReference.onUpdate((newVal, stopListening) => { + * if (newVal !== undefined) { + * stopListening(); + * + * // ... do the logic + * } + * }, { emitCurrentValue: true }); + * ``` * @param {Function} cb - Callback to be called each time the reference is * updated. Takes the new value in argument. * @param {Object} [options] @@ -138,6 +148,11 @@ export interface ISharedReference { /** * An `ISharedReference` which can only be read and not updated. * + * Because an `ISharedReference` is structurally compatible to a + * `IReadOnlySharedReference`, and because of TypeScript variance rules, it can + * be upcasted into a `IReadOnlySharedReference` at any time to make it clear in + * the code that some logic is not supposed to update the referenced value. + * * @example * ```ts * const myReference : ISharedReference = createSharedReference(4); @@ -151,11 +166,6 @@ export interface ISharedReference { * myReference.setValue(12); * shouldOnlyReadIt(myReference); // output: "current value: 12" * ``` - * - * Because an `ISharedReference` is structurally compatible to a - * `IReadOnlySharedReference`, and because of TypeScript variance rules, it can - * be upcasted into a `IReadOnlySharedReference` at any time to make it clear in - * the code that some logic is not supposed to update the referenced value. */ export type IReadOnlySharedReference = Pick, @@ -357,16 +367,12 @@ export default function createSharedReference(initialValue : T) : ISharedRefe options? : { clearSignal?: CancellationSignal | undefined } | undefined ) : void { - if (value !== undefined) { - cb(value as Exclude); - } else if (!isFinished) { - this.onUpdate((val : T, stopListening) => { - if (val !== undefined) { - stopListening(); - cb(value as Exclude); - } - }, { clearSignal: options?.clearSignal }); - } + this.onUpdate((val : T, stopListening) => { + if (val !== undefined) { + stopListening(); + cb(value as Exclude); + } + }, { clearSignal: options?.clearSignal, emitCurrentValue: true }); }, /** From 898e5ee7263553e2b50db2aa208e9185668e9ae6 Mon Sep 17 00:00:00 2001 From: Paul Berberian Date: Mon, 5 Dec 2022 21:32:36 +0100 Subject: [PATCH 07/86] Update tests and remove previous behavior as it was too different on preference handling --- .../period/create_empty_adaptation_stream.ts | 18 +++++++++++++++--- src/core/stream/period/period_stream.ts | 4 ++-- .../media/end_number.mpd | 2 +- .../integration/scenarios/dash_multi-track.js | 9 ++++----- 4 files changed, 22 insertions(+), 11 deletions(-) diff --git a/src/core/stream/period/create_empty_adaptation_stream.ts b/src/core/stream/period/create_empty_adaptation_stream.ts index d4bcb2b096..5b874de3b5 100644 --- a/src/core/stream/period/create_empty_adaptation_stream.ts +++ b/src/core/stream/period/create_empty_adaptation_stream.ts @@ -15,11 +15,14 @@ */ import { + combineLatest as observableCombineLatest, mergeMap, Observable, of as observableOf, } from "rxjs"; +import log from "../../../log"; import { Period } from "../../../manifest"; +import { IReadOnlySharedReference } from "../../../utils/reference"; import { IReadOnlyPlaybackObserver } from "../../api"; import { IBufferType } from "../../segment_buffers"; import { IStreamStatusEvent } from "../types"; @@ -31,26 +34,35 @@ import { IPeriodStreamPlaybackObservation } from "./period_stream"; * This observable will never download any segment and just emit a "full" * event when reaching the end. * @param {Observable} playbackObserver + * @param {Object} wantedBufferAhead * @param {string} bufferType * @param {Object} content * @returns {Observable} */ export default function createEmptyAdaptationStream( playbackObserver : IReadOnlyPlaybackObserver, + wantedBufferAhead : IReadOnlySharedReference, bufferType : IBufferType, content : { period : Period } ) : Observable { const { period } = content; + let hasFinishedLoading = false; + const wantedBufferAhead$ = wantedBufferAhead.asObservable(); const observation$ = playbackObserver.getReference().asObservable(); - return observation$.pipe( - mergeMap((observation) => { + return observableCombineLatest([observation$, + wantedBufferAhead$]).pipe( + mergeMap(([observation, wba]) => { const position = observation.position.last; + if (period.end !== undefined && position + wba >= period.end) { + log.debug("Stream: full \"empty\" AdaptationStream", bufferType); + hasFinishedLoading = true; + } return observableOf({ type: "stream-status" as const, value: { period, bufferType, position, imminentDiscontinuity: null, - hasFinishedLoading: true, + hasFinishedLoading, neededSegments: [], shouldRefreshManifest: false } }); }) diff --git a/src/core/stream/period/period_stream.ts b/src/core/stream/period/period_stream.ts index e46b7da69a..f4f65e062f 100644 --- a/src/core/stream/period/period_stream.ts +++ b/src/core/stream/period/period_stream.ts @@ -202,7 +202,7 @@ export default function PeriodStream({ return observableConcat( cleanBuffer$.pipe(map(() => EVENTS.adaptationChange(bufferType, null, period))), - createEmptyStream(playbackObserver, bufferType, { period }) + createEmptyStream(playbackObserver, wantedBufferAhead, bufferType, { period }) ); } @@ -313,7 +313,7 @@ export default function PeriodStream({ }); return observableConcat( observableOf(EVENTS.warning(formattedError)), - createEmptyStream(playbackObserver, bufferType, { period }) + createEmptyStream(playbackObserver, wantedBufferAhead, bufferType, { period }) ); } log.error(`Stream: ${bufferType} Stream crashed. Stopping playback.`, diff --git a/tests/contents/DASH_static_number_based_SegmentTimeline/media/end_number.mpd b/tests/contents/DASH_static_number_based_SegmentTimeline/media/end_number.mpd index 18c35096e8..96db6448b1 100644 --- a/tests/contents/DASH_static_number_based_SegmentTimeline/media/end_number.mpd +++ b/tests/contents/DASH_static_number_based_SegmentTimeline/media/end_number.mpd @@ -1,6 +1,6 @@ - + diff --git a/tests/integration/scenarios/dash_multi-track.js b/tests/integration/scenarios/dash_multi-track.js index 5eee3902c1..6015c85784 100644 --- a/tests/integration/scenarios/dash_multi-track.js +++ b/tests/integration/scenarios/dash_multi-track.js @@ -367,23 +367,22 @@ describe("DASH multi-track content (SegmentTimeline)", function () { }); it("should not update the current tracks for non-applied preferences", async () => { - player.setPreferredTextTracks([ { language: "de", - closedCaption: false } ], undefined); await loadContent(); player.setPreferredAudioTracks([ { language: "be", audioDescription: true } ]); player.setPreferredVideoTracks([ { codec: { all: false, test: /avc1\.640028/}, signInterpreted: true }], false); - player.setPreferredTextTracks([ null ], undefined); + player.setPreferredTextTracks([ { language: "de", + closedCaption: false } ], undefined); await sleep(100); checkAudioTrack("de", "deu", false); - checkTextTrack("de", "deu", false); + checkNoTextTrack(); checkVideoTrack({ all: true, test: /avc1\.42C014/ }, true); await goToSecondPeriod(); checkAudioTrack("be", "bel", true); - checkNoTextTrack(); + checkTextTrack("de", "deu", false); checkVideoTrack({ all: false, test: /avc1\.640028/ }, true); }); From 341b6993b755883384fae652514470c3935366d8 Mon Sep 17 00:00:00 2001 From: Paul Berberian Date: Fri, 18 Nov 2022 17:10:20 +0100 Subject: [PATCH 08/86] The ManifestFetcher now also perform manifest updates --- src/core/api/public_api.ts | 6 +- src/core/fetchers/README.md | 3 + src/core/fetchers/index.ts | 10 +- src/core/fetchers/manifest/index.ts | 12 +- .../fetchers/manifest/manifest_fetcher.ts | 581 +++++++++++++++--- .../init/media_source_content_initializer.ts | 107 ++-- .../init/utils/manifest_update_scheduler.ts | 341 ---------- 7 files changed, 545 insertions(+), 515 deletions(-) delete mode 100644 src/core/init/utils/manifest_update_scheduler.ts diff --git a/src/core/api/public_api.ts b/src/core/api/public_api.ts index b00993bfeb..096c937d98 100644 --- a/src/core/api/public_api.ts +++ b/src/core/api/public_api.ts @@ -665,7 +665,9 @@ class Player extends EventEmitter { const manifestRequestSettings = { lowLatencyMode, maxRetryRegular: manifestRetry, maxRetryOffline: offlineRetry, - requestTimeout: manifestRequestTimeout }; + requestTimeout: manifestRequestTimeout, + minimumManifestUpdateInterval, + initialManifest }; const relyOnVideoVisibilityAndSize = canRelyOnVideoVisibilityAndSize(); const throttlers : IABRThrottlers = { throttle: {}, @@ -744,11 +746,9 @@ class Player extends EventEmitter { adaptiveOptions, autoPlay, bufferOptions, - initialManifest, keySystems, lowLatencyMode, manifestRequestSettings, - minimumManifestUpdateInterval, transport: transportPipelines, segmentRequestOptions, speed: this._priv_speed, diff --git a/src/core/fetchers/README.md b/src/core/fetchers/README.md index 5b2b77b153..17e57d6caa 100644 --- a/src/core/fetchers/README.md +++ b/src/core/fetchers/README.md @@ -21,6 +21,9 @@ protocol-agnostic. This is the part of the code that interacts with `transports` to perform the request and parsing of the Manifest file. +It also regularly refreshes the Manifest, based on its attributes and other +criteria, like performances when doing that. + ## The SegmentFetcherCreator ################################################### diff --git a/src/core/fetchers/index.ts b/src/core/fetchers/index.ts index e13d3b628c..d7f95d3c23 100644 --- a/src/core/fetchers/index.ts +++ b/src/core/fetchers/index.ts @@ -15,8 +15,9 @@ */ import ManifestFetcher, { - IManifestFetcherParsedResult, - IManifestFetcherParserOptions, + IManifestFetcherSettings, + IManifestFetcherEvent, + IManifestRefreshSettings, } from "./manifest"; import SegmentFetcherCreator, { IPrioritizedSegmentFetcher, @@ -27,8 +28,9 @@ export { ManifestFetcher, SegmentFetcherCreator, - IManifestFetcherParserOptions, - IManifestFetcherParsedResult, + IManifestFetcherSettings, + IManifestFetcherEvent, + IManifestRefreshSettings, IPrioritizedSegmentFetcher, diff --git a/src/core/fetchers/manifest/index.ts b/src/core/fetchers/manifest/index.ts index d56abf35ce..5394a7db8e 100644 --- a/src/core/fetchers/manifest/index.ts +++ b/src/core/fetchers/manifest/index.ts @@ -15,12 +15,14 @@ */ import ManifestFetcher, { - IManifestFetcherParsedResult, - IManifestFetcherParserOptions, + IManifestFetcherSettings, + IManifestFetcherEvent, + IManifestRefreshSettings, } from "./manifest_fetcher"; -export default ManifestFetcher; export { - IManifestFetcherParsedResult, - IManifestFetcherParserOptions, + IManifestFetcherSettings, + IManifestFetcherEvent, + IManifestRefreshSettings, }; +export default ManifestFetcher; diff --git a/src/core/fetchers/manifest/manifest_fetcher.ts b/src/core/fetchers/manifest/manifest_fetcher.ts index 6eae3e07a1..4373597454 100644 --- a/src/core/fetchers/manifest/manifest_fetcher.ts +++ b/src/core/fetchers/manifest/manifest_fetcher.ts @@ -18,96 +18,71 @@ import config from "../../../config"; import { formatError } from "../../../errors"; import log from "../../../log"; import Manifest from "../../../manifest"; -import { IPlayerError } from "../../../public_types"; +import { + IInitialManifest, + ILoadedManifestFormat, + IPlayerError, +} from "../../../public_types"; import { IRequestedData, ITransportManifestPipeline, ITransportPipelines, } from "../../../transports"; import assert from "../../../utils/assert"; +import EventEmitter from "../../../utils/event_emitter"; import isNullOrUndefined from "../../../utils/is_null_or_undefined"; -import TaskCanceller, { - CancellationSignal, -} from "../../../utils/task_canceller"; +import noop from "../../../utils/noop"; +import TaskCanceller from "../../../utils/task_canceller"; import errorSelector from "../utils/error_selector"; import { IBackoffSettings, scheduleRequestPromise, } from "../utils/schedule_request"; - -/** What will be sent once parsed. */ -export interface IManifestFetcherParsedResult { - /** The resulting Manifest */ - manifest : Manifest; +/** + * Class allowing to facilitate the task of loading and parsing a Manifest, as + * well as automatically refreshing it. + * @class ManifestFetcher + */ +export default class ManifestFetcher extends EventEmitter { /** - * The time (`performance.now()`) at which the request was started (at which - * the JavaScript call was done). + * Allows to manually trigger a Manifest refresh. + * Will only have an effect if the Manifest has been fetched at least once. + * @param {Object} settings - refresh configuration. */ - sendingTime? : number | undefined; - /** The time (`performance.now()`) at which the request was fully received. */ - receivedTime? : number | undefined; - /* The time taken to parse the Manifest through the corresponding parse function. */ - parsingTime? : number | undefined; -} - -/** Response emitted by a Manifest fetcher. */ -export interface IManifestFetcherResponse { - /** Allows to parse a fetched Manifest into a `Manifest` structure. */ - parse(parserOptions : IManifestFetcherParserOptions) : - Promise; -} + public scheduleManualRefresh : (settings : IManifestRefreshSettings) => void; -export interface IManifestFetcherParserOptions { + /** `ManifestFetcher` configuration. */ + private _settings : IManifestFetcherSettings; + /** URLs through which the Manifest may be reached, by order of priority. */ + private _manifestUrls : string[] | undefined; /** - * If set, offset to add to `performance.now()` to obtain the current - * server's time. + * Manifest loading and parsing pipelines linked to the current transport + * protocol used. */ - externalClockOffset? : number | undefined; - /** The previous value of the Manifest (when updating). */ - previousManifest : Manifest | null; + private _pipelines : ITransportManifestPipeline; /** - * If set to `true`, the Manifest parser can perform advanced optimizations - * to speed-up the parsing process. Those optimizations might lead to a - * de-synchronization with what is actually on the server, hence the "unsafe" - * part. - * To use with moderation and only when needed. + * `TaskCanceller` called when this `ManifestFetcher` is disposed, to clean + * resources. */ - unsafeMode : boolean; -} - -/** Options used by `createManifestFetcher`. */ -export interface IManifestFetcherSettings { + private _canceller : TaskCanceller; /** - * Whether the content is played in a low-latency mode. - * This has an impact on default backoff delays. + * Set to `true` once the Manifest has been fetched at least once through this + * `ManifestFetcher`. */ - lowLatencyMode : boolean; - /** Maximum number of time a request on error will be retried. */ - maxRetryRegular : number | undefined; - /** Maximum number of time a request be retried when the user is offline. */ - maxRetryOffline : number | undefined; + private _isStarted : boolean; /** - * Timeout after which request are aborted and, depending on other options, - * retried. - * To set to `-1` for no timeout. - * `undefined` will lead to a default, large, timeout being used. + * Set to `true` when a Manifest refresh is currently pending. + * Allows to avoid doing multiple concurrent Manifest refresh, as this is + * most of the time unnecessary. */ - requestTimeout : number | undefined; -} - -/** - * Class allowing to facilitate the task of loading and parsing a Manifest. - * @class ManifestFetcher - */ -export default class ManifestFetcher { - private _settings : IManifestFetcherSettings; - private _manifestUrl : string | undefined; - private _pipelines : ITransportManifestPipeline; + private _isRefreshPending; + /** Number of consecutive times the Manifest parsing has been done in `unsafeMode`. */ + private _consecutiveUnsafeMode; /** * Construct a new ManifestFetcher. - * @param {string | undefined} url - Default Manifest url, will be used when + * @param {Array. | undefined} urls - Manifest URLs, will be used when * no URL is provided to the `fetch` function. * `undefined` if unknown or if a Manifest should be retrieved through other * means than an HTTP request. @@ -116,13 +91,69 @@ export default class ManifestFetcher { * @param {Object} settings - Configure the `ManifestFetcher`. */ constructor( - url : string | undefined, + urls : string[] | undefined, pipelines : ITransportPipelines, settings : IManifestFetcherSettings ) { - this._manifestUrl = url; + super(); + this.scheduleManualRefresh = noop; + this._manifestUrls = urls; this._pipelines = pipelines.manifest; this._settings = settings; + this._canceller = new TaskCanceller(); + this._isStarted = false; + this._isRefreshPending = false; + this._consecutiveUnsafeMode = 0; + } + + /** + * Free resources and stop refresh mechanism from happening. + * + * Once `dispose` has been called. This `ManifestFetcher` cannot be relied on + * anymore. + */ + public dispose() { + this._canceller.cancel(); + this.removeEventListener(); + } + + /** + * Start requesting the Manifest as well as the Manifest refreshing logic, if + * needed. + * + * Once `start` has been called, this mechanism can only be stopped by calling + * `dispose`. + */ + public start() : void { + if (this._isStarted) { + return; + } + this._isStarted = true; + + let manifestProm : Promise; + + const initialManifest = this._settings.initialManifest; + if (initialManifest instanceof Manifest) { + manifestProm = Promise.resolve({ manifest: initialManifest }); + } else if (initialManifest !== undefined) { + manifestProm = this.parse(initialManifest, + { previousManifest: null, unsafeMode: false }, + undefined); + } else { + manifestProm = this._fetchManifest(undefined) + .then((val) => { + return val.parse({ previousManifest: null, unsafeMode: false }); + }); + } + + manifestProm + .then((val : IManifestFetcherParsedResult) => { + this.trigger("manifestReady", val.manifest); + if (!this._canceller.isUsed) { + this._recursivelyRefreshManifest(val.manifest, val); + } + }) + .catch((err : unknown) => this._onFatalError(err)); } /** @@ -134,22 +165,21 @@ export default class ManifestFetcher { * If not set, the regular Manifest url - defined on the `ManifestFetcher` * instanciation - will be used instead. * - * @param {string} url - * @param {Function} onWarning - * @param {Object} cancelSignal + * @param {string | undefined} url * @returns {Promise} */ - public async fetch( - url : string | undefined, - onWarning : (err : IPlayerError) => void, - cancelSignal : CancellationSignal + private async _fetchManifest( + url : string | undefined ) : Promise { + const cancelSignal = this._canceller.signal; const settings = this._settings; const pipelines = this._pipelines; - const requestUrl = url ?? this._manifestUrl; + + // TODO Better handle multiple Manifest URLs + const requestUrl = url ?? this._manifestUrls?.[0]; const backoffSettings = this._getBackoffSetting((err) => { - onWarning(errorSelector(err)); + this.trigger("warning", errorSelector(err)); }); const loadingPromise = pipelines.resolveManifestUrl === undefined ? @@ -162,14 +192,10 @@ export default class ManifestFetcher { parse: (parserOptions : IManifestFetcherParserOptions) => { return this._parseLoadedManifest(response, parserOptions, - onWarning, - cancelSignal); + requestUrl); }, }; } catch (err) { - if (err instanceof CancellationSignal) { - throw err; - } throw errorSelector(err); } @@ -181,7 +207,9 @@ export default class ManifestFetcher { * @param {string | undefined} resolverUrl * @returns {Promise} */ - function callResolverWithRetries(resolverUrl : string | undefined) { + function callResolverWithRetries( + resolverUrl : string | undefined + ) : Promise { const { resolveManifestUrl } = pipelines; assert(resolveManifestUrl !== undefined); const callResolver = () => resolveManifestUrl(resolverUrl, cancelSignal); @@ -195,7 +223,9 @@ export default class ManifestFetcher { * @param {string | undefined} manifestUrl * @returns {Promise} */ - function callLoaderWithRetries(manifestUrl : string | undefined) { + function callLoaderWithRetries( + manifestUrl : string | undefined + ) : Promise> { const { loadManifest } = pipelines; let requestTimeout : number | undefined = isNullOrUndefined(settings.requestTimeout) ? @@ -220,22 +250,19 @@ export default class ManifestFetcher { * information on the request can be used by the parsing process. * @param {*} manifest * @param {Object} parserOptions - * @param {Function} onWarning - * @param {Object} cancelSignal + * @param {string | undefined} originalUrl * @returns {Promise} */ - public parse( + private parse( manifest : unknown, parserOptions : IManifestFetcherParserOptions, - onWarning : (err : IPlayerError) => void, - cancelSignal : CancellationSignal + originalUrl : string | undefined ) : Promise { return this._parseLoadedManifest({ responseData: manifest, size: undefined, requestDuration: undefined }, parserOptions, - onWarning, - cancelSignal); + originalUrl); } @@ -245,27 +272,27 @@ export default class ManifestFetcher { * @param {Object} loaded - Information about the loaded Manifest as well as * about the corresponding request. * @param {Object} parserOptions - Options used when parsing the Manifest. - * @param {Function} onWarning - * @param {Object} cancelSignal + * @param {string | undefined} requestUrl * @returns {Promise} */ private async _parseLoadedManifest( loaded : IRequestedData, parserOptions : IManifestFetcherParserOptions, - onWarning : (err : IPlayerError) => void, - cancelSignal : CancellationSignal + requestUrl : string | undefined ) : Promise { const parsingTimeStart = performance.now(); - const canceller = new TaskCanceller(); + const cancelSignal = this._canceller.signal; + const trigger = this.trigger.bind(this); const { sendingTime, receivedTime } = loaded; const backoffSettings = this._getBackoffSetting((err) => { - onWarning(errorSelector(err)); + this.trigger("warning", errorSelector(err)); }); + const originalUrl = requestUrl ?? this._manifestUrls?.[0]; const opts = { externalClockOffset: parserOptions.externalClockOffset, unsafeMode: parserOptions.unsafeMode, previousManifest: parserOptions.previousManifest, - originalUrl: this._manifestUrl }; + originalUrl }; try { const res = this._pipelines.parseManifest(loaded, opts, @@ -311,14 +338,14 @@ export default class ManifestFetcher { */ function onWarnings(warnings : Error[]) : void { for (const warning of warnings) { - if (canceller.isUsed) { + if (cancelSignal.isCancelled) { return; } const formattedError = formatError(warning, { defaultCode: "PIPELINE_PARSE_ERROR", defaultReason: "Unknown error when parsing the Manifest", }); - onWarning(formattedError); + trigger("warning", formattedError); } } @@ -365,6 +392,257 @@ export default class ManifestFetcher { maxRetryRegular, maxRetryOffline }; } + + /** + * Performs Manifest refresh (recursively) when it judges it is time to do so. + * @param {Object} manifest + * @param {Object} manifestRequestInfos - Various information linked to the + * last Manifest loading and parsing operations. + */ + private _recursivelyRefreshManifest ( + manifest : Manifest, + { sendingTime, parsingTime, updatingTime } : { sendingTime?: number | undefined; + parsingTime? : number | undefined; + updatingTime? : number | undefined; } + ) : void { + const { MAX_CONSECUTIVE_MANIFEST_PARSING_IN_UNSAFE_MODE, + MIN_MANIFEST_PARSING_TIME_TO_ENTER_UNSAFE_MODE } = config.getCurrent(); + + /** + * Total time taken to fully update the last Manifest, in milliseconds. + * Note: this time also includes possible requests done by the parsers. + */ + const totalUpdateTime = parsingTime !== undefined ? + parsingTime + (updatingTime ?? 0) : + undefined; + + /** + * "unsafeMode" is a mode where we unlock advanced Manifest parsing + * optimizations with the added risk to lose some information. + * `unsafeModeEnabled` is set to `true` when the `unsafeMode` is enabled. + * + * Only perform parsing in `unsafeMode` when the last full parsing took a + * lot of time and do not go higher than the maximum consecutive time. + */ + + const unsafeModeEnabled = this._consecutiveUnsafeMode > 0 ? + this._consecutiveUnsafeMode < MAX_CONSECUTIVE_MANIFEST_PARSING_IN_UNSAFE_MODE : + totalUpdateTime !== undefined ? + (totalUpdateTime >= MIN_MANIFEST_PARSING_TIME_TO_ENTER_UNSAFE_MODE) : + false; + + /** Time elapsed since the beginning of the Manifest request, in milliseconds. */ + const timeSinceRequest = sendingTime === undefined ? + 0 : + performance.now() - sendingTime; + + /** Minimum update delay we should not go below, in milliseconds. */ + const minInterval = Math.max(this._settings.minimumManifestUpdateInterval - + timeSinceRequest, + 0); + + /** + * Multiple refresh trigger are scheduled here, but only the first one should + * be effectively considered. + * `nextRefreshCanceller` will allow to cancel every other when one is triggered. + */ + const nextRefreshCanceller = new TaskCanceller({ cancelOn: this._canceller.signal }); + + /* Function to manually schedule a Manifest refresh */ + this.scheduleManualRefresh = (settings : IManifestRefreshSettings) => { + const { enablePartialRefresh, delay, canUseUnsafeMode } = settings; + const unsafeMode = canUseUnsafeMode && unsafeModeEnabled; + // The value allows to set a delay relatively to the last Manifest refresh + // (to avoid asking for it too often). + const timeSinceLastRefresh = sendingTime === undefined ? + 0 : + performance.now() - sendingTime; + const _minInterval = Math.max(this._settings.minimumManifestUpdateInterval - + timeSinceLastRefresh, + 0); + const timeoutId = setTimeout(() => { + nextRefreshCanceller.cancel(); + this._triggerNextManifestRefresh(manifest, { enablePartialRefresh, unsafeMode }); + }, Math.max((delay ?? 0) - timeSinceLastRefresh, _minInterval)); + nextRefreshCanceller.signal.register(() => { + clearTimeout(timeoutId); + }); + }; + + /* Handle Manifest expiration. */ + if (manifest.expired !== null) { + const timeoutId = setTimeout(() => { + manifest.expired?.then(() => { + nextRefreshCanceller.cancel(); + this._triggerNextManifestRefresh(manifest, { enablePartialRefresh: false, + unsafeMode: unsafeModeEnabled }); + }, noop /* `expired` should not reject */); + }, minInterval); + nextRefreshCanceller.signal.register(() => { + clearTimeout(timeoutId); + }); + } + + /* + * Trigger Manifest refresh when the Manifest needs to be refreshed + * according to the Manifest's internal properties (parsing time is also + * taken into account in this operation to avoid refreshing too often). + */ + if (manifest.lifetime !== undefined && manifest.lifetime >= 0) { + /** Regular refresh delay as asked by the Manifest. */ + const regularRefreshDelay = manifest.lifetime * 1000 - timeSinceRequest; + + /** Actually choosen delay to refresh the Manifest. */ + let actualRefreshInterval : number; + + if (totalUpdateTime === undefined) { + actualRefreshInterval = regularRefreshDelay; + } else if (manifest.lifetime < 3 && totalUpdateTime >= 100) { + // If Manifest update is very frequent and we take time to update it, + // postpone it. + actualRefreshInterval = Math.min( + Math.max( + // Take 3 seconds as a default safe value for a base interval. + 3000 - timeSinceRequest, + // Add update time to the original interval. + Math.max(regularRefreshDelay, 0) + totalUpdateTime + ), + + // Limit the postponment's higher bound to a very high value relative + // to `regularRefreshDelay`. + // This avoid perpetually postponing a Manifest update when + // performance seems to have been abysmal one time. + regularRefreshDelay * 6 + ); + log.info("MUS: Manifest update rythm is too frequent. Postponing next request.", + regularRefreshDelay, + actualRefreshInterval); + } else if (totalUpdateTime >= (manifest.lifetime * 1000) / 10) { + // If Manifest updating time is very long relative to its lifetime, + // postpone it: + actualRefreshInterval = Math.min( + // Just add the update time to the original waiting time + Math.max(regularRefreshDelay, 0) + totalUpdateTime, + + // Limit the postponment's higher bound to a very high value relative + // to `regularRefreshDelay`. + // This avoid perpetually postponing a Manifest update when + // performance seems to have been abysmal one time. + regularRefreshDelay * 6); + log.info("MUS: Manifest took too long to parse. Postponing next request", + actualRefreshInterval, + actualRefreshInterval); + } else { + actualRefreshInterval = regularRefreshDelay; + } + const timeoutId = setTimeout(() => { + nextRefreshCanceller.cancel(); + this._triggerNextManifestRefresh(manifest, { enablePartialRefresh: false, + unsafeMode: unsafeModeEnabled }); + }, Math.max(actualRefreshInterval, minInterval)); + nextRefreshCanceller.signal.register(() => { + clearTimeout(timeoutId); + }); + } + } + + /** + * Refresh the Manifest, performing a full update if a partial update failed. + * Also re-call `recursivelyRefreshManifest` to schedule the next refresh + * trigger. + * @param {Object} manifest + * @param {Object} refreshInformation + */ + private _triggerNextManifestRefresh( + manifest : Manifest, + { enablePartialRefresh, + unsafeMode } : { enablePartialRefresh : boolean; + unsafeMode : boolean; } + ) { + const manifestUpdateUrl = manifest.updateUrl; + const fullRefresh = !enablePartialRefresh || manifestUpdateUrl === undefined; + const refreshURL = fullRefresh ? manifest.getUrl() : + manifestUpdateUrl; + const externalClockOffset = manifest.clockOffset; + + if (unsafeMode) { + this._consecutiveUnsafeMode += 1; + log.info("Init: Refreshing the Manifest in \"unsafeMode\" for the " + + String(this._consecutiveUnsafeMode) + " consecutive time."); + } else if (this._consecutiveUnsafeMode > 0) { + log.info("Init: Not parsing the Manifest in \"unsafeMode\" anymore after " + + String(this._consecutiveUnsafeMode) + " consecutive times."); + this._consecutiveUnsafeMode = 0; + } + + if (this._isRefreshPending) { + return; + } + this._isRefreshPending = true; + this._fetchManifest(refreshURL) + .then(res => res.parse({ externalClockOffset, + previousManifest: manifest, + unsafeMode })) + .then(res => { + this._isRefreshPending = false; + const { manifest: newManifest, + sendingTime: newSendingTime, + parsingTime } = res; + const updateTimeStart = performance.now(); + + if (fullRefresh) { + manifest.replace(newManifest); + } else { + try { + manifest.update(newManifest); + } catch (e) { + const message = e instanceof Error ? e.message : + "unknown error"; + log.warn(`MUS: Attempt to update Manifest failed: ${message}`, + "Re-downloading the Manifest fully"); + const { FAILED_PARTIAL_UPDATE_MANIFEST_REFRESH_DELAY } = config.getCurrent(); + + // The value allows to set a delay relatively to the last Manifest refresh + // (to avoid asking for it too often). + const timeSinceLastRefresh = newSendingTime === undefined ? + 0 : + performance.now() - newSendingTime; + const _minInterval = Math.max(this._settings.minimumManifestUpdateInterval - + timeSinceLastRefresh, + 0); + let unregisterCanceller = noop; + const timeoutId = setTimeout(() => { + unregisterCanceller(); + this._triggerNextManifestRefresh(manifest, + { enablePartialRefresh: false, + unsafeMode: false }); + }, Math.max(FAILED_PARTIAL_UPDATE_MANIFEST_REFRESH_DELAY - + timeSinceLastRefresh, + _minInterval)); + unregisterCanceller = this._canceller.signal.register(() => { + clearTimeout(timeoutId); + }); + return; + } + } + const updatingTime = performance.now() - updateTimeStart; + this._recursivelyRefreshManifest(manifest, { sendingTime: newSendingTime, + parsingTime, + updatingTime }); + }) + .catch((err) => { + this._isRefreshPending = false; + this._onFatalError(err); + }); + } + + private _onFatalError(err : unknown) : void { + if (this._canceller.isUsed) { + return; + } + this.dispose(); + this.trigger("error", err); + } } /** @@ -376,3 +654,112 @@ export default class ManifestFetcher { function isPromise(val : T | Promise) : val is Promise { return val instanceof Promise; } + +/** What will be sent once parsed. */ +interface IManifestFetcherParsedResult { + /** The resulting Manifest */ + manifest : Manifest; + /** + * The time (`performance.now()`) at which the request was started (at which + * the JavaScript call was done). + */ + sendingTime? : number | undefined; + /** The time (`performance.now()`) at which the request was fully received. */ + receivedTime? : number | undefined; + /* The time taken to parse the Manifest through the corresponding parse function. */ + parsingTime? : number | undefined; +} + +/** Response emitted by a Manifest fetcher. */ +interface IManifestFetcherResponse { + /** Allows to parse a fetched Manifest into a `Manifest` structure. */ + parse(parserOptions : IManifestFetcherParserOptions) : + Promise; +} + +interface IManifestFetcherParserOptions { + /** + * If set, offset to add to `performance.now()` to obtain the current + * server's time. + */ + externalClockOffset? : number | undefined; + /** The previous value of the Manifest (when updating). */ + previousManifest : Manifest | null; + /** + * If set to `true`, the Manifest parser can perform advanced optimizations + * to speed-up the parsing process. Those optimizations might lead to a + * de-synchronization with what is actually on the server, hence the "unsafe" + * part. + * To use with moderation and only when needed. + */ + unsafeMode : boolean; +} + +/** Options used by `createManifestFetcher`. */ +export interface IManifestFetcherSettings { + /** + * Whether the content is played in a low-latency mode. + * This has an impact on default backoff delays. + */ + lowLatencyMode : boolean; + /** Maximum number of time a request on error will be retried. */ + maxRetryRegular : number | undefined; + /** Maximum number of time a request be retried when the user is offline. */ + maxRetryOffline : number | undefined; + /** + * Timeout after which request are aborted and, depending on other options, + * retried. + * To set to `-1` for no timeout. + * `undefined` will lead to a default, large, timeout being used. + */ + requestTimeout : number | undefined; + /** Limit the frequency of Manifest updates. */ + minimumManifestUpdateInterval : number; + /** + * Potential first Manifest to rely on, allowing to skip the initial Manifest + * request. + */ + initialManifest : IInitialManifest | undefined; +} + +/** Event sent by the `ManifestFetcher`. */ +export interface IManifestFetcherEvent { + /** Event sent by the `ManifestFetcher` when a minor error has been encountered. */ + warning : IPlayerError; + /** + * Event sent by the `ManifestFetcher` when a major error has been encountered, + * leading to the `ManifestFetcher` being disposed. + */ + error : unknown; + /** Event sent after the Manifest has first been fetched. */ + manifestReady : Manifest; +} + +/** Argument defined when forcing a Manifest refresh. */ +export interface IManifestRefreshSettings { + /** + * if `false`, the Manifest should be fully updated. + * if `true`, a shorter version with just the added information can be loaded + * instead. + * + * Basically can be set to `true` in most updates to improve performances, but + * should be set to `false` if you suspect some iregularities in the Manifest, + * so a complete and thorough refresh is performed. + * + * Note that this optimization is only possible when a shorter version of the + * Manifest is available. + * In other cases, setting this value to `true` won't have any effect. + */ + enablePartialRefresh : boolean; + /** + * Optional wanted refresh delay, which is the minimum time you want to wait + * before updating the Manifest + */ + delay? : number | undefined; + /** + * Whether the parsing can be done in the more efficient "unsafeMode". + * This mode is extremely fast but can lead to de-synchronisation with the + * server. + */ + canUseUnsafeMode : boolean; +} diff --git a/src/core/init/media_source_content_initializer.ts b/src/core/init/media_source_content_initializer.ts index 0fe8d8b922..169bd591f3 100644 --- a/src/core/init/media_source_content_initializer.ts +++ b/src/core/init/media_source_content_initializer.ts @@ -21,13 +21,9 @@ import log from "../../log"; import Manifest from "../../manifest"; import { IKeySystemOption, - ILoadedManifestFormat, IPlayerError, } from "../../public_types"; -import { - IManifestParserResult, - ITransportPipelines, -} from "../../transports"; +import { ITransportPipelines } from "../../transports"; import assert from "../../utils/assert"; import assertUnreachable from "../../utils/assert_unreachable"; import objectAssign from "../../utils/object_assign"; @@ -36,7 +32,7 @@ import createSharedReference, { ISharedReference, } from "../../utils/reference"; import TaskCanceller, { - CancellationError, CancellationSignal, + CancellationSignal, } from "../../utils/task_canceller"; import AdaptiveRepresentationSelector, { IAdaptiveRepresentationSelectorArguments, @@ -71,9 +67,6 @@ import getInitialTime, { import getLoadedReference from "./utils/get_loaded_reference"; import performInitialSeekAndPlay from "./utils/initial_seek_and_play"; import initializeContentDecryption from "./utils/initialize_content_decryption"; -import manifestUpdateScheduler, { - IManifestUpdateScheduler, -} from "./utils/manifest_update_scheduler"; import MediaDurationUpdater from "./utils/media_duration_updater"; import RebufferingController from "./utils/rebuffering_controller"; import streamEventsEmitter from "./utils/stream_events_emitter"; @@ -104,7 +97,7 @@ export default class MediaSourceContentInitializer extends ContentInitializer { * Promise resolving with the Manifest once it has been initially loaded. * `null` if the load task has not started yet. */ - private _initialManifestProm : Promise | null; + private _initialManifestProm : Promise | null; /** * Create a new `MediaSourceContentInitializer`, associated to the given @@ -116,9 +109,14 @@ export default class MediaSourceContentInitializer extends ContentInitializer { this._settings = settings; this._initCanceller = new TaskCanceller(); this._initialManifestProm = null; - this._manifestFetcher = new ManifestFetcher(settings.url, + const urls = settings.url === undefined ? undefined : + [settings.url]; + this._manifestFetcher = new ManifestFetcher(urls, settings.transport, settings.manifestRequestSettings); + this._initCanceller.signal.register(() => { + this._manifestFetcher.dispose(); + }); } /** @@ -129,27 +127,24 @@ export default class MediaSourceContentInitializer extends ContentInitializer { if (this._initialManifestProm !== null) { return; } - const initialManifest = this._settings.initialManifest; - this._settings.initialManifest = undefined; // Reset to free resources - if (initialManifest instanceof Manifest) { - this._initialManifestProm = Promise.resolve({ manifest: initialManifest }); - } else if (initialManifest !== undefined) { - this._initialManifestProm = this._manifestFetcher - .parse(initialManifest, - { previousManifest: null, - unsafeMode: false }, - (err : IPlayerError) => - this.trigger("warning", err), - this._initCanceller.signal); - } else { - this._initialManifestProm = this._manifestFetcher.fetch(undefined, (err) => { - this.trigger("warning", err); - }, this._initCanceller.signal) - .then((res) => res.parse({ previousManifest: null, - unsafeMode: false })); - } + this._initialManifestProm = new Promise((res, rej) => { + this._manifestFetcher.addEventListener("warning", (err : IPlayerError) => + this.trigger("warning", err)); + this._manifestFetcher.addEventListener("error", (err : unknown) => { + this.trigger("error", err); + rej(err); + }); + this._manifestFetcher.addEventListener("manifestReady", (manifest) => { + res(manifest); + }); + this._manifestFetcher.start(); + }); } + /** + * @param {HTMLMediaElement} mediaElement + * @param {Object} playbackObserver + */ public start( mediaElement : HTMLMediaElement, playbackObserver : PlaybackObserver @@ -172,9 +167,6 @@ export default class MediaSourceContentInitializer extends ContentInitializer { protectionRef, initResult.unlinkMediaSource)) .catch((err) => { - if (err instanceof CancellationError) { - return; - } this._onFatalError(err); }); } @@ -184,6 +176,9 @@ export default class MediaSourceContentInitializer extends ContentInitializer { } private _onFatalError(err : unknown) { + if (this._initCanceller.isUsed) { + return; + } this._initCanceller.cancel(); this.trigger("error", err); } @@ -259,12 +254,11 @@ export default class MediaSourceContentInitializer extends ContentInitializer { drmSystemId : string | undefined, protectionRef : ISharedReference, initialMediaSourceCanceller : TaskCanceller - ) : Promise { + ) : Promise { const { adaptiveOptions, autoPlay, bufferOptions, lowLatencyMode, - minimumManifestUpdateInterval, segmentRequestOptions, speed, startAt, @@ -273,8 +267,12 @@ export default class MediaSourceContentInitializer extends ContentInitializer { const initCanceller = this._initCanceller; assert(this._initialManifestProm !== null); const manifestProm = this._initialManifestProm; - const manifestResponse = await manifestProm; - const { manifest } = manifestResponse; + let manifest : Manifest; + try { + manifest = await manifestProm; + } catch (_e) { + return ; // The error should already have been processed through an event listener + } manifest.addEventListener("manifestUpdate", () => { this.trigger("manifestUpdate", null); @@ -291,18 +289,6 @@ export default class MediaSourceContentInitializer extends ContentInitializer { const representationEstimator = AdaptiveRepresentationSelector(adaptiveOptions); const subBufferOptions = objectAssign({ textTrackOptions, drmSystemId }, bufferOptions); - const manifestUpdater = manifestUpdateScheduler(manifestResponse, - this._manifestFetcher, - minimumManifestUpdateInterval, - (err : IPlayerError) => - this.trigger("warning", err), - (err : unknown) => { - initCanceller.cancel(); - this.trigger("error", err); - }); - initCanceller.signal.register(() => { - manifestUpdater.stop(); - }); const segmentFetcherCreator = new SegmentFetcherCreator(transport, segmentRequestOptions, @@ -310,7 +296,7 @@ export default class MediaSourceContentInitializer extends ContentInitializer { this.trigger("manifestReady", manifest); if (initCanceller.isUsed) { - return undefined; + return ; } const bufferOnMediaSource = this._startBufferingOnMediaSource.bind(this); @@ -342,7 +328,6 @@ export default class MediaSourceContentInitializer extends ContentInitializer { const opts = { mediaElement, playbackObserver, mediaSource, - manifestUpdater, initialTime: startingPos, autoPlay: shouldPlay, manifest, @@ -397,7 +382,6 @@ export default class MediaSourceContentInitializer extends ContentInitializer { bufferOptions, initialTime, manifest, - manifestUpdater, mediaElement, mediaSource, playbackObserver, @@ -552,12 +536,14 @@ export default class MediaSourceContentInitializer extends ContentInitializer { }); break; case "needs-manifest-refresh": - return manifestUpdater.forceRefresh({ completeRefresh: false, - canUseUnsafeMode: true }); + return this._manifestFetcher.scheduleManualRefresh({ + enablePartialRefresh: true, + canUseUnsafeMode: true, + }); case "manifest-might-be-out-of-sync": const { OUT_OF_SYNC_MANIFEST_REFRESH_DELAY } = config.getCurrent(); - manifestUpdater.forceRefresh({ - completeRefresh: true, + this._manifestFetcher.scheduleManualRefresh({ + enablePartialRefresh: false, canUseUnsafeMode: false, delay: OUT_OF_SYNC_MANIFEST_REFRESH_DELAY, }); @@ -647,19 +633,12 @@ export interface IInitializeArguments { /** Behavior when a new video and/or audio codec is encountered. */ onCodecSwitch : "continue" | "reload"; }; - /** - * Potential first Manifest to rely on, allowing to skip the initial Manifest - * fetching. - */ - initialManifest : ILoadedManifestFormat | undefined; /** Every encryption configuration set. */ keySystems : IKeySystemOption[]; /** `true` to play low-latency contents optimally. */ lowLatencyMode : boolean; /** Settings linked to Manifest requests. */ manifestRequestSettings : IManifestFetcherSettings; - /** Limit the frequency of Manifest updates. */ - minimumManifestUpdateInterval : number; /** Logic linked Manifest and segment loading and parsing. */ transport : ITransportPipelines; /** Configuration for the segment requesting logic. */ @@ -709,8 +688,6 @@ interface IBufferingMediaSettings { protectionRef : ISharedReference; /** `MediaSource` element on which the media will be buffered. */ mediaSource : MediaSource; - /** Interface allowing to refresh the Manifest. */ - manifestUpdater : IManifestUpdateScheduler; initialTime : number; autoPlay : boolean; } diff --git a/src/core/init/utils/manifest_update_scheduler.ts b/src/core/init/utils/manifest_update_scheduler.ts deleted file mode 100644 index de855a102a..0000000000 --- a/src/core/init/utils/manifest_update_scheduler.ts +++ /dev/null @@ -1,341 +0,0 @@ -/** - * 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. - */ - -import config from "../../../config"; -import log from "../../../log"; -import Manifest from "../../../manifest"; -import { IPlayerError } from "../../../public_types"; -import noop from "../../../utils/noop"; -import TaskCanceller from "../../../utils/task_canceller"; -import { ManifestFetcher } from "../../fetchers"; - -/** - * Refresh the Manifest at the right time. - * @param {Object} lastManifestResponse - Information about the last loading - * operation of the manifest. - * @param {Object} manifestFetcher - Interface allowing to refresh the Manifest. - * @param {number} minimumManifestUpdateInterval - Minimum interval to keep - * between Manifest updates. - * @param {Function} onWarning - Callback called when a minor error occurs. - * @param {Function} onError - Callback called when a major error occured, - * leading to a complete stop of Manifest refresh. - * @returns {Object} - Manifest Update Scheduler Interface allowing to manually - * schedule Manifest refresh and to stop them at any time. - */ -export default function createManifestUpdateScheduler( - lastManifestResponse : { manifest : Manifest; - sendingTime? : number | undefined; - receivedTime? : number | undefined; - parsingTime? : number | undefined; }, - manifestFetcher : ManifestFetcher, - minimumManifestUpdateInterval : number, - onWarning : (err : IPlayerError) => void, - onError : (err : unknown) => void -) : IManifestUpdateScheduler { - /** - * `TaskCanceller` allowing to cancel the refresh operation from ever - * happening. - * Used to dispose of this `IManifestUpdateScheduler`. - */ - const canceller = new TaskCanceller(); - - /** Function used to manually schedule a Manifest refresh. */ - let scheduleManualRefresh : (settings : IManifestRefreshSettings) => void = noop; - - /** - * Set to `true` when a Manifest refresh is currently pending. - * Allows to avoid doing multiple concurrent Manifest refresh, as this is - * most of the time unnecessary. - */ - let isRefreshAlreadyPending = false; - - // The Manifest always keeps the same reference - const { manifest } = lastManifestResponse; - - /** Number of consecutive times the parsing has been done in `unsafeMode`. */ - let consecutiveUnsafeMode = 0; - - /* Start-up the logic now. */ - recursivelyRefreshManifest(lastManifestResponse); - - return { - forceRefresh(settings : IManifestRefreshSettings) : void { - scheduleManualRefresh(settings); - }, - stop() : void { - scheduleManualRefresh = noop; - canceller.cancel(); - }, - }; - - /** - * Performs Manifest refresh (recursively) when it judges it is time to do so. - * @param {Object} manifestRequestInfos - Various information linked to the - * last Manifest loading and parsing operations. - */ - function recursivelyRefreshManifest( - { sendingTime, parsingTime, updatingTime } : { sendingTime?: number | undefined; - parsingTime? : number | undefined; - updatingTime? : number | undefined; } - ) : void { - const { MAX_CONSECUTIVE_MANIFEST_PARSING_IN_UNSAFE_MODE, - MIN_MANIFEST_PARSING_TIME_TO_ENTER_UNSAFE_MODE } = config.getCurrent(); - - /** - * Total time taken to fully update the last Manifest, in milliseconds. - * Note: this time also includes possible requests done by the parsers. - */ - const totalUpdateTime = parsingTime !== undefined ? - parsingTime + (updatingTime ?? 0) : - undefined; - - /** - * "unsafeMode" is a mode where we unlock advanced Manifest parsing - * optimizations with the added risk to lose some information. - * `unsafeModeEnabled` is set to `true` when the `unsafeMode` is enabled. - * - * Only perform parsing in `unsafeMode` when the last full parsing took a - * lot of time and do not go higher than the maximum consecutive time. - */ - - const unsafeModeEnabled = consecutiveUnsafeMode > 0 ? - consecutiveUnsafeMode < MAX_CONSECUTIVE_MANIFEST_PARSING_IN_UNSAFE_MODE : - totalUpdateTime !== undefined ? - (totalUpdateTime >= MIN_MANIFEST_PARSING_TIME_TO_ENTER_UNSAFE_MODE) : - false; - - /** Time elapsed since the beginning of the Manifest request, in milliseconds. */ - const timeSinceRequest = sendingTime === undefined ? 0 : - performance.now() - sendingTime; - - /** Minimum update delay we should not go below, in milliseconds. */ - const minInterval = Math.max(minimumManifestUpdateInterval - timeSinceRequest, 0); - - /** - * Multiple refresh trigger are scheduled here, but only the first one should - * be effectively considered. - * `nextRefreshCanceller` will allow to cancel every other when one is triggered. - */ - const nextRefreshCanceller = new TaskCanceller({ cancelOn: canceller.signal }); - - /* Function to manually schedule a Manifest refresh */ - scheduleManualRefresh = (settings : IManifestRefreshSettings) => { - const { completeRefresh, delay, canUseUnsafeMode } = settings; - const unsafeMode = canUseUnsafeMode && unsafeModeEnabled; - // The value allows to set a delay relatively to the last Manifest refresh - // (to avoid asking for it too often). - const timeSinceLastRefresh = sendingTime === undefined ? - 0 : - performance.now() - sendingTime; - const _minInterval = Math.max(minimumManifestUpdateInterval - timeSinceLastRefresh, - 0); - const timeoutId = setTimeout(() => { - nextRefreshCanceller.cancel(); - triggerNextManifestRefresh({ completeRefresh, unsafeMode }); - }, Math.max((delay ?? 0) - timeSinceLastRefresh, _minInterval)); - nextRefreshCanceller.signal.register(() => { - clearTimeout(timeoutId); - }); - }; - - /* Handle Manifest expiration. */ - if (manifest.expired !== null) { - const timeoutId = setTimeout(() => { - manifest.expired?.then(() => { - nextRefreshCanceller.cancel(); - triggerNextManifestRefresh({ completeRefresh: true, - unsafeMode: unsafeModeEnabled }); - }, noop /* `expired` should not reject */); - }, minInterval); - nextRefreshCanceller.signal.register(() => { - clearTimeout(timeoutId); - }); - } - - /* - * Trigger Manifest refresh when the Manifest needs to be refreshed - * according to the Manifest's internal properties (parsing time is also - * taken into account in this operation to avoid refreshing too often). - */ - if (manifest.lifetime !== undefined && manifest.lifetime >= 0) { - /** Regular refresh delay as asked by the Manifest. */ - const regularRefreshDelay = manifest.lifetime * 1000 - timeSinceRequest; - - /** Actually choosen delay to refresh the Manifest. */ - let actualRefreshInterval : number; - - if (totalUpdateTime === undefined) { - actualRefreshInterval = regularRefreshDelay; - } else if (manifest.lifetime < 3 && totalUpdateTime >= 100) { - // If Manifest update is very frequent and we take time to update it, - // postpone it. - actualRefreshInterval = Math.min( - Math.max( - // Take 3 seconds as a default safe value for a base interval. - 3000 - timeSinceRequest, - // Add update time to the original interval. - Math.max(regularRefreshDelay, 0) + totalUpdateTime - ), - - // Limit the postponment's higher bound to a very high value relative - // to `regularRefreshDelay`. - // This avoid perpetually postponing a Manifest update when - // performance seems to have been abysmal one time. - regularRefreshDelay * 6 - ); - log.info("MUS: Manifest update rythm is too frequent. Postponing next request.", - regularRefreshDelay, - actualRefreshInterval); - } else if (totalUpdateTime >= (manifest.lifetime * 1000) / 10) { - // If Manifest updating time is very long relative to its lifetime, - // postpone it: - actualRefreshInterval = Math.min( - // Just add the update time to the original waiting time - Math.max(regularRefreshDelay, 0) + totalUpdateTime, - - // Limit the postponment's higher bound to a very high value relative - // to `regularRefreshDelay`. - // This avoid perpetually postponing a Manifest update when - // performance seems to have been abysmal one time. - regularRefreshDelay * 6); - log.info("MUS: Manifest took too long to parse. Postponing next request", - actualRefreshInterval, - actualRefreshInterval); - } else { - actualRefreshInterval = regularRefreshDelay; - } - const timeoutId = setTimeout(() => { - nextRefreshCanceller.cancel(); - triggerNextManifestRefresh({ completeRefresh: false, - unsafeMode: unsafeModeEnabled }); - }, Math.max(actualRefreshInterval, minInterval)); - nextRefreshCanceller.signal.register(() => { - clearTimeout(timeoutId); - }); - } - } - - /** - * Refresh the Manifest, performing a full update if a partial update failed. - * Also re-call `recursivelyRefreshManifest` to schedule the next refresh - * trigger. - * @param {Object} refreshInformation - */ - function triggerNextManifestRefresh( - { completeRefresh, - unsafeMode } : { completeRefresh : boolean; - unsafeMode : boolean; } - ) { - const manifestUpdateUrl = manifest.updateUrl; - const fullRefresh = completeRefresh || manifestUpdateUrl === undefined; - const refreshURL = fullRefresh ? manifest.getUrl() : - manifestUpdateUrl; - const externalClockOffset = manifest.clockOffset; - - if (unsafeMode) { - consecutiveUnsafeMode += 1; - log.info("Init: Refreshing the Manifest in \"unsafeMode\" for the " + - String(consecutiveUnsafeMode) + " consecutive time."); - } else if (consecutiveUnsafeMode > 0) { - log.info("Init: Not parsing the Manifest in \"unsafeMode\" anymore after " + - String(consecutiveUnsafeMode) + " consecutive times."); - consecutiveUnsafeMode = 0; - } - - if (isRefreshAlreadyPending) { - return; - } - isRefreshAlreadyPending = true; - manifestFetcher.fetch(refreshURL, onWarning, canceller.signal) - .then(res => res.parse({ externalClockOffset, - previousManifest: manifest, - unsafeMode })) - .then(res => { - isRefreshAlreadyPending = false; - const { manifest: newManifest, - sendingTime: newSendingTime, - parsingTime } = res; - const updateTimeStart = performance.now(); - - if (fullRefresh) { - manifest.replace(newManifest); - } else { - try { - manifest.update(newManifest); - } catch (e) { - const message = e instanceof Error ? e.message : - "unknown error"; - log.warn(`MUS: Attempt to update Manifest failed: ${message}`, - "Re-downloading the Manifest fully"); - const { FAILED_PARTIAL_UPDATE_MANIFEST_REFRESH_DELAY } = config.getCurrent(); - - // The value allows to set a delay relatively to the last Manifest refresh - // (to avoid asking for it too often). - const timeSinceLastRefresh = newSendingTime === undefined ? - 0 : - performance.now() - newSendingTime; - const _minInterval = Math.max(minimumManifestUpdateInterval - - timeSinceLastRefresh, - 0); - let unregisterCanceller = noop; - const timeoutId = setTimeout(() => { - unregisterCanceller(); - triggerNextManifestRefresh({ completeRefresh: true, unsafeMode: false }); - }, Math.max(FAILED_PARTIAL_UPDATE_MANIFEST_REFRESH_DELAY - - timeSinceLastRefresh, - _minInterval)); - unregisterCanceller = canceller.signal.register(() => { - clearTimeout(timeoutId); - }); - return; - } - } - const updatingTime = performance.now() - updateTimeStart; - recursivelyRefreshManifest({ sendingTime: newSendingTime, - parsingTime, - updatingTime }); - }) - .catch((err) => { - isRefreshAlreadyPending = false; - onError(err); - }); - } -} - -export interface IManifestRefreshSettings { - /** - * if `true`, the Manifest should be fully updated. - * if `false`, a shorter version with just the added information can be loaded - * instead. - */ - completeRefresh : boolean; - /** - * Optional wanted refresh delay, which is the minimum time you want to wait - * before updating the Manifest - */ - delay? : number | undefined; - /** - * Whether the parsing can be done in the more efficient "unsafeMode". - * This mode is extremely fast but can lead to de-synchronisation with the - * server. - */ - canUseUnsafeMode : boolean; -} - -export interface IManifestUpdateScheduler { - forceRefresh(settings : IManifestRefreshSettings) : void; - stop() : void; -} From bb646c67ce5c4f365712f16f42e54d8b6ce90aa7 Mon Sep 17 00:00:00 2001 From: Paul Berberian Date: Wed, 30 Nov 2022 10:38:33 +0100 Subject: [PATCH 09/86] DASH: implement forced-subtitles --- .../tracks_management/track_choice_manager.ts | 67 ++++++++++++++----- src/manifest/adaptation.ts | 8 +++ .../dash/common/parse_adaptation_sets.ts | 20 +++++- src/parsers/manifest/types.ts | 7 ++ src/public_types.ts | 2 + 5 files changed, 85 insertions(+), 19 deletions(-) diff --git a/src/core/api/tracks_management/track_choice_manager.ts b/src/core/api/tracks_management/track_choice_manager.ts index 51f3c1ab7a..504dfd814e 100644 --- a/src/core/api/tracks_management/track_choice_manager.ts +++ b/src/core/api/tracks_management/track_choice_manager.ts @@ -84,6 +84,7 @@ type INormalizedPreferredTextTrack = null | /** Text track preference when it is not set to `null`. */ interface INormalizedPreferredTextTrackObject { normalized : string; + forced : boolean | undefined; closedCaption : boolean; } @@ -116,6 +117,7 @@ function normalizeTextTracks( return tracks.map(t => t === null ? t : { normalized: normalizeLanguage(t.language), + forced: t.forced, closedCaption: t.closedCaption }); } @@ -384,8 +386,10 @@ export default class TrackChoiceManager { // Find the optimal text Adaptation const preferredTextTracks = this._preferredTextTracks; const normalizedPref = normalizeTextTracks(preferredTextTracks); - const optimalAdaptation = findFirstOptimalTextAdaptation(textAdaptations, - normalizedPref); + const optimalAdaptation = findFirstOptimalTextAdaptation( + textAdaptations, + normalizedPref, + this._audioChoiceMemory.get(period)); this._textChoiceMemory.set(period, optimalAdaptation); textInfos.adaptation$.next(optimalAdaptation); } else { @@ -645,13 +649,17 @@ export default class TrackChoiceManager { return null; } - return { + const formatted : ITextTrack = { language: takeFirstSet(chosenTextAdaptation.language, ""), normalized: takeFirstSet(chosenTextAdaptation.normalizedLanguage, ""), closedCaption: chosenTextAdaptation.isClosedCaption === true, id: chosenTextAdaptation.id, label: chosenTextAdaptation.label, }; + if (chosenTextAdaptation.isForcedSubtitles !== undefined) { + formatted.forced = chosenTextAdaptation.isForcedSubtitles; + } + return formatted; } /** @@ -768,15 +776,21 @@ export default class TrackChoiceManager { null; return textInfos.adaptations - .map((adaptation) => ({ - language: takeFirstSet(adaptation.language, ""), - normalized: takeFirstSet(adaptation.normalizedLanguage, ""), - closedCaption: adaptation.isClosedCaption === true, - id: adaptation.id, - active: currentId === null ? false : - currentId === adaptation.id, - label: adaptation.label, - })); + .map((adaptation) => { + const formatted : IAvailableTextTrack = { + language: takeFirstSet(adaptation.language, ""), + normalized: takeFirstSet(adaptation.normalizedLanguage, ""), + closedCaption: adaptation.isClosedCaption === true, + id: adaptation.id, + active: currentId === null ? false : + currentId === adaptation.id, + label: adaptation.label, + }; + if (adaptation.isForcedSubtitles !== undefined) { + formatted.forced = adaptation.isForcedSubtitles; + } + return formatted; + }); } /** @@ -956,8 +970,10 @@ export default class TrackChoiceManager { return; } - const optimalAdaptation = findFirstOptimalTextAdaptation(textAdaptations, - normalizedPref); + const optimalAdaptation = findFirstOptimalTextAdaptation( + textAdaptations, + normalizedPref, + this._audioChoiceMemory.get(period)); this._textChoiceMemory.set(period, optimalAdaptation); textItem.adaptation$.next(optimalAdaptation); @@ -1162,7 +1178,9 @@ function createTextPreferenceMatcher( return takeFirstSet(textAdaptation.normalizedLanguage, "") === preferredTextTrack.normalized && (preferredTextTrack.closedCaption ? textAdaptation.isClosedCaption === true : - textAdaptation.isClosedCaption !== true); + textAdaptation.isClosedCaption !== true) && + (preferredTextTrack.forced === true ? textAdaptation.isForcedSubtitles === true : + textAdaptation.isForcedSubtitles !== true); }; } @@ -1173,12 +1191,14 @@ function createTextPreferenceMatcher( * `null` if the most optimal text adaptation is no text adaptation. * @param {Array.} textAdaptations * @param {Array.} preferredTextTracks + * @param {Object|null|undefined} chosenAudioAdaptation * @returns {Adaptation|null} */ function findFirstOptimalTextAdaptation( textAdaptations : Adaptation[], - preferredTextTracks : INormalizedPreferredTextTrack[] -) : Adaptation|null { + preferredTextTracks : INormalizedPreferredTextTrack[], + chosenAudioAdaptation : Adaptation | null | undefined +) : Adaptation | null { if (textAdaptations.length === 0) { return null; } @@ -1198,6 +1218,19 @@ function findFirstOptimalTextAdaptation( } } + const forcedSubtitles = textAdaptations.filter((ad) => ad.isForcedSubtitles === true); + if (forcedSubtitles.length > 0) { + if (chosenAudioAdaptation !== null && chosenAudioAdaptation !== undefined) { + const sameLanguage = arrayFind(forcedSubtitles, (f) => + f.normalizedLanguage === chosenAudioAdaptation.normalizedLanguage); + if (sameLanguage !== undefined) { + return sameLanguage; + } + } + return arrayFind(forcedSubtitles, (f) => f.normalizedLanguage === undefined) ?? + null; + } + // no optimal adaptation return null; } diff --git a/src/manifest/adaptation.ts b/src/manifest/adaptation.ts index 9ac5665777..7e454845a9 100644 --- a/src/manifest/adaptation.ts +++ b/src/manifest/adaptation.ts @@ -56,6 +56,14 @@ export default class Adaptation { /** Whether this Adaptation contains closed captions for the hard-of-hearing. */ public isClosedCaption? : boolean; + /** + * If `true` this Adaptation are subtitles Meant for display when no other text + * Adaptation is selected. It is used to clarify dialogue, alternate + * languages, texted graphics or location/person IDs that are not otherwise + * covered in the dubbed/localized audio Adaptation. + */ + public isForcedSubtitles? : boolean; + /** If true this Adaptation contains sign interpretation. */ public isSignInterpreted? : boolean; diff --git a/src/parsers/manifest/dash/common/parse_adaptation_sets.ts b/src/parsers/manifest/dash/common/parse_adaptation_sets.ts index 6f15c7d497..5dd553045d 100644 --- a/src/parsers/manifest/dash/common/parse_adaptation_sets.ts +++ b/src/parsers/manifest/dash/common/parse_adaptation_sets.ts @@ -147,14 +147,13 @@ function hasSignLanguageInterpretation( /** * Contruct Adaptation ID from the information we have. * @param {Object} adaptation - * @param {Array.} representations - * @param {Array.} representations * @param {Object} infos * @returns {string} */ function getAdaptationID( adaptation : IAdaptationSetIntermediateRepresentation, infos : { isClosedCaption : boolean | undefined; + isForcedSubtitle : boolean | undefined; isAudioDescription : boolean | undefined; isSignInterpreted : boolean | undefined; isTrickModeTrack: boolean; @@ -165,6 +164,7 @@ function getAdaptationID( } const { isClosedCaption, + isForcedSubtitle, isAudioDescription, isSignInterpreted, isTrickModeTrack, @@ -177,6 +177,9 @@ function getAdaptationID( if (isClosedCaption === true) { idString += "-cc"; } + if (isForcedSubtitle === true) { + idString += "-cc"; + } if (isAudioDescription === true) { idString += "-ad"; } @@ -369,6 +372,15 @@ export default function parseAdaptationSets( isClosedCaption = accessibilities.some(isHardOfHearing); } + let isForcedSubtitle; + if (type === "text" && + roles !== undefined && + roles.some((role) => role.value === "forced-subtitle" || + role.value === "forced_subtitle")) + { + isForcedSubtitle = true; + } + let isAudioDescription; if (type !== "audio") { isAudioDescription = false; @@ -385,6 +397,7 @@ export default function parseAdaptationSets( let adaptationID = getAdaptationID(adaptation, { isAudioDescription, + isForcedSubtitle, isClosedCaption, isSignInterpreted, isTrickModeTrack, @@ -421,6 +434,9 @@ export default function parseAdaptationSets( if (isDub === true) { parsedAdaptationSet.isDub = true; } + if (isForcedSubtitle !== undefined) { + parsedAdaptationSet.forcedSubtitles = isForcedSubtitle; + } if (isSignInterpreted === true) { parsedAdaptationSet.isSignInterpreted = true; } diff --git a/src/parsers/manifest/types.ts b/src/parsers/manifest/types.ts index 6b829e4e17..73332dd8bf 100644 --- a/src/parsers/manifest/types.ts +++ b/src/parsers/manifest/types.ts @@ -197,6 +197,13 @@ export interface IParsedAdaptation { * a video track). */ closedCaption? : boolean | undefined; + /** + * If `true` this Adaptation are subtitles Meant for display when no other text + * Adaptation is selected. It is used to clarify dialogue, alternate + * languages, texted graphics or location/person IDs that are not otherwise + * covered in the dubbed/localized audio Adaptation. + */ + forcedSubtitles? : boolean; /** * If true this Adaptation is in a dub: it was recorded in another language * than the original(s) one(s). diff --git a/src/public_types.ts b/src/public_types.ts index e64ae9bd7f..a8b9f7b658 100644 --- a/src/public_types.ts +++ b/src/public_types.ts @@ -636,6 +636,7 @@ export type IAudioTrackPreference = null | /** Single preference for a text track Adaptation. */ export type ITextTrackPreference = null | { language : string; + forced? : boolean | undefined; closedCaption : boolean; }; /** Single preference for a video track Adaptation. */ @@ -763,6 +764,7 @@ export interface IAudioTrack { language : string; export interface ITextTrack { language : string; normalized : string; closedCaption : boolean; + forced? : boolean | undefined; label? : string | undefined; id : number|string; } From 50081d805e7e3376a50897ab18cb96cb78412ec5 Mon Sep 17 00:00:00 2001 From: Paul Berberian Date: Tue, 6 Dec 2022 10:03:32 +0100 Subject: [PATCH 10/86] tests: raise timer because previous one was too low for GH actions --- tests/integration/scenarios/end_number.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/integration/scenarios/end_number.js b/tests/integration/scenarios/end_number.js index 5bdca99f7d..b0a2fb5f1e 100644 --- a/tests/integration/scenarios/end_number.js +++ b/tests/integration/scenarios/end_number.js @@ -48,7 +48,7 @@ describe("end number", function () { }); it("should not load segment later than the end number on a time-based SegmentTimeline", async function () { - this.timeout(10000); + this.timeout(15000); xhrMock.lock(); player.setVideoBitrate(0); player.setWantedBufferAhead(15); @@ -71,11 +71,11 @@ describe("end number", function () { await sleep(500); expect(xhrMock.getLockedXHR().length).to.equal(2); xhrMock.flush(); - player.seekTo(19); + player.seekTo(19.7); await sleep(50); expect(xhrMock.getLockedXHR().length).to.equal(2); xhrMock.flush(); - await sleep(3000); + await sleep(5000); expect(xhrMock.getLockedXHR().length).to.equal(0); expect(player.getPlayerState()).to.eql("ENDED"); expect(player.getPosition()).to.be.closeTo(20, 1); From acb239f56c7e750aff8f60cbf58068eea89d616d Mon Sep 17 00:00:00 2001 From: Paul Berberian Date: Tue, 22 Nov 2022 11:16:26 +0100 Subject: [PATCH 11/86] Avoid dangling promise in the case where the ManifestFetcher was disposed --- src/core/init/media_source_content_initializer.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/core/init/media_source_content_initializer.ts b/src/core/init/media_source_content_initializer.ts index 169bd591f3..9361d68015 100644 --- a/src/core/init/media_source_content_initializer.ts +++ b/src/core/init/media_source_content_initializer.ts @@ -32,6 +32,7 @@ import createSharedReference, { ISharedReference, } from "../../utils/reference"; import TaskCanceller, { + CancellationError, CancellationSignal, } from "../../utils/task_canceller"; import AdaptiveRepresentationSelector, { @@ -114,9 +115,6 @@ export default class MediaSourceContentInitializer extends ContentInitializer { this._manifestFetcher = new ManifestFetcher(urls, settings.transport, settings.manifestRequestSettings); - this._initCanceller.signal.register(() => { - this._manifestFetcher.dispose(); - }); } /** @@ -128,6 +126,10 @@ export default class MediaSourceContentInitializer extends ContentInitializer { return; } this._initialManifestProm = new Promise((res, rej) => { + this._initCanceller.signal.register((err : CancellationError) => { + this._manifestFetcher.dispose(); + rej(err); + }); this._manifestFetcher.addEventListener("warning", (err : IPlayerError) => this.trigger("warning", err)); this._manifestFetcher.addEventListener("error", (err : unknown) => { From e614a15d5b8e158154a3b631f82da86aae8a22bd Mon Sep 17 00:00:00 2001 From: Paul Berberian Date: Wed, 30 Nov 2022 11:27:40 +0100 Subject: [PATCH 12/86] doc: Add forced text tracks to the documentation --- doc/api/Player_Events.md | 15 +++++++++++++++ .../Track_Selection/getAvailableTextTracks.md | 6 ++++++ .../Track_Selection/getPreferredTextTracks.md | 6 ++++-- doc/api/Track_Selection/getTextTrack.md | 6 ++++++ .../Track_Selection/setPreferredTextTracks.md | 16 ++++++++++++---- src/manifest/adaptation.ts | 3 +++ 6 files changed, 46 insertions(+), 6 deletions(-) diff --git a/doc/api/Player_Events.md b/doc/api/Player_Events.md index 7503c990cc..07db1e9180 100644 --- a/doc/api/Player_Events.md +++ b/doc/api/Player_Events.md @@ -217,6 +217,12 @@ The array emitted contains object describing each available text track: - `closedCaption` (`Boolean`): Whether the track is specially adapted for the hard of hearing or not. +- `forced` (`Boolean`): If `true` this text track is meant to be displayed by + default if no other text track is selected. + + It is often used to clarify dialogue, alternate languages, texted graphics or + location and person identification. + - `active` (`Boolean`): Whether the track is the one currently active or not. @@ -259,9 +265,18 @@ The payload is an object describing the new track, with the following properties: - `id` (`Number|string`): The id used to identify the track. + - `language` (`string`): The language the text track is in. + - `closedCaption` (`Boolean`): Whether the track is specially adapted for the hard of hearing or not. + +- `forced` (`Boolean`): If `true` this text track is meant to be displayed by + default if no other text track is selected. + + It is often used to clarify dialogue, alternate languages, texted graphics or + location and person identification. + - `label` (`string|undefined`): A human readable label that may be displayed in the user interface providing a choice between text tracks. diff --git a/doc/api/Track_Selection/getAvailableTextTracks.md b/doc/api/Track_Selection/getAvailableTextTracks.md index c83b5e1c4d..46019df395 100644 --- a/doc/api/Track_Selection/getAvailableTextTracks.md +++ b/doc/api/Track_Selection/getAvailableTextTracks.md @@ -21,6 +21,12 @@ Each of the objects in the returned array have the following properties: - `closedCaption` (`Boolean`): Whether the track is specially adapted for the hard of hearing or not. +- `forced` (`Boolean`): If `true` this text track is meant to be displayed by + default if no other text track is selected. + + It is often used to clarify dialogue, alternate languages, texted graphics or + location and person identification. + - `label` (`string|undefined`): A human readable label that may be displayed in the user interface providing a choice between text tracks. diff --git a/doc/api/Track_Selection/getPreferredTextTracks.md b/doc/api/Track_Selection/getPreferredTextTracks.md index cfbd38b6f5..14be57caeb 100644 --- a/doc/api/Track_Selection/getPreferredTextTracks.md +++ b/doc/api/Track_Selection/getPreferredTextTracks.md @@ -12,8 +12,10 @@ it was called: { language: "fra", // {string} The wanted language // (ISO 639-1, ISO 639-2 or ISO 639-3 language code) - closedCaption: false // {Boolean} Whether the text track should be a closed - // caption for the hard of hearing + closedCaption: false, // {Boolean} Whether the text track should be a closed + // caption for the hard of hearing + forced: false // {Boolean|undefined} If `true` this text track is meant to be + // displayed by default if no other text track is selected. } ``` diff --git a/doc/api/Track_Selection/getTextTrack.md b/doc/api/Track_Selection/getTextTrack.md index c86de6e12b..9ba3211f73 100644 --- a/doc/api/Track_Selection/getTextTrack.md +++ b/doc/api/Track_Selection/getTextTrack.md @@ -31,6 +31,12 @@ return an object with the following properties: - `closedCaption` (`Boolean`): Whether the track is specially adapted for the hard of hearing or not. +- `forced` (`Boolean`): If `true` this text track is meant to be displayed by + default if no other text track is selected. + + It is often used to clarify dialogue, alternate languages, texted graphics or + location and person identification. + `undefined` if no text content has been loaded yet or if its information is unknown. diff --git a/doc/api/Track_Selection/setPreferredTextTracks.md b/doc/api/Track_Selection/setPreferredTextTracks.md index 635eb5d19e..ebce65ab52 100644 --- a/doc/api/Track_Selection/setPreferredTextTracks.md +++ b/doc/api/Track_Selection/setPreferredTextTracks.md @@ -13,14 +13,22 @@ apply to every future loaded content in the current RxPlayer instance. The first argument should be set as an array of objects, each object describing constraints a text track should respect. +Here are the list of properties that can be set on each of those objects: + + - **language** (`string`): The wanted language (preferably as an ISO 639-1, + ISO 639-2 or ISO 639-3 language code) + + - **closedCaption** (`boolean`): Whether the text track should be a closed + caption for the hard of hearing + + - **forced** (`boolean|undefined`): If `true` the text track should be a + "forced subtitle", which are default text tracks used when no other text + track is selected. + Here is all the properties that should be set in a single object of that array. ```js { - language: "fra", // {string} The wanted language - // (ISO 639-1, ISO 639-2 or ISO 639-3 language code) - closedCaption: false // {Boolean} Whether the text track should be a closed - // caption for the hard of hearing } ``` diff --git a/src/manifest/adaptation.ts b/src/manifest/adaptation.ts index 7e454845a9..fd94d36c7b 100644 --- a/src/manifest/adaptation.ts +++ b/src/manifest/adaptation.ts @@ -128,6 +128,9 @@ export default class Adaptation { if (parsedAdaptation.isDub !== undefined) { this.isDub = parsedAdaptation.isDub; } + if (parsedAdaptation.forcedSubtitles !== undefined) { + this.isForcedSubtitles = parsedAdaptation.forcedSubtitles; + } if (parsedAdaptation.isSignInterpreted !== undefined) { this.isSignInterpreted = parsedAdaptation.isSignInterpreted; } From d4df24668b65bf50d8f0f3833f7a769abce8708e Mon Sep 17 00:00:00 2001 From: Paul Berberian Date: Tue, 6 Dec 2022 10:48:36 +0100 Subject: [PATCH 13/86] add isEmptyStream on buffer status event to handle empty stream specificities --- .../init/media_source_content_initializer.ts | 2 -- src/core/init/types.ts | 5 ----- src/core/stream/events_generators.ts | 6 ------ .../orchestrator/are_streams_complete.ts | 20 +++++++++++++------ .../orchestrator/stream_orchestrator.ts | 5 ++--- .../period/create_empty_adaptation_stream.ts | 1 + .../representation/representation_stream.ts | 1 + src/core/stream/types.ts | 14 +++++-------- tests/integration/scenarios/end_number.js | 8 +++----- 9 files changed, 26 insertions(+), 36 deletions(-) diff --git a/src/core/init/media_source_content_initializer.ts b/src/core/init/media_source_content_initializer.ts index 9361d68015..fd21928fa9 100644 --- a/src/core/init/media_source_content_initializer.ts +++ b/src/core/init/media_source_content_initializer.ts @@ -569,8 +569,6 @@ export default class MediaSourceContentInitializer extends ContentInitializer { return this.trigger("periodStreamCleared", evt.value); case "representationChange": return this.trigger("representationChange", evt.value); - case "complete-stream": - return this.trigger("completeStream", evt.value); case "bitrateEstimationChange": return this.trigger("bitrateEstimationChange", evt.value); case "added-segment": diff --git a/src/core/init/types.ts b/src/core/init/types.ts index 665fb4eb9d..bd29f41f0e 100644 --- a/src/core/init/types.ts +++ b/src/core/init/types.ts @@ -177,11 +177,6 @@ export interface IContentInitializerEvents { */ period : Period; }; - /** - * The last (chronologically) `Period` for a given type has pushed all - * the segments it needs until the end. - */ - completeStream: { type: IBufferType }; /** Emitted when a new `Adaptation` is being considered. */ adaptationChange: { /** The type of buffer for which the Representation is changing. */ diff --git a/src/core/stream/events_generators.ts b/src/core/stream/events_generators.ts index bd1856f6fb..d170a03695 100644 --- a/src/core/stream/events_generators.ts +++ b/src/core/stream/events_generators.ts @@ -29,7 +29,6 @@ import { IActivePeriodChangedEvent, IAdaptationChangeEvent, IBitrateEstimationChangeEvent, - ICompletedStreamEvent, IEncryptionDataEncounteredEvent, IEndOfStreamEvent, ILockedStreamEvent, @@ -88,11 +87,6 @@ const EVENTS = { value: { type, bitrate } }; }, - streamComplete(bufferType: IBufferType) : ICompletedStreamEvent { - return { type: "complete-stream", - value: { type: bufferType } }; - }, - endOfStream() : IEndOfStreamEvent { return { type: "end-of-stream", value: undefined }; diff --git a/src/core/stream/orchestrator/are_streams_complete.ts b/src/core/stream/orchestrator/are_streams_complete.ts index 49ae11138c..77bb925801 100644 --- a/src/core/stream/orchestrator/are_streams_complete.ts +++ b/src/core/stream/orchestrator/are_streams_complete.ts @@ -22,7 +22,9 @@ import { Observable, startWith, } from "rxjs"; -import { IStreamOrchestratorEvent } from "../types"; +import Manifest from "../../../manifest"; +import filterMap from "../../../utils/filter_map"; +import { IStreamOrchestratorEvent, IStreamStatusEvent } from "../types"; /** * Returns an Observable which emits ``true`` when all PeriodStreams given are @@ -38,10 +40,12 @@ import { IStreamOrchestratorEvent } from "../types"; * segments needed for this Stream have been downloaded. * * When the Observable returned here emits, every Stream are finished. + * @param {Object} manifest * @param {...Observable} streams * @returns {Observable} */ export default function areStreamsComplete( + manifest : Manifest, ...streams : Array> ) : Observable { /** @@ -53,11 +57,15 @@ export default function areStreamsComplete( const isCompleteArray : Array> = streams .map((stream) => { return stream.pipe( - filter((evt) => { - return evt.type === "complete-stream" || - (evt.type === "stream-status" && !evt.value.hasFinishedLoading); - }), - map((evt) => evt.type === "complete-stream"), + filter((evt) : evt is IStreamStatusEvent => evt.type === "stream-status"), + filterMap((evt) => { + if (evt.value.hasFinishedLoading || evt.value.isEmptyStream) { + return manifest.getPeriodAfter(evt.value.period) === null ? + true : + null; // not the last Period: ignore event + } + return false; + }, null), startWith(false), distinctUntilChanged() ); diff --git a/src/core/stream/orchestrator/stream_orchestrator.ts b/src/core/stream/orchestrator/stream_orchestrator.ts index 6b3f3c67d9..a16ebece28 100644 --- a/src/core/stream/orchestrator/stream_orchestrator.ts +++ b/src/core/stream/orchestrator/stream_orchestrator.ts @@ -187,7 +187,7 @@ export default function StreamOrchestrator( // Emits an "end-of-stream" event once every PeriodStream are complete. // Emits a 'resume-stream" when it's not - const endOfStream$ = combineLatest([areStreamsComplete(...streamsArray), + const endOfStream$ = combineLatest([areStreamsComplete(manifest, ...streamsArray), isLastPeriodKnown$]) .pipe(map(([areComplete, isLastPeriodKnown]) => areComplete && isLastPeriodKnown), distinctUntilChanged(), @@ -506,8 +506,7 @@ export default function StreamOrchestrator( if (evt.value.hasFinishedLoading) { const nextPeriod = manifest.getPeriodAfter(basePeriod); if (nextPeriod === null) { - return observableConcat(observableOf(evt), - observableOf(EVENTS.streamComplete(bufferType))); + return observableOf(evt); } // current Stream is full, create the next one if not diff --git a/src/core/stream/period/create_empty_adaptation_stream.ts b/src/core/stream/period/create_empty_adaptation_stream.ts index 5b874de3b5..d744cf4754 100644 --- a/src/core/stream/period/create_empty_adaptation_stream.ts +++ b/src/core/stream/period/create_empty_adaptation_stream.ts @@ -62,6 +62,7 @@ export default function createEmptyAdaptationStream( bufferType, position, imminentDiscontinuity: null, + isEmptyStream: true, hasFinishedLoading, neededSegments: [], shouldRefreshManifest: false } }); diff --git a/src/core/stream/representation/representation_stream.ts b/src/core/stream/representation/representation_stream.ts index 998e63569e..fb26692594 100644 --- a/src/core/stream/representation/representation_stream.ts +++ b/src/core/stream/representation/representation_stream.ts @@ -260,6 +260,7 @@ export default function RepresentationStream({ position: observation.position.last, bufferType, imminentDiscontinuity: status.imminentDiscontinuity, + isEmptyStream: false, hasFinishedLoading: status.hasFinishedLoading, neededSegments: status.neededSegments } }); let bufferRemoval = EMPTY; diff --git a/src/core/stream/types.ts b/src/core/stream/types.ts index 70f7f171e4..63386ef61d 100644 --- a/src/core/stream/types.ts +++ b/src/core/stream/types.ts @@ -97,6 +97,11 @@ export interface IStreamStatusEvent { * end of the Period. */ hasFinishedLoading : boolean; + /** + * If `true`, this stream is a placeholder stream which will never load any + * segment. + */ + isEmptyStream : boolean; /** * Segments that will be scheduled for download to fill the buffer until * the buffer goal (first element of that list might already be ). @@ -317,13 +322,6 @@ export interface IEndOfStreamEvent { type: "end-of-stream"; export interface IResumeStreamEvent { type: "resume-stream"; value: undefined; } -/** - * The last (chronologically) `PeriodStream` for a given type has pushed all - * the segments it needs until the end. - */ -export interface ICompletedStreamEvent { type: "complete-stream"; - value : { type: IBufferType }; } - /** * A situation needs the MediaSource to be reloaded. * @@ -499,7 +497,6 @@ export type IPeriodStreamEvent = IPeriodStreamReadyEvent | /** Event coming from function(s) managing multiple PeriodStreams. */ export type IMultiplePeriodStreamsEvent = IPeriodStreamClearedEvent | - ICompletedStreamEvent | // From a PeriodStream @@ -531,7 +528,6 @@ export type IStreamOrchestratorEvent = IActivePeriodChangedEvent | IResumeStreamEvent | IPeriodStreamClearedEvent | - ICompletedStreamEvent | // From a PeriodStream diff --git a/tests/integration/scenarios/end_number.js b/tests/integration/scenarios/end_number.js index b0a2fb5f1e..13ca19c803 100644 --- a/tests/integration/scenarios/end_number.js +++ b/tests/integration/scenarios/end_number.js @@ -11,7 +11,7 @@ import { } from "../../contents/DASH_static_SegmentTimeline"; import RxPlayer from "../../../src"; import sleep from "../../utils/sleep.js"; -import { waitForLoadedStateAfterLoadVideo } from "../../utils/waitForPlayerState"; +import waitForState, { waitForLoadedStateAfterLoadVideo } from "../../utils/waitForPlayerState"; let player; @@ -71,13 +71,11 @@ describe("end number", function () { await sleep(500); expect(xhrMock.getLockedXHR().length).to.equal(2); xhrMock.flush(); - player.seekTo(19.7); + player.seekTo(19); await sleep(50); expect(xhrMock.getLockedXHR().length).to.equal(2); xhrMock.flush(); - await sleep(5000); - expect(xhrMock.getLockedXHR().length).to.equal(0); - expect(player.getPlayerState()).to.eql("ENDED"); + await waitForState(player, "ENDED", ["BUFFERING", "RELOADING", "PLAYING"]); expect(player.getPosition()).to.be.closeTo(20, 1); }); From 2868277cd23cbda071dac3e2402ebe1f57a2700b Mon Sep 17 00:00:00 2001 From: Paul Berberian Date: Tue, 22 Nov 2022 14:01:17 +0100 Subject: [PATCH 14/86] init: trigger error event before disposing in manifest_fetcher to fix error never being received --- src/core/fetchers/manifest/manifest_fetcher.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/fetchers/manifest/manifest_fetcher.ts b/src/core/fetchers/manifest/manifest_fetcher.ts index 4373597454..58fc6f8785 100644 --- a/src/core/fetchers/manifest/manifest_fetcher.ts +++ b/src/core/fetchers/manifest/manifest_fetcher.ts @@ -640,8 +640,8 @@ export default class ManifestFetcher extends EventEmitter if (this._canceller.isUsed) { return; } - this.dispose(); this.trigger("error", err); + this.dispose(); } } From 7f6ae03c83eb87effcc28cb27e092e06f29a873e Mon Sep 17 00:00:00 2001 From: Paul Berberian Date: Fri, 18 Nov 2022 17:10:20 +0100 Subject: [PATCH 15/86] Add `updateContentUrls` API This commit adds the `updateContentUrls` API allowing to update at any time and during playback, the URL through which a content's Manifest is reached. This may be used anytime an application prefer a new Manifest URLs to be used, whether it is because the previous URL will soon expire, because the new URL is seen as more qualitative or to rebalance load. The first argument given to `updateContentUrls` is actually an array of strings, listing new URLs from the most prioritized to the least prioritized. This is not used for now, but should soon be used to define fallback URLs when former one fail to fetch the resource. `updateContentUrls` can be given a second argument named `refreshNow` which can be used to ask the RxPlayer to refresh immediately the Manifest behind the given URL. For now, this API will throw for directfile contents, I'm not sure whether it is pertinent to enable it also in this case. --- doc/api/Content_Information/.docConfig.json | 4 ++ .../Content_Information/updateContentUrls.md | 56 +++++++++++++++++++ doc/reference/API_Reference.md | 3 + src/core/api/public_api.ts | 17 ++++++ .../fetchers/manifest/manifest_fetcher.ts | 38 ++++++++++++- .../init/directfile_content_initializer.ts | 4 ++ .../init/media_source_content_initializer.ts | 11 ++++ src/core/init/types.ts | 12 ++++ 8 files changed, 142 insertions(+), 3 deletions(-) create mode 100644 doc/api/Content_Information/updateContentUrls.md diff --git a/doc/api/Content_Information/.docConfig.json b/doc/api/Content_Information/.docConfig.json index b61314dd79..d018afb03c 100644 --- a/doc/api/Content_Information/.docConfig.json +++ b/doc/api/Content_Information/.docConfig.json @@ -4,6 +4,10 @@ "path": "./getUrl.md", "displayName": "getUrl" }, + { + "path": "./updateContentUrls.md", + "displayName": "updateContentUrls" + }, { "path": "./isLive.md", "displayName": "isLive" diff --git a/doc/api/Content_Information/updateContentUrls.md b/doc/api/Content_Information/updateContentUrls.md new file mode 100644 index 0000000000..7b3bb3f80a --- /dev/null +++ b/doc/api/Content_Information/updateContentUrls.md @@ -0,0 +1,56 @@ +# updateContentUrls + +## Description + +Update URL of the content currently being played (e.g. of DASH's MPD), +optionally also allowing to request an immediate refresh of it. + +This method can for example be called when you would prefer that the content and +its associated resources to be reached through another URL than what has been +used until now. + +Note that if a request through one of the given URL lead to a HTTP redirect, the +RxPlayer will generally prefer the redirected URL over the URL explicitely +communicated (to prevent more HTTP redirect). + +
+In DirectFile mode (see loadVideo options), +this method has no effect. +
+ +## Syntax + +```js +player.updateContentUrls(urls); +// or +player.updateContentUrls(urls, refreshNow); +``` + + - **arguments**: + + 1. _urls_ `Array.|under`: URLs to reach that content / Manifest + from the most prioritized URL to the least prioritized URL. + + 2. _refreshNow_ `boolean`: If `true` the resource in question (e.g. + DASH's MPD) will be refreshed immediately. + +## Examples + +```js +// Update with only one URL +player.updateContentUrls(["http://my.new.url"]); + +// Update with multiple URLs +player.updateContentUrls([ + "http://more.prioritized.url", + "http://less.prioritized.url", +]); + +// Set no URL (only is useful in some very specific situations, like for content +// with no Manifest refresh or when a `manifestLoader` is set). +player.updateContentUrls(undefined); + +// Update and ask to refresh immediately +player.updateContentUrls(["http://my.new.url"], true); +``` diff --git a/doc/reference/API_Reference.md b/doc/reference/API_Reference.md index 86ec8cd93e..b7d7b378e5 100644 --- a/doc/reference/API_Reference.md +++ b/doc/reference/API_Reference.md @@ -453,6 +453,9 @@ properties, methods, events and so on. - [`getUrl`](../api/Content_Information/getUrl.md): Get URL of the currently-played content. + - [`updateContentUrls`](../api/Content_Information/updateContentUrls.md): + Update URL(s) of the content currently being played. + - [`isLive`](../api/Content_Information/isLive.md): Returns `true` if the content is a "live" content. diff --git a/src/core/api/public_api.ts b/src/core/api/public_api.ts index 096c937d98..73fbe07b03 100644 --- a/src/core/api/public_api.ts +++ b/src/core/api/public_api.ts @@ -777,6 +777,7 @@ class Player extends EventEmitter { contentId: generateContentId(), originalUrl: url, currentContentCanceller, + initializer, isDirectFile, segmentBuffersStore: null, thumbnails: null, @@ -1129,6 +1130,20 @@ class Player extends EventEmitter { return undefined; } + /** + * Update URL of the content currently being played (e.g. DASH's MPD). + * @param {Array.|undefined} urls - URLs to reach that content / + * Manifest from the most prioritized URL to the least prioritized URL. + * @param {boolean} refreshNow - If `true` the resource in question (e.g. + * DASH's MPD) will be refreshed immediately. + */ + public updateContentUrls(urls : string[] | undefined, refreshNow : boolean) : void { + if (this._priv_contentInfos === null) { + throw new Error("No content loaded"); + } + this._priv_contentInfos.initializer.updateContentUrls(urls, refreshNow); + } + /** * Returns the video duration, in seconds. * NaN if no video is playing. @@ -2851,6 +2866,8 @@ interface IPublicApiContentInfos { contentId : string; /** Original URL set to load the content. */ originalUrl : string | undefined; + /** `ContentInitializer` used to load the content. */ + initializer : ContentInitializer; /** TaskCanceller triggered when it's time to stop the current content. */ currentContentCanceller : TaskCanceller; /** diff --git a/src/core/fetchers/manifest/manifest_fetcher.ts b/src/core/fetchers/manifest/manifest_fetcher.ts index 58fc6f8785..2de0c8433f 100644 --- a/src/core/fetchers/manifest/manifest_fetcher.ts +++ b/src/core/fetchers/manifest/manifest_fetcher.ts @@ -79,6 +79,11 @@ export default class ManifestFetcher extends EventEmitter private _isRefreshPending; /** Number of consecutive times the Manifest parsing has been done in `unsafeMode`. */ private _consecutiveUnsafeMode; + /** + * If set to a string or `undefined`, the given URL should be prioritized on + * the next Manifest fetching operation, it can then be reset to `null`. + */ + private _prioritizedContentUrl : string | undefined | null; /** * Construct a new ManifestFetcher. @@ -104,6 +109,7 @@ export default class ManifestFetcher extends EventEmitter this._isStarted = false; this._isRefreshPending = false; this._consecutiveUnsafeMode = 0; + this._prioritizedContentUrl = null; } /** @@ -156,6 +162,24 @@ export default class ManifestFetcher extends EventEmitter .catch((err : unknown) => this._onFatalError(err)); } + /** + * Update URL of the fetched Manifest. + * @param {Array. | undefined} urls - New Manifest URLs by order of + * priority or `undefined` if there's now no URL. + * @param {boolean} refreshNow - If set to `true`, the next Manifest refresh + * will be triggered immediately. + */ + public updateContentUrls(urls : string[] | undefined, refreshNow : boolean) : void { + this._prioritizedContentUrl = urls?.[0] ?? undefined; + if (refreshNow) { + this.scheduleManualRefresh({ + enablePartialRefresh: false, + delay: 0, + canUseUnsafeMode: false, + }); + } + } + /** * (re-)Load the Manifest. * This method does not yet parse it, parsing will then be available through @@ -560,9 +584,17 @@ export default class ManifestFetcher extends EventEmitter unsafeMode : boolean; } ) { const manifestUpdateUrl = manifest.updateUrl; - const fullRefresh = !enablePartialRefresh || manifestUpdateUrl === undefined; - const refreshURL = fullRefresh ? manifest.getUrl() : - manifestUpdateUrl; + let fullRefresh : boolean; + let refreshURL : string | undefined; + if (this._prioritizedContentUrl !== null) { + fullRefresh = true; + refreshURL = this._prioritizedContentUrl; + this._prioritizedContentUrl = null; + } else { + fullRefresh = !enablePartialRefresh || manifestUpdateUrl === undefined; + refreshURL = fullRefresh ? manifest.getUrl() : + manifestUpdateUrl; + } const externalClockOffset = manifest.clockOffset; if (unsafeMode) { diff --git a/src/core/init/directfile_content_initializer.ts b/src/core/init/directfile_content_initializer.ts index d9938a8f1b..ceb1e4ad8e 100644 --- a/src/core/init/directfile_content_initializer.ts +++ b/src/core/init/directfile_content_initializer.ts @@ -123,6 +123,10 @@ export default class DirectFileContentInitializer extends ContentInitializer { }, { emitCurrentValue: true, clearSignal: cancelSignal }); } + public updateContentUrls(_urls : string[] | undefined, _refreshNow : boolean) : void { + throw new Error("Cannot update content URL of directfile contents"); + } + public dispose(): void { this._initCanceller.cancel(); } diff --git a/src/core/init/media_source_content_initializer.ts b/src/core/init/media_source_content_initializer.ts index fd21928fa9..d5edb9d37e 100644 --- a/src/core/init/media_source_content_initializer.ts +++ b/src/core/init/media_source_content_initializer.ts @@ -173,6 +173,17 @@ export default class MediaSourceContentInitializer extends ContentInitializer { }); } + /** + * Update URL of the Manifest. + * @param {Array.|undefined} urls - URLs to reach that Manifest from + * the most prioritized URL to the least prioritized URL. + * @param {boolean} refreshNow - If `true` the resource in question (e.g. + * DASH's MPD) will be refreshed immediately. + */ + public updateContentUrls(urls : string[] | undefined, refreshNow : boolean) : void { + this._manifestFetcher.updateContentUrls(urls, refreshNow); + } + public dispose(): void { this._initCanceller.cancel(); } diff --git a/src/core/init/types.ts b/src/core/init/types.ts index bd29f41f0e..a233019ad8 100644 --- a/src/core/init/types.ts +++ b/src/core/init/types.ts @@ -83,6 +83,18 @@ export abstract class ContentInitializer extends EventEmitter|undefined} urls - URLs to reach that content / + * Manifest from the most prioritized URL to the least prioritized URL. + * @param {boolean} refreshNow - If `true` the resource in question (e.g. + * DASH's MPD) will be refreshed immediately. + */ + public abstract updateContentUrls( + urls : string[] | undefined, + refreshNow : boolean + ) : void; + /** * Stop playing the content linked to this `ContentInitializer` on the * `HTMLMediaElement` linked to it and dispose of every resources taken while From c14b8af284cd002fc2eac330169e1381a1830d47 Mon Sep 17 00:00:00 2001 From: Paul Berberian Date: Thu, 1 Dec 2022 12:11:21 +0100 Subject: [PATCH 16/86] Better detect closed captions and add integration tests for forced subtitles --- .../dash/common/parse_adaptation_sets.ts | 41 +++-- .../forced-subtitles.js | 23 +++ .../DASH_static_SegmentTimeline/index.js | 8 +- .../media/forced-subtitles.mpd | 133 ++++++++++++++++ .../DASH_static_SegmentTimeline/urls.js | 5 + .../scenarios/dash_forced-subtitles.js | 148 ++++++++++++++++++ 6 files changed, 342 insertions(+), 16 deletions(-) create mode 100644 tests/contents/DASH_static_SegmentTimeline/forced-subtitles.js create mode 100644 tests/contents/DASH_static_SegmentTimeline/media/forced-subtitles.mpd create mode 100644 tests/integration/scenarios/dash_forced-subtitles.js diff --git a/src/parsers/manifest/dash/common/parse_adaptation_sets.ts b/src/parsers/manifest/dash/common/parse_adaptation_sets.ts index 5dd553045d..29af2c504c 100644 --- a/src/parsers/manifest/dash/common/parse_adaptation_sets.ts +++ b/src/parsers/manifest/dash/common/parse_adaptation_sets.ts @@ -107,21 +107,36 @@ function isVisuallyImpaired( /** * Detect if the accessibility given defines an adaptation for the hard of * hearing. - * Based on DVB Document A168 (DVB-DASH). - * @param {Object} accessibility + * Based on DVB Document A168 (DVB-DASH) and DASH specification. + * @param {Array.} accessibilities + * @param {Array.} roles * @returns {Boolean} */ -function isHardOfHearing( - accessibility : { schemeIdUri? : string | undefined; - value? : string | undefined; } | - undefined +function isCaptionning( + accessibilities : Array<{ schemeIdUri? : string | undefined; + value? : string | undefined; }> | + undefined, + roles : Array<{ schemeIdUri? : string | undefined; + value? : string | undefined; }> | + undefined ) : boolean { - if (accessibility === undefined) { - return false; + if (accessibilities !== undefined) { + const hasDvbClosedCaptionSignaling = accessibilities.some(accessibility => + (accessibility.schemeIdUri === "urn:tva:metadata:cs:AudioPurposeCS:2007" && + accessibility.value === "2")); + if (hasDvbClosedCaptionSignaling) { + return true; + } } - - return (accessibility.schemeIdUri === "urn:tva:metadata:cs:AudioPurposeCS:2007" && - accessibility.value === "2"); + if (roles !== undefined) { + const hasDashCaptionSinaling = roles.some(role => + (role.schemeIdUri === "urn:mpeg:dash:role:2011" && + role.value === "caption")); + if (hasDashCaptionSinaling) { + return true; + } + } + return false; } /** @@ -368,8 +383,8 @@ export default function parseAdaptationSets( let isClosedCaption; if (type !== "text") { isClosedCaption = false; - } else if (accessibilities !== undefined) { - isClosedCaption = accessibilities.some(isHardOfHearing); + } else { + isClosedCaption = isCaptionning(accessibilities, roles); } let isForcedSubtitle; diff --git a/tests/contents/DASH_static_SegmentTimeline/forced-subtitles.js b/tests/contents/DASH_static_SegmentTimeline/forced-subtitles.js new file mode 100644 index 0000000000..f5eecb24d4 --- /dev/null +++ b/tests/contents/DASH_static_SegmentTimeline/forced-subtitles.js @@ -0,0 +1,23 @@ +const BASE_URL = "http://" + + /* eslint-disable no-undef */ + __TEST_CONTENT_SERVER__.URL + ":" + + __TEST_CONTENT_SERVER__.PORT + + /* eslint-enable no-undef */ + "/DASH_static_SegmentTimeline/media/"; +export default { + url: BASE_URL + "forced-subtitles.mpd", + transport: "dash", + isDynamic: false, + isLive: false, + minimumPosition: 0, + maximumPosition: 101.568367, + duration: 101.568367, + availabilityStartTime: 0, + + /** + * We don't care about that for now. As this content is only tested for track + * preferences. + * TODO still add it to our list of commonly tested contents? + */ + periods: [], +}; diff --git a/tests/contents/DASH_static_SegmentTimeline/index.js b/tests/contents/DASH_static_SegmentTimeline/index.js index f581990211..c9c0ccb3d3 100644 --- a/tests/contents/DASH_static_SegmentTimeline/index.js +++ b/tests/contents/DASH_static_SegmentTimeline/index.js @@ -1,19 +1,20 @@ import manifestInfos from "./infos.js"; -import trickModeInfos from "./trickmode.js"; import discontinuityInfos from "./discontinuity.js"; +import forcedSubtitles from "./forced-subtitles.js"; import multiAdaptationSetsInfos from "./multi-AdaptationSets.js"; import multiPeriodDifferentChoicesInfos from "./multi_period_different_choices"; import multiPeriodSameChoicesInfos from "./multi_period_same_choices"; import notStartingAt0ManifestInfos from "./not_starting_at_0.js"; -import streamEventsInfos from "./event-stream"; import segmentTemplateInheritanceASRep from "./segment_template_inheritance_as_rep"; import segmentTemplateInheritancePeriodAS from "./segment_template_inheritance_period_as"; import segmentTimelineEndNumber from "./segment_timeline_end_number"; +import streamEventsInfos from "./event-stream"; +import trickModeInfos from "./trickmode.js"; export { manifestInfos, - trickModeInfos, discontinuityInfos, + forcedSubtitles, multiAdaptationSetsInfos, multiPeriodDifferentChoicesInfos, multiPeriodSameChoicesInfos, @@ -22,4 +23,5 @@ export { segmentTemplateInheritancePeriodAS, segmentTimelineEndNumber, streamEventsInfos, + trickModeInfos, }; diff --git a/tests/contents/DASH_static_SegmentTimeline/media/forced-subtitles.mpd b/tests/contents/DASH_static_SegmentTimeline/media/forced-subtitles.mpd new file mode 100644 index 0000000000..8373f1b7a5 --- /dev/null +++ b/tests/contents/DASH_static_SegmentTimeline/media/forced-subtitles.mpd @@ -0,0 +1,133 @@ + + + + + dash/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/contents/DASH_static_SegmentTimeline/urls.js b/tests/contents/DASH_static_SegmentTimeline/urls.js index b65753d789..3278dd4382 100644 --- a/tests/contents/DASH_static_SegmentTimeline/urls.js +++ b/tests/contents/DASH_static_SegmentTimeline/urls.js @@ -91,6 +91,11 @@ module.exports = [ path: path.join(__dirname, "media/multi-AdaptationSets.mpd"), contentType: "application/dash+xml", }, + { + url: BASE_URL + "forced-subtitles.mpd", + path: path.join(__dirname, "media/forced-subtitles.mpd"), + contentType: "application/dash+xml", + }, { url: BASE_URL + "event-streams.mpd", path: path.join(__dirname, "media/event-streams.mpd"), diff --git a/tests/integration/scenarios/dash_forced-subtitles.js b/tests/integration/scenarios/dash_forced-subtitles.js new file mode 100644 index 0000000000..e162ce5e46 --- /dev/null +++ b/tests/integration/scenarios/dash_forced-subtitles.js @@ -0,0 +1,148 @@ +import { expect } from "chai"; +import RxPlayer from "../../../src"; +import { + forcedSubtitles, +} from "../../contents/DASH_static_SegmentTimeline"; +import XHRMock from "../../utils/request_mock"; +import { + waitForLoadedStateAfterLoadVideo, +} from "../../utils/waitForPlayerState"; + +describe("DASH forced-subtitles content (SegmentTimeline)", function () { + let player; + let xhrMock; + + async function loadContent() { + player.loadVideo({ url: forcedSubtitles.url, + transport: forcedSubtitles.transport }); + await waitForLoadedStateAfterLoadVideo(player); + } + + function checkNoTextTrack() { + const currentTextTrack = player.getTextTrack(); + expect(currentTextTrack).to.equal(null); + } + + function checkAudioTrack(language, normalizedLanguage, isAudioDescription) { + const currentAudioTrack = player.getAudioTrack(); + expect(currentAudioTrack).to.not.equal(null); + expect(currentAudioTrack.language).to.equal(language); + expect(currentAudioTrack.normalized).to.equal(normalizedLanguage); + expect(currentAudioTrack.audioDescription).to.equal(isAudioDescription); + } + + function checkTextTrack(language, normalizedLanguage, props) { + const currentTextTrack = player.getTextTrack(); + expect(currentTextTrack).to.not.equal(null); + expect(currentTextTrack.language).to.equal(language); + expect(currentTextTrack.normalized).to.equal(normalizedLanguage); + expect(currentTextTrack.closedCaption).to.equal( + props.closedCaption, + `"closedCaption" not set to "${props.closedCaption}" but ` + + `to "${currentTextTrack.closedCaption}"`); + expect(currentTextTrack.forced).to.equal( + props.forced, + `"forced" not set to "${props.forced}" but ` + + `to "${currentTextTrack.forced}"`); + } + + beforeEach(() => { + player = new RxPlayer(); + player.setWantedBufferAhead(5); // We don't really care + xhrMock = new XHRMock(); + }); + + afterEach(() => { + player.dispose(); + xhrMock.restore(); + }); + + it("should set the forced text track associated to the current audio track", async function () { + player.dispose(); + player = new RxPlayer({ + preferredAudioTracks: [{ + language: "fr", + audioDescription: false, + }], + }); + + await loadContent(); + checkAudioTrack("fr", "fra", false); + checkTextTrack("fr", "fra", { closedCaption: false, forced: true }); + + player.setPreferredAudioTracks([{ language: "de", audioDescription: false }]); + await loadContent(); + checkAudioTrack("de", "deu", false); + checkTextTrack("de", "deu", { closedCaption: false, forced: true }); + }); + + it("should set the forced text track associated to no language if none is linked to the audio track", async function () { + player.dispose(); + player = new RxPlayer({ + preferredAudioTracks: [{ + language: "en", + audioDescription: false, + }], + }); + + await loadContent(); + checkAudioTrack("en", "eng", false); + checkTextTrack("", "", { + closedCaption: false, + forced: true, + }); + }); + + it("should still prefer preferences over forced subtitles", async function () { + player.dispose(); + player = new RxPlayer({ + preferredAudioTracks: [{ + language: "fr", + audioDescription: false, + }], + preferredTextTracks: [{ + language: "fr", + closedCaption: false, + }], + }); + + await loadContent(); + checkAudioTrack("fr", "fra", false); + checkTextTrack("fr", "fra", { closedCaption: false, forced: undefined }); + + player.setPreferredTextTracks([{ language: "fr", closedCaption: true }]); + await loadContent(); + checkAudioTrack("fr", "fra", false); + checkTextTrack("fr", "fra", { closedCaption: true, forced: undefined }); + + player.setPreferredAudioTracks([{ language: "de", audioDescription: undefined }]); + await loadContent(); + checkAudioTrack("de", "deu", false); + checkTextTrack("fr", "fra", { closedCaption: true, forced: undefined }); + + player.setPreferredTextTracks([null]); + await loadContent(); + checkNoTextTrack(); + }); + + it("should fallback to forced subtitles if no preference match", async function () { + player.dispose(); + player = new RxPlayer({ + preferredAudioTracks: [{ + language: "fr", + audioDescription: false, + }], + preferredTextTracks: [{ + language: "swa", + closedCaption: false, + }, { + language: "de", + closedCaption: true, + }], + }); + + await loadContent(); + checkAudioTrack("fr", "fra", false); + checkTextTrack("fr", "fra", { closedCaption: false, forced: true }); + }); +}); From efd4420204becc6041b43345decd9f828223b4a4 Mon Sep 17 00:00:00 2001 From: Paul Berberian Date: Wed, 21 Dec 2022 14:58:49 +0100 Subject: [PATCH 17/86] Set second argument of updateContentUrls as an object --- doc/api/Content_Information/updateContentUrls.md | 13 +++++++++---- src/core/api/public_api.ts | 11 ++++++++--- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/doc/api/Content_Information/updateContentUrls.md b/doc/api/Content_Information/updateContentUrls.md index 7b3bb3f80a..a8c9ae5c7e 100644 --- a/doc/api/Content_Information/updateContentUrls.md +++ b/doc/api/Content_Information/updateContentUrls.md @@ -24,7 +24,7 @@ this method has no effect. ```js player.updateContentUrls(urls); // or -player.updateContentUrls(urls, refreshNow); +player.updateContentUrls(urls, params); ``` - **arguments**: @@ -32,8 +32,13 @@ player.updateContentUrls(urls, refreshNow); 1. _urls_ `Array.|under`: URLs to reach that content / Manifest from the most prioritized URL to the least prioritized URL. - 2. _refreshNow_ `boolean`: If `true` the resource in question (e.g. - DASH's MPD) will be refreshed immediately. + 2. _params_ `Object|undefined`: Optional parameters linked to this URL + change. + + Can contain the following properties: + + - _refresh_ `boolean`: If `true` the resource in question (e.g. + DASH's MPD) will be refreshed immediately. ## Examples @@ -52,5 +57,5 @@ player.updateContentUrls([ player.updateContentUrls(undefined); // Update and ask to refresh immediately -player.updateContentUrls(["http://my.new.url"], true); +player.updateContentUrls(["http://my.new.url"], { refresh: true }); ``` diff --git a/src/core/api/public_api.ts b/src/core/api/public_api.ts index 73fbe07b03..a345941727 100644 --- a/src/core/api/public_api.ts +++ b/src/core/api/public_api.ts @@ -1134,13 +1134,18 @@ class Player extends EventEmitter { * Update URL of the content currently being played (e.g. DASH's MPD). * @param {Array.|undefined} urls - URLs to reach that content / * Manifest from the most prioritized URL to the least prioritized URL. - * @param {boolean} refreshNow - If `true` the resource in question (e.g. - * DASH's MPD) will be refreshed immediately. + * @param {Object|undefined} [params] + * @param {boolean} params.refresh - If `true` the resource in question + * (e.g. DASH's MPD) will be refreshed immediately. */ - public updateContentUrls(urls : string[] | undefined, refreshNow : boolean) : void { + public updateContentUrls( + urls : string[] | undefined, + params? : { refresh?: boolean } | undefined + ) : void { if (this._priv_contentInfos === null) { throw new Error("No content loaded"); } + const refreshNow = params?.refresh === true; this._priv_contentInfos.initializer.updateContentUrls(urls, refreshNow); } From 78585503f47e65686e5518ceab9bfff239cea861 Mon Sep 17 00:00:00 2001 From: Paul Berberian Date: Tue, 20 Dec 2022 11:28:26 +0100 Subject: [PATCH 18/86] Remove RxJS code from the Stream modules --- src/compat/__tests__/set_element_src.test.ts | 71 -- .../__tests__/when_loaded_metadata.test.ts | 109 --- src/compat/index.ts | 2 - src/compat/set_element_src.ts | 47 -- src/compat/when_loaded_metadata.ts | 40 - src/core/api/public_api.ts | 20 +- .../init/media_source_content_initializer.ts | 388 +++++---- src/core/init/types.ts | 26 +- .../utils/content_time_boundaries_observer.ts | 423 ++++++++-- src/core/init/utils/rebuffering_controller.ts | 10 + .../stream/adaptation/adaptation_stream.ts | 589 ++++++-------- src/core/stream/adaptation/index.ts | 14 +- src/core/stream/adaptation/types.ts | 193 +++++ .../create_representation_estimator.ts | 14 +- src/core/stream/events_generators.ts | 211 ----- src/core/stream/index.ts | 10 +- .../orchestrator/active_period_emitter.ts | 146 ---- .../orchestrator/are_streams_complete.ts | 78 -- src/core/stream/orchestrator/index.ts | 2 + .../orchestrator/stream_orchestrator.ts | 746 ++++++++++-------- .../period/create_empty_adaptation_stream.ts | 71 -- src/core/stream/period/index.ts | 14 +- src/core/stream/period/period_stream.ts | 517 ++++++------ src/core/stream/period/types.ts | 131 +++ .../get_adaptation_switch_strategy.ts | 12 +- src/core/stream/reload_after_switch.ts | 76 -- src/core/stream/representation/README.md | 20 - .../representation/downloading_queue.ts | 632 --------------- src/core/stream/representation/index.ts | 14 +- .../representation/push_init_segment.ts | 87 -- .../representation/push_media_segment.ts | 125 --- .../representation/representation_stream.ts | 729 ++++++++--------- src/core/stream/representation/types.ts | 292 +++++++ .../{ => utils}/append_segment_to_buffer.ts | 16 +- .../{ => utils}/check_for_discontinuity.ts | 6 +- .../representation/utils/downloading_queue.ts | 592 ++++++++++++++ .../{ => utils}/force_garbage_collection.ts | 11 +- .../{ => utils}/get_buffer_status.ts | 10 +- .../{ => utils}/get_needed_segments.ts | 13 +- .../{ => utils}/get_segment_priority.ts | 3 +- .../representation/utils/push_init_segment.ts | 80 ++ .../utils/push_media_segment.ts | 113 +++ src/core/stream/types.ts | 554 ------------- .../__tests__/defer_subscriptions.test.ts | 50 -- src/utils/defer_subscriptions.ts | 96 --- src/utils/sorted_list.ts | 4 + .../scenarios/dash_multi_periods.js | 1 - tests/integration/scenarios/end_number.js | 11 +- tests/memory/index.js | 2 +- 49 files changed, 3387 insertions(+), 4034 deletions(-) delete mode 100644 src/compat/__tests__/set_element_src.test.ts delete mode 100644 src/compat/__tests__/when_loaded_metadata.test.ts delete mode 100644 src/compat/set_element_src.ts delete mode 100644 src/compat/when_loaded_metadata.ts create mode 100644 src/core/stream/adaptation/types.ts rename src/core/stream/adaptation/{ => utils}/create_representation_estimator.ts (92%) delete mode 100644 src/core/stream/events_generators.ts delete mode 100644 src/core/stream/orchestrator/active_period_emitter.ts delete mode 100644 src/core/stream/orchestrator/are_streams_complete.ts delete mode 100644 src/core/stream/period/create_empty_adaptation_stream.ts create mode 100644 src/core/stream/period/types.ts rename src/core/stream/period/{ => utils}/get_adaptation_switch_strategy.ts (96%) delete mode 100644 src/core/stream/reload_after_switch.ts delete mode 100644 src/core/stream/representation/downloading_queue.ts delete mode 100644 src/core/stream/representation/push_init_segment.ts delete mode 100644 src/core/stream/representation/push_media_segment.ts create mode 100644 src/core/stream/representation/types.ts rename src/core/stream/representation/{ => utils}/append_segment_to_buffer.ts (80%) rename src/core/stream/representation/{ => utils}/check_for_discontinuity.ts (98%) create mode 100644 src/core/stream/representation/utils/downloading_queue.ts rename src/core/stream/representation/{ => utils}/force_garbage_collection.ts (93%) rename src/core/stream/representation/{ => utils}/get_buffer_status.ts (98%) rename src/core/stream/representation/{ => utils}/get_needed_segments.ts (98%) rename src/core/stream/representation/{ => utils}/get_segment_priority.ts (97%) create mode 100644 src/core/stream/representation/utils/push_init_segment.ts create mode 100644 src/core/stream/representation/utils/push_media_segment.ts delete mode 100644 src/core/stream/types.ts delete mode 100644 src/utils/__tests__/defer_subscriptions.test.ts delete mode 100644 src/utils/defer_subscriptions.ts diff --git a/src/compat/__tests__/set_element_src.test.ts b/src/compat/__tests__/set_element_src.test.ts deleted file mode 100644 index f6a455ce07..0000000000 --- a/src/compat/__tests__/set_element_src.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -/** - * 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. - */ - -/* eslint-disable @typescript-eslint/no-unsafe-assignment */ -/* eslint-disable @typescript-eslint/no-unsafe-member-access */ -/* eslint-disable @typescript-eslint/no-var-requires */ -/* eslint-disable @typescript-eslint/no-unsafe-call */ -/* eslint-disable @typescript-eslint/no-unsafe-assignment */ -/* eslint-disable @typescript-eslint/no-unsafe-return */ - -import { map } from "rxjs"; - -describe("compat - setElementSrc", () => { - beforeEach(() => { - jest.resetModules(); - }); - - it("should set element src and clear it when unsubscribe", (done) => { - const fakeMediaElement = { - src: "", - removeAttribute: () => null, - }; - - const mockLogInfo = jest.fn((message) => message); - jest.mock("../../log", () => ({ - __esModule: true as const, - default: { - info: mockLogInfo, - }, - })); - const mockClearElementSrc = jest.fn(() => { - fakeMediaElement.src = ""; - }); - jest.mock("../clear_element_src", () => ({ - __esModule: true as const, - default: mockClearElementSrc, - })); - const fakeURL = "blob:http://fakeURL"; - - const setElementSrc = jest.requireActual("../set_element_src").default; - - const setElementSrc$ = setElementSrc(fakeMediaElement, fakeURL); - const subscribe = setElementSrc$.pipe( - map(() => { - expect(mockLogInfo).toHaveBeenCalledTimes(1); - expect(mockLogInfo) - .toHaveBeenCalledWith("Setting URL to HTMLMediaElement", fakeURL); - expect(fakeMediaElement.src).toBe(fakeURL); - }) - ).subscribe(); - - setTimeout(() => { - subscribe.unsubscribe(); - expect(fakeMediaElement.src).toBe(""); - done(); - }, 200); - }); -}); diff --git a/src/compat/__tests__/when_loaded_metadata.test.ts b/src/compat/__tests__/when_loaded_metadata.test.ts deleted file mode 100644 index 0cdfbd457f..0000000000 --- a/src/compat/__tests__/when_loaded_metadata.test.ts +++ /dev/null @@ -1,109 +0,0 @@ -/** - * 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. - */ - -/* eslint-disable @typescript-eslint/no-unsafe-assignment */ -/* eslint-disable @typescript-eslint/no-unsafe-member-access */ -/* eslint-disable @typescript-eslint/no-var-requires */ -/* eslint-disable @typescript-eslint/no-unsafe-call */ -/* eslint-disable @typescript-eslint/no-unsafe-assignment */ -/* eslint-disable @typescript-eslint/no-unsafe-return */ -/* eslint-disable @typescript-eslint/no-unsafe-argument */ - -import { - finalize, - interval as observableInterval, - mapTo, - tap, - timer as observableTimer, -} from "rxjs"; - -describe("compat - whenLoadedMetadata$", () => { - beforeEach(() => { - jest.resetModules(); - }); - - it("should wait for loaded metadata if they are not yet loaded", (done) => { - const fakeMediaElement = { - readyState: 0, - }; - - const mockOnLoadedMetadata$ = jest.fn(() => { - return observableTimer(500).pipe( - tap(() => fakeMediaElement.readyState = 1), - mapTo(null) - ); - }); - - jest.mock("../event_listeners", () => ({ - __esModule: true as const, - onLoadedMetadata$: mockOnLoadedMetadata$, - })); - - const whenLoadedMetadata$ = jest.requireActual("../when_loaded_metadata").default; - whenLoadedMetadata$(fakeMediaElement).subscribe(() => { - expect(fakeMediaElement.readyState).toBe(1); - expect(mockOnLoadedMetadata$).toHaveBeenCalledTimes(1); - done(); - }); - }); - - it("should emit once when metadata is loaded several times", (done) => { - const fakeMediaElement = { - readyState: 0, - }; - - const mockOnLoadedMetadata$ = jest.fn(() => { - return observableInterval(500).pipe( - tap(() => fakeMediaElement.readyState++), - mapTo(null) - ); - }); - - jest.mock("../event_listeners", () => ({ - __esModule: true as const, - onLoadedMetadata$: mockOnLoadedMetadata$, - })); - - const whenLoadedMetadata$ = jest.requireActual("../when_loaded_metadata").default; - whenLoadedMetadata$(fakeMediaElement).pipe( - finalize(() => { - expect(fakeMediaElement.readyState).toBe(1); - expect(mockOnLoadedMetadata$).toHaveBeenCalledTimes(1); - done(); - }) - ).subscribe(); - }); - - it("should emit if metadata is already loaded", (done) => { - const fakeMediaElement = { - readyState: 1, - }; - - const mockOnLoadedMetadata$ = jest.fn(() => null); - - jest.mock("../event_listeners", () => ({ - __esModule: true as const, - onLoadedMetadata$: mockOnLoadedMetadata$, - })); - - const whenLoadedMetadata$ = jest.requireActual("../when_loaded_metadata").default; - whenLoadedMetadata$(fakeMediaElement).subscribe(() => { - expect(fakeMediaElement.readyState).toBe(1); - expect(mockOnLoadedMetadata$).not.toHaveBeenCalled(); - done(); - }); - }); -}); diff --git a/src/compat/index.ts b/src/compat/index.ts index b04c9bb3b6..2f644aa6ff 100644 --- a/src/compat/index.ts +++ b/src/compat/index.ts @@ -56,14 +56,12 @@ import makeVTTCue from "./make_vtt_cue"; import onHeightWidthChange from "./on_height_width_change"; import patchWebkitSourceBuffer from "./patch_webkit_source_buffer"; import play from "./play"; -import setElementSrc$ from "./set_element_src"; // eslint-disable-next-line max-len import shouldReloadMediaSourceOnDecipherabilityUpdate from "./should_reload_media_source_on_decipherability_update"; import shouldRenewMediaKeySystemAccess from "./should_renew_media_key_system_access"; import shouldUnsetMediaKeys from "./should_unset_media_keys"; import shouldValidateMetadata from "./should_validate_metadata"; import shouldWaitForDataBeforeLoaded from "./should_wait_for_data_before_loaded"; -import whenLoadedMetadata$ from "./when_loaded_metadata"; // TODO To remove. This seems to be the only side-effect done on import, which // we would prefer to disallow (both for the understandability of the code and diff --git a/src/compat/set_element_src.ts b/src/compat/set_element_src.ts deleted file mode 100644 index 0ef3e6716d..0000000000 --- a/src/compat/set_element_src.ts +++ /dev/null @@ -1,47 +0,0 @@ -/** - * 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. - */ - -import { - Observable, - Observer, -} from "rxjs"; -import log from "../log"; -import clearElementSrc from "./clear_element_src"; - -/** - * Set an URL to the element's src. - * Emit ``undefined`` when done. - * Unlink src on unsubscription. - * - * @param {HTMLMediaElement} mediaElement - * @param {string} url - * @returns {Observable} - */ -export default function setElementSrc$( - mediaElement : HTMLMediaElement, - url : string -) : Observable { - return new Observable((observer : Observer) => { - log.info("Setting URL to HTMLMediaElement", url); - - mediaElement.src = url; - - observer.next(undefined); - return () => { - clearElementSrc(mediaElement); - }; - }); -} diff --git a/src/compat/when_loaded_metadata.ts b/src/compat/when_loaded_metadata.ts deleted file mode 100644 index 2fad2cec22..0000000000 --- a/src/compat/when_loaded_metadata.ts +++ /dev/null @@ -1,40 +0,0 @@ -/** - * 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. - */ - -import { - Observable, - of as observableOf, - take, -} from "rxjs"; -import { READY_STATES } from "./browser_compatibility_types"; -import { onLoadedMetadata$ } from "./event_listeners"; - -/** - * Returns an observable emitting a single time, as soon as a seek is possible - * (the metadata are loaded). - * @param {HTMLMediaElement} mediaElement - * @returns {Observable} - */ -export default function whenLoadedMetadata$( - mediaElement : HTMLMediaElement -) : Observable { - if (mediaElement.readyState >= READY_STATES.HAVE_METADATA) { - return observableOf(null); - } else { - return onLoadedMetadata$(mediaElement) - .pipe(take(1)); - } -} diff --git a/src/core/api/public_api.ts b/src/core/api/public_api.ts index a345941727..8fb9851d59 100644 --- a/src/core/api/public_api.ts +++ b/src/core/api/public_api.ts @@ -630,16 +630,6 @@ class Player extends EventEmitter { const videoElement = this.videoElement; - /** Global "playback observer" which will emit playback conditions */ - const playbackObserver = new PlaybackObserver(videoElement, { - withMediaSource: !isDirectFile, - lowLatencyMode, - }); - - currentContentCanceller.signal.register(() => { - playbackObserver.stop(); - }); - let initializer : ContentInitializer; let mediaElementTrackChoiceManager : MediaElementTrackChoiceManager | null = @@ -885,6 +875,16 @@ class Player extends EventEmitter { // content. this.stop(); + /** Global "playback observer" which will emit playback conditions */ + const playbackObserver = new PlaybackObserver(videoElement, { + withMediaSource: !isDirectFile, + lowLatencyMode, + }); + + currentContentCanceller.signal.register(() => { + playbackObserver.stop(); + }); + // Update the RxPlayer's state at the right events const playerStateRef = constructPlayerStateReference(initializer, videoElement, diff --git a/src/core/init/media_source_content_initializer.ts b/src/core/init/media_source_content_initializer.ts index d5edb9d37e..3f68f5be88 100644 --- a/src/core/init/media_source_content_initializer.ts +++ b/src/core/init/media_source_content_initializer.ts @@ -25,7 +25,6 @@ import { } from "../../public_types"; import { ITransportPipelines } from "../../transports"; import assert from "../../utils/assert"; -import assertUnreachable from "../../utils/assert_unreachable"; import objectAssign from "../../utils/object_assign"; import createSharedReference, { IReadOnlySharedReference, @@ -39,7 +38,7 @@ import AdaptiveRepresentationSelector, { IAdaptiveRepresentationSelectorArguments, IRepresentationEstimator, } from "../adaptive"; -import { PlaybackObserver } from "../api"; +import { IReadOnlyPlaybackObserver, PlaybackObserver } from "../api"; import { getCurrentKeySystem, IContentProtection, @@ -53,9 +52,10 @@ import SegmentBuffersStore, { ITextTrackSegmentBufferOptions, } from "../segment_buffers"; import StreamOrchestrator, { - IAdaptationChangeEvent, IAudioTrackSwitchingMode, IStreamOrchestratorOptions, + IStreamOrchestratorCallbacks, + IStreamOrchestratorPlaybackObservation, } from "../stream"; import { ContentInitializer } from "./types"; import ContentTimeBoundariesObserver from "./utils/content_time_boundaries_observer"; @@ -356,6 +356,9 @@ export default class MediaSourceContentInitializer extends ContentInitializer { autoPlay : boolean; } ) : void { currentCanceller.cancel(); + if (initCanceller.isUsed) { + return; + } triggerEvent("reloadingMediaSource", null); if (initCanceller.isUsed) { return; @@ -403,12 +406,6 @@ export default class MediaSourceContentInitializer extends ContentInitializer { segmentFetcherCreator, speed } = args; - /** Maintains the MediaSource's duration up-to-date with the Manifest */ - const mediaDurationUpdater = new MediaDurationUpdater(manifest, mediaSource); - cancelSignal.register(() => { - mediaDurationUpdater.stop(); - }); - const initialPeriod = manifest.getPeriodForTime(initialTime) ?? manifest.getNextPeriod(initialTime); if (initialPeriod === undefined) { @@ -435,24 +432,6 @@ export default class MediaSourceContentInitializer extends ContentInitializer { return; } - /** - * Class trying to avoid various stalling situations, emitting "stalled" - * events when it cannot, as well as "unstalled" events when it get out of one. - */ - const rebufferingController = new RebufferingController(playbackObserver, - manifest, - speed); - rebufferingController.addEventListener("stalled", (evt) => - this.trigger("stalled", evt)); - rebufferingController.addEventListener("unstalled", () => - this.trigger("unstalled", null)); - rebufferingController.addEventListener("warning", (err) => - this.trigger("warning", err)); - cancelSignal.register(() => { - rebufferingController.destroy(); - }); - rebufferingController.start(); - initialPlayPerformed.onUpdate((isPerformed, stopListening) => { if (isPerformed) { stopListening(); @@ -473,21 +452,17 @@ export default class MediaSourceContentInitializer extends ContentInitializer { speed, startTime: initialTime }); - /** Emit each time a new Adaptation is considered by the `StreamOrchestrator`. */ - const lastAdaptationChange = createSharedReference< - IAdaptationChangeEvent | null - >(null); - - const durationRef = ContentTimeBoundariesObserver(manifest, - lastAdaptationChange, - streamObserver, - (err) => - this.trigger("warning", err), - cancelSignal); - durationRef.onUpdate((newDuration) => { - log.debug("Init: Duration has to be updated.", newDuration); - mediaDurationUpdater.updateKnownDuration(newDuration); - }, { emitCurrentValue: true, clearSignal: cancelSignal }); + const rebufferingController = this._createRebufferingController(playbackObserver, + manifest, + speed, + cancelSignal); + + const contentTimeBoundariesObserver = this + ._createContentTimeBoundariesObserver(manifest, + mediaSource, + streamObserver, + segmentBuffersStore, + cancelSignal); /** * Emit a "loaded" events once the initial play has been performed and the @@ -506,113 +481,248 @@ export default class MediaSourceContentInitializer extends ContentInitializer { }) .catch((err) => { if (cancelSignal.isCancelled) { - return; + return; // Current loading cancelled, no need to trigger the error } this._onFatalError(err); }); - let endOfStreamCanceller : TaskCanceller | null = null; + /* eslint-disable-next-line @typescript-eslint/no-this-alias */ + const self = this; + StreamOrchestrator({ manifest, initialPeriod }, + streamObserver, + representationEstimator, + segmentBuffersStore, + segmentFetcherCreator, + bufferOptions, + handleStreamOrchestratorCallbacks(), + cancelSignal); - // Creates Observable which will manage every Stream for the given Content. - const streamSub = StreamOrchestrator({ manifest, initialPeriod }, - streamObserver, - representationEstimator, - segmentBuffersStore, - segmentFetcherCreator, - bufferOptions - ).subscribe({ - next: (evt) => { - switch (evt.type) { - case "needs-buffer-flush": - playbackObserver.setCurrentTime(mediaElement.currentTime + 0.001); - break; - case "end-of-stream": - if (endOfStreamCanceller === null) { - endOfStreamCanceller = new TaskCanceller({ cancelOn: cancelSignal }); - log.debug("Init: end-of-stream order received."); - maintainEndOfStream(mediaSource, endOfStreamCanceller.signal); - } - break; - case "resume-stream": - if (endOfStreamCanceller !== null) { - log.debug("Init: resume-stream order received."); - endOfStreamCanceller.cancel(); + /** + * Returns Object handling the callbacks from a `StreamOrchestrator`, which + * are basically how it communicates about events. + * @returns {Object} + */ + function handleStreamOrchestratorCallbacks() : IStreamOrchestratorCallbacks { + return { + needsBufferFlush: () => + playbackObserver.setCurrentTime(mediaElement.currentTime + 0.001), + + streamStatusUpdate(value) { + // Announce discontinuities if found + const { period, bufferType, imminentDiscontinuity, position } = value; + rebufferingController.updateDiscontinuityInfo({ + period, + bufferType, + discontinuity: imminentDiscontinuity, + position, + }); + if (cancelSignal.isCancelled) { + return; // Previous call has stopped streams due to a side-effect + } + + // If the status for the last Period indicates that segments are all loaded + // or on the contrary that the loading resumed, announce it to the + // ContentTimeBoundariesObserver. + if (manifest.isLastPeriodKnown && + value.period.id === manifest.periods[manifest.periods.length - 1].id) + { + const hasFinishedLoadingLastPeriod = value.hasFinishedLoading || + value.isEmptyStream; + if (hasFinishedLoadingLastPeriod) { + contentTimeBoundariesObserver + .onLastSegmentFinishedLoading(value.bufferType); + } else { + contentTimeBoundariesObserver + .onLastSegmentLoadingResume(value.bufferType); } - break; - case "stream-status": - const { period, bufferType, imminentDiscontinuity, position } = evt.value; - rebufferingController.updateDiscontinuityInfo({ - period, - bufferType, - discontinuity: imminentDiscontinuity, - position, - }); - break; - case "needs-manifest-refresh": - return this._manifestFetcher.scheduleManualRefresh({ - enablePartialRefresh: true, - canUseUnsafeMode: true, - }); - case "manifest-might-be-out-of-sync": - const { OUT_OF_SYNC_MANIFEST_REFRESH_DELAY } = config.getCurrent(); - this._manifestFetcher.scheduleManualRefresh({ - enablePartialRefresh: false, - canUseUnsafeMode: false, - delay: OUT_OF_SYNC_MANIFEST_REFRESH_DELAY, - }); - return ; - case "locked-stream": - rebufferingController.onLockedStream(evt.value.bufferType, evt.value.period); - break; - case "adaptationChange": - lastAdaptationChange.setValue(evt); - this.trigger("adaptationChange", evt.value); - break; - case "inband-events": - return this.trigger("inbandEvents", evt.value); - case "warning": - return this.trigger("warning", evt.value); - case "periodStreamReady": - return this.trigger("periodStreamReady", evt.value); - case "activePeriodChanged": - return this.trigger("activePeriodChanged", evt.value); - case "periodStreamCleared": - return this.trigger("periodStreamCleared", evt.value); - case "representationChange": - return this.trigger("representationChange", evt.value); - case "bitrateEstimationChange": - return this.trigger("bitrateEstimationChange", evt.value); - case "added-segment": - return this.trigger("addedSegment", evt.value); - case "needs-media-source-reload": - onReloadOrder(evt.value); - break; - case "needs-decipherability-flush": - const keySystem = getCurrentKeySystem(mediaElement); - if (shouldReloadMediaSourceOnDecipherabilityUpdate(keySystem)) { - onReloadOrder(evt.value); + } + }, + + needsManifestRefresh: () => self._manifestFetcher.scheduleManualRefresh({ + enablePartialRefresh: true, + canUseUnsafeMode: true, + }), + + manifestMightBeOufOfSync: () => { + const { OUT_OF_SYNC_MANIFEST_REFRESH_DELAY } = config.getCurrent(); + self._manifestFetcher.scheduleManualRefresh({ + enablePartialRefresh: false, + canUseUnsafeMode: false, + delay: OUT_OF_SYNC_MANIFEST_REFRESH_DELAY, + }); + }, + + lockedStream: (value) => + rebufferingController.onLockedStream(value.bufferType, value.period), + + adaptationChange: (value) => { + self.trigger("adaptationChange", value); + if (cancelSignal.isCancelled) { + return; // Previous call has stopped streams due to a side-effect + } + contentTimeBoundariesObserver.onAdaptationChange(value.type, + value.period, + value.adaptation); + }, + + representationChange: (value) => { + self.trigger("representationChange", value); + if (cancelSignal.isCancelled) { + return; // Previous call has stopped streams due to a side-effect + } + contentTimeBoundariesObserver.onRepresentationChange(value.type, value.period); + }, + + inbandEvent: (value) => self.trigger("inbandEvents", value), + + warning: (value) => self.trigger("warning", value), + + periodStreamReady: (value) => self.trigger("periodStreamReady", value), + + periodStreamCleared: (value) => { + contentTimeBoundariesObserver.onPeriodCleared(value.type, value.period); + if (cancelSignal.isCancelled) { + return; // Previous call has stopped streams due to a side-effect + } + self.trigger("periodStreamCleared", value); + }, + + bitrateEstimationChange: (value) => + self.trigger("bitrateEstimationChange", value), + + addedSegment: (value) => self.trigger("addedSegment", value), + + needsMediaSourceReload: (value) => onReloadOrder(value), + + needsDecipherabilityFlush(value) { + const keySystem = getCurrentKeySystem(mediaElement); + if (shouldReloadMediaSourceOnDecipherabilityUpdate(keySystem)) { + onReloadOrder(value); + } else { + // simple seek close to the current position + // to flush the buffers + if (value.position + 0.001 < value.duration) { + playbackObserver.setCurrentTime(mediaElement.currentTime + 0.001); } else { - // simple seek close to the current position - // to flush the buffers - if (evt.value.position + 0.001 < evt.value.duration) { - playbackObserver.setCurrentTime(mediaElement.currentTime + 0.001); - } else { - playbackObserver.setCurrentTime(evt.value.position); - } + playbackObserver.setCurrentTime(value.position); } - break; - case "encryption-data-encountered": - protectionRef.setValue(evt.value); - break; - default: - assertUnreachable(evt); - } - }, - error: (err) => this._onFatalError(err), + } + }, + + encryptionDataEncountered: (value) => { + for (const protectionData of value) { + protectionRef.setValue(protectionData); + if (cancelSignal.isCancelled) { + return; // Previous call has stopped streams due to a side-effect + } + } + }, + + error: (err) => self._onFatalError(err), + }; + } + } + + /** + * Creates a `ContentTimeBoundariesObserver`, a class indicating various + * events related to media time (such as duration updates, period changes, + * warnings about being out of the Manifest time boundaries or "endOfStream" + * management), handle those events and returns the class. + * + * Various methods from that class need then to be called at various events + * (see `ContentTimeBoundariesObserver`). + * @param {Object} manifest + * @param {MediaSource} mediaSource + * @param {Object} streamObserver + * @param {Object} segmentBuffersStore + * @param {Object} cancelSignal + * @returns {Object} + */ + private _createContentTimeBoundariesObserver( + manifest : Manifest, + mediaSource : MediaSource, + streamObserver : IReadOnlyPlaybackObserver, + segmentBuffersStore : SegmentBuffersStore, + cancelSignal : CancellationSignal + ) : ContentTimeBoundariesObserver { + /** Maintains the MediaSource's duration up-to-date with the Manifest */ + const mediaDurationUpdater = new MediaDurationUpdater(manifest, mediaSource); + cancelSignal.register(() => { + mediaDurationUpdater.stop(); }); + /** Allows to cancel a pending `end-of-stream` operation. */ + let endOfStreamCanceller : TaskCanceller | null = null; + const contentTimeBoundariesObserver = new ContentTimeBoundariesObserver( + manifest, + streamObserver, + segmentBuffersStore.getBufferTypes() + ); cancelSignal.register(() => { - streamSub.unsubscribe(); + contentTimeBoundariesObserver.dispose(); + }); + contentTimeBoundariesObserver.addEventListener("warning", (err) => + this.trigger("warning", err)); + contentTimeBoundariesObserver.addEventListener("periodChange", (period) => { + this.trigger("activePeriodChanged", { period }); + }); + contentTimeBoundariesObserver.addEventListener("durationUpdate", (newDuration) => { + log.debug("Init: Duration has to be updated.", newDuration); + mediaDurationUpdater.updateKnownDuration(newDuration); }); + contentTimeBoundariesObserver.addEventListener("endOfStream", () => { + if (endOfStreamCanceller === null) { + endOfStreamCanceller = new TaskCanceller({ cancelOn: cancelSignal }); + log.debug("Init: end-of-stream order received."); + maintainEndOfStream(mediaSource, endOfStreamCanceller.signal); + } + }); + contentTimeBoundariesObserver.addEventListener("resumeStream", () => { + if (endOfStreamCanceller !== null) { + log.debug("Init: resume-stream order received."); + endOfStreamCanceller.cancel(); + endOfStreamCanceller = null; + } + }); + return contentTimeBoundariesObserver; + } + + /** + * Creates a `RebufferingController`, a class trying to avoid various stalling + * situations (such as rebuffering periods), and returns it. + * + * Various methods from that class need then to be called at various events + * (see `RebufferingController` definition). + * + * This function also handles the `RebufferingController`'s events: + * - emit "stalled" events when stalling situations cannot be prevented, + * - emit "unstalled" events when we could get out of one, + * - emit "warning" on various rebuffering-related minor issues + * like discontinuity skipping. + * @param {Object} playbackObserver + * @param {Object} manifest + * @param {Object} speed + * @param {Object} cancelSignal + * @returns {Object} + */ + private _createRebufferingController( + playbackObserver : PlaybackObserver, + manifest : Manifest, + speed : IReadOnlySharedReference, + cancelSignal : CancellationSignal + ) : RebufferingController { + const rebufferingController = new RebufferingController(playbackObserver, + manifest, + speed); + // Bubble-up events + rebufferingController.addEventListener("stalled", + (evt) => this.trigger("stalled", evt)); + rebufferingController.addEventListener("unstalled", + () => this.trigger("unstalled", null)); + rebufferingController.addEventListener("warning", + (err) => this.trigger("warning", err)); + cancelSignal.register(() => rebufferingController.destroy()); + rebufferingController.start(); + return rebufferingController; } } @@ -699,6 +809,8 @@ interface IBufferingMediaSettings { protectionRef : ISharedReference; /** `MediaSource` element on which the media will be buffered. */ mediaSource : MediaSource; + /** The initial position to seek to in media time, in seconds. */ initialTime : number; + /** If `true` it should automatically play once enough data is loaded. */ autoPlay : boolean; } diff --git a/src/core/init/types.ts b/src/core/init/types.ts index a233019ad8..ffb3dfc87d 100644 --- a/src/core/init/types.ts +++ b/src/core/init/types.ts @@ -190,18 +190,7 @@ export interface IContentInitializerEvents { period : Period; }; /** Emitted when a new `Adaptation` is being considered. */ - adaptationChange: { - /** The type of buffer for which the Representation is changing. */ - type : IBufferType; - /** The `Period` linked to the `RepresentationStream` we're creating. */ - period : Period; - /** - * The `Adaptation` linked to the `AdaptationStream` we're creating. - * `null` when we're choosing no Adaptation at all. - */ - adaptation : Adaptation | - null; - }; + adaptationChange: IAdaptationChangeEventPayload; /** Emitted as new bitrate estimates are done. */ bitrateEstimationChange: { /** The type of buffer for which the estimation is done. */ @@ -245,6 +234,19 @@ export interface IContentInitializerEvents { inbandEvents : IInbandEvent[]; } +export interface IAdaptationChangeEventPayload { + /** The type of buffer for which the Representation is changing. */ + type : IBufferType; + /** The `Period` linked to the `RepresentationStream` we're creating. */ + period : Period; + /** + * The `Adaptation` linked to the `AdaptationStream` we're creating. + * `null` when we're choosing no Adaptation at all. + */ + adaptation : Adaptation | + null; +} + export type IStallingSituation = "seeking" | // Rebuffering after seeking "not-ready" | // Rebuffering after low ready state diff --git a/src/core/init/utils/content_time_boundaries_observer.ts b/src/core/init/utils/content_time_boundaries_observer.ts index 7ba93032b0..8929e7a548 100644 --- a/src/core/init/utils/content_time_boundaries_observer.ts +++ b/src/core/init/utils/content_time_boundaries_observer.ts @@ -18,107 +18,360 @@ import { MediaError } from "../../../errors"; import Manifest, { Adaptation, IRepresentationIndex, + Period, } from "../../../manifest"; import { IPlayerError } from "../../../public_types"; +import EventEmitter from "../../../utils/event_emitter"; import isNullOrUndefined from "../../../utils/is_null_or_undefined"; -import createSharedReference, { - IReadOnlySharedReference, -} from "../../../utils/reference"; -import { CancellationSignal } from "../../../utils/task_canceller"; +import SortedList from "../../../utils/sorted_list"; +import TaskCanceller from "../../../utils/task_canceller"; import { IReadOnlyPlaybackObserver } from "../../api"; -import { - IAdaptationChangeEvent, - IStreamOrchestratorPlaybackObservation, -} from "../../stream"; +import { IBufferType } from "../../segment_buffers"; +import { IStreamOrchestratorPlaybackObservation } from "../../stream"; /** - * Observes the position and Adaptations being played and: - * - emit warnings through the `onWarning` callback when what is being played - * is outside of the Manifest range. - * - Returns a shared reference indicating the theoretical duration of the - * content, and `undefined` if unknown. - * - * @param {Object} manifest - * @param {Object} lastAdaptationChange - * @param {Object} playbackObserver - * @param {Function} onWarning - * @param {Object} cancelSignal - * @returns {Object} + * Observes what's being played and take care of media events relating to time + * boundaries: + * - Emits a `durationUpdate` when the duration of the current content is + * known and every time it changes. + * - Emits `endOfStream` API once segments have been pushed until the end and + * `resumeStream` if downloads starts back. + * - Emits a `periodChange` event when the currently-playing Period seemed to + * have changed. + * - emit "warning" events when what is being played is outside of the + * Manifest range. + * @class ContentTimeBoundariesObserver */ -export default function ContentTimeBoundariesObserver( - manifest : Manifest, - lastAdaptationChange : IReadOnlySharedReference, - playbackObserver : IReadOnlyPlaybackObserver, - onWarning : (err : IPlayerError) => void, - cancelSignal : CancellationSignal -) : IReadOnlySharedReference { +export default class ContentTimeBoundariesObserver + extends EventEmitter { + + /** Allows to interrupt everything the `ContentTimeBoundariesObserver` is doing. */ + private _canceller : TaskCanceller; + + /** Store information on every created "Streams". */ + private _activeStreams : Map; + + /** The `Manifest` object linked to the current content. */ + private _manifest : Manifest; + + /** Allows to calculate at any time maximum positions of the content */ + private _maximumPositionCalculator : MaximumPositionCalculator; + + /** Enumerate all possible buffer types in the current content. */ + private _allBufferTypes : IBufferType[]; + + /** + * Stores the `id` property of the last Period for which a `periodChange` + * event has been sent. + * Allows to avoid multiple times in a row `periodChange` for the same + * Period. + */ + private _lastCurrentPeriodId : string | null; + + /** + * @param {Object} manifest + * @param {Object} playbackObserver + */ + constructor( + manifest : Manifest, + playbackObserver : IReadOnlyPlaybackObserver, + bufferTypes : IBufferType[] + ) { + super(); + + this._canceller = new TaskCanceller(); + this._manifest = manifest; + this._activeStreams = new Map(); + this._allBufferTypes = bufferTypes; + this._lastCurrentPeriodId = null; + + /** + * Allows to calculate the minimum and maximum playable position on the + * whole content. + */ + const maximumPositionCalculator = new MaximumPositionCalculator(manifest); + this._maximumPositionCalculator = maximumPositionCalculator; + + const cancelSignal = this._canceller.signal; + playbackObserver.listen(({ position }) => { + const wantedPosition = position.pending ?? position.last; + if (wantedPosition < manifest.getMinimumSafePosition()) { + const warning = new MediaError("MEDIA_TIME_BEFORE_MANIFEST", + "The current position is behind the " + + "earliest time announced in the Manifest."); + this.trigger("warning", warning); + } else if ( + wantedPosition > maximumPositionCalculator.getMaximumAvailablePosition() + ) { + const warning = new MediaError("MEDIA_TIME_AFTER_MANIFEST", + "The current position is after the latest " + + "time announced in the Manifest."); + this.trigger("warning", warning); + } + }, { includeLastObservation: true, clearSignal: cancelSignal }); + + manifest.addEventListener("manifestUpdate", () => { + this.trigger("durationUpdate", getManifestDuration()); + if (cancelSignal.isCancelled) { + return; + } + this._checkEndOfStream(); + }, cancelSignal); + + function getManifestDuration() : number | undefined { + return manifest.isDynamic ? + maximumPositionCalculator.getMaximumAvailablePosition() : + maximumPositionCalculator.getEndingPosition(); + } + } + + /** + * Method to call any time an Adaptation has been selected. + * + * That Adaptation switch will be considered as active until the + * `onPeriodCleared` method has been called for the same `bufferType` and + * `Period`, or until `dispose` is called. + * @param {string} bufferType - The type of buffer concerned by the Adaptation + * switch + * @param {Object} period - The Period concerned by the Adaptation switch + * @param {Object|null} adaptation - The Adaptation selected. `null` if the + * absence of `Adaptation` has been explicitely selected for this Period and + * buffer type (e.g. no video). + */ + public onAdaptationChange( + bufferType : IBufferType, + period : Period, + adaptation : Adaptation | null + ) : void { + if (this._manifest.isLastPeriodKnown) { + const lastPeriod = this._manifest.periods[this._manifest.periods.length - 1]; + if (period.id === lastPeriod?.id) { + if (bufferType === "audio" || bufferType === "video") { + if (bufferType === "audio") { + this._maximumPositionCalculator + .updateLastAudioAdaptation(adaptation); + } else { + this._maximumPositionCalculator + .updateLastVideoAdaptation(adaptation); + } + const newDuration = this._manifest.isDynamic ? + this._maximumPositionCalculator.getMaximumAvailablePosition() : + this._maximumPositionCalculator.getEndingPosition(); + this.trigger("durationUpdate", newDuration); + } + } + } + if (this._canceller.isUsed) { + return; + } + if (adaptation === null) { + this._addActivelyLoadedPeriod(period, bufferType); + } + } + + /** + * Method to call any time a Representation has been selected. + * + * That Representation switch will be considered as active until the + * `onPeriodCleared` method has been called for the same `bufferType` and + * `Period`, or until `dispose` is called. + * @param {string} bufferType - The type of buffer concerned by the + * Representation switch + * @param {Object} period - The Period concerned by the Representation switch + */ + public onRepresentationChange( + bufferType : IBufferType, + period : Period + ) : void { + this._addActivelyLoadedPeriod(period, bufferType); + } + + /** + * Method to call any time a Period and type combination is not considered + * anymore. + * + * Calling this method allows to signal that a previous Adaptation and/or + * Representation change respectively indicated by an `onAdaptationChange` and + * an `onRepresentationChange` call, are not active anymore. + * @param {string} bufferType - The type of buffer concerned + * @param {Object} period - The Period concerned + */ + public onPeriodCleared( + bufferType : IBufferType, + period : Period + ) : void { + this._removeActivelyLoadedPeriod(period, bufferType); + } + + /** + * Method to call when the last chronological segment for a given buffer type + * is known to have been loaded and is either pushed or in the process of + * being pushed to the corresponding MSE `SourceBuffer` or equivalent. + * + * This method can even be called multiple times in a row as long as the + * aforementioned condition is true, if it simplify your code's management. + * @param {string} bufferType + */ + public onLastSegmentFinishedLoading( + bufferType : IBufferType + ) : void { + const streamInfo = this._lazilyCreateActiveStreamInfo(bufferType); + if (!streamInfo.hasFinishedLoadingLastPeriod) { + streamInfo.hasFinishedLoadingLastPeriod = true; + this._checkEndOfStream(); + } + } + /** - * Allows to calculate the minimum and maximum playable position on the - * whole content. + * Method to call to "cancel" a previous call to + * `onLastSegmentFinishedLoading`. + * + * That is, calling this method indicates that the last chronological segment + * of a given buffer type is now either not loaded or it is not known. + * + * This method can even be called multiple times in a row as long as the + * aforementioned condition is true, if it simplify your code's management. + * @param {string} bufferType */ - const maximumPositionCalculator = new MaximumPositionCalculator(manifest); - - playbackObserver.listen(({ position } : IContentTimeObserverPlaybackObservation) => { - const wantedPosition = position.pending ?? position.last; - if (wantedPosition < manifest.getMinimumSafePosition()) { - const warning = new MediaError("MEDIA_TIME_BEFORE_MANIFEST", - "The current position is behind the " + - "earliest time announced in the Manifest."); - onWarning(warning); - } else if ( - wantedPosition > maximumPositionCalculator.getMaximumAvailablePosition() - ) { - const warning = new MediaError("MEDIA_TIME_AFTER_MANIFEST", - "The current position is after the latest " + - "time announced in the Manifest."); - onWarning(warning); + public onLastSegmentLoadingResume(bufferType : IBufferType) : void { + const streamInfo = this._lazilyCreateActiveStreamInfo(bufferType); + if (streamInfo.hasFinishedLoadingLastPeriod) { + streamInfo.hasFinishedLoadingLastPeriod = false; + this._checkEndOfStream(); } - }, { includeLastObservation: true, clearSignal: cancelSignal }); + } /** - * Contains the content duration according to the last audio and video - * Adaptation chosen for the last Period. - * `undefined` if unknown yet. + * Free all resources used by the `ContentTimeBoundariesObserver` and cancels + * all recurring processes it performs. */ - const contentDuration = createSharedReference( - getManifestDuration() - ); + public dispose() { + this.removeEventListener(); + this._canceller.cancel(); + } + + private _addActivelyLoadedPeriod(period : Period, bufferType : IBufferType) : void { + const streamInfo = this._lazilyCreateActiveStreamInfo(bufferType); + if (!streamInfo.activePeriods.has(period)) { + streamInfo.activePeriods.add(period); + this._checkCurrentPeriod(); + } + } + + private _removeActivelyLoadedPeriod( + period : Period, + bufferType : IBufferType + ) : void { + const streamInfo = this._activeStreams.get(bufferType); + if (streamInfo === undefined) { + return; + } + if (streamInfo.activePeriods.has(period)) { + streamInfo.activePeriods.removeElement(period); + this._checkCurrentPeriod(); + } + } - manifest.addEventListener("manifestUpdate", () => { - contentDuration.setValue(getManifestDuration()); - }, cancelSignal); + private _checkCurrentPeriod() : void { + if (this._allBufferTypes.length === 0) { + return; + } - lastAdaptationChange.onUpdate((message) => { - if (message === null || !manifest.isLastPeriodKnown) { + const streamInfo = this._activeStreams.get(this._allBufferTypes[0]); + if (streamInfo === undefined) { return; } - const lastPeriod = manifest.periods[manifest.periods.length - 1]; - if (message.value.period.id === lastPeriod?.id) { - if (message.value.type === "audio" || message.value.type === "video") { - if (message.value.type === "audio") { - maximumPositionCalculator - .updateLastAudioAdaptation(message.value.adaptation); - } else { - maximumPositionCalculator - .updateLastVideoAdaptation(message.value.adaptation); + for (const period of streamInfo.activePeriods.toArray()) { + let wasFoundInAllTypes = true; + for (let i = 1; i < this._allBufferTypes.length; i++) { + const streamInfo2 = this._activeStreams.get(this._allBufferTypes[i]); + if (streamInfo2 === undefined) { + return; } - const newDuration = manifest.isDynamic ? - maximumPositionCalculator.getMaximumAvailablePosition() : - maximumPositionCalculator.getEndingPosition(); - contentDuration.setValue(newDuration); + + const activePeriods = streamInfo2.activePeriods.toArray(); + const hasPeriod = activePeriods.some((p) => p.id === period.id); + if (!hasPeriod) { + wasFoundInAllTypes = false; + break; + } + } + if (wasFoundInAllTypes) { + if (this._lastCurrentPeriodId !== period.id) { + this._lastCurrentPeriodId = period.id; + this.trigger("periodChange", period); + } + return; } } - }, { emitCurrentValue: true, clearSignal: cancelSignal }); + } - return contentDuration; + private _lazilyCreateActiveStreamInfo(bufferType : IBufferType) : IActiveStreamsInfo { + let streamInfo = this._activeStreams.get(bufferType); + if (streamInfo === undefined) { + streamInfo = { + activePeriods: new SortedList((a, b) => a.start - b.start), + hasFinishedLoadingLastPeriod: false, + }; + this._activeStreams.set(bufferType, streamInfo); + } + return streamInfo; + } - function getManifestDuration() : number | undefined { - return manifest.isDynamic ? - maximumPositionCalculator.getEndingPosition() : - maximumPositionCalculator.getMaximumAvailablePosition(); + private _checkEndOfStream() : void { + if (!this._manifest.isLastPeriodKnown) { + return; + } + const everyBufferTypeLoaded = this._allBufferTypes.every((bt) => { + const streamInfo = this._activeStreams.get(bt); + return streamInfo !== undefined && streamInfo.hasFinishedLoadingLastPeriod; + }); + if (everyBufferTypeLoaded) { + this.trigger("endOfStream", null); + } else { + this.trigger("resumeStream", null); + } } } +/** + * Events triggered by a `ContentTimeBoundariesObserver` where the keys are the + * event names and the value is the payload of those events. + */ +export interface IContentTimeBoundariesObserverEvent { + /** Triggered when a minor error is encountered. */ + warning : IPlayerError; + /** Triggered when a new `Period` is currently playing. */ + periodChange : Period; + /** + * Triggered when the duration of the currently-playing content became known + * or changed. + */ + durationUpdate : number | undefined; + /** + * Triggered when the last possible chronological segment for all types of + * buffers has either been pushed or is being pushed to the corresponding + * MSE `SourceBuffer` or equivalent. + * As such, the `endOfStream` MSE API might from now be able to be called. + * + * Note that it is possible to receive this event even if `endOfStream` has + * already been called and even if an "endOfStream" event has already been + * triggered. + */ + endOfStream : null; + /** + * Triggered when the last possible chronological segment for all types of + * buffers have NOT been pushed, or if it is not known whether is has been + * pushed, and as such any potential pending `endOfStream` MSE API call + * need to be cancelled. + * + * Note that it is possible to receive this event even if `endOfStream` has + * not been called and even if an "resumeStream" event has already been + * triggered. + */ + resumeStream : null; +} + /** * Calculate the last position from the last chosen audio and video Adaptations * for the last Period (or a default one, if no Adaptations has been chosen). @@ -334,5 +587,23 @@ function getEndingPositionFromAdaptation( return min; } +interface IActiveStreamsInfo { + /** + * Active Periods being currently actively loaded by the "Streams". + * That is: either this Period's corresponding `Representation` has been + * selected or we didn't chose any `Adaptation` for that type), in + * chronological order. + * + * The first chronological Period in that list is the active one for + * the current type. + */ + activePeriods : SortedList; + /** + * If `true` the last segment for the last currently known Period has been + * pushed for the current Adaptation and Representation choice. + */ + hasFinishedLoadingLastPeriod : boolean; +} + export type IContentTimeObserverPlaybackObservation = Pick; diff --git a/src/core/init/utils/rebuffering_controller.ts b/src/core/init/utils/rebuffering_controller.ts index efa1b6824c..a51b118edc 100644 --- a/src/core/init/utils/rebuffering_controller.ts +++ b/src/core/init/utils/rebuffering_controller.ts @@ -302,6 +302,12 @@ export default class RebufferingController }, { includeLastObservation: true, clearSignal: this._canceller.signal }); } + /** + * Update information on an upcoming discontinuity for a given buffer type and + * Period. + * Each new update for the same Period and type overwrites the previous one. + * @param {Object} evt + */ public updateDiscontinuityInfo(evt: IDiscontinuityEvent) : void { if (!this._isStarted) { this.start(); @@ -345,6 +351,10 @@ export default class RebufferingController } } + /** + * Stops the `RebufferingController` from montoring stalling situations, + * forever. + */ public destroy() : void { this._canceller.cancel(); } diff --git a/src/core/stream/adaptation/adaptation_stream.ts b/src/core/stream/adaptation/adaptation_stream.ts index 5235b3927d..75623fd593 100644 --- a/src/core/stream/adaptation/adaptation_stream.ts +++ b/src/core/stream/adaptation/adaptation_stream.ts @@ -14,95 +14,78 @@ * limitations under the License. */ -/** - * This file allows to create `AdaptationStream`s. - * - * An `AdaptationStream` downloads and push segment for a single Adaptation - * (e.g. a single audio, video or text track). - * It chooses which Representation to download mainly thanks to the - * IRepresentationEstimator, and orchestrates a RepresentationStream, - * which will download and push segments corresponding to a chosen - * Representation. - */ - -import { - catchError, - concat as observableConcat, - defer as observableDefer, - distinctUntilChanged, - EMPTY, - exhaustMap, - filter, - map, - merge as observableMerge, - mergeMap, - Observable, - of as observableOf, - share, - Subject, - take, - tap, -} from "rxjs"; +import nextTick from "next-tick"; import config from "../../../config"; import { formatError } from "../../../errors"; import log from "../../../log"; -import Manifest, { - Adaptation, - Period, - Representation, -} from "../../../manifest"; -import deferSubscriptions from "../../../utils/defer_subscriptions"; +import { Representation } from "../../../manifest"; +import objectAssign from "../../../utils/object_assign"; import { + createMappedReference, createSharedReference, IReadOnlySharedReference, } from "../../../utils/reference"; -import TaskCanceller from "../../../utils/task_canceller"; -import { - IABREstimate, - IRepresentationEstimator, -} from "../../adaptive"; -import { IReadOnlyPlaybackObserver } from "../../api"; -import { SegmentFetcherCreator } from "../../fetchers"; -import { SegmentBuffer } from "../../segment_buffers"; -import EVENTS from "../events_generators"; -import reloadAfterSwitch from "../reload_after_switch"; +import TaskCanceller, { + CancellationSignal, +} from "../../../utils/task_canceller"; import RepresentationStream, { - IRepresentationStreamPlaybackObservation, + IRepresentationStreamCallbacks, ITerminationOrder, } from "../representation"; import { - IAdaptationStreamEvent, - IRepresentationStreamEvent, -} from "../types"; -import createRepresentationEstimator from "./create_representation_estimator"; + IAdaptationStreamArguments, + IAdaptationStreamCallbacks, +} from "./types"; +import createRepresentationEstimator from "./utils/create_representation_estimator"; /** - * Create new AdaptationStream Observable, which task will be to download the - * media data for a given Adaptation (i.e. "track"). + * Create new `AdaptationStream` whose task will be to download the media data + * for a given Adaptation (i.e. "track"). * * It will rely on the IRepresentationEstimator to choose at any time the * best Representation for this Adaptation and then run the logic to download * and push the corresponding segments in the SegmentBuffer. * - * After being subscribed to, it will start running and will emit various events - * to report its current status. + * @param {Object} args - Various arguments allowing the `AdaptationStream` to + * determine which Representation to choose and which segments to load from it. + * You can check the corresponding type for more information. + * @param {Object} callbacks - The `AdaptationStream` relies on a system of + * callbacks that it will call on various events. + * + * Depending on the event, the caller may be supposed to perform actions to + * react upon some of them. * - * @param {Object} args - * @returns {Observable} + * This approach is taken instead of a more classical EventEmitter pattern to: + * - Allow callbacks to be called synchronously after the + * `AdaptationStream` is called. + * - Simplify bubbling events up, by just passing through callbacks + * - Force the caller to explicitely handle or not the different events. + * + * Callbacks may start being called immediately after the `AdaptationStream` + * call and may be called until either the `parentCancelSignal` argument is + * triggered, or until the `error` callback is called, whichever comes first. + * @param {Object} parentCancelSignal - `CancellationSignal` allowing, when + * triggered, to immediately stop all operations the `AdaptationStream` is + * doing. */ -export default function AdaptationStream({ - playbackObserver, - content, - options, - representationEstimator, - segmentBuffer, - segmentFetcherCreator, - wantedBufferAhead, - maxVideoBufferSize, -} : IAdaptationStreamArguments) : Observable { +export default function AdaptationStream( + { playbackObserver, + content, + options, + representationEstimator, + segmentBuffer, + segmentFetcherCreator, + wantedBufferAhead, + maxVideoBufferSize } : IAdaptationStreamArguments, + callbacks : IAdaptationStreamCallbacks, + parentCancelSignal : CancellationSignal +) : void { const directManualBitrateSwitching = options.manualBitrateSwitchingMode === "direct"; const { manifest, period, adaptation } = content; + /** Allows to cancel everything the `AdaptationStream` is doing. */ + const adapStreamCanceller = new TaskCanceller({ cancelOn: parentCancelSignal }); + /** * The buffer goal ratio base itself on the value given by `wantedBufferAhead` * to determine a more dynamic buffer goal for a given Representation. @@ -111,19 +94,28 @@ export default function AdaptationStream({ * buffering and tells us that we should try to bufferize less data : * https://developers.google.com/web/updates/2017/10/quotaexceedederror */ - const bufferGoalRatioMap: Partial> = {}; - const currentRepresentation = createSharedReference(null); + const bufferGoalRatioMap: Map = new Map(); - /** Errors when the adaptive logic fails with an error. */ - const abrErrorSubject = new Subject(); - const adaptiveCanceller = new TaskCanceller(); - const { estimateRef, abrCallbacks } = - createRepresentationEstimator(content, - representationEstimator, - currentRepresentation, - playbackObserver, - (err) => { abrErrorSubject.error(err); }, - adaptiveCanceller.signal); + /** + * Emit the currently chosen `Representation`. + * `null` if no Representation is chosen for now. + */ + const currentRepresentation = createSharedReference( + null, + adapStreamCanceller.signal + ); + + const { estimateRef, abrCallbacks } = createRepresentationEstimator( + content, + representationEstimator, + currentRepresentation, + playbackObserver, + (err) => { + adapStreamCanceller.cancel(); + callbacks.error(err); + }, + adapStreamCanceller.signal + ); /** Allows the `RepresentationStream` to easily fetch media segments. */ const segmentFetcher = segmentFetcherCreator @@ -135,95 +127,100 @@ export default function AdaptationStream({ onMetrics: abrCallbacks.metrics }); /* eslint-enable @typescript-eslint/unbound-method */ - /** - * Stores the last estimate emitted through the `abrEstimate$` Observable, - * starting with `null`. - * This allows to easily rely on that value in inner Observables which might also - * need the last already-considered value. - */ - const lastEstimate = createSharedReference(null); - - /** Emits abr estimates on Subscription. */ - const abrEstimate$ = estimateRef.asObservable().pipe( - tap((estimate) => { lastEstimate.setValue(estimate); }), - deferSubscriptions(), - share()); + /** Stores the last emitted bitrate. */ + let previousBitrate : number | undefined; /** Emit at each bitrate estimate done by the IRepresentationEstimator. */ - const bitrateEstimate$ = abrEstimate$.pipe( - filter(({ bitrate }) => bitrate != null), - distinctUntilChanged((old, current) => old.bitrate === current.bitrate), - map(({ bitrate }) => { - log.debug(`Stream: new ${adaptation.type} bitrate estimate`, bitrate); - return EVENTS.bitrateEstimationChange(adaptation.type, bitrate); - }) - ); + estimateRef.onUpdate(({ bitrate }) => { + if (bitrate === undefined) { + return ; + } - /** Recursively create `RepresentationStream`s according to the last estimate. */ - const representationStreams$ = abrEstimate$ - .pipe(exhaustMap((estimate, i) : Observable => { - return recursivelyCreateRepresentationStreams(estimate, i === 0); - })); + if (bitrate === previousBitrate) { + return ; + } + previousBitrate = bitrate; + log.debug(`Stream: new ${adaptation.type} bitrate estimate`, bitrate); + callbacks.bitrateEstimationChange({ type: adaptation.type, bitrate }); + }, { emitCurrentValue: true, clearSignal: adapStreamCanceller.signal }); - return observableMerge(abrErrorSubject, - representationStreams$, - bitrateEstimate$, - // Cancel adaptive logic on unsubscription - new Observable(() => () => adaptiveCanceller.cancel())); + recursivelyCreateRepresentationStreams(true); /** - * Create `RepresentationStream`s starting with the Representation indicated in - * `fromEstimate` argument. + * Create `RepresentationStream`s starting with the Representation of the last + * estimate performed. * Each time a new estimate is made, this function will create a new * `RepresentationStream` corresponding to that new estimate. - * @param {Object} fromEstimate - The first estimate we should start with * @param {boolean} isFirstEstimate - Whether this is the first time we're - * creating a RepresentationStream in the corresponding `AdaptationStream`. + * creating a `RepresentationStream` in the corresponding `AdaptationStream`. * This is important because manual quality switches might need a full reload * of the MediaSource _except_ if we are talking about the first quality chosen. - * @returns {Observable} */ - function recursivelyCreateRepresentationStreams( - fromEstimate : IABREstimate, - isFirstEstimate : boolean - ) : Observable { - const { representation } = fromEstimate; + function recursivelyCreateRepresentationStreams(isFirstEstimate : boolean) : void { + /** + * `TaskCanceller` triggered when the current `RepresentationStream` is + * terminating and as such the next one might be immediately created + * recursively. + */ + const repStreamTerminatingCanceller = new TaskCanceller({ + cancelOn: adapStreamCanceller.signal, + }); + const { representation, manual } = estimateRef.getValue(); // A manual bitrate switch might need an immediate feedback. // To do that properly, we need to reload the MediaSource - if (directManualBitrateSwitching && - fromEstimate.manual && - !isFirstEstimate) - { + if (directManualBitrateSwitching && manual && !isFirstEstimate) { const { DELTA_POSITION_AFTER_RELOAD } = config.getCurrent(); - return reloadAfterSwitch(period, - adaptation.type, - playbackObserver, - DELTA_POSITION_AFTER_RELOAD.bitrateSwitch); + + // We begin by scheduling a micro-task to reduce the possibility of race + // conditions where the inner logic would be called synchronously before + // the next observation (which may reflect very different playback conditions) + // is actually received. + return nextTick(() => { + playbackObserver.listen((observation) => { + const { manual: newManual } = estimateRef.getValue(); + if (!newManual) { + return; + } + const currentTime = playbackObserver.getCurrentTime(); + const pos = currentTime + DELTA_POSITION_AFTER_RELOAD.bitrateSwitch; + + // Bind to Period start and end + const position = Math.min(Math.max(period.start, pos), + period.end ?? Infinity); + const autoPlay = !(observation.paused.pending ?? + playbackObserver.getIsPaused()); + return callbacks.waitingMediaSourceReload({ bufferType: adaptation.type, + period, + position, + autoPlay }); + }, { includeLastObservation: true, + clearSignal: repStreamTerminatingCanceller.signal }); + }); } /** * Emit when the current RepresentationStream should be terminated to make * place for a new one (e.g. when switching quality). */ - const terminateCurrentStream$ = lastEstimate.asObservable().pipe( - filter((newEstimate) => newEstimate === null || - newEstimate.representation.id !== representation.id || - (newEstimate.manual && !fromEstimate.manual)), - take(1), - map((newEstimate) => { - if (newEstimate === null) { - log.info("Stream: urgent Representation termination", adaptation.type); - return ({ urgent: true }); - } - if (newEstimate.urgent) { - log.info("Stream: urgent Representation switch", adaptation.type); - return ({ urgent: true }); - } else { - log.info("Stream: slow Representation switch", adaptation.type); - return ({ urgent: false }); - } - })); + const terminateCurrentStream = createSharedReference( + null, + repStreamTerminatingCanceller.signal + ); + + /** Allows to stop listening to estimateRef on the following line. */ + estimateRef.onUpdate((estimate) => { + if (estimate.representation.id === representation.id) { + return; + } + if (estimate.urgent) { + log.info("Stream: urgent Representation switch", adaptation.type); + return terminateCurrentStream.setValue({ urgent: true }); + } else { + log.info("Stream: slow Representation switch", adaptation.type); + return terminateCurrentStream.setValue({ urgent: false }); + } + }, { clearSignal: repStreamTerminatingCanceller.signal, emitCurrentValue: true }); /** * "Fast-switching" is a behavior allowing to replace low-quality segments @@ -236,207 +233,137 @@ export default function AdaptationStream({ * Set to `undefined` to indicate that there's no threshold (anything can be * replaced by higher-quality segments). */ - const fastSwitchThreshold$ = !options.enableFastSwitching ? - observableOf(0) : // Do not fast-switch anything - lastEstimate.asObservable().pipe( - map((estimate) => estimate === null ? undefined : - estimate.knownStableBitrate), - distinctUntilChanged()); + const fastSwitchThreshold = createSharedReference(0); + if (options.enableFastSwitching) { + estimateRef.onUpdate((estimate) => { + fastSwitchThreshold.setValueIfChanged(estimate?.knownStableBitrate); + }, { clearSignal: repStreamTerminatingCanceller.signal, emitCurrentValue: true }); + } - const representationChange$ = - observableOf(EVENTS.representationChange(adaptation.type, - period, - representation)); + const repInfo = { type: adaptation.type, period, representation }; + currentRepresentation.setValue(representation); + if (adapStreamCanceller.isUsed) { + return ; // previous callback has stopped everything by side-effect + } + callbacks.representationChange(repInfo); + if (adapStreamCanceller.isUsed) { + return ; // previous callback has stopped everything by side-effect + } - return observableConcat(representationChange$, - createRepresentationStream(representation, - terminateCurrentStream$, - fastSwitchThreshold$)).pipe( - tap((evt) : void => { - if (evt.type === "added-segment") { - abrCallbacks.addedSegment(evt.value); + const representationStreamCallbacks : IRepresentationStreamCallbacks = { + streamStatusUpdate: callbacks.streamStatusUpdate, + encryptionDataEncountered: callbacks.encryptionDataEncountered, + manifestMightBeOufOfSync: callbacks.manifestMightBeOufOfSync, + needsManifestRefresh: callbacks.needsManifestRefresh, + inbandEvent: callbacks.inbandEvent, + warning: callbacks.warning, + error(err : unknown) { + adapStreamCanceller.cancel(); + callbacks.error(err); + }, + addedSegment(segmentInfo) { + abrCallbacks.addedSegment(segmentInfo); + if (adapStreamCanceller.isUsed) { + return; } - if (evt.type === "representationChange") { - currentRepresentation.setValue(evt.value.representation); - } - }), - mergeMap((evt) => { - if (evt.type === "stream-terminating") { - const estimate = lastEstimate.getValue(); - if (estimate === null) { - return EMPTY; - } - return recursivelyCreateRepresentationStreams(estimate, false); - } - return observableOf(evt); - })); + callbacks.addedSegment(segmentInfo); + }, + terminating() { + repStreamTerminatingCanceller.cancel(); + return recursivelyCreateRepresentationStreams(false); + }, + }; + + createRepresentationStream(representation, + terminateCurrentStream, + fastSwitchThreshold, + representationStreamCallbacks); } /** - * Create and returns a new RepresentationStream Observable, linked to the + * Create and returns a new `RepresentationStream`, linked to the * given Representation. - * @param {Representation} representation - * @returns {Observable} + * @param {Object} representation + * @param {Object} terminateCurrentStream + * @param {Object} fastSwitchThreshold + * @param {Object} representationStreamCallbacks */ function createRepresentationStream( representation : Representation, - terminateCurrentStream$ : Observable, - fastSwitchThreshold$ : Observable - ) : Observable { - return observableDefer(() => { - const oldBufferGoalRatio = bufferGoalRatioMap[representation.id]; - const bufferGoalRatio = oldBufferGoalRatio != null ? oldBufferGoalRatio : - 1; - bufferGoalRatioMap[representation.id] = bufferGoalRatio; - - const bufferGoal$ = wantedBufferAhead.asObservable().pipe( - map((wba) => wba * bufferGoalRatio) - ); - // eslint-disable-next-line max-len - const maxBufferSize$ = adaptation.type === "video" ? maxVideoBufferSize.asObservable() : - observableOf(Infinity); + terminateCurrentStream : IReadOnlySharedReference, + fastSwitchThreshold : IReadOnlySharedReference, + representationStreamCallbacks : IRepresentationStreamCallbacks + ) : void { + /** + * `TaskCanceller` triggered when the `RepresentationStream` calls its + * `terminating` callback. + */ + const terminatingRepStreamCanceller = new TaskCanceller({ + cancelOn: adapStreamCanceller.signal, + }); + const bufferGoal = createMappedReference(wantedBufferAhead, prev => { + return prev * getBufferGoalRatio(representation); + }, terminatingRepStreamCanceller.signal); + const maxBufferSize = adaptation.type === "video" ? + maxVideoBufferSize : + createSharedReference(Infinity); - log.info("Stream: changing representation", - adaptation.type, - representation.id, - representation.bitrate); - return RepresentationStream({ playbackObserver, - content: { representation, - adaptation, - period, - manifest }, - segmentBuffer, - segmentFetcher, - terminate$: terminateCurrentStream$, - options: { bufferGoal$, - maxBufferSize$, - drmSystemId: options.drmSystemId, - fastSwitchThreshold$ } }) - .pipe(catchError((err : unknown) => { - const formattedError = formatError(err, { - defaultCode: "NONE", - defaultReason: "Unknown `RepresentationStream` error", - }); - if (formattedError.code === "BUFFER_FULL_ERROR") { - const wba = wantedBufferAhead.getValue(); - const lastBufferGoalRatio = bufferGoalRatio; - if (lastBufferGoalRatio <= 0.25 || wba * lastBufferGoalRatio <= 2) { - throw formattedError; - } - bufferGoalRatioMap[representation.id] = lastBufferGoalRatio - 0.25; - return createRepresentationStream(representation, - terminateCurrentStream$, - fastSwitchThreshold$); + log.info("Stream: changing representation", + adaptation.type, + representation.id, + representation.bitrate); + const updatedCallbacks = objectAssign({}, representationStreamCallbacks, { + error(err : unknown) { + const formattedError = formatError(err, { + defaultCode: "NONE", + defaultReason: "Unknown `RepresentationStream` error", + }); + if (formattedError.code === "BUFFER_FULL_ERROR") { + const wba = wantedBufferAhead.getValue(); + const lastBufferGoalRatio = bufferGoalRatioMap.get(representation.id) ?? 1; + if (lastBufferGoalRatio <= 0.25 || wba * lastBufferGoalRatio <= 2) { + throw formattedError; } - throw formattedError; - })); + bufferGoalRatioMap.set(representation.id, lastBufferGoalRatio - 0.25); + return createRepresentationStream(representation, + terminateCurrentStream, + fastSwitchThreshold, + representationStreamCallbacks); + } + representationStreamCallbacks.error(err); + }, + terminating() { + terminatingRepStreamCanceller.cancel(); + representationStreamCallbacks.terminating(); + }, }); - } -} - -/** Regular playback information needed by the AdaptationStream. */ -export interface IAdaptationStreamPlaybackObservation extends - IRepresentationStreamPlaybackObservation { - /** - * For the current SegmentBuffer, difference in seconds between the next position - * where no segment data is available and the current position. - */ - bufferGap : number; - /** `duration` property of the HTMLMediaElement on which the content plays. */ - duration : number; - /** - * Information on whether the media element was paused at the time of the - * Observation. - */ - paused : IPausedPlaybackObservation; - /** Last "playback rate" asked by the user. */ - speed : number; - /** Theoretical maximum position on the content that can currently be played. */ - maximumPosition : number; + RepresentationStream({ playbackObserver, + content: { representation, + adaptation, + period, + manifest }, + segmentBuffer, + segmentFetcher, + terminate: terminateCurrentStream, + options: { bufferGoal, + maxBufferSize, + drmSystemId: options.drmSystemId, + fastSwitchThreshold } }, + updatedCallbacks, + adapStreamCanceller.signal); } -/** Pause-related information linked to an emitted Playback observation. */ -export interface IPausedPlaybackObservation { /** - * Known paused state at the time the Observation was emitted. - * - * `true` indicating that the HTMLMediaElement was in a paused state. - * - * Note that it might have changed since. If you want truly precize - * information, you should recuperate it from the HTMLMediaElement directly - * through another mean. + * @param {Object} representation + * @returns {number} */ - last : boolean; - /** - * Actually wanted paused state not yet reached. - * This might for example be set to `false` when the content is currently - * loading (and thus paused) but with autoPlay enabled. - */ - pending : boolean | undefined; -} - -/** Arguments given when creating a new `AdaptationStream`. */ -export interface IAdaptationStreamArguments { - /** Regularly emit playback conditions. */ - playbackObserver : IReadOnlyPlaybackObserver; - /** Content you want to create this Stream for. */ - content : { manifest : Manifest; - period : Period; - adaptation : Adaptation; }; - options: IAdaptationStreamOptions; - /** Estimate the right Representation to play. */ - representationEstimator : IRepresentationEstimator; - /** SourceBuffer wrapper - needed to push media segments. */ - segmentBuffer : SegmentBuffer; - /** Module used to fetch the wanted media segments. */ - segmentFetcherCreator : SegmentFetcherCreator; - /** - * "Buffer goal" wanted, or the ideal amount of time ahead of the current - * position in the current SegmentBuffer. When this amount has been reached - * this AdaptationStream won't try to download new segments. - */ - wantedBufferAhead : IReadOnlySharedReference; - maxVideoBufferSize : IReadOnlySharedReference; -} - -/** - * Various specific stream "options" which tweak the behavior of the - * AdaptationStream. - */ -export interface IAdaptationStreamOptions { - /** - * Hex-encoded DRM "system ID" as found in: - * https://dashif.org/identifiers/content_protection/ - * - * Allows to identify which DRM system is currently used, to allow potential - * optimizations. - * - * Set to `undefined` in two cases: - * - no DRM system is used (e.g. the content is unencrypted). - * - We don't know which DRM system is currently used. - */ - drmSystemId : string | undefined; - /** - * Strategy taken when the user switch manually the current Representation: - * - "seamless": the switch will happen smoothly, with the Representation - * with the new bitrate progressively being pushed alongside the old - * Representation. - * - "direct": hard switch. The Representation switch will be directly - * visible but may necessitate the current MediaSource to be reloaded. - */ - manualBitrateSwitchingMode : "seamless" | "direct"; - /** - * If `true`, the AdaptationStream might replace segments of a lower-quality - * (with a lower bitrate) with segments of a higher quality (with a higher - * bitrate). This allows to have a fast transition when network conditions - * improve. - * If `false`, this strategy will be disabled: segments of a lower-quality - * will not be replaced. - * - * Some targeted devices support poorly segment replacement in a - * SourceBuffer. - * As such, this option can be used to disable that unnecessary behavior on - * those devices. - */ - enableFastSwitching : boolean; + function getBufferGoalRatio(representation : Representation) : number { + const oldBufferGoalRatio = bufferGoalRatioMap.get(representation.id); + const bufferGoalRatio = oldBufferGoalRatio !== undefined ? oldBufferGoalRatio : + 1; + if (oldBufferGoalRatio === undefined) { + bufferGoalRatioMap.set(representation.id, bufferGoalRatio); + } + return bufferGoalRatio; + } } diff --git a/src/core/stream/adaptation/index.ts b/src/core/stream/adaptation/index.ts index 83103e07dc..ed17cf64c3 100644 --- a/src/core/stream/adaptation/index.ts +++ b/src/core/stream/adaptation/index.ts @@ -14,17 +14,7 @@ * limitations under the License. */ -import AdaptationStream, { - IAdaptationStreamArguments, - IAdaptationStreamPlaybackObservation, - IAdaptationStreamOptions, - IPausedPlaybackObservation, -} from "./adaptation_stream"; +import AdaptationStream from "./adaptation_stream"; +export * from "./types"; export default AdaptationStream; -export { - IAdaptationStreamArguments, - IAdaptationStreamPlaybackObservation, - IAdaptationStreamOptions, - IPausedPlaybackObservation, -}; diff --git a/src/core/stream/adaptation/types.ts b/src/core/stream/adaptation/types.ts new file mode 100644 index 0000000000..7cca5cc6fd --- /dev/null +++ b/src/core/stream/adaptation/types.ts @@ -0,0 +1,193 @@ +import Manifest, { + Adaptation, + Period, + Representation, +} from "../../../manifest"; +import { IReadOnlySharedReference } from "../../../utils/reference"; +import { IRepresentationEstimator } from "../../adaptive"; +import { IReadOnlyPlaybackObserver } from "../../api"; +import { SegmentFetcherCreator } from "../../fetchers"; +import { + IBufferType, + SegmentBuffer, +} from "../../segment_buffers"; +import { + IRepresentationStreamCallbacks, + IRepresentationStreamPlaybackObservation, +} from "../representation"; + +/** Callbacks called by the `AdaptationStream` on various events. */ +export interface IAdaptationStreamCallbacks + extends Omit, "terminating"> +{ + /** Called as new bitrate estimates are done. */ + bitrateEstimationChange(payload : IBitrateEstimationChangePayload) : void; + /** + * Called when a new `RepresentationStream` is created to load segments from a + * `Representation`. + */ + representationChange(payload : IRepresentationChangePayload) : void; + /** + * Callback called when a stream cannot go forward loading segments because it + * needs the `MediaSource` to be reloaded first. + */ + waitingMediaSourceReload(payload : IWaitingMediaSourceReloadPayload) : void; +} + +/** Payload for the `bitrateEstimationChange` callback. */ +export interface IBitrateEstimationChangePayload { + /** The type of buffer for which the estimation is done. */ + type : IBufferType; + /** + * The bitrate estimate, in bits per seconds. `undefined` when no bitrate + * estimate is currently available. + */ + bitrate : number|undefined; +} + +/** Payload for the `representationChange` callback. */ +export interface IRepresentationChangePayload { + /** The type of buffer linked to that `RepresentationStream`. */ + type : IBufferType; + /** The `Period` linked to the `RepresentationStream` we're creating. */ + period : Period; + /** + * The `Representation` linked to the `RepresentationStream` we're creating. + * `null` when we're choosing no Representation at all. + */ + representation : Representation | + null; +} + +/** Payload for the `waitingMediaSourceReload` callback. */ +export interface IWaitingMediaSourceReloadPayload { + /** Period concerned. */ + period : Period; + /** Buffer type concerned. */ + bufferType : IBufferType; + /** + * The position in seconds and the time at which the MediaSource should be + * reset once it has been reloaded. + */ + position : number; + /** + * If `true`, we want the HTMLMediaElement to play right after the reload is + * done. + * If `false`, we want to stay in a paused state at that point. + */ + autoPlay : boolean; +} + +/** Regular playback information needed by the AdaptationStream. */ +export interface IAdaptationStreamPlaybackObservation extends + IRepresentationStreamPlaybackObservation { + /** + * For the current SegmentBuffer, difference in seconds between the next position + * where no segment data is available and the current position. + */ + bufferGap : number; + /** `duration` property of the HTMLMediaElement on which the content plays. */ + duration : number; + /** + * Information on whether the media element was paused at the time of the + * Observation. + */ + paused : IPausedPlaybackObservation; + /** Last "playback rate" asked by the user. */ + speed : number; + /** Theoretical maximum position on the content that can currently be played. */ + maximumPosition : number; + } + +/** Pause-related information linked to an emitted Playback observation. */ +export interface IPausedPlaybackObservation { + /** + * Known paused state at the time the Observation was emitted. + * + * `true` indicating that the HTMLMediaElement was in a paused state. + * + * Note that it might have changed since. If you want truly precize + * information, you should recuperate it from the HTMLMediaElement directly + * through another mean. + */ + last : boolean; + /** + * Actually wanted paused state not yet reached. + * This might for example be set to `false` when the content is currently + * loading (and thus paused) but with autoPlay enabled. + */ + pending : boolean | undefined; +} + +/** Arguments given when creating a new `AdaptationStream`. */ +export interface IAdaptationStreamArguments { + /** Regularly emit playback conditions. */ + playbackObserver : IReadOnlyPlaybackObserver; + /** Content you want to create this Stream for. */ + content : { manifest : Manifest; + period : Period; + adaptation : Adaptation; }; + options: IAdaptationStreamOptions; + /** Estimate the right Representation to play. */ + representationEstimator : IRepresentationEstimator; + /** SourceBuffer wrapper - needed to push media segments. */ + segmentBuffer : SegmentBuffer; + /** Module used to fetch the wanted media segments. */ + segmentFetcherCreator : SegmentFetcherCreator; + /** + * "Buffer goal" wanted, or the ideal amount of time ahead of the current + * position in the current SegmentBuffer. When this amount has been reached + * this AdaptationStream won't try to download new segments. + */ + wantedBufferAhead : IReadOnlySharedReference; + /** + * The buffer size limit in memory that we can reach for the video buffer. + * + * Once reached, no segments will be loaded until it goes below that size + * again + */ + maxVideoBufferSize : IReadOnlySharedReference; +} + +/** + * Various specific stream "options" which tweak the behavior of the + * AdaptationStream. + */ +export interface IAdaptationStreamOptions { + /** + * Hex-encoded DRM "system ID" as found in: + * https://dashif.org/identifiers/content_protection/ + * + * Allows to identify which DRM system is currently used, to allow potential + * optimizations. + * + * Set to `undefined` in two cases: + * - no DRM system is used (e.g. the content is unencrypted). + * - We don't know which DRM system is currently used. + */ + drmSystemId : string | undefined; + /** + * Strategy taken when the user switch manually the current Representation: + * - "seamless": the switch will happen smoothly, with the Representation + * with the new bitrate progressively being pushed alongside the old + * Representation. + * - "direct": hard switch. The Representation switch will be directly + * visible but may necessitate the current MediaSource to be reloaded. + */ + manualBitrateSwitchingMode : "seamless" | "direct"; + /** + * If `true`, the AdaptationStream might replace segments of a lower-quality + * (with a lower bitrate) with segments of a higher quality (with a higher + * bitrate). This allows to have a fast transition when network conditions + * improve. + * If `false`, this strategy will be disabled: segments of a lower-quality + * will not be replaced. + * + * Some targeted devices support poorly segment replacement in a + * SourceBuffer. + * As such, this option can be used to disable that unnecessary behavior on + * those devices. + */ + enableFastSwitching : boolean; +} + diff --git a/src/core/stream/adaptation/create_representation_estimator.ts b/src/core/stream/adaptation/utils/create_representation_estimator.ts similarity index 92% rename from src/core/stream/adaptation/create_representation_estimator.ts rename to src/core/stream/adaptation/utils/create_representation_estimator.ts index bf5db29e5c..3e7dd15388 100644 --- a/src/core/stream/adaptation/create_representation_estimator.ts +++ b/src/core/stream/adaptation/utils/create_representation_estimator.ts @@ -14,24 +14,24 @@ * limitations under the License. */ -import { MediaError } from "../../../errors"; +import { MediaError } from "../../../../errors"; import Manifest, { Adaptation, Period, Representation, -} from "../../../manifest"; -import { IPlayerError } from "../../../public_types"; +} from "../../../../manifest"; +import { IPlayerError } from "../../../../public_types"; import createSharedReference, { IReadOnlySharedReference, -} from "../../../utils/reference"; -import { CancellationSignal } from "../../../utils/task_canceller"; +} from "../../../../utils/reference"; +import { CancellationSignal } from "../../../../utils/task_canceller"; import { IABREstimate, IRepresentationEstimatorPlaybackObservation, IRepresentationEstimator, IRepresentationEstimatorCallbacks, -} from "../../adaptive"; -import { IReadOnlyPlaybackObserver } from "../../api"; +} from "../../../adaptive"; +import { IReadOnlyPlaybackObserver } from "../../../api"; /** * Produce estimates to know which Representation should be played. diff --git a/src/core/stream/events_generators.ts b/src/core/stream/events_generators.ts deleted file mode 100644 index d170a03695..0000000000 --- a/src/core/stream/events_generators.ts +++ /dev/null @@ -1,211 +0,0 @@ -/** - * 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. - */ - -import { Subject } from "rxjs"; -import Manifest, { - Adaptation, - ISegment, - Period, - Representation, -} from "../../manifest"; -import { IRepresentationProtectionData } from "../../manifest/representation"; -import { IPlayerError } from "../../public_types"; -import objectAssign from "../../utils/object_assign"; -import { IBufferType } from "../segment_buffers"; -import { - IActivePeriodChangedEvent, - IAdaptationChangeEvent, - IBitrateEstimationChangeEvent, - IEncryptionDataEncounteredEvent, - IEndOfStreamEvent, - ILockedStreamEvent, - INeedsBufferFlushEvent, - INeedsDecipherabilityFlush, - INeedsMediaSourceReload, - IPeriodStreamClearedEvent, - IPeriodStreamReadyEvent, - IRepresentationChangeEvent, - IResumeStreamEvent, - IStreamEventAddedSegment, - IStreamManifestMightBeOutOfSync, - IStreamNeedsManifestRefresh, - IStreamTerminatingEvent, - IStreamWarningEvent, - IWaitingMediaSourceReloadInternalEvent, -} from "./types"; - -const EVENTS = { - activePeriodChanged(period : Period) : IActivePeriodChangedEvent { - return { type : "activePeriodChanged", - value : { period } }; - }, - - adaptationChange( - bufferType : IBufferType, - adaptation : Adaptation|null, - period : Period - ) : IAdaptationChangeEvent { - return { type: "adaptationChange", - value : { type: bufferType, - adaptation, - period } }; - }, - - addedSegment( - content : { adaptation : Adaptation; - period : Period; - representation : Representation; }, - segment : ISegment, - buffered : TimeRanges, - segmentData : T - ) : IStreamEventAddedSegment { - return { type : "added-segment", - value : { content, - segment, - segmentData, - buffered } }; - }, - - bitrateEstimationChange( - type : IBufferType, - bitrate : number|undefined - ) : IBitrateEstimationChangeEvent { - return { type: "bitrateEstimationChange", - value: { type, bitrate } }; - }, - - endOfStream() : IEndOfStreamEvent { - return { type: "end-of-stream", - value: undefined }; - }, - - needsManifestRefresh() : IStreamNeedsManifestRefresh { - return { type : "needs-manifest-refresh", - value : undefined }; - }, - - manifestMightBeOufOfSync() : IStreamManifestMightBeOutOfSync { - return { type : "manifest-might-be-out-of-sync", - value : undefined }; - }, - - /** - * @param {number} reloadAt - Position at which we should reload - * @param {boolean} reloadOnPause - If `false`, stay on pause after reloading. - * if `true`, automatically play once reloaded. - * @returns {Object} - */ - needsMediaSourceReload( - reloadAt : number, - reloadOnPause : boolean - ) : INeedsMediaSourceReload { - return { type: "needs-media-source-reload", - value: { position : reloadAt, - autoPlay : reloadOnPause } }; - }, - - /** - * @param {string} bufferType - The buffer type for which the stream cannot - * currently load segments. - * @param {Object} period - The Period for which the stream cannot - * currently load segments. - * media source reload is linked. - * @returns {Object} - */ - lockedStream( - bufferType : IBufferType, - period : Period - ) : ILockedStreamEvent { - return { type: "locked-stream", - value: { bufferType, period } }; - }, - - needsBufferFlush(): INeedsBufferFlushEvent { - return { type: "needs-buffer-flush", value: undefined }; - }, - - needsDecipherabilityFlush( - position : number, - autoPlay : boolean, - duration : number - ) : INeedsDecipherabilityFlush { - return { type: "needs-decipherability-flush", - value: { position, autoPlay, duration } }; - }, - - periodStreamReady( - type : IBufferType, - period : Period, - adaptation$ : Subject - ) : IPeriodStreamReadyEvent { - return { type: "periodStreamReady", - value: { type, period, adaptation$ } }; - }, - - periodStreamCleared( - type : IBufferType, - period : Period - ) : IPeriodStreamClearedEvent { - return { type: "periodStreamCleared", - value: { type, period } }; - }, - - encryptionDataEncountered( - reprProtData : IRepresentationProtectionData, - content : { manifest : Manifest; - period : Period; - adaptation : Adaptation; - representation : Representation; } - ) : IEncryptionDataEncounteredEvent { - return { type: "encryption-data-encountered", - value: objectAssign({ content }, reprProtData) }; - }, - - representationChange( - type : IBufferType, - period : Period, - representation : Representation - ) : IRepresentationChangeEvent { - return { type: "representationChange", - value: { type, period, representation } }; - }, - - streamTerminating() : IStreamTerminatingEvent { - return { type: "stream-terminating", - value: undefined }; - }, - - resumeStream() : IResumeStreamEvent { - return { type: "resume-stream", - value: undefined }; - }, - - warning(value : IPlayerError) : IStreamWarningEvent { - return { type: "warning", value }; - }, - - waitingMediaSourceReload( - bufferType : IBufferType, - period : Period, - position : number, - autoPlay : boolean - ) : IWaitingMediaSourceReloadInternalEvent { - return { type: "waiting-media-source-reload", - value: { bufferType, period, position, autoPlay } }; - }, -}; - -export default EVENTS; diff --git a/src/core/stream/index.ts b/src/core/stream/index.ts index ffdc4990e4..cbc86ca6ea 100644 --- a/src/core/stream/index.ts +++ b/src/core/stream/index.ts @@ -17,12 +17,20 @@ import StreamOrchestrator, { IStreamOrchestratorOptions, IStreamOrchestratorPlaybackObservation, + IStreamOrchestratorCallbacks, } from "./orchestrator"; export { IAudioTrackSwitchingMode } from "./period"; -export * from "./types"; +export { + IInbandEvent, + IStreamStatusPayload, +} from "./representation"; +export { + IWaitingMediaSourceReloadPayload, +} from "./adaptation"; export default StreamOrchestrator; export { IStreamOrchestratorPlaybackObservation, IStreamOrchestratorOptions, + IStreamOrchestratorCallbacks, }; diff --git a/src/core/stream/orchestrator/active_period_emitter.ts b/src/core/stream/orchestrator/active_period_emitter.ts deleted file mode 100644 index b9dc63f276..0000000000 --- a/src/core/stream/orchestrator/active_period_emitter.ts +++ /dev/null @@ -1,146 +0,0 @@ -/** - * 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. - */ - -import { - distinctUntilChanged, - filter, - map, - merge as observableMerge, - Observable, - scan, -} from "rxjs"; -import { Period } from "../../../manifest"; -import { IBufferType } from "../../segment_buffers"; -import { IStreamOrchestratorEvent } from "../types"; - -interface IPeriodObject { period : Period; - buffers: Set; } - -type IPeriodsList = Partial>; - -/** - * Emit the active Period each times it changes. - * - * The active Period is the first Period (in chronological order) which has - * a RepresentationStream associated for every defined BUFFER_TYPES. - * - * Emit null if no Period can be considered active currently. - * - * @example - * For 4 BUFFER_TYPES: "AUDIO", "VIDEO", "TEXT" and "IMAGE": - * ``` - * +-------------+ - * Period 1 | Period 2 | Period 3 - * AUDIO |=========| | |=== | | - * VIDEO | |===== | | - * TEXT |(NO TEXT)| | |(NO TEXT)| | |==== | - * IMAGE |=========| | |= | | - * +-------------+ - * - * The active Period here is Period 2 as Period 1 has no video - * RepresentationStream. - * - * If we are missing a or multiple PeriodStreams in the first chronological - * Period, like that is the case here, it generally means that we are - * currently switching between Periods. - * - * For here we are surely switching from Period 1 to Period 2 beginning by the - * video PeriodStream. As every PeriodStream is ready for Period 2, we can - * already inform that it is the current Period. - * ``` - * - * @param {Array.} buffers$ - * @returns {Observable} - */ -export default function ActivePeriodEmitter( - buffers$: Array> -) : Observable { - const numberOfStreams = buffers$.length; - return observableMerge(...buffers$).pipe( - // not needed to filter, this is an optim - filter(({ type }) => type === "periodStreamCleared" || - type === "adaptationChange" || - type === "representationChange"), - scan((acc, evt) => { - switch (evt.type) { - case "periodStreamCleared": { - const { period, type } = evt.value; - const currentInfos = acc[period.id]; - if (currentInfos !== undefined && currentInfos.buffers.has(type)) { - currentInfos.buffers.delete(type); - if (currentInfos.buffers.size === 0) { - delete acc[period.id]; - } - } - } - break; - - case "adaptationChange": { - // For Adaptations that are not null, we will receive a - // `representationChange` event. We can thus skip this event and only - // listen to the latter. - if (evt.value.adaptation !== null) { - return acc; - } - } - // /!\ fallthrough done on purpose - // Note that we fall-through only when the Adaptation sent through the - // `adaptationChange` event is `null`. This is because in those cases, - // we won't receive any "representationChange" event. We however still - // need to register that Period as active for the current type. - // eslint-disable-next-line no-fallthrough - case "representationChange": { - const { period, type } = evt.value; - const currentInfos = acc[period.id]; - if (currentInfos === undefined) { - const bufferSet = new Set(); - bufferSet.add(type); - acc[period.id] = { period, buffers: bufferSet }; - } else if (!currentInfos.buffers.has(type)) { - currentInfos.buffers.add(type); - } - } - break; - - } - return acc; - }, {}), - - map((list) : Period | null => { - const activePeriodIDs = Object.keys(list); - const completePeriods : Period[] = []; - for (let i = 0; i < activePeriodIDs.length; i++) { - const periodInfos = list[activePeriodIDs[i]]; - if (periodInfos !== undefined && periodInfos.buffers.size === numberOfStreams) { - completePeriods.push(periodInfos.period); - } - } - - return completePeriods.reduce((acc, period) => { - if (acc === null) { - return period; - } - return period.start < acc.start ? period : - acc; - }, null); - }), - - distinctUntilChanged((a, b) => { - return a === null && b === null || - a !== null && b !== null && a.id === b.id; - }) - ); -} diff --git a/src/core/stream/orchestrator/are_streams_complete.ts b/src/core/stream/orchestrator/are_streams_complete.ts deleted file mode 100644 index 77bb925801..0000000000 --- a/src/core/stream/orchestrator/are_streams_complete.ts +++ /dev/null @@ -1,78 +0,0 @@ -/** - * 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. - */ - -import { - combineLatest as observableCombineLatest, - distinctUntilChanged, - filter, - map, - Observable, - startWith, -} from "rxjs"; -import Manifest from "../../../manifest"; -import filterMap from "../../../utils/filter_map"; -import { IStreamOrchestratorEvent, IStreamStatusEvent } from "../types"; - -/** - * Returns an Observable which emits ``true`` when all PeriodStreams given are - * _complete_. - * Returns false otherwise. - * - * A PeriodStream for a given type is considered _complete_ when both of these - * conditions are true: - * - it is the last PeriodStream in the content for the given type - * - it has finished downloading segments (it is _full_) - * - * Simply put a _complete_ PeriodStream for a given type means that every - * segments needed for this Stream have been downloaded. - * - * When the Observable returned here emits, every Stream are finished. - * @param {Object} manifest - * @param {...Observable} streams - * @returns {Observable} - */ -export default function areStreamsComplete( - manifest : Manifest, - ...streams : Array> -) : Observable { - /** - * Array of Observables linked to the Array of Streams which emit: - * - true when the corresponding Stream is considered _complete_. - * - false when the corresponding Stream is considered _active_. - * @type {Array.} - */ - const isCompleteArray : Array> = streams - .map((stream) => { - return stream.pipe( - filter((evt) : evt is IStreamStatusEvent => evt.type === "stream-status"), - filterMap((evt) => { - if (evt.value.hasFinishedLoading || evt.value.isEmptyStream) { - return manifest.getPeriodAfter(evt.value.period) === null ? - true : - null; // not the last Period: ignore event - } - return false; - }, null), - startWith(false), - distinctUntilChanged() - ); - }); - - return observableCombineLatest(isCompleteArray).pipe( - map((areComplete) => areComplete.every((isComplete) => isComplete)), - distinctUntilChanged() - ); -} diff --git a/src/core/stream/orchestrator/index.ts b/src/core/stream/orchestrator/index.ts index 0b31e55cc8..4fdf56bb09 100644 --- a/src/core/stream/orchestrator/index.ts +++ b/src/core/stream/orchestrator/index.ts @@ -15,12 +15,14 @@ */ import StreamOrchestrator, { + IStreamOrchestratorCallbacks, IStreamOrchestratorOptions, IStreamOrchestratorPlaybackObservation, } from "./stream_orchestrator"; export default StreamOrchestrator; export { + IStreamOrchestratorCallbacks, IStreamOrchestratorOptions, IStreamOrchestratorPlaybackObservation, }; diff --git a/src/core/stream/orchestrator/stream_orchestrator.ts b/src/core/stream/orchestrator/stream_orchestrator.ts index a16ebece28..bb74496875 100644 --- a/src/core/stream/orchestrator/stream_orchestrator.ts +++ b/src/core/stream/orchestrator/stream_orchestrator.ts @@ -14,27 +14,7 @@ * limitations under the License. */ -import { - combineLatest, - concat as observableConcat, - defer as observableDefer, - distinctUntilChanged, - EMPTY, - exhaustMap, - filter, - ignoreElements, - map, - merge as observableMerge, - mergeMap, - Observable, - of as observableOf, - share, - startWith, - Subject, - take, - takeUntil, - tap, -} from "rxjs"; +import nextTick from "next-tick"; import config from "../../../config"; import { MediaError } from "../../../errors"; import log from "../../../log"; @@ -42,17 +22,14 @@ import Manifest, { IDecipherabilityUpdateElement, Period, } from "../../../manifest"; -import deferSubscriptions from "../../../utils/defer_subscriptions"; -import { fromEvent } from "../../../utils/event_emitter"; -import filterMap from "../../../utils/filter_map"; import { createMappedReference, IReadOnlySharedReference, } from "../../../utils/reference"; -import fromCancellablePromise from "../../../utils/rx-from_cancellable_promise"; -import nextTickObs from "../../../utils/rx-next-tick"; import SortedList from "../../../utils/sorted_list"; -import TaskCanceller from "../../../utils/task_canceller"; +import TaskCanceller, { + CancellationSignal, +} from "../../../utils/task_canceller"; import WeakMapMemory from "../../../utils/weak_map_memory"; import { IRepresentationEstimator } from "../../adaptive"; import type { IReadOnlyPlaybackObserver } from "../../api"; @@ -62,39 +39,18 @@ import SegmentBuffersStore, { IBufferType, SegmentBuffer, } from "../../segment_buffers"; -import EVENTS from "../events_generators"; +import { IWaitingMediaSourceReloadPayload } from "../adaptation"; import PeriodStream, { - IPeriodStreamPlaybackObservation, + IPeriodStreamCallbacks, IPeriodStreamOptions, + IPeriodStreamPlaybackObservation, + IPeriodStreamReadyPayload, } from "../period"; -import { - IMultiplePeriodStreamsEvent, - IPeriodStreamEvent, - IStreamOrchestratorEvent, -} from "../types"; -import ActivePeriodEmitter from "./active_period_emitter"; -import areStreamsComplete from "./are_streams_complete"; +import { IStreamStatusPayload } from "../representation"; import getTimeRangesForContent from "./get_time_ranges_for_content"; -// NOTE As of now (RxJS 7.4.0), RxJS defines `ignoreElements` default -// first type parameter as `any` instead of the perfectly fine `unknown`, -// leading to linter issues, as it forbids the usage of `any`. -// This is why we're disabling the eslint rule. -/* eslint-disable @typescript-eslint/no-unsafe-argument */ - -export type IStreamOrchestratorPlaybackObservation = IPeriodStreamPlaybackObservation; - - -/** Options tweaking the behavior of the StreamOrchestrator. */ -export type IStreamOrchestratorOptions = - IPeriodStreamOptions & - { wantedBufferAhead : IReadOnlySharedReference; - maxVideoBufferSize : IReadOnlySharedReference; - maxBufferAhead : IReadOnlySharedReference; - maxBufferBehind : IReadOnlySharedReference; }; - /** - * Create and manage the various Stream Observables needed for the content to + * Create and manage the various "Streams" needed for the content to * play: * * - Create or dispose SegmentBuffers depending on the chosen Adaptations. @@ -106,7 +62,7 @@ export type IStreamOrchestratorOptions = * - Concatenate Streams for adaptation from separate Periods at the right * time, to allow smooth transitions between periods. * - * - Emit various events to notify of its health and issues + * - Call various callbacks to notify of its health and issues * * @param {Object} content * @param {Observable} playbackObserver - Emit position information @@ -116,7 +72,24 @@ export type IStreamOrchestratorOptions = * SegmentBuffer instances associated with the current content. * @param {Object} segmentFetcherCreator - Allow to download segments. * @param {Object} options - * @returns {Observable} + * @param {Object} callbacks - The `StreamOrchestrator` relies on a system of + * callbacks that it will call on various events. + * + * Depending on the event, the caller may be supposed to perform actions to + * react upon some of them. + * + * This approach is taken instead of a more classical EventEmitter pattern to: + * - Allow callbacks to be called synchronously after the + * `StreamOrchestrator` is called. + * - Simplify bubbling events up, by just passing through callbacks + * - Force the caller to explicitely handle or not the different events. + * + * Callbacks may start being called immediately after the `StreamOrchestrator` + * call and may be called until either the `parentCancelSignal` argument is + * triggered, or until the `error` callback is called, whichever comes first. + * @param {Object} orchestratorCancelSignal - `CancellationSignal` allowing, + * when triggered, to immediately stop all operations the `PeriodStream` is + * doing. */ export default function StreamOrchestrator( content : { manifest : Manifest; @@ -125,8 +98,10 @@ export default function StreamOrchestrator( representationEstimator : IRepresentationEstimator, segmentBuffersStore : SegmentBuffersStore, segmentFetcherCreator : SegmentFetcherCreator, - options: IStreamOrchestratorOptions -) : Observable { + options : IStreamOrchestratorOptions, + callbacks : IStreamOrchestratorCallbacks, + orchestratorCancelSignal : CancellationSignal +) : void { const { manifest, initialPeriod } = content; const { maxBufferAhead, maxBufferBehind, @@ -135,6 +110,7 @@ export default function StreamOrchestrator( const { MAXIMUM_MAX_BUFFER_AHEAD, MAXIMUM_MAX_BUFFER_BEHIND } = config.getCurrent(); + // Keep track of a unique BufferGarbageCollector created per // SegmentBuffer. const garbageCollectors = @@ -146,56 +122,28 @@ export default function StreamOrchestrator( const defaultMaxAhead = MAXIMUM_MAX_BUFFER_AHEAD[bufferType] != null ? MAXIMUM_MAX_BUFFER_AHEAD[bufferType] as number : Infinity; - return new Observable(() => { - const canceller = new TaskCanceller(); + return (gcCancelSignal : CancellationSignal) => { BufferGarbageCollector( { segmentBuffer, playbackObserver, maxBufferBehind: createMappedReference(maxBufferBehind, (val) => Math.min(val, defaultMaxBehind), - canceller.signal), + gcCancelSignal), maxBufferAhead: createMappedReference(maxBufferAhead, (val) => Math.min(val, defaultMaxAhead), - canceller.signal) }, - canceller.signal + gcCancelSignal) }, + gcCancelSignal ); - return () => { canceller.cancel(); }; - }); + }; }); - // Every PeriodStreams for every possible types - const streamsArray = segmentBuffersStore.getBufferTypes().map((bufferType) => { - return manageEveryStreams(bufferType, initialPeriod) - .pipe(deferSubscriptions(), share()); + // Create automatically the right `PeriodStream` for every possible types + segmentBuffersStore.getBufferTypes().map((bufferType) => { + manageEveryStreams(bufferType, initialPeriod); }); - // Emits the activePeriodChanged events every time the active Period changes. - const activePeriodChanged$ = ActivePeriodEmitter(streamsArray).pipe( - filter((period) : period is Period => period !== null), - map(period => { - log.info("Stream: New active period", period.start); - return EVENTS.activePeriodChanged(period); - })); - - const isLastPeriodKnown$ = fromEvent(manifest, "manifestUpdate").pipe( - map(() => manifest.isLastPeriodKnown), - startWith(manifest.isLastPeriodKnown), - distinctUntilChanged() - ); - - // Emits an "end-of-stream" event once every PeriodStream are complete. - // Emits a 'resume-stream" when it's not - const endOfStream$ = combineLatest([areStreamsComplete(manifest, ...streamsArray), - isLastPeriodKnown$]) - .pipe(map(([areComplete, isLastPeriodKnown]) => areComplete && isLastPeriodKnown), - distinctUntilChanged(), - map((emitEndOfStream) => - emitEndOfStream ? EVENTS.endOfStream() : EVENTS.resumeStream())); - - return observableMerge(...streamsArray, activePeriodChanged$, endOfStream$); - /** * Manage creation and removal of Streams for every Periods for a given type. * @@ -204,58 +152,98 @@ export default function StreamOrchestrator( * current position goes out of the bounds of these Streams. * @param {string} bufferType - e.g. "audio" or "video" * @param {Period} basePeriod - Initial Period downloaded. - * @returns {Observable} */ - function manageEveryStreams( - bufferType : IBufferType, - basePeriod : Period - ) : Observable { - // Each Period for which there is currently a Stream, chronologically + function manageEveryStreams(bufferType : IBufferType, basePeriod : Period) : void { + /** Each Period for which there is currently a Stream, chronologically */ const periodList = new SortedList((a, b) => a.start - b.start); - const destroyStreams$ = new Subject(); - // When set to `true`, all the currently active PeriodStream will be destroyed - // and re-created from the new current position if we detect it to be out of - // their bounds. - // This is set to false when we're in the process of creating the first - // PeriodStream, to avoid interferences while no PeriodStream is available. + /** + * When set to `true`, all the currently active PeriodStream will be destroyed + * and re-created from the new current position if we detect it to be out of + * their bounds. + * This is set to false when we're in the process of creating the first + * PeriodStream, to avoid interferences while no PeriodStream is available. + */ let enableOutOfBoundsCheck = false; + /** Cancels currently created `PeriodStream`s. */ + let currentCanceller = new TaskCanceller({ cancelOn: orchestratorCancelSignal }); + + // Restart the current Stream when the wanted time is in another period + // than the ones already considered + playbackObserver.listen(({ position }) => { + const time = position.pending ?? position.last; + if (!enableOutOfBoundsCheck || !isOutOfPeriodList(time)) { + return ; + } + + log.info("Stream: Destroying all PeriodStreams due to out of bounds situation", + bufferType, time); + enableOutOfBoundsCheck = false; + while (periodList.length() > 0) { + const period = periodList.get(periodList.length() - 1); + periodList.removeElement(period); + callbacks.periodStreamCleared({ type: bufferType, period }); + } + currentCanceller.cancel(); + currentCanceller = new TaskCanceller({ cancelOn: orchestratorCancelSignal }); + + const nextPeriod = manifest.getPeriodForTime(time) ?? + manifest.getNextPeriod(time); + if (nextPeriod === undefined) { + log.warn("Stream: The wanted position is not found in the Manifest."); + return; + } + launchConsecutiveStreamsForPeriod(nextPeriod); + }, { clearSignal: orchestratorCancelSignal, includeLastObservation: true }); + + manifest.addEventListener("decipherabilityUpdate", (evt) => { + onDecipherabilityUpdates(evt).catch(err => { + currentCanceller.cancel(); + callbacks.error(err); + }); + }, orchestratorCancelSignal); + + return launchConsecutiveStreamsForPeriod(basePeriod); + /** * @param {Object} period - * @returns {Observable} */ - function launchConsecutiveStreamsForPeriod( - period : Period - ) : Observable { - return manageConsecutivePeriodStreams(bufferType, period, destroyStreams$).pipe( - map((message) => { - switch (message.type) { - case "waiting-media-source-reload": - // Only reload the MediaSource when the more immediately required - // Period is the one asking for it - const firstPeriod = periodList.head(); - if (firstPeriod === undefined || - firstPeriod.id !== message.value.period.id) - { - return EVENTS.lockedStream(message.value.bufferType, - message.value.period); - } else { - const { position, autoPlay } = message.value; - return EVENTS.needsMediaSourceReload(position, autoPlay); - } - case "periodStreamReady": - enableOutOfBoundsCheck = true; - periodList.add(message.value.period); - break; - case "periodStreamCleared": - periodList.removeElement(message.value.period); - break; + function launchConsecutiveStreamsForPeriod(period : Period) : void { + const consecutivePeriodStreamCb = { + ...callbacks, + waitingMediaSourceReload(payload : IWaitingMediaSourceReloadPayload) : void { + // Only reload the MediaSource when the more immediately required + // Period is the one asking for it + const firstPeriod = periodList.head(); + if (firstPeriod === undefined || + firstPeriod.id !== payload.period.id) + { + callbacks.lockedStream({ bufferType: payload.bufferType, + period: payload.period }); + } else { + const { position, autoPlay } = payload; + callbacks.needsMediaSourceReload({ position, autoPlay }); } - return message; - }), - share() - ); + }, + periodStreamReady(payload : IPeriodStreamReadyPayload) : void { + enableOutOfBoundsCheck = true; + periodList.add(payload.period); + callbacks.periodStreamReady(payload); + }, + periodStreamCleared(payload : IPeriodStreamClearedPayload) : void { + periodList.removeElement(payload.period); + callbacks.periodStreamCleared(payload); + }, + error(err : unknown) : void { + currentCanceller.cancel(); + callbacks.error(err); + }, + }; + manageConsecutivePeriodStreams(bufferType, + period, + consecutivePeriodStreamCb, + currentCanceller.signal); } /** @@ -276,51 +264,14 @@ export default function StreamOrchestrator( last.end) < time; } - // Restart the current Stream when the wanted time is in another period - // than the ones already considered - const observation$ = playbackObserver.getReference().asObservable(); - const restartStreamsWhenOutOfBounds$ = observation$.pipe( - filterMap< - IStreamOrchestratorPlaybackObservation, - Period, - null - >(({ position }) => { - const time = position.pending ?? position.last; - if (!enableOutOfBoundsCheck || !isOutOfPeriodList(time)) { - return null; - } - const nextPeriod = manifest.getPeriodForTime(time) ?? - manifest.getNextPeriod(time); - if (nextPeriod === undefined) { - return null; - } - log.info("SO: Current position out of the bounds of the active periods," + - "re-creating Streams.", - bufferType, - time); - enableOutOfBoundsCheck = false; - destroyStreams$.next(); - return nextPeriod; - }, null), - mergeMap((newInitialPeriod) => { - if (newInitialPeriod == null) { - throw new MediaError("MEDIA_TIME_NOT_FOUND", - "The wanted position is not found in the Manifest."); - } - return launchConsecutiveStreamsForPeriod(newInitialPeriod); - }) - ); - - const handleDecipherabilityUpdate$ = fromEvent(manifest, "decipherabilityUpdate") - .pipe(mergeMap(onDecipherabilityUpdates)); - - return observableMerge(restartStreamsWhenOutOfBounds$, - handleDecipherabilityUpdate$, - launchConsecutiveStreamsForPeriod(basePeriod)); - - function onDecipherabilityUpdates( + /** + * React to a Manifest's decipherability updates. + * @param {Array.} + * @returns {Promise} + */ + async function onDecipherabilityUpdates( updates : IDecipherabilityUpdateElement[] - ) : Observable { + ) : Promise { const segmentBufferStatus = segmentBuffersStore.getStatus(bufferType); const ofCurrentType = updates .filter(update => update.adaptation.type === bufferType); @@ -333,7 +284,7 @@ export default function StreamOrchestrator( ) { // Data won't have to be removed from the buffers, no need to stop the // current Streams. - return EMPTY; + return ; } const segmentBuffer = segmentBufferStatus.value; @@ -363,179 +314,326 @@ export default function StreamOrchestrator( // First close all Stream currently active so they don't continue to // load and push segments. enableOutOfBoundsCheck = false; - destroyStreams$.next(); + + log.info("Stream: Destroying all PeriodStreams for decipherability matters", + bufferType); + while (periodList.length() > 0) { + const period = periodList.get(periodList.length() - 1); + periodList.removeElement(period); + callbacks.periodStreamCleared({ type: bufferType, period }); + } + + currentCanceller.cancel(); + currentCanceller = new TaskCanceller({ cancelOn: orchestratorCancelSignal }); /** Remove from the `SegmentBuffer` all the concerned time ranges. */ - const cleanOperations = [...undecipherableRanges, ...rangesToRemove] - .map(({ start, end }) => { - if (start >= end) { - return EMPTY; + for (const { start, end } of [...undecipherableRanges, ...rangesToRemove]) { + if (start < end) { + await segmentBuffer.removeBuffer(start, end, orchestratorCancelSignal); + } + } + + // Schedule micro task before checking the last playback observation + // to reduce the risk of race conditions where the next observation + // was going to be emitted synchronously. + nextTick(() => { + if (orchestratorCancelSignal.isCancelled) { + return ; + } + const observation = playbackObserver.getReference().getValue(); + if (needsFlushingAfterClean(observation, undecipherableRanges)) { + const shouldAutoPlay = !(observation.paused.pending ?? + playbackObserver.getIsPaused()); + callbacks.needsDecipherabilityFlush({ position: observation.position.last, + autoPlay: shouldAutoPlay, + duration: observation.duration }); + if (orchestratorCancelSignal.isCancelled) { + return ; } - const canceller = new TaskCanceller(); - return fromCancellablePromise(canceller, () => { - return segmentBuffer.removeBuffer(start, end, canceller.signal); - }).pipe(ignoreElements()); - }); - - return observableConcat( - ...cleanOperations, - - // Schedule micro task before checking the last playback observation - // to reduce the risk of race conditions where the next observation - // was going to be emitted synchronously. - nextTickObs().pipe(ignoreElements()), - playbackObserver.getReference().asObservable().pipe( - take(1), - mergeMap((observation) => { - const restartStream$ = observableDefer(() => { - const lastPosition = observation.position.pending ?? - observation.position.last; - const newInitialPeriod = manifest.getPeriodForTime(lastPosition); - if (newInitialPeriod == null) { - throw new MediaError( - "MEDIA_TIME_NOT_FOUND", - "The wanted position is not found in the Manifest."); - } - return launchConsecutiveStreamsForPeriod(newInitialPeriod); - }); - - - if (needsFlushingAfterClean(observation, undecipherableRanges)) { - const shouldAutoPlay = !(observation.paused.pending ?? - playbackObserver.getIsPaused()); - return observableConcat( - observableOf(EVENTS.needsDecipherabilityFlush(observation.position.last, - shouldAutoPlay, - observation.duration)), - restartStream$); - } else if (needsFlushingAfterClean(observation, rangesToRemove)) { - return observableConcat(observableOf(EVENTS.needsBufferFlush()), - restartStream$); - } - return restartStream$; - }))); + } else if (needsFlushingAfterClean(observation, rangesToRemove)) { + callbacks.needsBufferFlush(); + if (orchestratorCancelSignal.isCancelled) { + return ; + } + } + + const lastPosition = observation.position.pending ?? + observation.position.last; + const newInitialPeriod = manifest.getPeriodForTime(lastPosition); + if (newInitialPeriod == null) { + callbacks.error( + new MediaError("MEDIA_TIME_NOT_FOUND", + "The wanted position is not found in the Manifest.") + ); + return; + } + launchConsecutiveStreamsForPeriod(newInitialPeriod); + }); } } /** * Create lazily consecutive PeriodStreams: * - * It first creates the PeriodStream for `basePeriod` and - once it becomes + * It first creates the `PeriodStream` for `basePeriod` and - once it becomes * full - automatically creates the next chronological one. - * This process repeats until the PeriodStream linked to the last Period is + * This process repeats until the `PeriodStream` linked to the last Period is * full. * - * If an "old" PeriodStream becomes active again, it destroys all PeriodStream - * coming after it (from the last chronological one to the first). + * If an "old" `PeriodStream` becomes active again, it destroys all + * `PeriodStream` coming after it (from the last chronological one to the + * first). * * To clean-up PeriodStreams, each one of them are also automatically * destroyed once the current position is superior or equal to the end of * the concerned Period. * - * A "periodStreamReady" event is sent each times a new PeriodStream is - * created. The first one (for `basePeriod`) should be sent synchronously on - * subscription. + * The "periodStreamReady" callback is alled each times a new `PeriodStream` + * is created. * - * A "periodStreamCleared" event is sent each times a PeriodStream is - * destroyed. + * The "periodStreamCleared" callback is called each times a PeriodStream is + * destroyed (this callback is though not called if it was destroyed due to + * the given `cancelSignal` emitting or due to a fatal error). * @param {string} bufferType - e.g. "audio" or "video" * @param {Period} basePeriod - Initial Period downloaded. - * @param {Observable} destroy$ - Emit when/if all created Streams from this - * point should be destroyed. - * @returns {Observable} + * @param {Object} consecutivePeriodStreamCb - Callbacks called on various + * events. See type for more information. + * @param {Object} cancelSignal - `CancellationSignal` allowing to stop + * everything that this function was doing. Callbacks in + * `consecutivePeriodStreamCb` might still be sent as a consequence of this + * signal emitting. */ function manageConsecutivePeriodStreams( bufferType : IBufferType, basePeriod : Period, - destroy$ : Observable - ) : Observable { - log.info("SO: Creating new Stream for", bufferType, basePeriod.start); - - // Emits the Period of the next Period Stream when it can be created. - const createNextPeriodStream$ = new Subject(); - - // Emits when the Streams for the next Periods should be destroyed, if - // created. - const destroyNextStreams$ = new Subject(); - - // Emits when the current position goes over the end of the current Stream. - const endOfCurrentStream$ = playbackObserver.getReference().asObservable() - .pipe(filter(({ position }) => - basePeriod.end != null && - (position.pending ?? position.last) >= basePeriod.end)); - - // Create Period Stream for the next Period. - const nextPeriodStream$ = createNextPeriodStream$ - .pipe(exhaustMap((nextPeriod) => - manageConsecutivePeriodStreams(bufferType, nextPeriod, destroyNextStreams$) - )); - - // Allows to destroy each created Stream, from the newest to the oldest, - // once destroy$ emits. - const destroyAll$ = destroy$.pipe( - take(1), - tap(() => { - // first complete createNextStream$ to allow completion of the - // nextPeriodStream$ observable once every further Streams have been - // cleared. - createNextPeriodStream$.complete(); - - // emit destruction signal to the next Stream first - destroyNextStreams$.next(); - destroyNextStreams$.complete(); // we do not need it anymore - }), - share() // share side-effects - ); - - // Will emit when the current Stream should be destroyed. - const killCurrentStream$ = observableMerge(endOfCurrentStream$, destroyAll$); - - const periodStream$ = PeriodStream({ bufferType, - content: { manifest, period: basePeriod }, - garbageCollectors, - maxVideoBufferSize, - segmentFetcherCreator, - segmentBuffersStore, - options, - playbackObserver, - representationEstimator, - wantedBufferAhead } - ).pipe( - mergeMap((evt : IPeriodStreamEvent) : Observable => { - if (evt.type === "stream-status") { - if (evt.value.hasFinishedLoading) { - const nextPeriod = manifest.getPeriodAfter(basePeriod); - if (nextPeriod === null) { - return observableOf(evt); - } + consecutivePeriodStreamCb : IPeriodStreamCallbacks & { + periodStreamCleared(payload : IPeriodStreamClearedPayload) : void; + }, + cancelSignal : CancellationSignal + ) : void { + log.info("Stream: Creating new Stream for", bufferType, basePeriod.start); + /** + * Contains properties linnked to the next chronological `PeriodStream` that + * may be created here. + */ + let nextStreamInfo : { + /** Emits when the `PeriodStreamfor should be destroyed, if created. */ + canceller : TaskCanceller; + /** The `Period` concerned. */ + period : Period; + } | null = null; + + /** Emits when the `PeriodStream` linked to `basePeriod` should be destroyed. */ + const currentStreamCanceller = new TaskCanceller({ cancelOn: cancelSignal }); + + // Stop current PeriodStream when the current position goes over the end of + // that Period. + playbackObserver.listen(({ position }, stopListeningObservations) => { + if (basePeriod.end !== undefined && + (position.pending ?? position.last) >= basePeriod.end) + { + log.info("Stream: Destroying PeriodStream as the current playhead moved above it", + bufferType, + basePeriod.start, + position.pending ?? position.last, + basePeriod.end); + stopListeningObservations(); + consecutivePeriodStreamCb.periodStreamCleared({ type: bufferType, + period: basePeriod }); + currentStreamCanceller.cancel(); + } + }, { clearSignal: cancelSignal, includeLastObservation: true }); + + const periodStreamArgs = { bufferType, + content: { manifest, period: basePeriod }, + garbageCollectors, + maxVideoBufferSize, + segmentFetcherCreator, + segmentBuffersStore, + options, + playbackObserver, + representationEstimator, + wantedBufferAhead }; + const periodStreamCallbacks : IPeriodStreamCallbacks = { + ...consecutivePeriodStreamCb, + streamStatusUpdate(value : IStreamStatusPayload) : void { + if (value.hasFinishedLoading) { + const nextPeriod = manifest.getPeriodAfter(basePeriod); + if (nextPeriod !== null) { // current Stream is full, create the next one if not - createNextPeriodStream$.next(nextPeriod); - } else { - // current Stream is active, destroy next Stream if created - destroyNextStreams$.next(); + createNextPeriodStream(nextPeriod); } + } else if (nextStreamInfo !== null) { + // current Stream is active, destroy next Stream if created + log.info("Stream: Destroying next PeriodStream due to current one being active", + bufferType, nextStreamInfo.period.start); + consecutivePeriodStreamCb + .periodStreamCleared({ type: bufferType, period: nextStreamInfo.period }); + nextStreamInfo.canceller.cancel(); + nextStreamInfo = null; } - return observableOf(evt); - }), - share() - ); - - // Stream for the current Period. - const currentStream$ : Observable = - observableConcat( - periodStream$.pipe(takeUntil(killCurrentStream$)), - observableOf(EVENTS.periodStreamCleared(bufferType, basePeriod)) - .pipe(tap(() => { - log.info("SO: Destroying Stream for", bufferType, basePeriod.start); - }))); - - return observableMerge(currentStream$, - nextPeriodStream$, - destroyAll$.pipe(ignoreElements())); + consecutivePeriodStreamCb.streamStatusUpdate(value); + }, + error(err : unknown) : void { + if (nextStreamInfo !== null) { + nextStreamInfo.canceller.cancel(); + nextStreamInfo = null; + } + currentStreamCanceller.cancel(); + consecutivePeriodStreamCb.error(err); + }, + }; + + PeriodStream(periodStreamArgs, periodStreamCallbacks, currentStreamCanceller.signal); + + /** + * Create `PeriodStream` for the next Period, specified under `nextPeriod`. + * @param {Object} nextPeriod + */ + function createNextPeriodStream(nextPeriod : Period) : void { + if (nextStreamInfo !== null) { + log.warn("Stream: Creating next `PeriodStream` while it was already created."); + consecutivePeriodStreamCb.periodStreamCleared({ type: bufferType, + period: nextStreamInfo.period }); + nextStreamInfo.canceller.cancel(); + } + nextStreamInfo = { canceller: new TaskCanceller({ cancelOn: cancelSignal }), + period: nextPeriod }; + manageConsecutivePeriodStreams(bufferType, + nextPeriod, + consecutivePeriodStreamCb, + nextStreamInfo.canceller.signal); + } } } +export type IStreamOrchestratorPlaybackObservation = IPeriodStreamPlaybackObservation; + +/** Options tweaking the behavior of the StreamOrchestrator. */ +export type IStreamOrchestratorOptions = + IPeriodStreamOptions & + { wantedBufferAhead : IReadOnlySharedReference; + maxVideoBufferSize : IReadOnlySharedReference; + maxBufferAhead : IReadOnlySharedReference; + maxBufferBehind : IReadOnlySharedReference; }; + +/** Callbacks called by the `StreamOrchestrator` on various events. */ +export interface IStreamOrchestratorCallbacks + extends Omit +{ + /** + * Called when a `PeriodStream` has been removed. + * This event can be used for clean-up purposes. For example, you are free to + * remove from scope the object used to choose a track for that + * `PeriodStream`. + * + * This callback might not be called when a `PeriodStream` is cleared due to + * an `error` callback or to the `StreamOrchestrator` being cancellated as + * both already indicate implicitly that all `PeriodStream` have been cleared. + */ + periodStreamCleared(payload : IPeriodStreamClearedPayload) : void; + /** + * Called when a situation needs the MediaSource to be reloaded. + * + * Once the MediaSource is reloaded, the `StreamOrchestrator` need to be + * restarted from scratch. + */ + needsMediaSourceReload(payload : INeedsMediaSourceReloadPayload) : void; + /** + * Called when the stream is unable to load segments for a particular Period + * and buffer type until that Period becomes the currently-played Period. + * + * This might be the case for example when a track change happened for an + * upcoming Period, which necessitates the reloading of the media source + * once the Period is the current one. + * Here, the stream might stay in a locked mode for segments linked to that + * Period and buffer type, meaning it will not load any such segment until that + * next Period becomes the current one (in which case it will probably ask to + * reload through the proper callback, `needsMediaSourceReload`). + * + * This callback can be useful when investigating rebuffering situation: one + * might be due to the next Period not loading segment of a certain type + * because of a locked stream. In that case, playing until or seeking at the + * start of the corresponding Period should be enough to "unlock" the stream. + */ + lockedStream(payload : ILockedStreamPayload) : void; + /** + * Called after the SegmentBuffer have been "cleaned" to remove from it + * every non-decipherable segments - usually following an update of the + * decipherability status of some `Representation`(s). + * + * When that event is emitted, the current HTMLMediaElement's buffer might need + * to be "flushed" to continue (e.g. through a little seek operation) or in + * worst cases completely removed and re-created through the "reload" mechanism, + * depending on the platform. + */ + needsDecipherabilityFlush(payload : INeedsDecipherabilityFlushPayload) : void; +} + +/** Payload for the `periodStreamCleared` callback. */ +export interface IPeriodStreamClearedPayload { + /** + * The type of buffer linked to the `PeriodStream` we just removed. + * + * The combination of this and `Period` should give you enough information + * about which `PeriodStream` has been removed. + */ + type : IBufferType; + /** + * The `Period` linked to the `PeriodStream` we just removed. + * + * The combination of this and `Period` should give you enough information + * about which `PeriodStream` has been removed. + */ + period : Period; +} + +/** Payload for the `needsMediaSourceReload` callback. */ +export interface INeedsMediaSourceReloadPayload { + /** + * The position in seconds and the time at which the MediaSource should be + * reset once it has been reloaded. + */ + position : number; + /** + * If `true`, we want the HTMLMediaElement to play right after the reload is + * done. + * If `false`, we want to stay in a paused state at that point. + */ + autoPlay : boolean; +} + +/** Payload for the `lockedStream` callback. */ +export interface ILockedStreamPayload { + /** Period concerned. */ + period : Period; + /** Buffer type concerned. */ + bufferType : IBufferType; +} + +/** Payload for the `needsDecipherabilityFlush` callback. */ +export interface INeedsDecipherabilityFlushPayload { + /** + * Indicated in the case where the MediaSource has to be reloaded, + * in which case the time of the HTMLMediaElement should be reset to that + * position, in seconds, once reloaded. + */ + position : number; + /** + * If `true`, we want the HTMLMediaElement to play right after the flush is + * done. + * If `false`, we want to stay in a paused state at that point. + */ + autoPlay : boolean; + /** + * The duration (maximum seekable position) of the content. + * This is indicated in the case where a seek has to be performed, to avoid + * seeking too far in the content. + */ + duration : number; +} + /** * Returns `true` if low-level buffers have to be "flushed" after the given * `cleanedRanges` time ranges have been removed from an audio or video diff --git a/src/core/stream/period/create_empty_adaptation_stream.ts b/src/core/stream/period/create_empty_adaptation_stream.ts deleted file mode 100644 index d744cf4754..0000000000 --- a/src/core/stream/period/create_empty_adaptation_stream.ts +++ /dev/null @@ -1,71 +0,0 @@ -/** - * 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. - */ - -import { - combineLatest as observableCombineLatest, - mergeMap, - Observable, - of as observableOf, -} from "rxjs"; -import log from "../../../log"; -import { Period } from "../../../manifest"; -import { IReadOnlySharedReference } from "../../../utils/reference"; -import { IReadOnlyPlaybackObserver } from "../../api"; -import { IBufferType } from "../../segment_buffers"; -import { IStreamStatusEvent } from "../types"; -import { IPeriodStreamPlaybackObservation } from "./period_stream"; - -/** - * Create empty AdaptationStream Observable, linked to a Period. - * - * This observable will never download any segment and just emit a "full" - * event when reaching the end. - * @param {Observable} playbackObserver - * @param {Object} wantedBufferAhead - * @param {string} bufferType - * @param {Object} content - * @returns {Observable} - */ -export default function createEmptyAdaptationStream( - playbackObserver : IReadOnlyPlaybackObserver, - wantedBufferAhead : IReadOnlySharedReference, - bufferType : IBufferType, - content : { period : Period } -) : Observable { - const { period } = content; - let hasFinishedLoading = false; - const wantedBufferAhead$ = wantedBufferAhead.asObservable(); - const observation$ = playbackObserver.getReference().asObservable(); - return observableCombineLatest([observation$, - wantedBufferAhead$]).pipe( - mergeMap(([observation, wba]) => { - const position = observation.position.last; - if (period.end !== undefined && position + wba >= period.end) { - log.debug("Stream: full \"empty\" AdaptationStream", bufferType); - hasFinishedLoading = true; - } - return observableOf({ type: "stream-status" as const, - value: { period, - bufferType, - position, - imminentDiscontinuity: null, - isEmptyStream: true, - hasFinishedLoading, - neededSegments: [], - shouldRefreshManifest: false } }); - }) - ); -} diff --git a/src/core/stream/period/index.ts b/src/core/stream/period/index.ts index 64f65b84a2..bb9e36b11c 100644 --- a/src/core/stream/period/index.ts +++ b/src/core/stream/period/index.ts @@ -14,17 +14,7 @@ * limitations under the License. */ -export { IAudioTrackSwitchingMode } from "../../../public_types"; -import PeriodStream, { - IPeriodStreamArguments, - IPeriodStreamOptions, - IPeriodStreamPlaybackObservation, -} from "./period_stream"; +import PeriodStream from "./period_stream"; +export * from "./types"; export default PeriodStream; - -export { - IPeriodStreamArguments, - IPeriodStreamOptions, - IPeriodStreamPlaybackObservation, -}; diff --git a/src/core/stream/period/period_stream.ts b/src/core/stream/period/period_stream.ts index f4f65e062f..28e2ac2759 100644 --- a/src/core/stream/period/period_stream.ts +++ b/src/core/stream/period/period_stream.ts @@ -14,115 +14,44 @@ * limitations under the License. */ -import { - catchError, - concat as observableConcat, - defer as observableDefer, - EMPTY, - ignoreElements, - map, - merge as observableMerge, - mergeMap, - Observable, - of as observableOf, - ReplaySubject, - startWith, - switchMap, -} from "rxjs"; +import nextTick from "next-tick"; +import { ReplaySubject } from "rxjs"; import config from "../../../config"; import { formatError, MediaError, } from "../../../errors"; import log from "../../../log"; -import Manifest, { +import { Adaptation, Period, } from "../../../manifest"; -import { IAudioTrackSwitchingMode } from "../../../public_types"; import objectAssign from "../../../utils/object_assign"; import { getLeftSizeOfRange } from "../../../utils/ranges"; import createSharedReference, { IReadOnlySharedReference, } from "../../../utils/reference"; -import fromCancellablePromise from "../../../utils/rx-from_cancellable_promise"; import TaskCanceller, { + CancellationError, CancellationSignal, } from "../../../utils/task_canceller"; -import WeakMapMemory from "../../../utils/weak_map_memory"; -import { IRepresentationEstimator } from "../../adaptive"; import { IReadOnlyPlaybackObserver } from "../../api"; -import { SegmentFetcherCreator } from "../../fetchers"; import SegmentBuffersStore, { IBufferType, ITextTrackSegmentBufferOptions, SegmentBuffer, } from "../../segment_buffers"; import AdaptationStream, { - IAdaptationStreamOptions, + IAdaptationStreamCallbacks, IAdaptationStreamPlaybackObservation, - IPausedPlaybackObservation, } from "../adaptation"; -import EVENTS from "../events_generators"; -import reloadAfterSwitch from "../reload_after_switch"; -import { IPositionPlaybackObservation } from "../representation"; import { - IAdaptationStreamEvent, - IPeriodStreamEvent, - IStreamWarningEvent, -} from "../types"; -import createEmptyStream from "./create_empty_adaptation_stream"; -import getAdaptationSwitchStrategy from "./get_adaptation_switch_strategy"; - - -/** Playback observation required by the `PeriodStream`. */ -export interface IPeriodStreamPlaybackObservation { - /** - * Information on whether the media element was paused at the time of the - * Observation. - */ - paused : IPausedPlaybackObservation; - /** - * Information on the current media position in seconds at the time of the - * Observation. - */ - position : IPositionPlaybackObservation; - /** `duration` property of the HTMLMediaElement. */ - duration : number; - /** `readyState` property of the HTMLMediaElement. */ - readyState : number; - /** Target playback rate at which we want to play the content. */ - speed : number; - /** Theoretical maximum position on the content that can currently be played. */ - maximumPosition : number; -} - -/** Arguments required by the `PeriodStream`. */ -export interface IPeriodStreamArguments { - bufferType : IBufferType; - content : { manifest : Manifest; - period : Period; }; - garbageCollectors : WeakMapMemory>; - segmentFetcherCreator : SegmentFetcherCreator; - segmentBuffersStore : SegmentBuffersStore; - playbackObserver : IReadOnlyPlaybackObserver; - options: IPeriodStreamOptions; - representationEstimator : IRepresentationEstimator; - wantedBufferAhead : IReadOnlySharedReference; - maxVideoBufferSize : IReadOnlySharedReference; -} + IPeriodStreamArguments, + IPeriodStreamCallbacks, + IPeriodStreamPlaybackObservation, +} from "./types"; +import getAdaptationSwitchStrategy from "./utils/get_adaptation_switch_strategy"; -/** Options tweaking the behavior of the PeriodStream. */ -export type IPeriodStreamOptions = - IAdaptationStreamOptions & - { - /** RxPlayer's behavior when switching the audio track. */ - audioTrackSwitchingMode : IAudioTrackSwitchingMode; - /** Behavior when a new video and/or audio codec is encountered. */ - onCodecSwitch : "continue" | "reload"; - /** Options specific to the text SegmentBuffer. */ - textTrackOptions? : ITextTrackSegmentBufferOptions; - }; /** * Create single PeriodStream Observable: * - Lazily create (or reuse) a SegmentBuffer for the given type. @@ -130,153 +59,185 @@ export type IPeriodStreamOptions = * download and append the corresponding segments to the SegmentBuffer. * - Announce when the Stream is full or is awaiting new Segments through * events - * @param {Object} args - * @returns {Observable} + * + * @param {Object} args - Various arguments allowing the `PeriodStream` to + * determine which Adaptation and which Representation to choose, as well as + * which segments to load from it. + * You can check the corresponding type for more information. + * @param {Object} callbacks - The `PeriodStream` relies on a system of + * callbacks that it will call on various events. + * + * Depending on the event, the caller may be supposed to perform actions to + * react upon some of them. + * + * This approach is taken instead of a more classical EventEmitter pattern to: + * - Allow callbacks to be called synchronously after the + * `AdaptationStream` is called. + * - Simplify bubbling events up, by just passing through callbacks + * - Force the caller to explicitely handle or not the different events. + * + * Callbacks may start being called immediately after the `AdaptationStream` + * call and may be called until either the `parentCancelSignal` argument is + * triggered, or until the `error` callback is called, whichever comes first. + * @param {Object} parentCancelSignal - `CancellationSignal` allowing, when + * triggered, to immediately stop all operations the `PeriodStream` is + * doing. */ -export default function PeriodStream({ - bufferType, - content, - garbageCollectors, - playbackObserver, - representationEstimator, - segmentFetcherCreator, - segmentBuffersStore, - options, - wantedBufferAhead, - maxVideoBufferSize, -} : IPeriodStreamArguments) : Observable { +export default function PeriodStream( + { bufferType, + content, + garbageCollectors, + playbackObserver, + representationEstimator, + segmentFetcherCreator, + segmentBuffersStore, + options, + wantedBufferAhead, + maxVideoBufferSize } : IPeriodStreamArguments, + callbacks : IPeriodStreamCallbacks, + parentCancelSignal : CancellationSignal +) : void { const { period } = content; // Emits the chosen Adaptation for the current type. // `null` when no Adaptation is chosen (e.g. no subtitles) const adaptation$ = new ReplaySubject(1); - return adaptation$.pipe( - switchMap(( - adaptation : Adaptation | null, - switchNb : number - ) : Observable => { - /** - * If this is not the first Adaptation choice, we might want to apply a - * delta to the current position so we can re-play back some media in the - * new Adaptation to give some context back. - * This value contains this relative position, in seconds. - * @see reloadAfterSwitch - */ - const { DELTA_POSITION_AFTER_RELOAD } = config.getCurrent(); - const relativePosAfterSwitch = - switchNb === 0 ? 0 : - bufferType === "audio" ? DELTA_POSITION_AFTER_RELOAD.trackSwitch.audio : - bufferType === "video" ? DELTA_POSITION_AFTER_RELOAD.trackSwitch.video : - DELTA_POSITION_AFTER_RELOAD.trackSwitch.other; + + callbacks.periodStreamReady({ type: bufferType, period, adaptation$ }); + if (parentCancelSignal.isCancelled) { + return; + } + + let currentStreamCanceller : TaskCanceller | undefined; + let isFirstAdaptationSwitch = true; + + const subscription = adaptation$.subscribe((adaptation : Adaptation | null) => { + // As an IIFE to profit from async/await while respecting subscribe's signature + (async () : Promise => { + const streamCanceller = new TaskCanceller({ cancelOn: parentCancelSignal }); + currentStreamCanceller?.cancel(); // Cancel oreviously created stream if one + currentStreamCanceller = streamCanceller; if (adaptation === null) { // Current type is disabled for that Period log.info(`Stream: Set no ${bufferType} Adaptation. P:`, period.start); const segmentBufferStatus = segmentBuffersStore.getStatus(bufferType); - let cleanBuffer$ : Observable; if (segmentBufferStatus.type === "initialized") { log.info(`Stream: Clearing previous ${bufferType} SegmentBuffer`); if (SegmentBuffersStore.isNative(bufferType)) { - return reloadAfterSwitch(period, bufferType, playbackObserver, 0); - } - const canceller = new TaskCanceller(); - cleanBuffer$ = fromCancellablePromise(canceller, () => { - if (period.end === undefined) { - return segmentBufferStatus.value.removeBuffer(period.start, - Infinity, - canceller.signal); - } else if (period.end <= period.start) { - return Promise.resolve(); + return askForMediaSourceReload(0, streamCanceller.signal); + } else { + const periodEnd = period.end ?? Infinity; + if (period.start > periodEnd) { + log.warn("Stream: Can't free buffer: period's start is after its end"); } else { - return segmentBufferStatus.value.removeBuffer(period.start, - period.end, - canceller.signal); + await segmentBufferStatus.value.removeBuffer(period.start, + periodEnd, + streamCanceller.signal); + if (streamCanceller.isUsed) { + return; // The stream has been cancelled + } } - }); - } else { - if (segmentBufferStatus.type === "uninitialized") { - segmentBuffersStore.disableSegmentBuffer(bufferType); } - cleanBuffer$ = observableOf(null); + } else if (segmentBufferStatus.type === "uninitialized") { + segmentBuffersStore.disableSegmentBuffer(bufferType); + if (streamCanceller.isUsed) { + return; // The stream has been cancelled + } + } + + callbacks.adaptationChange({ type: bufferType, adaptation: null, period }); + if (streamCanceller.isUsed) { + return; // Previous call has provoken Stream cancellation by side-effect } - return observableConcat( - cleanBuffer$.pipe(map(() => EVENTS.adaptationChange(bufferType, null, period))), - createEmptyStream(playbackObserver, wantedBufferAhead, bufferType, { period }) - ); + return createEmptyAdaptationStream(playbackObserver, + wantedBufferAhead, + bufferType, + { period }, + callbacks, + streamCanceller.signal); } + /** + * If this is not the first Adaptation choice, we might want to apply a + * delta to the current position so we can re-play back some media in the + * new Adaptation to give some context back. + * This value contains this relative position, in seconds. + * @see askForMediaSourceReload + */ + const { DELTA_POSITION_AFTER_RELOAD } = config.getCurrent(); + const relativePosAfterSwitch = + isFirstAdaptationSwitch ? 0 : + bufferType === "audio" ? DELTA_POSITION_AFTER_RELOAD.trackSwitch.audio : + bufferType === "video" ? DELTA_POSITION_AFTER_RELOAD.trackSwitch.video : + DELTA_POSITION_AFTER_RELOAD.trackSwitch.other; + isFirstAdaptationSwitch = false; + if (SegmentBuffersStore.isNative(bufferType) && segmentBuffersStore.getStatus(bufferType).type === "disabled") { - return reloadAfterSwitch(period, - bufferType, - playbackObserver, - relativePosAfterSwitch); + return askForMediaSourceReload(relativePosAfterSwitch, streamCanceller.signal); } log.info(`Stream: Updating ${bufferType} adaptation`, `A: ${adaptation.id}`, `P: ${period.start}`); - const newStream$ = observableDefer(() => { - const readyState = playbackObserver.getReadyState(); - const segmentBuffer = createOrReuseSegmentBuffer(segmentBuffersStore, - bufferType, - adaptation, - options); - const playbackInfos = { currentTime: playbackObserver.getCurrentTime(), - readyState }; - const strategy = getAdaptationSwitchStrategy(segmentBuffer, - period, - adaptation, - playbackInfos, - options); - if (strategy.type === "needs-reload") { - return reloadAfterSwitch(period, - bufferType, - playbackObserver, - relativePosAfterSwitch); + callbacks.adaptationChange({ type: bufferType, adaptation, period }); + if (streamCanceller.isUsed) { + return; // Previous call has provoken cancellation by side-effect + } + + const readyState = playbackObserver.getReadyState(); + const segmentBuffer = createOrReuseSegmentBuffer(segmentBuffersStore, + bufferType, + adaptation, + options); + const playbackInfos = { currentTime: playbackObserver.getCurrentTime(), + readyState }; + const strategy = getAdaptationSwitchStrategy(segmentBuffer, + period, + adaptation, + playbackInfos, + options); + if (strategy.type === "needs-reload") { + return askForMediaSourceReload(relativePosAfterSwitch, streamCanceller.signal); + } + + await segmentBuffersStore.waitForUsableBuffers(streamCanceller.signal); + if (streamCanceller.isUsed) { + return; // The Stream has since been cancelled + } + if (strategy.type === "flush-buffer" || strategy.type === "clean-buffer") { + for (const { start, end } of strategy.value) { + await segmentBuffer.removeBuffer(start, end, streamCanceller.signal); + if (streamCanceller.isUsed) { + return; // The Stream has since been cancelled + } } + if (strategy.type === "flush-buffer") { + callbacks.needsBufferFlush(); + if (streamCanceller.isUsed) { + return ; // Previous callback cancelled the Stream by side-effect + } + } + } - const needsBufferFlush$ = strategy.type === "flush-buffer" - ? observableOf(EVENTS.needsBufferFlush()) - : EMPTY; - - const cleanBuffer$ = - strategy.type === "clean-buffer" || strategy.type === "flush-buffer" ? - observableConcat(...strategy.value.map(({ start, end }) => { - const canceller = new TaskCanceller(); - return fromCancellablePromise(canceller, () => - segmentBuffer.removeBuffer(start, end, canceller.signal)); - }) - // NOTE As of now (RxJS 7.4.0), RxJS defines `ignoreElements` default - // first type parameter as `any` instead of the perfectly fine `unknown`, - // leading to linter issues, as it forbids the usage of `any`. - // This is why we're disabling the eslint rule. - /* eslint-disable-next-line @typescript-eslint/no-unsafe-argument */ - ).pipe(ignoreElements()) : EMPTY; - - const bufferGarbageCollector$ = garbageCollectors.get(segmentBuffer); - const adaptationStream$ = createAdaptationStream(adaptation, segmentBuffer); - - const cancelWait = new TaskCanceller(); - return fromCancellablePromise(cancelWait, () => - segmentBuffersStore.waitForUsableBuffers(cancelWait.signal) - ).pipe(mergeMap(() => - observableConcat(cleanBuffer$, - needsBufferFlush$, - observableMerge(adaptationStream$, - bufferGarbageCollector$)))); - }); - - return observableConcat( - observableOf(EVENTS.adaptationChange(bufferType, adaptation, period)), - newStream$ - ); - }), - startWith(EVENTS.periodStreamReady(bufferType, period, adaptation$)) - ); + garbageCollectors.get(segmentBuffer)(streamCanceller.signal); + createAdaptationStream(adaptation, segmentBuffer, streamCanceller.signal); + })().catch((err) => { + if (err instanceof CancellationError) { + return; + } + currentStreamCanceller?.cancel(); + callbacks.error(err); + }); + }); + + parentCancelSignal.register(() => { + subscription.unsubscribe(); + }); /** * @param {Object} adaptation @@ -285,41 +246,98 @@ export default function PeriodStream({ */ function createAdaptationStream( adaptation : Adaptation, - segmentBuffer : SegmentBuffer - ) : Observable { + segmentBuffer : SegmentBuffer, + cancelSignal : CancellationSignal + ) : void { const { manifest } = content; const adaptationPlaybackObserver = createAdaptationStreamPlaybackObserver(playbackObserver, segmentBuffer); - return AdaptationStream({ content: { manifest, period, adaptation }, - options, - playbackObserver: adaptationPlaybackObserver, - representationEstimator, - segmentBuffer, - segmentFetcherCreator, - wantedBufferAhead, - maxVideoBufferSize }).pipe( - catchError((error : unknown) => { - // Stream linked to a non-native media buffer should not impact the - // stability of the player. ie: if a text buffer sends an error, we want - // to continue playing without any subtitles - if (!SegmentBuffersStore.isNative(bufferType)) { - log.error(`Stream: ${bufferType} Stream crashed. Aborting it.`, - error instanceof Error ? error : ""); - segmentBuffersStore.disposeSegmentBuffer(bufferType); - - const formattedError = formatError(error, { - defaultCode: "NONE", - defaultReason: "Unknown `AdaptationStream` error", - }); - return observableConcat( - observableOf(EVENTS.warning(formattedError)), - createEmptyStream(playbackObserver, wantedBufferAhead, bufferType, { period }) - ); - } - log.error(`Stream: ${bufferType} Stream crashed. Stopping playback.`, + + AdaptationStream({ content: { manifest, period, adaptation }, + options, + playbackObserver: adaptationPlaybackObserver, + representationEstimator, + segmentBuffer, + segmentFetcherCreator, + wantedBufferAhead, + maxVideoBufferSize }, + { ...callbacks, error: onAdaptationStreamError }, + cancelSignal); + + function onAdaptationStreamError(error : unknown) : void { + // Stream linked to a non-native media buffer should not impact the + // stability of the player. ie: if a text buffer sends an error, we want + // to continue playing without any subtitles + if (!SegmentBuffersStore.isNative(bufferType)) { + log.error(`Stream: ${bufferType} Stream crashed. Aborting it.`, error instanceof Error ? error : ""); - throw error; - })); + segmentBuffersStore.disposeSegmentBuffer(bufferType); + + const formattedError = formatError(error, { + defaultCode: "NONE", + defaultReason: "Unknown `AdaptationStream` error", + }); + callbacks.warning(formattedError); + if (cancelSignal.isCancelled) { + return ; // Previous callback cancelled the Stream by side-effect + } + + return createEmptyAdaptationStream(playbackObserver, + wantedBufferAhead, + bufferType, + { period }, + callbacks, + cancelSignal); + } + log.error(`Stream: ${bufferType} Stream crashed. Stopping playback.`, + error instanceof Error ? error : ""); + callbacks.error(error); + } + } + + /** + * Regularly ask to reload the MediaSource on each playback observation + * performed by the playback observer. + * + * If and only if the Period currently played corresponds to the concerned + * Period, applies an offset to the reloaded position corresponding to + * `deltaPos`. + * This can be useful for example when switching the audio/video tracks, where + * you might want to give back some context if that was the currently played + * track. + * + * @param {number} deltaPos - If the concerned Period is playing at the time + * this function is called, we will add this value, in seconds, to the current + * position to indicate the position we should reload at. + * This value allows to give back context (by replaying some media data) after + * a switch. + * @param {Object} cancelSignal + */ + function askForMediaSourceReload( + deltaPos : number, + cancelSignal : CancellationSignal + ) : void { + // We begin by scheduling a micro-task to reduce the possibility of race + // conditions where `askForMediaSourceReload` would be called synchronously before + // the next observation (which may reflect very different playback conditions) + // is actually received. + // It can happen when `askForMediaSourceReload` is called as a side-effect of + // the same event that triggers the playback observation to be emitted. + nextTick(() => { + playbackObserver.listen((observation) => { + const currentTime = playbackObserver.getCurrentTime(); + const pos = currentTime + deltaPos; + + // Bind to Period start and end + const position = Math.min(Math.max(period.start, pos), + period.end ?? Infinity); + const autoPlay = !(observation.paused.pending ?? playbackObserver.getIsPaused()); + callbacks.waitingMediaSourceReload({ bufferType, + period, + position, + autoPlay }); + }, { includeLastObservation: true, clearSignal: cancelSignal }); + }); } } @@ -404,3 +422,48 @@ function createAdaptationStreamPlaybackObserver( }); } + +/** + * Create empty AdaptationStream, linked to a Period. + * This AdaptationStream will never download any segment and just emit a "full" + * event when reaching the end. + * @param {Observable} playbackObserver + * @param {Object} wantedBufferAhead + * @param {string} bufferType + * @param {Object} content + * @param {Object} callbacks + * @param {Object} cancelSignal + */ +function createEmptyAdaptationStream( + playbackObserver : IReadOnlyPlaybackObserver, + wantedBufferAhead : IReadOnlySharedReference, + bufferType : IBufferType, + content : { period : Period }, + callbacks : Pick, "streamStatusUpdate">, + cancelSignal : CancellationSignal +) : void { + const { period } = content; + let hasFinishedLoading = false; + wantedBufferAhead.onUpdate(sendStatus, + { emitCurrentValue: false, clearSignal: cancelSignal }); + playbackObserver.listen(sendStatus, + { includeLastObservation: false, clearSignal: cancelSignal }); + sendStatus(); + + function sendStatus() : void { + const observation = playbackObserver.getReference().getValue(); + const wba = wantedBufferAhead.getValue(); + const position = observation.position.last; + if (period.end !== undefined && position + wba >= period.end) { + log.debug("Stream: full \"empty\" AdaptationStream", bufferType); + hasFinishedLoading = true; + } + callbacks.streamStatusUpdate({ period, + bufferType, + position, + imminentDiscontinuity: null, + isEmptyStream: true, + hasFinishedLoading, + neededSegments: [] }); + } +} diff --git a/src/core/stream/period/types.ts b/src/core/stream/period/types.ts new file mode 100644 index 0000000000..f8e97754bc --- /dev/null +++ b/src/core/stream/period/types.ts @@ -0,0 +1,131 @@ +import { Subject } from "rxjs"; +import Manifest, { + Adaptation, + Period, +} from "../../../manifest"; +import { IAudioTrackSwitchingMode } from "../../../public_types"; +import { IReadOnlySharedReference } from "../../../utils/reference"; +import { CancellationSignal } from "../../../utils/task_canceller"; +import WeakMapMemory from "../../../utils/weak_map_memory"; +import { IRepresentationEstimator } from "../../adaptive"; +import { IReadOnlyPlaybackObserver } from "../../api"; +import { SegmentFetcherCreator } from "../../fetchers"; +import SegmentBuffersStore, { + IBufferType, + ITextTrackSegmentBufferOptions, + SegmentBuffer, +} from "../../segment_buffers"; +import { + IAdaptationStreamCallbacks, + IAdaptationStreamOptions, + IPausedPlaybackObservation, +} from "../adaptation"; +import { IPositionPlaybackObservation } from "../representation"; + +/** Callbacks called by the `AdaptationStream` on various events. */ +export interface IPeriodStreamCallbacks extends + IAdaptationStreamCallbacks +{ + /** + * Called when a new `PeriodStream` is ready to start but needs an Adaptation + * (i.e. track) to be chosen first. + */ + periodStreamReady(payload : IPeriodStreamReadyPayload) : void; + /** + * Called when a new `AdaptationStream` is created to load segments from an + * `Adaptation`. + */ + adaptationChange(payload : IAdaptationChangePayload) : void; + /** + * Some situations might require the browser's buffers to be refreshed. + * This callback is called when such situation arised. + * + * Generally flushing/refreshing low-level buffers can be performed simply by + * performing a very small seek. + */ + needsBufferFlush() : void; +} + +/** Payload for the `adaptationChange` callback. */ +export interface IAdaptationChangePayload { + /** The type of buffer for which the Representation is changing. */ + type : IBufferType; + /** The `Period` linked to the `RepresentationStream` we're creating. */ + period : Period; + /** + * The `Adaptation` linked to the `AdaptationStream` we're creating. + * `null` when we're choosing no Adaptation at all. + */ + adaptation : Adaptation | + null; +} + +/** Payload for the `periodStreamReady` callback. */ +export interface IPeriodStreamReadyPayload { + /** The type of buffer linked to the `PeriodStream` we want to create. */ + type : IBufferType; + /** The `Period` linked to the `PeriodStream` we have created. */ + period : Period; + /** + * The subject through which any Adaptation (i.e. track) choice should be + * emitted for that `PeriodStream`. + * + * The `PeriodStream` will not do anything until this subject has emitted + * at least one to give its initial choice. + * You can send `null` through it to tell this `PeriodStream` that you don't + * want any `Adaptation`. + */ + adaptation$ : Subject; +} + +/** Playback observation required by the `PeriodStream`. */ +export interface IPeriodStreamPlaybackObservation { + /** + * Information on whether the media element was paused at the time of the + * Observation. + */ + paused : IPausedPlaybackObservation; + /** + * Information on the current media position in seconds at the time of the + * Observation. + */ + position : IPositionPlaybackObservation; + /** `duration` property of the HTMLMediaElement. */ + duration : number; + /** `readyState` property of the HTMLMediaElement. */ + readyState : number; + /** Target playback rate at which we want to play the content. */ + speed : number; + /** Theoretical maximum position on the content that can currently be played. */ + maximumPosition : number; +} + +/** Arguments required by the `PeriodStream`. */ +export interface IPeriodStreamArguments { + bufferType : IBufferType; + content : { manifest : Manifest; + period : Period; }; + garbageCollectors : WeakMapMemory void>; + segmentFetcherCreator : SegmentFetcherCreator; + segmentBuffersStore : SegmentBuffersStore; + playbackObserver : IReadOnlyPlaybackObserver; + options: IPeriodStreamOptions; + representationEstimator : IRepresentationEstimator; + wantedBufferAhead : IReadOnlySharedReference; + maxVideoBufferSize : IReadOnlySharedReference; +} + +/** Options tweaking the behavior of the PeriodStream. */ +export type IPeriodStreamOptions = + IAdaptationStreamOptions & + { + /** RxPlayer's behavior when switching the audio track. */ + audioTrackSwitchingMode : IAudioTrackSwitchingMode; + /** Behavior when a new video and/or audio codec is encountered. */ + onCodecSwitch : "continue" | "reload"; + /** Options specific to the text SegmentBuffer. */ + textTrackOptions? : ITextTrackSegmentBufferOptions; + }; + +export { IAudioTrackSwitchingMode } from "../../../public_types"; diff --git a/src/core/stream/period/get_adaptation_switch_strategy.ts b/src/core/stream/period/utils/get_adaptation_switch_strategy.ts similarity index 96% rename from src/core/stream/period/get_adaptation_switch_strategy.ts rename to src/core/stream/period/utils/get_adaptation_switch_strategy.ts index b1aa2782ff..5d6f09ff6e 100644 --- a/src/core/stream/period/get_adaptation_switch_strategy.ts +++ b/src/core/stream/period/utils/get_adaptation_switch_strategy.ts @@ -14,13 +14,13 @@ * limitations under the License. */ -import config from "../../../config"; +import config from "../../../../config"; import { Adaptation, Period, -} from "../../../manifest"; -import { IAudioTrackSwitchingMode } from "../../../public_types"; -import areCodecsCompatible from "../../../utils/are_codecs_compatible"; +} from "../../../../manifest"; +import { IAudioTrackSwitchingMode } from "../../../../public_types"; +import areCodecsCompatible from "../../../../utils/are_codecs_compatible"; import { convertToRanges, excludeFromRanges, @@ -28,11 +28,11 @@ import { isTimeInRange, isTimeInRanges, keepRangeIntersection, -} from "../../../utils/ranges"; +} from "../../../../utils/ranges"; import { IBufferedChunk, SegmentBuffer, -} from "../../segment_buffers"; +} from "../../../segment_buffers"; export type IAdaptationSwitchStrategy = diff --git a/src/core/stream/reload_after_switch.ts b/src/core/stream/reload_after_switch.ts deleted file mode 100644 index bb145d914b..0000000000 --- a/src/core/stream/reload_after_switch.ts +++ /dev/null @@ -1,76 +0,0 @@ -/** - * 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. - */ - -import { - map, - mergeMap, - Observable, -} from "rxjs"; -import { Period } from "../../manifest"; -import nextTickObs from "../../utils/rx-next-tick"; -import { IReadOnlyPlaybackObserver } from "../api"; -import { IBufferType } from "../segment_buffers"; -import EVENTS from "./events_generators"; -import { IWaitingMediaSourceReloadInternalEvent } from "./types"; - -/** - * Regularly ask to reload the MediaSource on each playback observation - * performed by the playback observer. - * - * If and only if the Period currently played corresponds to `Period`, applies - * an offset to the reloaded position corresponding to `deltaPos`. - * This can be useful for example when switching the audio/video track, where - * you might want to give back some context if that was the currently played - * track. - * - * @param {Object} period - The Period linked to the Adaptation or - * Representation that you want to switch to. - * @param {Observable} playbackObserver - emit playback conditions. - * Has to emit last playback conditions immediately on subscribe. - * @param {number} deltaPos - If the concerned Period is playing at the time - * this function is called, we will add this value, in seconds, to the current - * position to indicate the position we should reload at. - * This value allows to give back context (by replaying some media data) after - * a switch. - * @returns {Observable} - */ -export default function reloadAfterSwitch( - period : Period, - bufferType : IBufferType, - playbackObserver : IReadOnlyPlaybackObserver<{ - paused : { last: boolean; pending: boolean | undefined }; - }>, - deltaPos : number -) : Observable { - // We begin by scheduling a micro-task to reduce the possibility of race - // conditions where `reloadAfterSwitch` would be called synchronously before - // the next observation (which may reflect very different playback conditions) - // is actually received. - // It can happen when `reloadAfterSwitch` is called as a side-effect of the - // same event that triggers the playback observation to be emitted. - return nextTickObs().pipe( - mergeMap(() => playbackObserver.getReference().asObservable()), - map((observation) => { - const currentTime = playbackObserver.getCurrentTime(); - const pos = currentTime + deltaPos; - - // Bind to Period start and end - const reloadAt = Math.min(Math.max(period.start, pos), - period.end ?? Infinity); - const autoPlay = !(observation.paused.pending ?? playbackObserver.getIsPaused()); - return EVENTS.waitingMediaSourceReload(bufferType, period, reloadAt, autoPlay); - })); -} diff --git a/src/core/stream/representation/README.md b/src/core/stream/representation/README.md index 03665aff8e..6b47b78574 100644 --- a/src/core/stream/representation/README.md +++ b/src/core/stream/representation/README.md @@ -15,23 +15,3 @@ Multiple `RepresentationStream` observables can be ran on the same `SegmentBuffer` without problems, as long as they are linked to different Periods of the Manifest. This allows for example smooth transitions between multiple periods. - - - -## Return value ################################################################ - -The `RepresentationStream` returns an Observable which emits multiple -notifications depending on what is happening at its core, like: - - - when segments are scheduled for download - - - when segments are pushed to the associated `SegmentBuffer` - - - when the Manifest needs to be refreshed to obtain information on possible - - - whether the `RepresentationStream` finished to load segments until the end - of the current Period. This can for example allow the creation of a - `RepresentationStream` for the next Period for pre-loading purposes. - - - whether there are discontinuities: holes in the stream that won't be filled - by segments and can thus be skipped diff --git a/src/core/stream/representation/downloading_queue.ts b/src/core/stream/representation/downloading_queue.ts deleted file mode 100644 index a9f9f4172f..0000000000 --- a/src/core/stream/representation/downloading_queue.ts +++ /dev/null @@ -1,632 +0,0 @@ -/** - * 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. - */ - -import { - defer as observableDefer, - EMPTY, - filter, - finalize, - merge as observableMerge, - Observable, - of as observableOf, - share, - Subscription, - switchMap, -} from "rxjs"; -import log from "../../../log"; -import Manifest, { - Adaptation, - ISegment, - Period, - Representation, -} from "../../../manifest"; -import { IPlayerError } from "../../../public_types"; -import { - ISegmentParserParsedInitChunk, - ISegmentParserParsedMediaChunk, -} from "../../../transports"; -import assert from "../../../utils/assert"; -import objectAssign from "../../../utils/object_assign"; -import createSharedReference, { - IReadOnlySharedReference, - ISharedReference, -} from "../../../utils/reference"; -import TaskCanceller from "../../../utils/task_canceller"; -import { IPrioritizedSegmentFetcher } from "../../fetchers"; -import { IQueuedSegment } from "../types"; - -/** - * Class scheduling segment downloads for a single Representation. - * @class DownloadingQueue - */ -export default class DownloadingQueue { - /** Context of the Representation that will be loaded through this DownloadingQueue. */ - private _content : IDownloadingQueueContext; - /** - * Observable doing segment requests and emitting related events. - * We only can have maximum one at a time. - * `null` when `start` has never been called. - */ - private _currentObs$ : Observable> | null; - /** - * Current queue of segments scheduled for download. - * - * Segments whose request are still pending are still in that queue. Segments - * are only removed from it once their request has succeeded. - */ - private _downloadQueue : IReadOnlySharedReference; - /** - * Pending request for the initialization segment. - * `null` if no request is pending for it. - */ - private _initSegmentRequest : ISegmentRequestObject|null; - /** - * Pending request for a media (i.e. non-initialization) segment. - * `null` if no request is pending for it. - */ - private _mediaSegmentRequest : ISegmentRequestObject|null; - /** Interface used to load segments. */ - private _segmentFetcher : IPrioritizedSegmentFetcher; - - /** - * Emit the timescale anounced in the initialization segment once parsed. - * `undefined` when this is not yet known. - * `null` when no initialization segment or timescale exists. - */ - private _initSegmentInfoRef : ISharedReference; - /** - * Some media segments might have been loaded and are only awaiting for the - * initialization segment to be parsed before being parsed themselves. - * This `Set` will contain the `id` property of all segments that are - * currently awaiting this event. - */ - private _mediaSegmentsAwaitingInitMetadata : Set; - - /** - * Create a new `DownloadingQueue`. - * - * @param {Object} content - The context of the Representation you want to - * load segments for. - * @param {Object} downloadQueue - Queue of segments you want to load. - * @param {Object} segmentFetcher - Interface to facilitate the download of - * segments. - * @param {boolean} hasInitSegment - Declare that an initialization segment - * will need to be downloaded. - * - * A `DownloadingQueue` ALWAYS wait for the initialization segment to be - * loaded and parsed before parsing a media segment. - * - * In cases where no initialization segment exist, this would lead to the - * `DownloadingQueue` waiting indefinitely for it. - * - * By setting that value to `false`, you anounce to the `DownloadingQueue` - * that it should not wait for an initialization segment before parsing a - * media segment. - */ - constructor( - content: IDownloadingQueueContext, - downloadQueue : IReadOnlySharedReference, - segmentFetcher : IPrioritizedSegmentFetcher, - hasInitSegment : boolean - ) { - this._content = content; - this._currentObs$ = null; - this._downloadQueue = downloadQueue; - this._initSegmentRequest = null; - this._mediaSegmentRequest = null; - this._segmentFetcher = segmentFetcher; - this._initSegmentInfoRef = createSharedReference(undefined); - this._mediaSegmentsAwaitingInitMetadata = new Set(); - if (!hasInitSegment) { - this._initSegmentInfoRef.setValue(null); - } - } - - /** - * Returns the initialization segment currently being requested. - * Returns `null` if no initialization segment request is pending. - * @returns {Object | null} - */ - public getRequestedInitSegment() : ISegment | null { - return this._initSegmentRequest === null ? null : - this._initSegmentRequest.segment; - } - - /** - * Returns the media segment currently being requested. - * Returns `null` if no media segment request is pending. - * @returns {Object | null} - */ - public getRequestedMediaSegment() : ISegment | null { - return this._mediaSegmentRequest === null ? null : - this._mediaSegmentRequest.segment; - } - - /** - * Start the current downloading queue, emitting events as it loads and parses - * initialization and media segments. - * - * If it was already started, returns the same - shared - Observable. - * @returns {Observable} - */ - public start() : Observable> { - if (this._currentObs$ !== null) { - return this._currentObs$; - } - const obs = observableDefer(() => { - const mediaQueue$ = this._downloadQueue.asObservable().pipe( - filter(({ segmentQueue }) => { - // First, the first elements of the segmentQueue might be already - // loaded but awaiting the initialization segment to be parsed. - // Filter those out. - let nextSegmentToLoadIdx = 0; - for (; nextSegmentToLoadIdx < segmentQueue.length; nextSegmentToLoadIdx++) { - const nextSegment = segmentQueue[nextSegmentToLoadIdx].segment; - if (!this._mediaSegmentsAwaitingInitMetadata.has(nextSegment.id)) { - break; - } - } - - const currentSegmentRequest = this._mediaSegmentRequest; - if (nextSegmentToLoadIdx >= segmentQueue.length) { - return currentSegmentRequest !== null; - } else if (currentSegmentRequest === null) { - return true; - } - const nextItem = segmentQueue[nextSegmentToLoadIdx]; - if (currentSegmentRequest.segment.id !== nextItem.segment.id) { - return true; - } - if (currentSegmentRequest.priority !== nextItem.priority) { - this._segmentFetcher.updatePriority(currentSegmentRequest.request, - nextItem.priority); - } - return false; - }), - switchMap(({ segmentQueue }) => - segmentQueue.length > 0 ? this._requestMediaSegments() : - EMPTY)); - - const initSegmentPush$ = this._downloadQueue.asObservable().pipe( - filter((next) => { - const initSegmentRequest = this._initSegmentRequest; - if (next.initSegment !== null && initSegmentRequest !== null) { - if (next.initSegment.priority !== initSegmentRequest.priority) { - this._segmentFetcher.updatePriority(initSegmentRequest.request, - next.initSegment.priority); - } - return false; - } else { - return next.initSegment === null || initSegmentRequest === null; - } - }), - switchMap((nextQueue) => { - if (nextQueue.initSegment === null) { - return EMPTY; - } - return this._requestInitSegment(nextQueue.initSegment); - })); - - return observableMerge(initSegmentPush$, mediaQueue$); - }).pipe(share()); - - this._currentObs$ = obs; - - return obs; - } - - /** - * Internal logic performing media segment requests. - * @returns {Observable} - */ - private _requestMediaSegments( - ) : Observable | - IEndOfSegmentEvent> { - - const { segmentQueue } = this._downloadQueue.getValue(); - const currentNeededSegment = segmentQueue[0]; - - /* eslint-disable-next-line @typescript-eslint/no-this-alias */ - const self = this; - - return observableDefer(() => - recursivelyRequestSegments(currentNeededSegment) - ).pipe(finalize(() => { this._mediaSegmentRequest = null; })); - - function recursivelyRequestSegments( - startingSegment : IQueuedSegment | undefined - ) : Observable | - IEndOfSegmentEvent> { - if (startingSegment === undefined) { - return observableOf({ type : "end-of-queue", - value : null }); - } - const { segment, priority } = startingSegment; - const context = objectAssign({ segment }, self._content); - return new Observable< - ILoaderRetryEvent | - IEndOfQueueEvent | - IParsedSegmentEvent | - IEndOfSegmentEvent - >((obs) => { - /** TaskCanceller linked to this Observable's lifecycle. */ - const canceller = new TaskCanceller(); - - /** - * If `true` , the Observable has either errored, completed, or was - * unsubscribed from. - * This only conserves the Observable for the current segment's request, - * not the other recursively-created future ones. - */ - let isComplete = false; - - /** - * Subscription to request the following segment (as this function is - * recursive). - * `undefined` if no following segment has been requested. - */ - let nextSegmentSubscription : Subscription | undefined; - - /** - * If true, we're currently waiting for the initialization segment to be - * parsed before parsing a received chunk. - * - * In that case, the `DownloadingQueue` has to remain careful to only - * send further events and complete the Observable only once the - * initialization segment has been parsed AND the chunk parsing has been - * done (this can be done very simply by listening to the same - * `ISharedReference`, as its callbacks are called in the same order - * than the one in which they are added. - */ - let isWaitingOnInitSegment = false; - - /** Scheduled actual segment request. */ - const request = self._segmentFetcher.createRequest(context, priority, { - - /** - * Callback called when the request has to be retried. - * @param {Error} error - */ - onRetry(error : IPlayerError) : void { - obs.next({ type: "retry" as const, value: { segment, error } }); - }, - - /** - * Callback called when the request has to be interrupted and - * restarted later. - */ - beforeInterrupted() { - log.info("Stream: segment request interrupted temporarly.", - segment.id, - segment.time); - }, - - /** - * Callback called when a decodable chunk of the segment is available. - * @param {Function} parse - Function allowing to parse the segment. - */ - onChunk( - parse : ( - initTimescale : number | undefined - ) => ISegmentParserParsedInitChunk | ISegmentParserParsedMediaChunk - ) : void { - const initTimescale = self._initSegmentInfoRef.getValue(); - if (initTimescale !== undefined) { - emitChunk(parse(initTimescale ?? undefined)); - } else { - isWaitingOnInitSegment = true; - - // We could also technically call `waitUntilDefined` in both cases, - // but I found it globally clearer to segregate the two cases, - // especially to always have a meaningful `isWaitingOnInitSegment` - // boolean which is a very important variable. - self._initSegmentInfoRef.waitUntilDefined((actualTimescale) => { - emitChunk(parse(actualTimescale ?? undefined)); - }, { clearSignal: canceller.signal }); - } - }, - - /** Callback called after all chunks have been sent. */ - onAllChunksReceived() : void { - if (!isWaitingOnInitSegment) { - obs.next({ type: "end-of-segment" as const, - value: { segment } }); - } else { - self._mediaSegmentsAwaitingInitMetadata.add(segment.id); - self._initSegmentInfoRef.waitUntilDefined(() => { - obs.next({ type: "end-of-segment" as const, - value: { segment } }); - self._mediaSegmentsAwaitingInitMetadata.delete(segment.id); - isWaitingOnInitSegment = false; - }, { clearSignal: canceller.signal }); - } - }, - - /** - * Callback called right after the request ended but before the next - * requests are scheduled. It is used to schedule the next segment. - */ - beforeEnded() : void { - self._mediaSegmentRequest = null; - - if (isWaitingOnInitSegment) { - self._initSegmentInfoRef.waitUntilDefined( - continueToNextSegment, { clearSignal: canceller.signal }); - } else { - continueToNextSegment(); - } - }, - - }, canceller.signal); - - request.catch((error : unknown) => { - if (!isComplete) { - isComplete = true; - obs.error(error); - } - }); - - self._mediaSegmentRequest = { segment, priority, request }; - return () => { - self._mediaSegmentsAwaitingInitMetadata.delete(segment.id); - if (nextSegmentSubscription !== undefined) { - nextSegmentSubscription.unsubscribe(); - } - if (isComplete) { - return; - } - isComplete = true; - isWaitingOnInitSegment = false; - canceller.cancel(); - }; - - function emitChunk( - parsed : ISegmentParserParsedInitChunk | - ISegmentParserParsedMediaChunk - ) : void { - assert(parsed.segmentType === "media", - "Should have loaded a media segment."); - obs.next(objectAssign({}, - parsed, - { type: "parsed-media" as const, - segment })); - } - - function continueToNextSegment() : void { - const lastQueue = self._downloadQueue.getValue().segmentQueue; - if (lastQueue.length === 0) { - obs.next({ type : "end-of-queue" as const, - value : null }); - isComplete = true; - obs.complete(); - return; - } else if (lastQueue[0].segment.id === segment.id) { - lastQueue.shift(); - } - isComplete = true; - nextSegmentSubscription = recursivelyRequestSegments(lastQueue[0]) - .subscribe(obs); - } - }); - } - } - - /** - * Internal logic performing initialization segment requests. - * @param {Object} queuedInitSegment - * @returns {Observable} - */ - private _requestInitSegment( - queuedInitSegment : IQueuedSegment | null - ) : Observable | - IEndOfSegmentEvent> { - if (queuedInitSegment === null) { - this._initSegmentRequest = null; - return EMPTY; - } - - /* eslint-disable-next-line @typescript-eslint/no-this-alias */ - const self = this; - return new Observable< - ILoaderRetryEvent | - IParsedInitSegmentEvent | - IEndOfSegmentEvent - >((obs) => { - /** TaskCanceller linked to this Observable's lifecycle. */ - const canceller = new TaskCanceller(); - const { segment, priority } = queuedInitSegment; - const context = objectAssign({ segment }, this._content); - - /** - * If `true` , the Observable has either errored, completed, or was - * unsubscribed from. - */ - let isComplete = false; - - const request = this._segmentFetcher.createRequest(context, priority, { - onRetry(err : IPlayerError) { - obs.next({ type: "retry" as const, - value: { segment, error: err } }); - }, - beforeInterrupted() { - log.info("Stream: init segment request interrupted temporarly.", segment.id); - }, - beforeEnded() { - self._initSegmentRequest = null; - isComplete = true; - obs.complete(); - }, - onChunk(parse : (x : undefined) => ISegmentParserParsedInitChunk | - ISegmentParserParsedMediaChunk) - { - const parsed = parse(undefined); - assert(parsed.segmentType === "init", - "Should have loaded an init segment."); - obs.next(objectAssign({}, - parsed, - { type: "parsed-init" as const, - segment })); - if (parsed.segmentType === "init") { - self._initSegmentInfoRef.setValue(parsed.initTimescale ?? null); - } - - }, - onAllChunksReceived() { - obs.next({ type: "end-of-segment" as const, - value: { segment } }); - }, - }, canceller.signal); - - request.catch((error : unknown) => { - if (!isComplete) { - isComplete = true; - obs.error(error); - } - }); - - this._initSegmentRequest = { segment, priority, request }; - - return () => { - this._initSegmentRequest = null; - if (isComplete) { - return; - } - isComplete = true; - canceller.cancel(); - }; - }); - } -} - -/** Event sent by the DownloadingQueue. */ -export type IDownloadingQueueEvent = IParsedInitSegmentEvent | - IParsedSegmentEvent | - IEndOfSegmentEvent | - ILoaderRetryEvent | - IEndOfQueueEvent; - -/** - * Notify that the initialization segment has been fully loaded and parsed. - * - * You can now push that segment to its corresponding buffer and use its parsed - * metadata. - * - * Only sent if an initialization segment exists (when the `DownloadingQueue`'s - * `hasInitSegment` constructor option has been set to `true`). - * In that case, an `IParsedInitSegmentEvent` will always be sent before any - * `IParsedSegmentEvent` event is sent. - */ -export type IParsedInitSegmentEvent = ISegmentParserParsedInitChunk & - { segment : ISegment; - type : "parsed-init"; }; - -/** - * Notify that a media chunk (decodable sub-part of a media segment) has been - * loaded and parsed. - * - * If an initialization segment exists (when the `DownloadingQueue`'s - * `hasInitSegment` constructor option has been set to `true`), an - * `IParsedSegmentEvent` will always be sent AFTER the `IParsedInitSegmentEvent` - * event. - * - * It can now be pushed to its corresponding buffer. Note that there might be - * multiple `IParsedSegmentEvent` for a single segment, if that segment is - * divided into multiple decodable chunks. - * You will know that all `IParsedSegmentEvent` have been loaded for a given - * segment once you received the `IEndOfSegmentEvent` for that segment. - */ -export type IParsedSegmentEvent = ISegmentParserParsedMediaChunk & - { segment : ISegment; - type : "parsed-media"; }; - -/** Notify that a media or initialization segment has been fully-loaded. */ -export interface IEndOfSegmentEvent { type : "end-of-segment"; - value: { segment : ISegment }; } - -/** - * Notify that a media or initialization segment request is retried. - * This happened most likely because of an HTTP error. - */ -export interface ILoaderRetryEvent { type : "retry"; - value : { segment : ISegment; - error : IPlayerError; }; } - -/** - * Notify that the media segment queue is now empty. - * This can be used to re-check if any segment are now needed. - */ -export interface IEndOfQueueEvent { type : "end-of-queue"; value : null } - -/** - * Structure of the object that has to be emitted through the `downloadQueue` - * Observable, to signal which segments are currently needed. - */ -export interface IDownloadQueueItem { - /** - * A potential initialization segment that needs to be loaded and parsed. - * It will generally be requested in parralel of the first media segments. - * - * Can be set to `null` if you don't need to load the initialization segment - * for now. - * - * If the `DownloadingQueue`'s `hasInitSegment` constructor option has been - * set to `true`, no media segment will be parsed before the initialization - * segment has been loaded and parsed. - */ - initSegment : IQueuedSegment | null; - - /** - * The queue of media segments currently needed for download. - * - * Those will be loaded from the first element in that queue to the last - * element in it. - * - * Note that any media segments in the segment queue will only be parsed once - * either of these is true: - * - An initialization segment has been loaded and parsed by this - * `DownloadingQueue` instance. - * - The `DownloadingQueue`'s `hasInitSegment` constructor option has been - * set to `false`. - */ - segmentQueue : IQueuedSegment[]; -} - -/** Object describing a pending Segment request. */ -interface ISegmentRequestObject { - /** The segment the request is for. */ - segment : ISegment; - /** The request Observable itself. Can be used to update its priority. */ - request: Promise; - /** Last set priority of the segment request (lower number = higher priority). */ - priority : number; -} - -/** Context for segments downloaded through the DownloadingQueue. */ -export interface IDownloadingQueueContext { - /** Adaptation linked to the segments you want to load. */ - adaptation : Adaptation; - /** Manifest linked to the segments you want to load. */ - manifest : Manifest; - /** Period linked to the segments you want to load. */ - period : Period; - /** Representation linked to the segments you want to load. */ - representation : Representation; -} diff --git a/src/core/stream/representation/index.ts b/src/core/stream/representation/index.ts index aa2fbc565d..2f8a69fbbd 100644 --- a/src/core/stream/representation/index.ts +++ b/src/core/stream/representation/index.ts @@ -14,17 +14,7 @@ * limitations under the License. */ -import RepresentationStream, { - IPositionPlaybackObservation, - IRepresentationStreamArguments, - IRepresentationStreamPlaybackObservation, - ITerminationOrder, -} from "./representation_stream"; +import RepresentationStream from "./representation_stream"; +export * from "./types"; export default RepresentationStream; -export { - IPositionPlaybackObservation, - IRepresentationStreamArguments, - IRepresentationStreamPlaybackObservation, - ITerminationOrder, -}; diff --git a/src/core/stream/representation/push_init_segment.ts b/src/core/stream/representation/push_init_segment.ts deleted file mode 100644 index 704a905534..0000000000 --- a/src/core/stream/representation/push_init_segment.ts +++ /dev/null @@ -1,87 +0,0 @@ -/** - * 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. - */ - -import { - defer as observableDefer, - EMPTY, - map, - Observable, -} from "rxjs"; -import Manifest, { - Adaptation, - ISegment, - Period, - Representation, -} from "../../../manifest"; -import fromCancellablePromise from "../../../utils/rx-from_cancellable_promise"; -import TaskCanceller from "../../../utils/task_canceller"; -import { IReadOnlyPlaybackObserver } from "../../api"; -import { - IPushedChunkData, - SegmentBuffer, -} from "../../segment_buffers"; -import EVENTS from "../events_generators"; -import { IStreamEventAddedSegment } from "../types"; -import appendSegmentToBuffer from "./append_segment_to_buffer"; -import { IRepresentationStreamPlaybackObservation } from "./representation_stream"; - -/** - * Push the initialization segment to the SegmentBuffer. - * The Observable returned: - * - emit an event once the segment has been pushed. - * - throws on Error. - * @param {Object} args - * @returns {Observable} - */ -export default function pushInitSegment( - { playbackObserver, - content, - segment, - segmentData, - segmentBuffer } : - { playbackObserver : IReadOnlyPlaybackObserver< - IRepresentationStreamPlaybackObservation - >; - content: { adaptation : Adaptation; - manifest : Manifest; - period : Period; - representation : Representation; }; - segmentData : T | null; - segment : ISegment; - segmentBuffer : SegmentBuffer; } -) : Observable< IStreamEventAddedSegment > { - return observableDefer(() => { - if (segmentData === null) { - return EMPTY; - } - const codec = content.representation.getMimeTypeString(); - const data : IPushedChunkData = { initSegment: segmentData, - chunk: null, - timestampOffset: 0, - appendWindow: [ undefined, undefined ], - codec }; - const canceller = new TaskCanceller(); - return fromCancellablePromise(canceller, () => - appendSegmentToBuffer(playbackObserver, - segmentBuffer, - { data, inventoryInfos: null }, - canceller.signal)) - .pipe(map(() => { - const buffered = segmentBuffer.getBufferedRanges(); - return EVENTS.addedSegment(content, segment, buffered, segmentData); - })); - }); -} diff --git a/src/core/stream/representation/push_media_segment.ts b/src/core/stream/representation/push_media_segment.ts deleted file mode 100644 index a3c6a4c83b..0000000000 --- a/src/core/stream/representation/push_media_segment.ts +++ /dev/null @@ -1,125 +0,0 @@ -/** - * 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. - */ - -import { - defer as observableDefer, - EMPTY, - map, - Observable, -} from "rxjs"; -import config from "../../../config"; -import Manifest, { - Adaptation, - ISegment, - Period, - Representation, -} from "../../../manifest"; -import { ISegmentParserParsedMediaChunk } from "../../../transports"; -import objectAssign from "../../../utils/object_assign"; -import fromCancellablePromise from "../../../utils/rx-from_cancellable_promise"; -import TaskCanceller from "../../../utils/task_canceller"; -import { IReadOnlyPlaybackObserver } from "../../api"; -import { SegmentBuffer } from "../../segment_buffers"; -import EVENTS from "../events_generators"; -import { IStreamEventAddedSegment } from "../types"; -import appendSegmentToBuffer from "./append_segment_to_buffer"; -import { IRepresentationStreamPlaybackObservation } from "./representation_stream"; - - -/** - * Push a given media segment (non-init segment) to a SegmentBuffer. - * The Observable returned: - * - emit an event once the segment has been pushed. - * - throws on Error. - * @param {Object} args - * @returns {Observable} - */ -export default function pushMediaSegment( - { playbackObserver, - content, - initSegmentData, - parsedSegment, - segment, - segmentBuffer } : - { playbackObserver : IReadOnlyPlaybackObserver< - IRepresentationStreamPlaybackObservation - >; - content: { adaptation : Adaptation; - manifest : Manifest; - period : Period; - representation : Representation; }; - initSegmentData : T | null; - parsedSegment : ISegmentParserParsedMediaChunk; - segment : ISegment; - segmentBuffer : SegmentBuffer; } -) : Observable< IStreamEventAddedSegment > { - return observableDefer(() => { - if (parsedSegment.chunkData === null) { - return EMPTY; - } - const { chunkData, - chunkInfos, - chunkOffset, - chunkSize, - appendWindow } = parsedSegment; - const codec = content.representation.getMimeTypeString(); - const { APPEND_WINDOW_SECURITIES } = config.getCurrent(); - // Cutting exactly at the start or end of the appendWindow can lead to - // cases of infinite rebuffering due to how browser handle such windows. - // To work-around that, we add a small offset before and after those. - const safeAppendWindow : [ number | undefined, number | undefined ] = [ - appendWindow[0] !== undefined ? - Math.max(0, appendWindow[0] - APPEND_WINDOW_SECURITIES.START) : - undefined, - appendWindow[1] !== undefined ? - appendWindow[1] + APPEND_WINDOW_SECURITIES.END : - undefined, - ]; - - const data = { initSegment: initSegmentData, - chunk: chunkData, - timestampOffset: chunkOffset, - appendWindow: safeAppendWindow, - codec }; - - let estimatedStart = chunkInfos?.time ?? segment.time; - const estimatedDuration = chunkInfos?.duration ?? segment.duration; - let estimatedEnd = estimatedStart + estimatedDuration; - if (safeAppendWindow[0] !== undefined) { - estimatedStart = Math.max(estimatedStart, safeAppendWindow[0]); - } - if (safeAppendWindow[1] !== undefined) { - estimatedEnd = Math.min(estimatedEnd, safeAppendWindow[1]); - } - - const inventoryInfos = objectAssign({ segment, - chunkSize, - start: estimatedStart, - end: estimatedEnd }, - content); - const canceller = new TaskCanceller(); - - return fromCancellablePromise(canceller, () => - appendSegmentToBuffer(playbackObserver, - segmentBuffer, - { data, inventoryInfos }, - canceller.signal)) - .pipe(map(() => { - const buffered = segmentBuffer.getBufferedRanges(); - return EVENTS.addedSegment(content, segment, buffered, chunkData); - })); - }); -} diff --git a/src/core/stream/representation/representation_stream.ts b/src/core/stream/representation/representation_stream.ts index fb26692594..644f0de540 100644 --- a/src/core/stream/representation/representation_stream.ts +++ b/src/core/stream/representation/representation_stream.ts @@ -23,94 +23,91 @@ * position and what is currently buffered. */ -import nextTick from "next-tick"; -import { - combineLatest as observableCombineLatest, - concat as observableConcat, - defer as observableDefer, - EMPTY, - ignoreElements, - merge as observableMerge, - mergeMap, - Observable, - of as observableOf, - share, - startWith, - Subject, - take, - takeWhile, - withLatestFrom, -} from "rxjs"; import config from "../../../config"; import log from "../../../log"; -import Manifest, { - Adaptation, - ISegment, - Period, - Representation, -} from "../../../manifest"; -import assertUnreachable from "../../../utils/assert_unreachable"; +import { ISegment } from "../../../manifest"; import objectAssign from "../../../utils/object_assign"; import { createSharedReference } from "../../../utils/reference"; -import fromCancellablePromise from "../../../utils/rx-from_cancellable_promise"; -import TaskCanceller from "../../../utils/task_canceller"; -import { IReadOnlyPlaybackObserver } from "../../api"; -import { IPrioritizedSegmentFetcher } from "../../fetchers"; -import { SegmentBuffer } from "../../segment_buffers"; -import EVENTS from "../events_generators"; +import TaskCanceller, { + CancellationError, + CancellationSignal, +} from "../../../utils/task_canceller"; import { - IEncryptionDataEncounteredEvent, IQueuedSegment, - IRepresentationStreamEvent, - IStreamEventAddedSegment, - IStreamManifestMightBeOutOfSync, - IStreamNeedsManifestRefresh, - IStreamStatusEvent, - IStreamTerminatingEvent, - IInbandEventsEvent, - IStreamWarningEvent, -} from "../types"; + IRepresentationStreamArguments, + IRepresentationStreamCallbacks, +} from "./types"; import DownloadingQueue, { - IDownloadingQueueEvent, IDownloadQueueItem, - IParsedInitSegmentEvent, - IParsedSegmentEvent, -} from "./downloading_queue"; -import getBufferStatus from "./get_buffer_status"; -import getSegmentPriority from "./get_segment_priority"; -import pushInitSegment from "./push_init_segment"; -import pushMediaSegment from "./push_media_segment"; + IParsedInitSegmentPayload, + IParsedSegmentPayload, +} from "./utils/downloading_queue"; +import getBufferStatus from "./utils/get_buffer_status"; +import getSegmentPriority from "./utils/get_segment_priority"; +import pushInitSegment from "./utils/push_init_segment"; +import pushMediaSegment from "./utils/push_media_segment"; /** - * Build up buffer for a single Representation. + * Perform the logic to load the right segments for the given Representation and + * push them to the given `SegmentBuffer`. * - * Download and push segments linked to the given Representation according - * to what is already in the SegmentBuffer and where the playback currently is. + * In essence, this is the entry point of the core streaming logic of the + * RxPlayer, the one actually responsible for finding which are the current + * right segments to load, loading them, and pushing them so they can be decoded. * - * Multiple RepresentationStream observables can run on the same SegmentBuffer. + * Multiple RepresentationStream can run on the same SegmentBuffer. * This allows for example smooth transitions between multiple periods. * - * @param {Object} args - * @returns {Observable} + * @param {Object} args - Various arguments allowing to know which segments to + * load, loading them and pushing them. + * You can check the corresponding type for more information. + * @param {Object} callbacks - The `RepresentationStream` relies on a system of + * callbacks that it will call on various events. + * + * Depending on the event, the caller may be supposed to perform actions to + * react upon some of them. + * + * This approach is taken instead of a more classical EventEmitter pattern to: + * - Allow callbacks to be called synchronously after the + * `RepresentationStream` is called. + * - Simplify bubbling events up, by just passing through callbacks + * - Force the caller to explicitely handle or not the different events. + * + * Callbacks may start being called immediately after the `RepresentationStream` + * call and may be called until either the `parentCancelSignal` argument is + * triggered, until the `terminating` callback has been triggered AND all loaded + * segments have been pushed, or until the `error` callback is called, whichever + * comes first. + * @param {Object} parentCancelSignal - `CancellationSignal` allowing, when + * triggered, to immediately stop all operations the `RepresentationStream` is + * doing. */ -export default function RepresentationStream({ - content, - options, - playbackObserver, - segmentBuffer, - segmentFetcher, - terminate$, -} : IRepresentationStreamArguments -) : Observable { - const { period, - adaptation, - representation } = content; - const { bufferGoal$, - maxBufferSize$, - drmSystemId, - fastSwitchThreshold$ } = options; +export default function RepresentationStream( + { content, + options, + playbackObserver, + segmentBuffer, + segmentFetcher, + terminate } : IRepresentationStreamArguments, + callbacks : IRepresentationStreamCallbacks, + parentCancelSignal : CancellationSignal +) : void { + const { period, adaptation, representation } = content; + const { bufferGoal, maxBufferSize, drmSystemId, fastSwitchThreshold } = options; const bufferType = adaptation.type; + /** `TaskCanceller` stopping ALL operations performed by the `RepresentationStream` */ + const globalCanceller = new TaskCanceller({ cancelOn: parentCancelSignal }); + + /** + * `TaskCanceller` allowing to only stop segment loading and checking operations. + * This allows to stop only tasks linked to network resource usage, which is + * often a limited resource, while still letting buffer operations to finish. + */ + const segmentsLoadingCanceller = new TaskCanceller({ + cancelOn: globalCanceller.signal, + }); + /** Saved initialization segment state for this representation. */ const initSegmentState : IInitSegmentState = { segment: representation.index.getInitSegment(), @@ -118,21 +115,14 @@ export default function RepresentationStream({ isLoaded: false, }; - /** Allows to manually re-check which segments are needed. */ - const reCheckNeededSegments$ = new Subject(); - /** Emit the last scheduled downloading queue for segments. */ const lastSegmentQueue = createSharedReference({ initSegment: null, segmentQueue: [], }); - const hasInitSegment = initSegmentState.segment !== null; - /** Will load every segments in `lastSegmentQueue` */ - const downloadingQueue = new DownloadingQueue(content, - lastSegmentQueue, - segmentFetcher, - hasInitSegment); + /** If `true`, the current Representation has a linked initialization segment. */ + const hasInitSegment = initSegmentState.segment !== null; if (!hasInitSegment) { initSegmentState.segmentData = null; @@ -145,7 +135,6 @@ export default function RepresentationStream({ * Allows to avoid sending multiple times protection events. */ let hasSentEncryptionData = false; - let encryptionEvent$ : Observable = EMPTY; // If the DRM system id is already known, and if we already have encryption data // for it, we may not need to wait until the initialization segment is loaded to @@ -156,190 +145,181 @@ export default function RepresentationStream({ // If some key ids are not known yet, it may be safer to wait for this initialization // segment to be loaded first if (encryptionData.length > 0 && encryptionData.every(e => e.keyIds !== undefined)) { - encryptionEvent$ = observableOf(...encryptionData.map(d => - EVENTS.encryptionDataEncountered(d, content))); hasSentEncryptionData = true; + callbacks.encryptionDataEncountered( + encryptionData.map(d => objectAssign({ content }, d)) + ); + if (globalCanceller.isUsed) { + return ; // previous callback has stopped everything by side-effect + } } } - /** Observable loading and pushing segments scheduled through `lastSegmentQueue`. */ - const queue$ = downloadingQueue.start() - .pipe(mergeMap(onQueueEvent)); - - /** Observable emitting the stream "status" and filling `lastSegmentQueue`. */ - const status$ = observableCombineLatest([ - playbackObserver.getReference().asObservable(), - bufferGoal$, - maxBufferSize$, - terminate$.pipe(take(1), - startWith(null)), - reCheckNeededSegments$.pipe(startWith(undefined)), - ]).pipe( - withLatestFrom(fastSwitchThreshold$), - mergeMap(function ( - [ [ observation, bufferGoal, maxBufferSize, terminate ], - fastSwitchThreshold ] - ) : Observable - { - const initialWantedTime = observation.position.pending ?? - observation.position.last; - const status = getBufferStatus(content, - initialWantedTime, - playbackObserver, - fastSwitchThreshold, - bufferGoal, - maxBufferSize, - segmentBuffer); - const { neededSegments } = status; + /** Will load every segments in `lastSegmentQueue` */ + const downloadingQueue = new DownloadingQueue(content, + lastSegmentQueue, + segmentFetcher, + hasInitSegment); + downloadingQueue.addEventListener("error", (err) => { + if (segmentsLoadingCanceller.signal.isCancelled) { + return; // ignore post requests-cancellation loading-related errors, + } + globalCanceller.cancel(); // Stop every operations + callbacks.error(err); + }); + downloadingQueue.addEventListener("parsedInitSegment", onParsedChunk); + downloadingQueue.addEventListener("parsedMediaSegment", onParsedChunk); + downloadingQueue.addEventListener("emptyQueue", checkStatus); + downloadingQueue.addEventListener("requestRetry", (payload) => { + callbacks.warning(payload.error); + if (segmentsLoadingCanceller.signal.isCancelled) { + return; // If the previous callback led to loading operations being stopped, skip + } + const retriedSegment = payload.segment; + const { index } = representation; + if (index.isSegmentStillAvailable(retriedSegment) === false) { + checkStatus(); + } else if (index.canBeOutOfSyncError(payload.error, retriedSegment)) { + callbacks.manifestMightBeOufOfSync(); + } + }); + downloadingQueue.addEventListener("fullyLoadedSegment", (segment) => { + segmentBuffer.endOfSegment(objectAssign({ segment }, content), globalCanceller.signal) + .catch(onFatalBufferError); + }); + downloadingQueue.start(); + segmentsLoadingCanceller.signal.register(() => { + downloadingQueue.removeEventListener(); + downloadingQueue.stop(); + }); - let neededInitSegment : IQueuedSegment | null = null; + playbackObserver.listen(checkStatus, { + includeLastObservation: false, + clearSignal: segmentsLoadingCanceller.signal, + }); + bufferGoal.onUpdate(checkStatus, { + emitCurrentValue: false, + clearSignal: segmentsLoadingCanceller.signal , + }); + maxBufferSize.onUpdate(checkStatus, { + emitCurrentValue: false, + clearSignal: segmentsLoadingCanceller.signal, + }); + terminate.onUpdate(checkStatus, { + emitCurrentValue: false, + clearSignal: segmentsLoadingCanceller.signal, + }); + checkStatus(); + return ; - // Add initialization segment if required - if (!representation.index.isInitialized()) { - if (initSegmentState.segment === null) { - log.warn("Stream: Uninitialized index without an initialization segment"); - } else if (initSegmentState.isLoaded) { - log.warn("Stream: Uninitialized index with an already loaded " + - "initialization segment"); - } else { - const wantedStart = observation.position.pending ?? + /** + * Produce a buffer status update synchronously on call, update the list + * of current segments to update and check various buffer and manifest related + * issues at the current time, calling the right callbacks if necessary. + */ + function checkStatus() : void { + if (segmentsLoadingCanceller.isUsed) { + return ; // Stop all buffer status checking if load operations are stopped + } + const observation = playbackObserver.getReference().getValue(); + const initialWantedTime = observation.position.pending ?? observation.position.last; - neededInitSegment = { segment: initSegmentState.segment, - priority: getSegmentPriority(period.start, - wantedStart) }; - } - } else if (neededSegments.length > 0 && - !initSegmentState.isLoaded && - initSegmentState.segment !== null) - { - const initSegmentPriority = neededSegments[0].priority; + const status = getBufferStatus(content, + initialWantedTime, + playbackObserver, + fastSwitchThreshold.getValue(), + bufferGoal.getValue(), + maxBufferSize.getValue(), + segmentBuffer); + const { neededSegments } = status; + + let neededInitSegment : IQueuedSegment | null = null; + + // Add initialization segment if required + if (!representation.index.isInitialized()) { + if (initSegmentState.segment === null) { + log.warn("Stream: Uninitialized index without an initialization segment"); + } else if (initSegmentState.isLoaded) { + log.warn("Stream: Uninitialized index with an already loaded " + + "initialization segment"); + } else { + const wantedStart = observation.position.pending ?? + observation.position.last; neededInitSegment = { segment: initSegmentState.segment, - priority: initSegmentPriority }; + priority: getSegmentPriority(period.start, + wantedStart) }; } + } else if (neededSegments.length > 0 && + !initSegmentState.isLoaded && + initSegmentState.segment !== null) + { + const initSegmentPriority = neededSegments[0].priority; + neededInitSegment = { segment: initSegmentState.segment, + priority: initSegmentPriority }; + } - if (terminate === null) { - lastSegmentQueue.setValue({ initSegment: neededInitSegment, - segmentQueue: neededSegments }); - } else if (terminate.urgent) { - log.debug("Stream: Urgent switch, terminate now.", bufferType); - lastSegmentQueue.setValue({ initSegment: null, segmentQueue: [] }); + const terminateVal = terminate.getValue(); + if (terminateVal === null) { + lastSegmentQueue.setValue({ initSegment: neededInitSegment, + segmentQueue: neededSegments }); + } else if (terminateVal.urgent) { + log.debug("Stream: Urgent switch, terminate now.", bufferType); + lastSegmentQueue.setValue({ initSegment: null, segmentQueue: [] }); + lastSegmentQueue.finish(); + segmentsLoadingCanceller.cancel(); + callbacks.terminating(); + return; + } else { + // Non-urgent termination wanted: + // End the download of the current media segment if pending and + // terminate once either that request is finished or another segment + // is wanted instead, whichever comes first. + + const mostNeededSegment = neededSegments[0]; + const initSegmentRequest = downloadingQueue.getRequestedInitSegment(); + const currentSegmentRequest = downloadingQueue.getRequestedMediaSegment(); + + const nextQueue = currentSegmentRequest === null || + mostNeededSegment === undefined || + currentSegmentRequest.id !== mostNeededSegment.segment.id ? + [] : + [mostNeededSegment]; + + const nextInit = initSegmentRequest === null ? null : + neededInitSegment; + lastSegmentQueue.setValue({ initSegment: nextInit, + segmentQueue: nextQueue }); + if (nextQueue.length === 0 && nextInit === null) { + log.debug("Stream: No request left, terminate", bufferType); lastSegmentQueue.finish(); - return observableOf(EVENTS.streamTerminating()); - } else { - // Non-urgent termination wanted: - // End the download of the current media segment if pending and - // terminate once either that request is finished or another segment - // is wanted instead, whichever comes first. - - const mostNeededSegment = neededSegments[0]; - const initSegmentRequest = downloadingQueue.getRequestedInitSegment(); - const currentSegmentRequest = downloadingQueue.getRequestedMediaSegment(); - - const nextQueue = currentSegmentRequest === null || - mostNeededSegment === undefined || - currentSegmentRequest.id !== mostNeededSegment.segment.id ? - [] : - [mostNeededSegment]; - - const nextInit = initSegmentRequest === null ? null : - neededInitSegment; - lastSegmentQueue.setValue({ initSegment: nextInit, - segmentQueue: nextQueue }); - if (nextQueue.length === 0 && nextInit === null) { - log.debug("Stream: No request left, terminate", bufferType); - lastSegmentQueue.finish(); - return observableOf(EVENTS.streamTerminating()); - } - } - - const bufferStatusEvt : Observable = - observableOf({ type: "stream-status" as const, - value: { period, - position: observation.position.last, - bufferType, - imminentDiscontinuity: status.imminentDiscontinuity, - isEmptyStream: false, - hasFinishedLoading: status.hasFinishedLoading, - neededSegments: status.neededSegments } }); - let bufferRemoval = EMPTY; - const { UPTO_CURRENT_POSITION_CLEANUP } = config.getCurrent(); - if (status.isBufferFull) { - const gcedPosition = Math.max( - 0, - initialWantedTime - UPTO_CURRENT_POSITION_CLEANUP); - if (gcedPosition > 0) { - const removalCanceller = new TaskCanceller(); - bufferRemoval = fromCancellablePromise(removalCanceller, () => - segmentBuffer.removeBuffer(0, gcedPosition, removalCanceller.signal) - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument - ).pipe(ignoreElements()); - } + segmentsLoadingCanceller.cancel(); + callbacks.terminating(); + return; } - return status.shouldRefreshManifest ? - observableConcat(observableOf(EVENTS.needsManifestRefresh()), - bufferStatusEvt, bufferRemoval) : - observableConcat(bufferStatusEvt, bufferRemoval); - }), - takeWhile((e) => e.type !== "stream-terminating", true) - ); - - return observableMerge(status$, queue$, encryptionEvent$).pipe(share()); - - /** - * React to event from the `DownloadingQueue`. - * @param {Object} evt - * @returns {Observable} - */ - function onQueueEvent( - evt : IDownloadingQueueEvent - ) : Observable | - IStreamWarningEvent | - IEncryptionDataEncounteredEvent | - IInbandEventsEvent | - IStreamNeedsManifestRefresh | - IStreamManifestMightBeOutOfSync> - { - switch (evt.type) { - case "retry": - return observableConcat( - observableOf({ type: "warning" as const, value: evt.value.error }), - observableDefer(() => { // better if done after warning is emitted - const retriedSegment = evt.value.segment; - const { index } = representation; - if (index.isSegmentStillAvailable(retriedSegment) === false) { - reCheckNeededSegments$.next(); - } else if (index.canBeOutOfSyncError(evt.value.error, retriedSegment)) { - return observableOf(EVENTS.manifestMightBeOufOfSync()); - } - return EMPTY; // else, ignore. - })); - - case "parsed-init": - case "parsed-media": - return onParsedChunk(evt); + } - case "end-of-segment": { - const { segment } = evt.value; - const endOfSegmentCanceller = new TaskCanceller(); - return fromCancellablePromise(endOfSegmentCanceller, () => - segmentBuffer.endOfSegment(objectAssign({ segment }, content), - endOfSegmentCanceller.signal)) - // NOTE As of now (RxJS 7.4.0), RxJS defines `ignoreElements` default - // first type parameter as `any` instead of the perfectly fine `unknown`, - // leading to linter issues, as it forbids the usage of `any`. - // This is why we're disabling the eslint rule. - /* eslint-disable-next-line @typescript-eslint/no-unsafe-argument */ - .pipe(ignoreElements()); + callbacks.streamStatusUpdate({ period, + position: observation.position.last, + bufferType, + imminentDiscontinuity: status.imminentDiscontinuity, + isEmptyStream: false, + hasFinishedLoading: status.hasFinishedLoading, + neededSegments: status.neededSegments }); + if (segmentsLoadingCanceller.signal.isCancelled) { + return ; // previous callback has stopped loading operations by side-effect + } + const { UPTO_CURRENT_POSITION_CLEANUP } = config.getCurrent(); + if (status.isBufferFull) { + const gcedPosition = Math.max( + 0, + initialWantedTime - UPTO_CURRENT_POSITION_CLEANUP); + if (gcedPosition > 0) { + segmentBuffer.removeBuffer(0, gcedPosition, segmentsLoadingCanceller.signal) + .catch(onFatalBufferError); } - - case "end-of-queue": - reCheckNeededSegments$.next(); - return EMPTY; - - default: - assertUnreachable(evt); + } + if (status.shouldRefreshManifest) { + callbacks.needsManifestRefresh(); } } @@ -347,39 +327,48 @@ export default function RepresentationStream({ * Process a chunk that has just been parsed by pushing it to the * SegmentBuffer and emitting the right events. * @param {Object} evt - * @returns {Observable} */ function onParsedChunk( - evt : IParsedInitSegmentEvent | - IParsedSegmentEvent - ) : Observable | - IEncryptionDataEncounteredEvent | - IInbandEventsEvent | - IStreamNeedsManifestRefresh | - IStreamManifestMightBeOutOfSync> - { + evt : IParsedInitSegmentPayload | + IParsedSegmentPayload + ) : void { + if (globalCanceller.isUsed) { + // We should not do anything with segments if the `RepresentationStream` + // is not running anymore. + return ; + } if (evt.segmentType === "init") { - nextTick(() => { - reCheckNeededSegments$.next(); - }); initSegmentState.segmentData = evt.initializationData; initSegmentState.isLoaded = true; // Now that the initialization segment has been parsed - which may have // included encryption information - take care of the encryption event // if not already done. - const allEncryptionData = representation.getAllEncryptionData(); - const initEncEvt$ = !hasSentEncryptionData && - allEncryptionData.length > 0 ? - observableOf(...allEncryptionData.map(p => - EVENTS.encryptionDataEncountered(p, content))) : - EMPTY; - const pushEvent$ = pushInitSegment({ playbackObserver, - content, - segment: evt.segment, - segmentData: evt.initializationData, - segmentBuffer }); - return observableMerge(initEncEvt$, pushEvent$); + if (!hasSentEncryptionData) { + const allEncryptionData = representation.getAllEncryptionData(); + if (allEncryptionData.length > 0) { + callbacks.encryptionDataEncountered( + allEncryptionData.map(p => objectAssign({ content }, p)) + ); + } + } + + pushInitSegment({ playbackObserver, + content, + segment: evt.segment, + segmentData: evt.initializationData, + segmentBuffer }, + globalCanceller.signal) + .then((result) => { + if (result !== null) { + callbacks.addedSegment(result); + } + }) + .catch(onFatalBufferError); + + // Sometimes the segment list is only known once the initialization segment + // is parsed. Thus we immediately re-check if there's new segments to load. + checkStatus(); } else { const { inbandEvents, needsManifestRefresh, @@ -387,154 +376,64 @@ export default function RepresentationStream({ // TODO better handle use cases like key rotation by not always grouping // every protection data together? To check. - const segmentEncryptionEvent$ = protectionDataUpdate && - !hasSentEncryptionData ? - observableOf(...representation.getAllEncryptionData().map(p => - EVENTS.encryptionDataEncountered(p, content))) : - EMPTY; + if (!hasSentEncryptionData && protectionDataUpdate) { + const allEncryptionData = representation.getAllEncryptionData(); + if (allEncryptionData.length > 0) { + callbacks.encryptionDataEncountered( + allEncryptionData.map(p => objectAssign({ content }, p)) + ); + if (globalCanceller.isUsed) { + return ; // previous callback has stopped everything by side-effect + } + } + } - const manifestRefresh$ = needsManifestRefresh === true ? - observableOf(EVENTS.needsManifestRefresh()) : - EMPTY; - const inbandEvents$ = inbandEvents !== undefined && - inbandEvents.length > 0 ? - observableOf({ type: "inband-events" as const, - value: inbandEvents }) : - EMPTY; + if (needsManifestRefresh === true) { + callbacks.needsManifestRefresh(); + if (globalCanceller.isUsed) { + return ; // previous callback has stopped everything by side-effect + } + } + if (inbandEvents !== undefined && inbandEvents.length > 0) { + callbacks.inbandEvent(inbandEvents); + if (globalCanceller.isUsed) { + return ; // previous callback has stopped everything by side-effect + } + } const initSegmentData = initSegmentState.segmentData; - const pushMediaSegment$ = pushMediaSegment({ playbackObserver, - content, - initSegmentData, - parsedSegment: evt, - segment: evt.segment, - segmentBuffer }); - return observableConcat(segmentEncryptionEvent$, - manifestRefresh$, - inbandEvents$, - pushMediaSegment$); + pushMediaSegment({ playbackObserver, + content, + initSegmentData, + parsedSegment: evt, + segment: evt.segment, + segmentBuffer }, + globalCanceller.signal) + .then((result) => { + if (result !== null) { + callbacks.addedSegment(result); + } + }) + .catch(onFatalBufferError); } } -} - -/** Object that should be emitted by the given `IReadOnlyPlaybackObserver`. */ -export interface IRepresentationStreamPlaybackObservation { - /** - * Information on the current media position in seconds at the time of a - * Playback Observation. - */ - position : IPositionPlaybackObservation; -} -/** Position-related information linked to an emitted Playback observation. */ -export interface IPositionPlaybackObservation { /** - * Known position at the time the Observation was emitted, in seconds. - * - * Note that it might have changed since. If you want truly precize - * information, you should recuperate it from the HTMLMediaElement directly - * through another mean. + * Handle Buffer-related fatal errors by cancelling everything the + * `RepresentationStream` is doing and calling the error callback with the + * corresponding error. + * @param {*} err */ - last : number; - /** - * Actually wanted position in seconds that is not yet reached. - * - * This might for example be set to the initial position when the content is - * loading (and thus potentially at a `0` position) but which will be seeked - * to a given position once possible. - */ - pending : number | undefined; -} - -/** Item emitted by the `terminate$` Observable given to a RepresentationStream. */ -export interface ITerminationOrder { - /* - * If `true`, the RepresentationStream should interrupt immediately every long - * pending operations such as segment downloads. - * If it is set to `false`, it can continue until those operations are - * finished. - */ - urgent : boolean; -} - -/** Arguments to give to the RepresentationStream. */ -export interface IRepresentationStreamArguments { - /** The context of the Representation you want to load. */ - content: { adaptation : Adaptation; - manifest : Manifest; - period : Period; - representation : Representation; }; - /** The `SegmentBuffer` on which segments will be pushed. */ - segmentBuffer : SegmentBuffer; - /** Interface used to load new segments. */ - segmentFetcher : IPrioritizedSegmentFetcher; - /** - * Observable emitting when the RepresentationStream should "terminate". - * - * When this Observable emits, the RepresentationStream will begin a - * "termination process": it will, depending on the type of termination - * wanted, either stop immediately pending segment requests or wait until they - * are finished before fully terminating (sending the - * `IStreamTerminatingEvent` and then completing the `RepresentationStream` - * Observable once the corresponding segments have been pushed). - */ - terminate$ : Observable; - /** Periodically emits the current playback conditions. */ - playbackObserver : IReadOnlyPlaybackObserver; - /** Supplementary arguments which configure the RepresentationStream's behavior. */ - options: IRepresentationStreamOptions; -} - - -/** - * Various specific stream "options" which tweak the behavior of the - * RepresentationStream. - */ -export interface IRepresentationStreamOptions { - /** - * The buffer size we have to reach in seconds (compared to the current - * position. When that size is reached, no segments will be loaded until it - * goes below that size again. - */ - bufferGoal$ : Observable; - - /** - * The buffer size limit in memory that we can reach. - * Once reached, no segments will be loaded until it - * goes below that size again - */ - maxBufferSize$ : Observable; - - /** - * Hex-encoded DRM "system ID" as found in: - * https://dashif.org/identifiers/content_protection/ - * - * Allows to identify which DRM system is currently used, to allow potential - * optimizations. - * - * Set to `undefined` in two cases: - * - no DRM system is used (e.g. the content is unencrypted). - * - We don't know which DRM system is currently used. - */ - drmSystemId : string | undefined; - /** - * Bitrate threshold from which no "fast-switching" should occur on a segment. - * - * Fast-switching is an optimization allowing to replace segments from a - * low-bitrate Representation by segments from a higher-bitrate - * Representation. This allows the user to see/hear an improvement in quality - * faster, hence "fast-switching". - * - * This Observable allows to limit this behavior to only allow the replacement - * of segments with a bitrate lower than a specific value - the number emitted - * by that Observable. - * - * If set to `undefined`, no threshold is active and any segment can be - * replaced by higher quality segment(s). - * - * `0` can be emitted to disable any kind of fast-switching. - */ - fastSwitchThreshold$: Observable< undefined | number>; + function onFatalBufferError(err : unknown) : void { + if (globalCanceller.isUsed && err instanceof CancellationError) { + // The error is linked to cancellation AND we explicitely cancelled buffer + // operations. + // We can thus ignore it, it is very unlikely to lead to true buffer issues. + return; + } + globalCanceller.cancel(); + callbacks.error(err); + } } /** diff --git a/src/core/stream/representation/types.ts b/src/core/stream/representation/types.ts new file mode 100644 index 0000000000..817f013ceb --- /dev/null +++ b/src/core/stream/representation/types.ts @@ -0,0 +1,292 @@ +import Manifest, { + Adaptation, + ISegment, + Period, + Representation, +} from "../../../manifest"; +import { IEMSG } from "../../../parsers/containers/isobmff"; +import { IPlayerError } from "../../../public_types"; +import { IReadOnlySharedReference } from "../../../utils/reference"; +import { IReadOnlyPlaybackObserver } from "../../api"; +import { IContentProtection } from "../../decrypt"; +import { IPrioritizedSegmentFetcher } from "../../fetchers"; +import { + IBufferType, + SegmentBuffer, +} from "../../segment_buffers"; + +/** Callbacks called by the `RepresentationStream` on various events. */ +export interface IRepresentationStreamCallbacks { + /** + * Called to announce the current status regarding the buffer for its + * associated Period and type (e.g. "audio", "video", "text" etc.). + * + * Each new `IStreamStatusPayload` event replace the precedent one for the + * same Period and type. + */ + streamStatusUpdate(payload : IStreamStatusPayload) : void; + /** Called after a new segment has been succesfully added to the SegmentBuffer */ + addedSegment(payload : IStreamEventAddedSegmentPayload) : void; + /** Called when a segment with protection information has been encountered. */ + encryptionDataEncountered(payload : IContentProtection[]) : void; + /** + * Called when the Manifest is possibly out-of-sync and needs to be refreshed + * completely. + * + * The Stream made that guess because a segment that should have been available + * is not and because it suspects this is due to a synchronization problem. + */ + manifestMightBeOufOfSync() : void; + /** + * Callback called when a `RepresentationStream` is being terminated: + * + * - it has finished all its segment requests and won't do new ones. + * + * - it has stopped regularly checking for its current status. + * + * - it only waits until all the segments it has loaded have been pushed to the + * SegmentBuffer before actually stopping everything it does. + * + * You can use this call as a hint that a new `RepresentationStream` can be + * created for the same `Period` and type (e.g. to switch quality). + */ + terminating() : void; + /** + * Called when the Manifest needs to be refreshed. + * Note that segment might still be loaded and pushed even after calling + * this callback. + */ + needsManifestRefresh() : void; + /** + * Called when an "inband" event, as found in a media segment, has been + * encountered. + */ + inbandEvent(payload : IInbandEvent[]) : void; + /** + * Called when a minor error has been encountered, that does not interrupt + * the segment loading and pushing operations. + */ + warning(payload : IPlayerError) : void; + /** + * Called when a fatal error has been encountered. + * Such errors have led to all the Stream's operations to be stopped. + */ + error(payload : unknown) : void; +} + +/** Payload for the `streamStatusUpdate` callback. */ +export interface IStreamStatusPayload { + /** Period concerned. */ + period : Period; + /** Buffer type concerned. */ + bufferType : IBufferType; + /** + * Present or future "hole" in the SegmentBuffer's buffer that will not be + * filled by a segment, despite being part of the time period indicated by + * the associated Period. + * + * This value is set to the most imminent of such "discontinuity", which + * can be either: + * + * - current (no segment available at `position` but future segments are + * available), in which case this discontinuity's true beginning might + * be unknown. + * + * - a future hole between two segments in that Period. + * + * - missing media data at the end of the time period associated to that + * Period. + * + * The presence or absence of a discontinuity can evolve during playback + * (because new tracks or qualities might not have the same ones). + * As such, it is advised to only consider the last discontinuity sent + * through a `"stream-status"` event. + */ + imminentDiscontinuity : IBufferDiscontinuity | null; + /** + * If `true`, no segment are left to be loaded to be able to play until the + * end of the Period. + */ + hasFinishedLoading : boolean; + /** + * If `true`, this stream is a placeholder stream which will never load any + * segment. + */ + isEmptyStream : boolean; + /** + * Segments that will be scheduled for download to fill the buffer until + * the buffer goal (first element of that list might already be loading). + */ + neededSegments : IQueuedSegment[]; + /** Position in the content in seconds from which this status was done. */ + position : number; +} + +/** Payload for the `addedSegment` callback. */ +export interface IStreamEventAddedSegmentPayload { + /** Context about the content that has been added. */ + content: { period : Period; + adaptation : Adaptation; + representation : Representation; }; + /** The concerned Segment. */ + segment : ISegment; + /** TimeRanges of the concerned SegmentBuffer after the segment was pushed. */ + buffered : TimeRanges; + /* The data pushed */ + segmentData : T; +} + +/** Structure describing an "inband" event, as found in a media segment. */ +export interface IInbandEvent { + /** Type when the event was foud inside a "emsg` ISOBMFF box */ + type: "emsg"; + /** Value when the event was foud inside a "emsg` ISOBMFF box */ + value: IEMSG; +} + +/** Information about a Segment waiting to be loaded by the Stream. */ +export interface IQueuedSegment { + /** Priority of the segment request (lower number = higher priority). */ + priority : number; + /** Segment wanted. */ + segment : ISegment; +} + +/** Describe an encountered hole in the buffer, called a "discontinuity". */ +export interface IBufferDiscontinuity { + /** + * Start time, in seconds, at which the discontinuity starts. + * + * if set to `undefined`, its true start time is unknown but the current + * position is part of it. It is thus a discontinuity that is currently + * encountered. + */ + start : number | undefined; + /** + * End time, in seconds at which the discontinuity ends (and thus where + * new segments are encountered). + * + * If `null`, no new media segment is available for that Period and + * buffer type until the end of the Period. + */ + end : number | null; +} + +/** Object that should be emitted by the given `IReadOnlyPlaybackObserver`. */ +export interface IRepresentationStreamPlaybackObservation { + /** + * Information on the current media position in seconds at the time of a + * Playback Observation. + */ + position : IPositionPlaybackObservation; +} + +/** Position-related information linked to an emitted Playback observation. */ +export interface IPositionPlaybackObservation { + /** + * Known position at the time the Observation was emitted, in seconds. + * + * Note that it might have changed since. If you want truly precize + * information, you should recuperate it from the HTMLMediaElement directly + * through another mean. + */ + last : number; + /** + * Actually wanted position in seconds that is not yet reached. + * + * This might for example be set to the initial position when the content is + * loading (and thus potentially at a `0` position) but which will be seeked + * to a given position once possible. + */ + pending : number | undefined; +} + +/** Item emitted by the `terminate` reference given to a RepresentationStream. */ +export interface ITerminationOrder { + /* + * If `true`, the RepresentationStream should interrupt immediately every long + * pending operations such as segment downloads. + * If it is set to `false`, it can continue until those operations are + * finished. + */ + urgent : boolean; +} + +/** Arguments to give to the RepresentationStream. */ +export interface IRepresentationStreamArguments { + /** The context of the Representation you want to load. */ + content: { adaptation : Adaptation; + manifest : Manifest; + period : Period; + representation : Representation; }; + /** The `SegmentBuffer` on which segments will be pushed. */ + segmentBuffer : SegmentBuffer; + /** Interface used to load new segments. */ + segmentFetcher : IPrioritizedSegmentFetcher; + /** + * Reference emitting when the RepresentationStream should "terminate". + * + * When this Reference emits an object, the RepresentationStream will begin a + * "termination process": it will, depending on the type of termination + * wanted, either stop immediately pending segment requests or wait until they + * are finished before fully terminating (calling the `terminating` callback + * and stopping all `RepresentationStream` current tasks). + */ + terminate : IReadOnlySharedReference; + /** Periodically emits the current playback conditions. */ + playbackObserver : IReadOnlyPlaybackObserver; + /** Supplementary arguments which configure the RepresentationStream's behavior. */ + options: IRepresentationStreamOptions; +} + + +/** + * Various specific stream "options" which tweak the behavior of the + * RepresentationStream. + */ +export interface IRepresentationStreamOptions { + /** + * The buffer size we have to reach in seconds (compared to the current + * position. When that size is reached, no segments will be loaded until it + * goes below that size again. + */ + bufferGoal : IReadOnlySharedReference; + + /** + * The buffer size limit in memory that we can reach. + * Once reached, no segments will be loaded until it + * goes below that size again + */ + maxBufferSize : IReadOnlySharedReference; + + /** + * Hex-encoded DRM "system ID" as found in: + * https://dashif.org/identifiers/content_protection/ + * + * Allows to identify which DRM system is currently used, to allow potential + * optimizations. + * + * Set to `undefined` in two cases: + * - no DRM system is used (e.g. the content is unencrypted). + * - We don't know which DRM system is currently used. + */ + drmSystemId : string | undefined; + /** + * Bitrate threshold from which no "fast-switching" should occur on a segment. + * + * Fast-switching is an optimization allowing to replace segments from a + * low-bitrate Representation by segments from a higher-bitrate + * Representation. This allows the user to see/hear an improvement in quality + * faster, hence "fast-switching". + * + * This Reference allows to limit this behavior to only allow the replacement + * of segments with a bitrate lower than a specific value - the number emitted + * by that Reference. + * + * If set to `undefined`, no threshold is active and any segment can be + * replaced by higher quality segment(s). + * + * `0` can be emitted to disable any kind of fast-switching. + */ + fastSwitchThreshold: IReadOnlySharedReference< undefined | number>; +} diff --git a/src/core/stream/representation/append_segment_to_buffer.ts b/src/core/stream/representation/utils/append_segment_to_buffer.ts similarity index 80% rename from src/core/stream/representation/append_segment_to_buffer.ts rename to src/core/stream/representation/utils/append_segment_to_buffer.ts index 10b2d7b9d8..4bd21e9bff 100644 --- a/src/core/stream/representation/append_segment_to_buffer.ts +++ b/src/core/stream/representation/utils/append_segment_to_buffer.ts @@ -18,15 +18,15 @@ * This file allows any Stream to push data to a SegmentBuffer. */ -import { MediaError } from "../../../errors"; -import { CancellationSignal } from "../../../utils/task_canceller"; -import { IReadOnlyPlaybackObserver } from "../../api"; +import { MediaError } from "../../../../errors"; +import { CancellationError, CancellationSignal } from "../../../../utils/task_canceller"; +import { IReadOnlyPlaybackObserver } from "../../../api"; import { IPushChunkInfos, SegmentBuffer, -} from "../../segment_buffers"; +} from "../../../segment_buffers"; +import { IRepresentationStreamPlaybackObservation } from "../types"; import forceGarbageCollection from "./force_garbage_collection"; -import { IRepresentationStreamPlaybackObservation } from "./representation_stream"; /** * Append a segment to the given segmentBuffer. @@ -47,7 +47,11 @@ export default async function appendSegmentToBuffer( try { await segmentBuffer.pushChunk(dataInfos, cancellationSignal); } catch (appendError : unknown) { - if (!(appendError instanceof Error) || appendError.name !== "QuotaExceededError") { + if (cancellationSignal.isCancelled && appendError instanceof CancellationError) { + throw appendError; + } else if (!(appendError instanceof Error) || + appendError.name !== "QuotaExceededError") + { const reason = appendError instanceof Error ? appendError.toString() : "An unknown error happened when pushing content"; diff --git a/src/core/stream/representation/check_for_discontinuity.ts b/src/core/stream/representation/utils/check_for_discontinuity.ts similarity index 98% rename from src/core/stream/representation/check_for_discontinuity.ts rename to src/core/stream/representation/utils/check_for_discontinuity.ts index 2e6a9f78ec..2a1c816341 100644 --- a/src/core/stream/representation/check_for_discontinuity.ts +++ b/src/core/stream/representation/utils/check_for_discontinuity.ts @@ -14,13 +14,13 @@ * limitations under the License. */ -import log from "../../../log"; +import log from "../../../../log"; import Manifest, { Adaptation, Period, Representation, -} from "../../../manifest"; -import { IBufferedChunk } from "../../segment_buffers"; +} from "../../../../manifest"; +import { IBufferedChunk } from "../../../segment_buffers"; import { IBufferDiscontinuity } from "../types"; /** diff --git a/src/core/stream/representation/utils/downloading_queue.ts b/src/core/stream/representation/utils/downloading_queue.ts new file mode 100644 index 0000000000..8836f2fb43 --- /dev/null +++ b/src/core/stream/representation/utils/downloading_queue.ts @@ -0,0 +1,592 @@ +/** + * 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. + */ + +import log from "../../../../log"; +import Manifest, { + Adaptation, + ISegment, + Period, + Representation, +} from "../../../../manifest"; +import { IPlayerError } from "../../../../public_types"; +import { + ISegmentParserParsedInitChunk, + ISegmentParserParsedMediaChunk, +} from "../../../../transports"; +import assert from "../../../../utils/assert"; +import EventEmitter from "../../../../utils/event_emitter"; +import objectAssign from "../../../../utils/object_assign"; +import createSharedReference, { + IReadOnlySharedReference, + ISharedReference, +} from "../../../../utils/reference"; +import TaskCanceller from "../../../../utils/task_canceller"; +import { IPrioritizedSegmentFetcher } from "../../../fetchers"; +import { IQueuedSegment } from "../types"; + +/** + * Class scheduling segment downloads for a single Representation. + * + * TODO The request scheduling abstractions might be simplified by integrating + * the `DownloadingQueue` in the segment fetchers code, instead of having it as + * an utilis of the `RepresentationStream` like here. + * @class DownloadingQueue + */ +export default class DownloadingQueue + extends EventEmitter> +{ + /** Context of the Representation that will be loaded through this DownloadingQueue. */ + private _content : IDownloadingQueueContext; + /** + * Current queue of segments scheduled for download. + * + * Segments whose request are still pending are still in that queue. Segments + * are only removed from it once their request has succeeded. + */ + private _downloadQueue : IReadOnlySharedReference; + /** + * Allows to stop listening to queue updates and stop performing requests. + * Set to `null` if the DownloadingQueue is not started right now. + */ + private _currentCanceller : TaskCanceller | null; + /** + * Pending request for the initialization segment. + * `null` if no request is pending for it. + */ + private _initSegmentRequest : ISegmentRequestObject|null; + /** + * Pending request for a media (i.e. non-initialization) segment. + * `null` if no request is pending for it. + */ + private _mediaSegmentRequest : ISegmentRequestObject|null; + /** Interface used to load segments. */ + private _segmentFetcher : IPrioritizedSegmentFetcher; + /** + * Emit the timescale anounced in the initialization segment once parsed. + * `undefined` when this is not yet known. + * `null` when no initialization segment or timescale exists. + */ + private _initSegmentInfoRef : ISharedReference; + /** + * Some media segment might have been loaded and are only awaiting for the + * initialization segment to be parsed before being parsed themselves. + * This string will contain the `id` property of that segment if one exist or + * `null` if no segment is awaiting an init segment. + */ + private _mediaSegmentAwaitingInitMetadata : string | null; + + /** + * Create a new `DownloadingQueue`. + * + * @param {Object} content - The context of the Representation you want to + * load segments for. + * @param {Object} downloadQueue - Queue of segments you want to load. + * @param {Object} segmentFetcher - Interface to facilitate the download of + * segments. + * @param {boolean} hasInitSegment - Declare that an initialization segment + * will need to be downloaded. + * + * A `DownloadingQueue` ALWAYS wait for the initialization segment to be + * loaded and parsed before parsing a media segment. + * + * In cases where no initialization segment exist, this would lead to the + * `DownloadingQueue` waiting indefinitely for it. + * + * By setting that value to `false`, you anounce to the `DownloadingQueue` + * that it should not wait for an initialization segment before parsing a + * media segment. + */ + constructor( + content: IDownloadingQueueContext, + downloadQueue : IReadOnlySharedReference, + segmentFetcher : IPrioritizedSegmentFetcher, + hasInitSegment : boolean + ) { + super(); + this._content = content; + this._currentCanceller = null; + this._downloadQueue = downloadQueue; + this._initSegmentRequest = null; + this._mediaSegmentRequest = null; + this._segmentFetcher = segmentFetcher; + this._initSegmentInfoRef = createSharedReference(undefined); + this._mediaSegmentAwaitingInitMetadata = null; + if (!hasInitSegment) { + this._initSegmentInfoRef.setValue(null); + } + } + + /** + * Returns the initialization segment currently being requested. + * Returns `null` if no initialization segment request is pending. + * @returns {Object | null} + */ + public getRequestedInitSegment() : ISegment | null { + return this._initSegmentRequest === null ? null : + this._initSegmentRequest.segment; + } + + /** + * Returns the media segment currently being requested. + * Returns `null` if no media segment request is pending. + * @returns {Object | null} + */ + public getRequestedMediaSegment() : ISegment | null { + return this._mediaSegmentRequest === null ? null : + this._mediaSegmentRequest.segment; + } + + /** + * Start the current downloading queue, emitting events as it loads and parses + * initialization and media segments. + */ + public start() : void { + if (this._currentCanceller !== null) { + return ; + } + this._currentCanceller = new TaskCanceller(); + + // Listen for asked media segments + this._downloadQueue.onUpdate((queue) => { + const { segmentQueue } = queue; + + if (segmentQueue.length > 0 && + segmentQueue[0].segment.id === this._mediaSegmentAwaitingInitMetadata) + { + // The most needed segment is still the same one, and there's no need to + // update its priority as the request already ended, just quit. + return; + } + + const currentSegmentRequest = this._mediaSegmentRequest; + if (segmentQueue.length === 0) { + if (currentSegmentRequest === null) { + // There's nothing to load but there's already no request pending. + return; + } + log.debug("Stream: no more media segment to request. Cancelling queue.", + this._content.adaptation.type); + this._restartMediaSegmentDownloadingQueue(); + return; + } else if (currentSegmentRequest === null) { + // There's no request although there are needed segments: start requests + log.debug("Stream: Media segments now need to be requested. Starting queue.", + this._content.adaptation.type, segmentQueue.length); + this._restartMediaSegmentDownloadingQueue(); + return; + } else { + const nextItem = segmentQueue[0]; + if (currentSegmentRequest.segment.id !== nextItem.segment.id) { + // The most important request if for another segment, request it + log.debug("Stream: Next media segment changed, cancelling previous", + this._content.adaptation.type); + this._restartMediaSegmentDownloadingQueue(); + return; + } + if (currentSegmentRequest.priority !== nextItem.priority) { + // The priority of the most important request has changed, update it + log.debug("Stream: Priority of next media segment changed, updating", + this._content.adaptation.type, + currentSegmentRequest.priority, + nextItem.priority); + this._segmentFetcher.updatePriority(currentSegmentRequest.request, + nextItem.priority); + } + return; + } + }, { emitCurrentValue: true, clearSignal: this._currentCanceller.signal }); + + // Listen for asked init segment + this._downloadQueue.onUpdate((next) => { + const initSegmentRequest = this._initSegmentRequest; + if (next.initSegment !== null && initSegmentRequest !== null) { + if (next.initSegment.priority !== initSegmentRequest.priority) { + this._segmentFetcher.updatePriority(initSegmentRequest.request, + next.initSegment.priority); + } + return; + } else if (next.initSegment?.segment.id === initSegmentRequest?.segment.id) { + return ; + } + if (next.initSegment === null) { + log.debug("Stream: no more init segment to request. Cancelling queue.", + this._content.adaptation.type); + } + this._restartInitSegmentDownloadingQueue(next.initSegment); + }, { emitCurrentValue: true, clearSignal: this._currentCanceller.signal }); + } + + public stop() { + this._currentCanceller?.cancel(); + } + + /** + * Internal logic performing media segment requests. + */ + private _restartMediaSegmentDownloadingQueue() : void { + if (this._mediaSegmentRequest !== null) { + this._mediaSegmentRequest.canceller.cancel(); + } + const { segmentQueue } = this._downloadQueue.getValue(); + const currentNeededSegment = segmentQueue[0]; + const recursivelyRequestSegments = ( + startingSegment : IQueuedSegment | undefined + ) : void => { + if (this._currentCanceller !== null && this._currentCanceller.isUsed) { + this._mediaSegmentRequest = null; + return; + } + if (startingSegment === undefined) { + this._mediaSegmentRequest = null; + this.trigger("emptyQueue", null); + return; + } + const canceller = new TaskCanceller({ cancelOn: this._currentCanceller?.signal }); + + const { segment, priority } = startingSegment; + const context = objectAssign({ segment }, this._content); + + /** + * If `true` , the current task has either errored, finished, or was + * cancelled. + */ + let isComplete = false; + + /** + * If true, we're currently waiting for the initialization segment to be + * parsed before parsing a received chunk. + */ + let isWaitingOnInitSegment = false; + + canceller.signal.register(() => { + this._mediaSegmentRequest = null; + if (isComplete) { + return; + } + if (this._mediaSegmentAwaitingInitMetadata === segment.id) { + this._mediaSegmentAwaitingInitMetadata = null; + } + isComplete = true; + isWaitingOnInitSegment = false; + }); + const emitChunk = ( + parsed : ISegmentParserParsedInitChunk | + ISegmentParserParsedMediaChunk + ) : void => { + assert(parsed.segmentType === "media", "Should have loaded a media segment."); + this.trigger("parsedMediaSegment", objectAssign({}, parsed, { segment })); + }; + + + const continueToNextSegment = () : void => { + const lastQueue = this._downloadQueue.getValue().segmentQueue; + if (lastQueue.length === 0) { + isComplete = true; + this.trigger("emptyQueue", null); + return; + } else if (lastQueue[0].segment.id === segment.id) { + lastQueue.shift(); + } + isComplete = true; + recursivelyRequestSegments(lastQueue[0]); + }; + + /** Scheduled actual segment request. */ + const request = this._segmentFetcher.createRequest(context, priority, { + /** + * Callback called when the request has to be retried. + * @param {Error} error + */ + onRetry: (error : IPlayerError) : void => { + this.trigger("requestRetry", { segment, error }); + }, + + /** + * Callback called when the request has to be interrupted and + * restarted later. + */ + beforeInterrupted() { + log.info("Stream: segment request interrupted temporarly.", + segment.id, + segment.time); + }, + + /** + * Callback called when a decodable chunk of the segment is available. + * @param {Function} parse - Function allowing to parse the segment. + */ + onChunk: ( + parse : ( + initTimescale : number | undefined + ) => ISegmentParserParsedInitChunk | ISegmentParserParsedMediaChunk + ) : void => { + const initTimescale = this._initSegmentInfoRef.getValue(); + if (initTimescale !== undefined) { + emitChunk(parse(initTimescale ?? undefined)); + } else { + isWaitingOnInitSegment = true; + + // We could also technically call `waitUntilDefined` in both cases, + // but I found it globally clearer to segregate the two cases, + // especially to always have a meaningful `isWaitingOnInitSegment` + // boolean which is a very important variable. + this._initSegmentInfoRef.waitUntilDefined((actualTimescale) => { + emitChunk(parse(actualTimescale ?? undefined)); + }, { clearSignal: canceller.signal }); + } + }, + + /** Callback called after all chunks have been sent. */ + onAllChunksReceived: () : void => { + if (!isWaitingOnInitSegment) { + this.trigger("fullyLoadedSegment", segment); + } else { + this._mediaSegmentAwaitingInitMetadata = segment.id; + this._initSegmentInfoRef.waitUntilDefined(() => { + this._mediaSegmentAwaitingInitMetadata = null; + isWaitingOnInitSegment = false; + this.trigger("fullyLoadedSegment", segment); + }, { clearSignal: canceller.signal }); + } + }, + + /** + * Callback called right after the request ended but before the next + * requests are scheduled. It is used to schedule the next segment. + */ + beforeEnded: () : void => { + this._mediaSegmentRequest = null; + + if (isWaitingOnInitSegment) { + this._initSegmentInfoRef.waitUntilDefined(continueToNextSegment, + { clearSignal: canceller.signal }); + } else { + continueToNextSegment(); + } + }, + + }, canceller.signal); + + request.catch((error : unknown) => { + if (!isComplete) { + isComplete = true; + this.stop(); + this.trigger("error", error); + } + }); + + this._mediaSegmentRequest = { segment, priority, request, canceller }; + }; + recursivelyRequestSegments(currentNeededSegment); + } + + /** + * Internal logic performing initialization segment requests. + * @param {Object} queuedInitSegment + */ + private _restartInitSegmentDownloadingQueue( + queuedInitSegment : IQueuedSegment | null + ) : void { + if (this._currentCanceller !== null && this._currentCanceller.isUsed) { + return; + } + if (this._initSegmentRequest !== null) { + this._initSegmentRequest.canceller.cancel(); + } + if (queuedInitSegment === null) { + return ; + } + + const canceller = new TaskCanceller({ cancelOn: this._currentCanceller?.signal }); + const { segment, priority } = queuedInitSegment; + const context = objectAssign({ segment }, this._content); + + /** + * If `true` , the current task has either errored, finished, or was + * cancelled. + */ + let isComplete = false; + + const request = this._segmentFetcher.createRequest(context, priority, { + onRetry: (err : IPlayerError) : void => { + this.trigger("requestRetry", { segment, error: err }); + }, + beforeInterrupted: () => { + log.info("Stream: init segment request interrupted temporarly.", segment.id); + }, + beforeEnded: () => { + this._initSegmentRequest = null; + isComplete = true; + }, + onChunk: (parse : (x : undefined) => ISegmentParserParsedInitChunk | + ISegmentParserParsedMediaChunk) : void => { + const parsed = parse(undefined); + assert(parsed.segmentType === "init", "Should have loaded an init segment."); + this.trigger("parsedInitSegment", objectAssign({}, parsed, { segment })); + if (parsed.segmentType === "init") { + this._initSegmentInfoRef.setValue(parsed.initTimescale ?? null); + } + }, + onAllChunksReceived: () : void => { + this.trigger("fullyLoadedSegment", segment); + }, + }, canceller.signal); + + request.catch((error : unknown) => { + if (!isComplete) { + isComplete = true; + this.stop(); + this.trigger("error", error); + } + }); + + canceller.signal.register(() => { + this._initSegmentRequest = null; + if (isComplete) { + return; + } + isComplete = true; + }); + + this._initSegmentRequest = { segment, priority, request, canceller }; + } +} + +/** + * Events sent by the `DownloadingQueue`. + * + * The key is the event's name and the value the format of the corresponding + * event's payload. + */ +export interface IDownloadingQueueEvent { + /** + * Notify that the initialization segment has been fully loaded and parsed. + * + * You can now push that segment to its corresponding buffer and use its parsed + * metadata. + * + * Only sent if an initialization segment exists (when the `DownloadingQueue`'s + * `hasInitSegment` constructor option has been set to `true`). + * In that case, an `IParsedInitSegmentEvent` will always be sent before any + * `IParsedSegmentEvent` event is sent. + */ + parsedInitSegment : IParsedInitSegmentPayload; + /** + * Notify that a media chunk (decodable sub-part of a media segment) has been + * loaded and parsed. + * + * If an initialization segment exists (when the `DownloadingQueue`'s + * `hasInitSegment` constructor option has been set to `true`), an + * `IParsedSegmentEvent` will always be sent AFTER the `IParsedInitSegmentEvent` + * event. + * + * It can now be pushed to its corresponding buffer. Note that there might be + * multiple `IParsedSegmentEvent` for a single segment, if that segment is + * divided into multiple decodable chunks. + * You will know that all `IParsedSegmentEvent` have been loaded for a given + * segment once you received the `IEndOfSegmentEvent` for that segment. + */ + parsedMediaSegment : IParsedSegmentPayload; + /** Notify that a media or initialization segment has been fully-loaded. */ + fullyLoadedSegment : ISegment; + /** + * Notify that a media or initialization segment request is retried. + * This happened most likely because of an HTTP error. + */ + requestRetry : IRequestRetryPayload; + /** + * Notify that the media segment queue is now empty. + * This can be used to re-check if any segment are now needed. + */ + emptyQueue : null; + /** + * Notify that a fatal error happened (such as request failures), which has + * completely stopped the downloading queue. + * + * You may still restart the queue after receiving this event. + */ + error : unknown; +} + +/** Payload for a `parsedInitSegment` event. */ +export type IParsedInitSegmentPayload = ISegmentParserParsedInitChunk & + { segment : ISegment }; +/** Payload for a `parsedMediaSegment` event. */ +export type IParsedSegmentPayload = ISegmentParserParsedMediaChunk & + { segment : ISegment }; +/** Payload for a `requestRetry` event. */ +export interface IRequestRetryPayload { + segment : ISegment; + error : IPlayerError; +} + +/** + * Structure of the object that has to be emitted through the `downloadQueue` + * shared reference, to signal which segments are currently needed. + */ +export interface IDownloadQueueItem { + /** + * A potential initialization segment that needs to be loaded and parsed. + * It will generally be requested in parralel of the first media segments. + * + * Can be set to `null` if you don't need to load the initialization segment + * for now. + * + * If the `DownloadingQueue`'s `hasInitSegment` constructor option has been + * set to `true`, no media segment will be parsed before the initialization + * segment has been loaded and parsed. + */ + initSegment : IQueuedSegment | null; + + /** + * The queue of media segments currently needed for download. + * + * Those will be loaded from the first element in that queue to the last + * element in it. + * + * Note that any media segments in the segment queue will only be parsed once + * either of these is true: + * - An initialization segment has been loaded and parsed by this + * `DownloadingQueue` instance. + * - The `DownloadingQueue`'s `hasInitSegment` constructor option has been + * set to `false`. + */ + segmentQueue : IQueuedSegment[]; +} + +/** Object describing a pending Segment request. */ +interface ISegmentRequestObject { + /** The segment the request is for. */ + segment : ISegment; + /** The request itself. Can be used to update its priority. */ + request: Promise; + /** Last set priority of the segment request (lower number = higher priority). */ + priority : number; + /** Allows to cancel that segment from being requested. */ + canceller : TaskCanceller; +} + +/** Context for segments downloaded through the DownloadingQueue. */ +export interface IDownloadingQueueContext { + /** Adaptation linked to the segments you want to load. */ + adaptation : Adaptation; + /** Manifest linked to the segments you want to load. */ + manifest : Manifest; + /** Period linked to the segments you want to load. */ + period : Period; + /** Representation linked to the segments you want to load. */ + representation : Representation; +} diff --git a/src/core/stream/representation/force_garbage_collection.ts b/src/core/stream/representation/utils/force_garbage_collection.ts similarity index 93% rename from src/core/stream/representation/force_garbage_collection.ts rename to src/core/stream/representation/utils/force_garbage_collection.ts index 93e36b4a54..2b86916581 100644 --- a/src/core/stream/representation/force_garbage_collection.ts +++ b/src/core/stream/representation/utils/force_garbage_collection.ts @@ -14,12 +14,11 @@ * limitations under the License. */ -import config from "../../../config"; -import log from "../../../log"; -import { getInnerAndOuterTimeRanges } from "../../../utils/ranges"; -import { CancellationSignal } from "../../../utils/task_canceller"; -import { SegmentBuffer } from "../../segment_buffers"; - +import config from "../../../../config"; +import log from "../../../../log"; +import { getInnerAndOuterTimeRanges } from "../../../../utils/ranges"; +import { CancellationSignal } from "../../../../utils/task_canceller"; +import { SegmentBuffer } from "../../../segment_buffers"; /** * Run the garbage collector. diff --git a/src/core/stream/representation/get_buffer_status.ts b/src/core/stream/representation/utils/get_buffer_status.ts similarity index 98% rename from src/core/stream/representation/get_buffer_status.ts rename to src/core/stream/representation/utils/get_buffer_status.ts index 7e73bec1f9..8b3ea68c8b 100644 --- a/src/core/stream/representation/get_buffer_status.ts +++ b/src/core/stream/representation/utils/get_buffer_status.ts @@ -14,20 +14,20 @@ * limitations under the License. */ -import config from "../../../config"; +import config from "../../../../config"; import Manifest, { Adaptation, Period, Representation, -} from "../../../manifest"; -import isNullOrUndefined from "../../../utils/is_null_or_undefined"; -import { IReadOnlyPlaybackObserver } from "../../api"; +} from "../../../../manifest"; +import isNullOrUndefined from "../../../../utils/is_null_or_undefined"; +import { IReadOnlyPlaybackObserver } from "../../../api"; import { IBufferedChunk, IEndOfSegmentOperation, SegmentBuffer, SegmentBufferOperation, -} from "../../segment_buffers"; +} from "../../../segment_buffers"; import { IBufferDiscontinuity, IQueuedSegment, diff --git a/src/core/stream/representation/get_needed_segments.ts b/src/core/stream/representation/utils/get_needed_segments.ts similarity index 98% rename from src/core/stream/representation/get_needed_segments.ts rename to src/core/stream/representation/utils/get_needed_segments.ts index 1342e9424a..c139532f14 100644 --- a/src/core/stream/representation/get_needed_segments.ts +++ b/src/core/stream/representation/utils/get_needed_segments.ts @@ -14,22 +14,21 @@ * limitations under the License. */ -// eslint-disable-next-line max-len -import config from "../../../config"; -import log from "../../../log"; +import config from "../../../../config"; +import log from "../../../../log"; import Manifest, { Adaptation, areSameContent, ISegment, Period, Representation, -} from "../../../manifest"; -import objectAssign from "../../../utils/object_assign"; -import { IBufferedChunk, IEndOfSegmentInfos } from "../../segment_buffers"; +} from "../../../../manifest"; +import objectAssign from "../../../../utils/object_assign"; +import { IBufferedChunk, IEndOfSegmentInfos } from "../../../segment_buffers"; import { IBufferedHistoryEntry, IChunkContext, -} from "../../segment_buffers/inventory"; +} from "../../../segment_buffers/inventory"; interface IContentContext { diff --git a/src/core/stream/representation/get_segment_priority.ts b/src/core/stream/representation/utils/get_segment_priority.ts similarity index 97% rename from src/core/stream/representation/get_segment_priority.ts rename to src/core/stream/representation/utils/get_segment_priority.ts index f35c1ae5b8..1f6a07a8e8 100644 --- a/src/core/stream/representation/get_segment_priority.ts +++ b/src/core/stream/representation/utils/get_segment_priority.ts @@ -14,8 +14,7 @@ * limitations under the License. */ -import config from "../../../config"; - +import config from "../../../../config"; /** * Calculate the priority number for a given segment start time, in function of diff --git a/src/core/stream/representation/utils/push_init_segment.ts b/src/core/stream/representation/utils/push_init_segment.ts new file mode 100644 index 0000000000..bd3c66612a --- /dev/null +++ b/src/core/stream/representation/utils/push_init_segment.ts @@ -0,0 +1,80 @@ +/** + * 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. + */ + +import Manifest, { + Adaptation, + ISegment, + Period, + Representation, +} from "../../../../manifest"; +import { CancellationSignal } from "../../../../utils/task_canceller"; +import { IReadOnlyPlaybackObserver } from "../../../api"; +import { + IPushedChunkData, + SegmentBuffer, +} from "../../../segment_buffers"; +import { + IRepresentationStreamPlaybackObservation, + IStreamEventAddedSegmentPayload, +} from "../types"; +import appendSegmentToBuffer from "./append_segment_to_buffer"; + +/** + * Push the initialization segment to the SegmentBuffer. + * @param {Object} args + * @param {Object} cancelSignal + * @returns {Promise} + */ +export default async function pushInitSegment( + { + playbackObserver, + content, + segment, + segmentData, + segmentBuffer, + } : { + playbackObserver : IReadOnlyPlaybackObserver< + IRepresentationStreamPlaybackObservation + >; + content: { adaptation : Adaptation; + manifest : Manifest; + period : Period; + representation : Representation; }; + segmentData : T | null; + segment : ISegment; + segmentBuffer : SegmentBuffer; + }, + cancelSignal : CancellationSignal +) : Promise< IStreamEventAddedSegmentPayload | null > { + if (segmentData === null) { + return null; + } + if (cancelSignal.cancellationError !== null) { + throw cancelSignal.cancellationError; + } + const codec = content.representation.getMimeTypeString(); + const data : IPushedChunkData = { initSegment: segmentData, + chunk: null, + timestampOffset: 0, + appendWindow: [ undefined, undefined ], + codec }; + await appendSegmentToBuffer(playbackObserver, + segmentBuffer, + { data, inventoryInfos: null }, + cancelSignal); + const buffered = segmentBuffer.getBufferedRanges(); + return { content, segment, buffered, segmentData }; +} diff --git a/src/core/stream/representation/utils/push_media_segment.ts b/src/core/stream/representation/utils/push_media_segment.ts new file mode 100644 index 0000000000..396a0b0368 --- /dev/null +++ b/src/core/stream/representation/utils/push_media_segment.ts @@ -0,0 +1,113 @@ +/** + * 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. + */ + +import config from "../../../../config"; +import Manifest, { + Adaptation, + ISegment, + Period, + Representation, +} from "../../../../manifest"; +import { ISegmentParserParsedMediaChunk } from "../../../../transports"; +import objectAssign from "../../../../utils/object_assign"; +import { CancellationSignal } from "../../../../utils/task_canceller"; +import { IReadOnlyPlaybackObserver } from "../../../api"; +import { SegmentBuffer } from "../../../segment_buffers"; +import { + IRepresentationStreamPlaybackObservation, + IStreamEventAddedSegmentPayload, +} from "../types"; +import appendSegmentToBuffer from "./append_segment_to_buffer"; + +/** + * Push a given media segment (non-init segment) to a SegmentBuffer. + * @param {Object} args + * @param {Object} cancelSignal + * @returns {Promise} + */ +export default async function pushMediaSegment( + { playbackObserver, + content, + initSegmentData, + parsedSegment, + segment, + segmentBuffer } : + { playbackObserver : IReadOnlyPlaybackObserver< + IRepresentationStreamPlaybackObservation + >; + content: { adaptation : Adaptation; + manifest : Manifest; + period : Period; + representation : Representation; }; + initSegmentData : T | null; + parsedSegment : ISegmentParserParsedMediaChunk; + segment : ISegment; + segmentBuffer : SegmentBuffer; }, + cancelSignal : CancellationSignal +) : Promise< IStreamEventAddedSegmentPayload | null > { + if (parsedSegment.chunkData === null) { + return null; + } + if (cancelSignal.cancellationError !== null) { + throw cancelSignal.cancellationError; + } + const { chunkData, + chunkInfos, + chunkOffset, + chunkSize, + appendWindow } = parsedSegment; + const codec = content.representation.getMimeTypeString(); + const { APPEND_WINDOW_SECURITIES } = config.getCurrent(); + // Cutting exactly at the start or end of the appendWindow can lead to + // cases of infinite rebuffering due to how browser handle such windows. + // To work-around that, we add a small offset before and after those. + const safeAppendWindow : [ number | undefined, number | undefined ] = [ + appendWindow[0] !== undefined ? + Math.max(0, appendWindow[0] - APPEND_WINDOW_SECURITIES.START) : + undefined, + appendWindow[1] !== undefined ? + appendWindow[1] + APPEND_WINDOW_SECURITIES.END : + undefined, + ]; + + const data = { initSegment: initSegmentData, + chunk: chunkData, + timestampOffset: chunkOffset, + appendWindow: safeAppendWindow, + codec }; + + let estimatedStart = chunkInfos?.time ?? segment.time; + const estimatedDuration = chunkInfos?.duration ?? segment.duration; + let estimatedEnd = estimatedStart + estimatedDuration; + if (safeAppendWindow[0] !== undefined) { + estimatedStart = Math.max(estimatedStart, safeAppendWindow[0]); + } + if (safeAppendWindow[1] !== undefined) { + estimatedEnd = Math.min(estimatedEnd, safeAppendWindow[1]); + } + + const inventoryInfos = objectAssign({ segment, + chunkSize, + start: estimatedStart, + end: estimatedEnd }, + content); + await appendSegmentToBuffer(playbackObserver, + segmentBuffer, + { data, inventoryInfos }, + cancelSignal); + const buffered = segmentBuffer.getBufferedRanges(); + return { content, segment, buffered, segmentData: chunkData }; +} diff --git a/src/core/stream/types.ts b/src/core/stream/types.ts deleted file mode 100644 index 63386ef61d..0000000000 --- a/src/core/stream/types.ts +++ /dev/null @@ -1,554 +0,0 @@ -/** - * 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. - */ - -import { Subject } from "rxjs"; -import { - Adaptation, - ISegment, - Period, - Representation, -} from "../../manifest"; -import { IEMSG } from "../../parsers/containers/isobmff"; -import { IPlayerError } from "../../public_types"; -import { IContentProtection } from "../decrypt"; -import { IBufferType } from "../segment_buffers"; - -/** Information about a Segment waiting to be loaded by the Stream. */ -export interface IQueuedSegment { - /** Priority of the segment request (lower number = higher priority). */ - priority : number; - /** Segment wanted. */ - segment : ISegment; -} - -/** Describe an encountered hole in the buffer, called a "discontinuity". */ -export interface IBufferDiscontinuity { - /** - * Start time, in seconds, at which the discontinuity starts. - * - * if set to `undefined`, its true start time is unknown but the current - * position is part of it. It is thus a discontinuity that is currently - * encountered. - */ - start : number | undefined; - /** - * End time, in seconds at which the discontinuity ends (and thus where - * new segments are encountered). - * - * If `null`, no new media segment is available for that Period and - * buffer type until the end of the Period. - */ - end : number | null; -} - -/** - * Event sent by a `RepresentationStream` to announce the current status - * regarding the buffer for its associated Period and type (e.g. "audio", - * "video", "text" etc.). - * - * Each new `IStreamStatusEvent` event replace the precedent one for the - * same Period and type. - */ -export interface IStreamStatusEvent { - type : "stream-status"; - value : { - /** Period concerned. */ - period : Period; - /** Buffer type concerned. */ - bufferType : IBufferType; - /** - * Present or future "hole" in the SegmentBuffer's buffer that will not be - * filled by a segment, despite being part of the time period indicated by - * the associated Period. - * - * This value is set to the most imminent of such "discontinuity", which - * can be either: - * - * - current (no segment available at `position` but future segments are - * available), in which case this discontinuity's true beginning might - * be unknown. - * - * - a future hole between two segments in that Period. - * - * - missing media data at the end of the time period associated to that - * Period. - * - * The presence or absence of a discontinuity can evolve during playback - * (because new tracks or qualities might not have the same ones). - * As such, it is advised to only consider the last discontinuity sent - * through a `"stream-status"` event. - */ - imminentDiscontinuity : IBufferDiscontinuity | null; - /** - * If `true`, no segment are left to be loaded to be able to play until the - * end of the Period. - */ - hasFinishedLoading : boolean; - /** - * If `true`, this stream is a placeholder stream which will never load any - * segment. - */ - isEmptyStream : boolean; - /** - * Segments that will be scheduled for download to fill the buffer until - * the buffer goal (first element of that list might already be ). - */ - neededSegments : IQueuedSegment[]; - /** Position in the content in seconds from which this status was done. */ - position : number; - }; -} - -/** Event sent when a minor error happened, which doesn't stop playback. */ -export interface IStreamWarningEvent { - type : "warning"; - /** The error corresponding to the warning given. */ - value : IPlayerError; -} - -/** Emitted after a new segment has been succesfully added to the SegmentBuffer */ -export interface IStreamEventAddedSegment { - type : "added-segment"; - value : { - /** Context about the content that has been added. */ - content: { period : Period; - adaptation : Adaptation; - representation : Representation; }; - /** The concerned Segment. */ - segment : ISegment; - /** TimeRanges of the concerned SegmentBuffer after the segment was pushed. */ - buffered : TimeRanges; - /* The data pushed */ - segmentData : T; - }; -} - -/** - * The Manifest needs to be refreshed. - * Note that a `RepresentationStream` might still be active even after sending - * this event: - * It might download and push segments, send any other event etc. - */ -export interface IStreamNeedsManifestRefresh { - type : "needs-manifest-refresh"; - value: undefined; -} - -/** - * The Manifest is possibly out-of-sync and needs to be refreshed completely. - * - * The Stream made that guess because a segment that should have been available - * is not and because it suspects this is due to a synchronization problem. - */ -export interface IStreamManifestMightBeOutOfSync { - type : "manifest-might-be-out-of-sync"; - value : undefined; -} - -/** Emitted when a segment with protection information has been encountered. */ -export interface IEncryptionDataEncounteredEvent { - type : "encryption-data-encountered"; - value : IContentProtection; -} - -/** Structure describing an "inband" event, as found in a media segment. */ -export interface IInbandEvent { - /** Type when the event was foud inside a "emsg` ISOBMFF box */ - type: "emsg"; - /** Value when the event was foud inside a "emsg` ISOBMFF box */ - value: IEMSG; -} - -export interface IInbandEventsEvent { type : "inband-events"; - value : IInbandEvent[]; } - -/** - * Event sent when a `RepresentationStream` is terminating: - * - * - it has finished all its segment requests and won't do new ones. - * - * - it has stopped regularly checking for its current status. - * - * - it only waits until all the segments it has loaded have been pushed to the - * SegmentBuffer before actually completing. - * - * You can use this event as a hint that a new `RepresentationStream` can be - * created. - */ -export interface IStreamTerminatingEvent { - type : "stream-terminating"; - value : undefined; -} - -/** Emitted as new bitrate estimates are done. */ -export interface IBitrateEstimationChangeEvent { - type : "bitrateEstimationChange"; - value : { - /** The type of buffer for which the estimation is done. */ - type : IBufferType; - /** - * The bitrate estimate, in bits per seconds. `undefined` when no bitrate - * estimate is currently available. - */ - bitrate : number|undefined; - }; -} - -/** - * Emitted when a new `RepresentationStream` is created to load segments from a - * `Representation`. - */ -export interface IRepresentationChangeEvent { - type : "representationChange"; - value : { - /** The type of buffer linked to that `RepresentationStream`. */ - type : IBufferType; - /** The `Period` linked to the `RepresentationStream` we're creating. */ - period : Period; - /** - * The `Representation` linked to the `RepresentationStream` we're creating. - * `null` when we're choosing no Representation at all. - */ - representation : Representation | - null; }; -} - -/** - * Emitted when a new `AdaptationStream` is created to load segments from an - * `Adaptation`. - */ -export interface IAdaptationChangeEvent { - type : "adaptationChange"; - value : { - /** The type of buffer for which the Representation is changing. */ - type : IBufferType; - /** The `Period` linked to the `RepresentationStream` we're creating. */ - period : Period; - /** - * The `Adaptation` linked to the `AdaptationStream` we're creating. - * `null` when we're choosing no Adaptation at all. - */ - adaptation : Adaptation | - null; - }; -} - -/** Emitted when a new `Period` is currently playing. */ -export interface IActivePeriodChangedEvent { - type: "activePeriodChanged"; - value : { - /** The Period we're now playing. */ - period: Period; - }; -} - -/** - * A new `PeriodStream` is ready to start but needs an Adaptation (i.e. track) - * to be chosen first. - */ -export interface IPeriodStreamReadyEvent { - type : "periodStreamReady"; - value : { - /** The type of buffer linked to the `PeriodStream` we want to create. */ - type : IBufferType; - /** The `Period` linked to the `PeriodStream` we have created. */ - period : Period; - /** - * The subject through which any Adaptation (i.e. track) choice should be - * emitted for that `PeriodStream`. - * - * The `PeriodStream` will not do anything until this subject has emitted - * at least one to give its initial choice. - * You can send `null` through it to tell this `PeriodStream` that you don't - * want any `Adaptation`. - */ - adaptation$ : Subject; - }; -} - -/** - * A `PeriodStream` has been removed. - * This event can be used for clean-up purposes. For example, you are free to - * remove from scope the subject that you used to choose a track for that - * `PeriodStream`. - */ -export interface IPeriodStreamClearedEvent { - type : "periodStreamCleared"; - value : { - /** - * The type of buffer linked to the `PeriodStream` we just removed. - * - * The combination of this and `Period` should give you enough information - * about which `PeriodStream` has been removed. - */ - type : IBufferType; - /** - * The `Period` linked to the `PeriodStream` we just removed. - * - * The combination of this and `Period` should give you enough information - * about which `PeriodStream` has been removed. - */ - period : Period; - }; -} - -/** - * The last (chronologically) PeriodStreams from every type of buffers are full. - * This means usually that segments for the whole content have been pushed to - * the end. - */ -export interface IEndOfStreamEvent { type: "end-of-stream"; - value: undefined; } - -/** - * At least a single PeriodStream is now pushing segments. - * This event is sent to cancel a previous `IEndOfStreamEvent`. - * - * Note that it also can be send if no `IEndOfStreamEvent` has been sent before. - */ -export interface IResumeStreamEvent { type: "resume-stream"; - value: undefined; } - -/** - * A situation needs the MediaSource to be reloaded. - * - * Once the MediaSource is reloaded, the Streams need to be restarted from - * scratch. - */ -export interface INeedsMediaSourceReload { - type: "needs-media-source-reload"; - value: { - /** - * The position in seconds and the time at which the MediaSource should be - * reset once it has been reloaded. - */ - position : number; - /** - * If `true`, we want the HTMLMediaElement to play right after the reload is - * done. - * If `false`, we want to stay in a paused state at that point. - */ - autoPlay : boolean; - }; -} - -/** - * A stream cannot go forward loading segments because it needs the - * `MediaSource` to be reloaded first. - * - * This is a Stream internal event before being translated into either an - * `INeedsMediaSourceReload` event or an `ILockedStreamEvent` depending on if - * the reloading action has to be taken now or when the corresponding Period - * becomes active. - */ -export interface IWaitingMediaSourceReloadInternalEvent { - type: "waiting-media-source-reload"; - value: { - /** Period concerned. */ - period : Period; - /** Buffer type concerned. */ - bufferType : IBufferType; - /** - * The position in seconds and the time at which the MediaSource should be - * reset once it has been reloaded. - */ - position : number; - /** - * If `true`, we want the HTMLMediaElement to play right after the reload is - * done. - * If `false`, we want to stay in a paused state at that point. - */ - autoPlay : boolean; - }; -} - -/** - * The stream is unable to load segments for a particular Period and buffer - * type until that Period becomes the currently-played Period. - * - * This might be the case for example when a track change happened for an - * upcoming Period, which necessitates the reloading of the media source - - * through a `INeedsMediaSourceReload` event once the Period is the current one. - * Here, the stream might stay in a locked mode for segments linked to that - * Period and buffer type, meaning it will not load any such segment until that - * next Period becomes the current one (in which case it will probably ask to - * reload). - * - * This event can be useful when investigating rebuffering situation: one might - * be due to the next Period not loading segment of a certain type because of a - * locked stream. In that case, playing until or seeking at the start of the - * corresponding Period should be enough to "unlock" the stream. - */ -export interface ILockedStreamEvent { - type : "locked-stream"; - value : { - /** Period concerned. */ - period : Period; - /** Buffer type concerned. */ - bufferType : IBufferType; - }; -} - -/** - * Event emitted after the SegmentBuffer have been "cleaned" to remove from it - * every non-decipherable segments - usually following an update of the - * decipherability status of some `Representation`(s). - * - * When that event is emitted, the current HTMLMediaElement's buffer might need - * to be "flushed" to continue (e.g. through a little seek operation) or in - * worst cases completely removed and re-created through the "reload" mechanism, - * depending on the platform. - */ -export interface INeedsDecipherabilityFlush { - type: "needs-decipherability-flush"; - value: { - /** - * Indicated in the case where the MediaSource has to be reloaded, - * in which case the time of the HTMLMediaElement should be reset to that - * position, in seconds, once reloaded. - */ - position : number; - /** - * If `true`, we want the HTMLMediaElement to play right after the flush is - * done. - * If `false`, we want to stay in a paused state at that point. - */ - autoPlay : boolean; - /** - * The duration (maximum seekable position) of the content. - * This is indicated in the case where a seek has to be performed, to avoid - * seeking too far in the content. - */ - duration : number; - }; -} - -/** - * Some situations might require the browser's buffers to be refreshed. - * This event is emitted when such situation arised. - */ -export interface INeedsBufferFlushEvent { - type: "needs-buffer-flush"; - value: undefined; -} - -/** Event sent by a `RepresentationStream`. */ -export type IRepresentationStreamEvent = IStreamStatusEvent | - IStreamEventAddedSegment | - IEncryptionDataEncounteredEvent | - IStreamManifestMightBeOutOfSync | - IStreamTerminatingEvent | - IStreamNeedsManifestRefresh | - IStreamWarningEvent | - IInbandEventsEvent; - -/** Event sent by an `AdaptationStream`. */ -export type IAdaptationStreamEvent = IBitrateEstimationChangeEvent | - INeedsDecipherabilityFlush | - IRepresentationChangeEvent | - INeedsBufferFlushEvent | - IWaitingMediaSourceReloadInternalEvent | - - // From a RepresentationStream - - IStreamStatusEvent | - IStreamEventAddedSegment | - IEncryptionDataEncounteredEvent | - IStreamManifestMightBeOutOfSync | - IStreamNeedsManifestRefresh | - IStreamWarningEvent | - IInbandEventsEvent; - -/** Event sent by a `PeriodStream`. */ -export type IPeriodStreamEvent = IPeriodStreamReadyEvent | - IAdaptationChangeEvent | - IWaitingMediaSourceReloadInternalEvent | - - // From an AdaptationStream - - IBitrateEstimationChangeEvent | - INeedsMediaSourceReload | - INeedsBufferFlushEvent | - INeedsDecipherabilityFlush | - IRepresentationChangeEvent | - - // From a RepresentationStream - - IStreamStatusEvent | - IStreamEventAddedSegment | - IEncryptionDataEncounteredEvent | - IStreamManifestMightBeOutOfSync | - IStreamNeedsManifestRefresh | - IStreamWarningEvent | - IInbandEventsEvent; - -/** Event coming from function(s) managing multiple PeriodStreams. */ -export type IMultiplePeriodStreamsEvent = IPeriodStreamClearedEvent | - - // From a PeriodStream - - IPeriodStreamReadyEvent | - INeedsBufferFlushEvent | - IAdaptationChangeEvent | - IWaitingMediaSourceReloadInternalEvent | - - // From an AdaptationStream - - IBitrateEstimationChangeEvent | - INeedsMediaSourceReload | - INeedsDecipherabilityFlush | - IRepresentationChangeEvent | - - // From a RepresentationStream - - IStreamStatusEvent | - IStreamEventAddedSegment | - IEncryptionDataEncounteredEvent | - IStreamManifestMightBeOutOfSync | - IStreamNeedsManifestRefresh | - IStreamWarningEvent | - IInbandEventsEvent; - -/** Every event sent by the `StreamOrchestrator`. */ -export type IStreamOrchestratorEvent = IActivePeriodChangedEvent | - IEndOfStreamEvent | - IResumeStreamEvent | - - IPeriodStreamClearedEvent | - - // From a PeriodStream - - IPeriodStreamReadyEvent | - IAdaptationChangeEvent | - ILockedStreamEvent | - - // From an AdaptationStream - - IBitrateEstimationChangeEvent | - INeedsMediaSourceReload | - INeedsBufferFlushEvent | - INeedsDecipherabilityFlush | - IRepresentationChangeEvent | - - // From a RepresentationStream - - IStreamStatusEvent | - IStreamEventAddedSegment | - IEncryptionDataEncounteredEvent | - IStreamManifestMightBeOutOfSync | - IStreamNeedsManifestRefresh | - IStreamWarningEvent | - IInbandEventsEvent; diff --git a/src/utils/__tests__/defer_subscriptions.test.ts b/src/utils/__tests__/defer_subscriptions.test.ts deleted file mode 100644 index 745fcb02cd..0000000000 --- a/src/utils/__tests__/defer_subscriptions.test.ts +++ /dev/null @@ -1,50 +0,0 @@ -/** - * 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. - */ - -import { - map, - share, - startWith, - timer, -} from "rxjs"; -import deferSubscriptions from "../defer_subscriptions"; - -describe("utils - deferSubscriptions", () => { - /* eslint-disable max-len */ - it("should wait until all subscription in the current script are done before emitting", (done) => { - /* eslint-enable max-len */ - let logs = ""; - const myObservableDeferred = timer(5).pipe(map(() => "A"), - startWith("S"), - deferSubscriptions(), - share()); - - myObservableDeferred.subscribe({ - next: x => { logs += `1:${x}-`; }, - error: () => { /* noop */ }, - complete: () => { - expect(logs).toEqual("1:S-2:S-1:A-2:A-3:A-4:A-"); - done(); - }, - }); - myObservableDeferred.subscribe(x => { logs += `2:${x}-`; }); - - setTimeout(() => { - myObservableDeferred.subscribe(x => { logs += `3:${x}-`; }); - myObservableDeferred.subscribe(x => { logs += `4:${x}-`; }); - }, 1); - }); -}); diff --git a/src/utils/defer_subscriptions.ts b/src/utils/defer_subscriptions.ts deleted file mode 100644 index 9920e926ff..0000000000 --- a/src/utils/defer_subscriptions.ts +++ /dev/null @@ -1,96 +0,0 @@ -/** - * 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. - */ - -import { - asapScheduler, - Observable, - subscribeOn, -} from "rxjs"; - -/** - * At subscription, instead of "running" the Observable right away, wait until - * the current task has finished executing before actually running this - * Observable. - * - * This can be important for example when you want in a given function to - * exploit the same shared Observable which may send synchronous events directly - * after subscription. - * - * Here, you might be left in a situation where the first element subscribing to - * that Observable will receive those synchronous events immediately on - * subscription. Further subscriptions on that Observable will miss out on those - * events - even if those subscriptions happen synchronously after the first - * one. - * - * Calling `deferSubscriptions` in those cases will make sure that all such - * subscriptions can be registered before the Observable start emitting events - * (as long as such Subscriptions are done synchronously). - * - * @example - * ```js - * const myObservable = rxjs.timer(100).pipe(mapTo("ASYNC MSG"), - * startWith("SYNCHRONOUS MSG"), - * share()); - * - * myObservable.subscribe(x => console.log("Sub1:", x)); - * myObservable.subscribe(x => console.log("Sub2:", x)); - * - * setTimeout(() => { - * myObservable.subscribe(x => console.log("Sub3:", x)); - * }, 50); - * - * // You will get: - * // Sub1: SYNCHRONOUS MSG - * // Sub1: ASYNC MSG - * // Sub2: ASYNC MSG - * // Sub3: ASYNC MSG - * - * // ------------------------------ - * - * const myObservableDeferred = rxjs.timer(100).pipe(mapTo("ASYNC MSG"), - * startWith("SYNCHRONOUS MSG"), - * deferSubscriptions(), - * // NOTE: the order is important here - * share()); - * - * myObservableDeferred.subscribe(x => console.log("Sub1:", x)); - * myObservableDeferred.subscribe(x => console.log("Sub2:", x)); - * - * setTimeout(() => { - * myObservableDeferred.subscribe(x => console.log("Sub3:", x)); - * }, 50); - * - * // You will get: - * // Sub1: SYNCHRONOUS MSG - * // Sub2: SYNCHRONOUS MSG - * // Sub1: ASYNC MSG - * // Sub2: ASYNC MSG - * // Sub3: ASYNC MSG - * ``` - * @returns {function} - */ -export default function deferSubscriptions( -) : (source: Observable) => Observable { - return (source: Observable) => { - // TODO asapScheduler seems to not push the subscription in the microtask - // queue as nextTick does but in a regular event loop queue. - // This means that the subscription will be run even later that we wish for. - // This is not dramatic but it could be better. - // Either this is a problem with RxJS or this was wanted, in which case we - // may need to add our own scheduler. - return source.pipe(subscribeOn(asapScheduler)); - }; -} diff --git a/src/utils/sorted_list.ts b/src/utils/sorted_list.ts index 4fd4bef936..bc44b89a47 100644 --- a/src/utils/sorted_list.ts +++ b/src/utils/sorted_list.ts @@ -110,6 +110,10 @@ export default class SortedList { return this._array[index]; } + public toArray() : T[] { + return this._array.slice(); + } + /** * Find the first element corresponding to the given predicate. * diff --git a/tests/integration/scenarios/dash_multi_periods.js b/tests/integration/scenarios/dash_multi_periods.js index 5559e85be2..0ae831cb9d 100644 --- a/tests/integration/scenarios/dash_multi_periods.js +++ b/tests/integration/scenarios/dash_multi_periods.js @@ -125,7 +125,6 @@ describe("DASH multi-Period with different choices", function () { expect(periodChangeEvents).to.have.length(1); expect(periodChangeEvents[0].id).to.equal("1"); - await goToSecondPeriod(); expect(availableAudioTracksChange).to.have.length(2); diff --git a/tests/integration/scenarios/end_number.js b/tests/integration/scenarios/end_number.js index 13ca19c803..12a7ee0165 100644 --- a/tests/integration/scenarios/end_number.js +++ b/tests/integration/scenarios/end_number.js @@ -60,20 +60,21 @@ describe("end number", function () { autoPlay: true, }); await sleep(50); - expect(xhrMock.getLockedXHR().length).to.equal(1); + expect(xhrMock.getLockedXHR().length).to.equal(1); // Manifest await xhrMock.flush(); await sleep(50); expect(player.getMaximumPosition()).to.be.closeTo(20, 1); - expect(xhrMock.getLockedXHR().length).to.equal(4); + expect(xhrMock.getLockedXHR().length).to.equal(4); // Init + media of audio + // + video await xhrMock.flush(); await waitForLoadedStateAfterLoadVideo(player); - await sleep(500); - expect(xhrMock.getLockedXHR().length).to.equal(2); + await sleep(50); + expect(xhrMock.getLockedXHR().length).to.equal(2); // next audio + video xhrMock.flush(); player.seekTo(19); await sleep(50); - expect(xhrMock.getLockedXHR().length).to.equal(2); + expect(xhrMock.getLockedXHR().length).to.equal(2); // last audio + video xhrMock.flush(); await waitForState(player, "ENDED", ["BUFFERING", "RELOADING", "PLAYING"]); expect(player.getPosition()).to.be.closeTo(20, 1); diff --git a/tests/memory/index.js b/tests/memory/index.js index 17283c9202..958aa06dab 100644 --- a/tests/memory/index.js +++ b/tests/memory/index.js @@ -117,7 +117,7 @@ describe("Memory tests", () => { | Initial heap usage (B) | ${initialMemory.usedJSHeapSize} | Difference (B) | ${heapDifference} `); - expect(heapDifference).to.be.below(5e6); + expect(heapDifference).to.be.below(3e6); }); it("should not have a sensible memory leak after 1000 setTime calls of VideoThumbnailLoader", async function() { From c78c447a0d2b242f72ffc279a1315ad73a11a1c7 Mon Sep 17 00:00:00 2001 From: Paul Berberian Date: Tue, 27 Dec 2022 18:27:51 +0100 Subject: [PATCH 19/86] demo: Add segment and manifest timeouts configuration --- .../components/Options/NetworkConfig.jsx | 123 +++++++++++++++- demo/full/scripts/controllers/Settings.jsx | 136 ++++++++++++------ demo/full/scripts/lib/defaultOptionsValues.js | 2 + 3 files changed, 213 insertions(+), 48 deletions(-) diff --git a/demo/full/scripts/components/Options/NetworkConfig.jsx b/demo/full/scripts/components/Options/NetworkConfig.jsx index 63ced55fe3..972c5b09b4 100644 --- a/demo/full/scripts/components/Options/NetworkConfig.jsx +++ b/demo/full/scripts/components/Options/NetworkConfig.jsx @@ -9,17 +9,24 @@ import DEFAULT_VALUES from "../../lib/defaultOptionsValues"; * @param {Object} props * @returns {Object} */ -function NetworkConfig({ +function RequestConfig({ segmentRetry, + segmentTimeout, manifestRetry, offlineRetry, + manifestTimeout, onSegmentRetryInput, + onSegmentTimeoutInput, onManifestRetryInput, onOfflineRetryInput, + onManifestTimeoutInput, }) { const [isSegmentRetryLimited, setSegmentRetryLimit] = useState( segmentRetry !== Infinity ); + const [isSegmentTimeoutLimited, setSegmentTimeoutLimit] = useState( + segmentTimeout !== -1 + ); const [isManifestRetryLimited, setManifestRetryLimit] = useState( manifestRetry !== Infinity ); @@ -27,6 +34,9 @@ function NetworkConfig({ offlineRetry !== Infinity ); + const [isManifestTimeoutLimited, setManifestTimeoutLimit] = useState( + manifestTimeout !== -1 + ); const onChangeLimitSegmentRetry = (evt) => { const isNotLimited = getCheckBoxValue(evt.target); if (isNotLimited) { @@ -38,6 +48,17 @@ function NetworkConfig({ } }; + const onChangeLimitSegmentTimeout = (evt) => { + const isNotLimited = getCheckBoxValue(evt.target); + if (isNotLimited) { + setSegmentTimeoutLimit(false); + onSegmentTimeoutInput("-1"); + } else { + setSegmentTimeoutLimit(true); + onSegmentTimeoutInput(DEFAULT_VALUES.segmentTimeout); + } + }; + const onChangeLimitManifestRetry = (evt) => { const isNotLimited = getCheckBoxValue(evt.target); if (isNotLimited) { @@ -60,6 +81,17 @@ function NetworkConfig({ } }; + const onChangeLimitManifestTimeout = (evt) => { + const isNotLimited = getCheckBoxValue(evt.target); + if (isNotLimited) { + setManifestTimeoutLimit(false); + onManifestTimeoutInput("-1"); + } else { + setManifestTimeoutLimit(true); + onManifestTimeoutInput(DEFAULT_VALUES.manifestTimeout); + } + }; + return (
  • @@ -104,6 +136,50 @@ function NetworkConfig({ Do not limit
  • + +
  • +
    + + + onSegmentTimeoutInput(evt.target.value)} + value={segmentTimeout} + disabled={isSegmentTimeoutLimited === false} + className="optionInput" + /> +
    + + Do not limit + +
  • +
  • @@ -188,8 +264,51 @@ function NetworkConfig({ Do not limit
  • + +
  • +
    + + + onManifestTimeoutInput(evt.target.value)} + value={manifestTimeout} + disabled={isManifestTimeoutLimited === false} + className="optionInput" + /> +
    + + Do not limit + +
  • ); } -export default React.memo(NetworkConfig); +export default React.memo(RequestConfig); diff --git a/demo/full/scripts/controllers/Settings.jsx b/demo/full/scripts/controllers/Settings.jsx index c0bcba8457..60099aacdb 100644 --- a/demo/full/scripts/controllers/Settings.jsx +++ b/demo/full/scripts/controllers/Settings.jsx @@ -16,9 +16,7 @@ class Settings extends React.Component { constructor(...args) { super(...args); - this.state = { - ...defaultOptionsValues, - }; + this.state = Object.assign({}, defaultOptionsValues); } getOptions() { @@ -42,8 +40,10 @@ class Settings extends React.Component { onCodecSwitch, enableFastSwitching, segmentRetry, + segmentTimeout, manifestRetry, offlineRetry, + manifestTimeout, } = this.state; return { initOpts: { @@ -69,77 +69,108 @@ class Settings extends React.Component { enableFastSwitching, networkConfig: { segmentRetry: parseFloat(segmentRetry), + segmentRequestTimeout: parseFloat(segmentTimeout), manifestRetry: parseFloat(manifestRetry), + manifestRequestTimeout: parseFloat(manifestTimeout), offlineRetry: parseFloat(offlineRetry), }, }, }; } - onAutoPlayClick = (evt) => + onAutoPlayClick(evt) { this.setState({ autoPlay: getCheckBoxValue(evt.target) }); + } - onManualBrSwitchingModeChange = (value) => + onManualBrSwitchingModeChange(value) { this.setState({ manualBrSwitchingMode: value }); + } - onInitialVideoBrInput = (value) => + onInitialVideoBrInput(value) { this.setState({ initialVideoBr: value }); + } - onInitialAudioBrInput = (value) => + onInitialAudioBrInput(value) { this.setState({ initialAudioBr: value }); + } - onMinVideoBrInput = (value) => + onMinVideoBrInput(value) { this.setState({ minVideoBr: value }); + } - onMinAudioBrInput = (value) => + onMinAudioBrInput(value) { this.setState({ minAudioBr: value }); + } - onMaxVideoBrInput = (value) => + onMaxVideoBrInput(value) { this.setState({ maxVideoBr: value }); + } - onMaxAudioBrInput = (value) => + onMaxAudioBrInput(value) { this.setState({ maxAudioBr: value }); + } - onLimitVideoWidthClick = (evt) => + onLimitVideoWidthClick(evt) { this.setState({ limitVideoWidth: getCheckBoxValue(evt.target) }); + } - onThrottleVideoBitrateWhenHiddenClick = (evt) => + onThrottleVideoBitrateWhenHiddenClick(evt) { this.setState({ throttleVideoBitrateWhenHidden: getCheckBoxValue(evt.target), }); + } - onStopAtEndClick = (evt) => + onStopAtEndClick(evt) { this.setState({ stopAtEnd: getCheckBoxValue(evt.target) }); + } - onSegmentRetryInput = (value) => + onSegmentRetryInput(value) { this.setState({ segmentRetry: value }); + } - onManifestRetryInput = (value) => + onSegmentTimeoutInput(value) { + this.setState({ segmentTimeout: value }); + } + + onManifestRetryInput(value) { this.setState({ manifestRetry: value }); + } - onOfflineRetryInput = (value) => + onOfflineRetryInput(value) { this.setState({ offlineRetry: value }); + } - onEnableFastSwitchingClick = (evt) => + onManifestTimeoutInput(value) { + this.setState({ manifestTimeout: value }); + } + + onEnableFastSwitchingClick(evt) { this.setState({ enableFastSwitching: getCheckBoxValue(evt.target) }); + } - onAudioTrackSwitchingModeChange = (value) => + onAudioTrackSwitchingModeChange(value) { this.setState({ audioTrackSwitchingMode: value }); + } - onCodecSwitchChange = (value) => + onCodecSwitchChange(value) { this.setState({ onCodecSwitch: value }); + } - onWantedBufferAheadInput = (value) => + onWantedBufferAheadInput(value) { this.setState({ wantedBufferAhead: value }); - - onMaxVideoBufferSizeInput = (value) => + } + + onMaxVideoBufferSizeInput(value) { this.setState({ maxVideoBufferSize: value}); + } - onMaxBufferBehindInput = (value) => + onMaxBufferBehindInput(value) { this.setState({ maxBufferBehind: value }); + } - onMaxBufferAheadInput = (value) => + onMaxBufferAheadInput(value) { this.setState({ maxBufferAhead: value }); + } render() { const { @@ -155,8 +186,10 @@ class Settings extends React.Component { throttleVideoBitrateWhenHidden, stopAtEnd, segmentRetry, + segmentTimeout, manifestRetry, offlineRetry, + manifestTimeout, enableFastSwitching, audioTrackSwitchingMode, onCodecSwitch, @@ -175,33 +208,38 @@ class Settings extends React.Component { maxAudioBr, limitVideoWidth, throttleVideoBitrateWhenHidden, - onInitialVideoBrInput: this.onInitialVideoBrInput, - onInitialAudioBrInput: this.onInitialAudioBrInput, - onMinAudioBrInput: this.onMinAudioBrInput, - onMinVideoBrInput: this.onMinVideoBrInput, - onMaxAudioBrInput: this.onMaxAudioBrInput, - onMaxVideoBrInput: this.onMaxVideoBrInput, - onLimitVideoWidthClick: this.onLimitVideoWidthClick, + onInitialVideoBrInput: this.onInitialVideoBrInput.bind(this), + onInitialAudioBrInput: this.onInitialAudioBrInput.bind(this), + onMinAudioBrInput: this.onMinAudioBrInput.bind(this), + onMinVideoBrInput: this.onMinVideoBrInput.bind(this), + onMaxAudioBrInput: this.onMaxAudioBrInput.bind(this), + onMaxVideoBrInput: this.onMaxVideoBrInput.bind(this), + onLimitVideoWidthClick: this.onLimitVideoWidthClick.bind(this), onThrottleVideoBitrateWhenHiddenClick: - this.onThrottleVideoBitrateWhenHiddenClick, + this.onThrottleVideoBitrateWhenHiddenClick.bind(this), }; const networkConfig = { + manifestTimeout, segmentRetry, + segmentTimeout, manifestRetry, offlineRetry, - onSegmentRetryInput: this.onSegmentRetryInput, - onManifestRetryInput: this.onManifestRetryInput, - onOfflineRetryInput: this.onOfflineRetryInput, + onSegmentRetryInput: this.onSegmentRetryInput.bind(this), + onSegmentTimeoutInput: this.onSegmentTimeoutInput.bind(this), + onManifestRetryInput: this.onManifestRetryInput.bind(this), + onManifestTimeoutInput: this.onManifestTimeoutInput.bind(this), + onOfflineRetryInput: this.onOfflineRetryInput.bind(this), }; const trackSwitchModeConfig = { enableFastSwitching, audioTrackSwitchingMode, onCodecSwitch, - onEnableFastSwitchingClick: this.onEnableFastSwitchingClick, - onAudioTrackSwitchingModeChange: this.onAudioTrackSwitchingModeChange, - onCodecSwitchChange: this.onCodecSwitchChange, + onEnableFastSwitchingClick: this.onEnableFastSwitchingClick.bind(this), + onAudioTrackSwitchingModeChange: this + .onAudioTrackSwitchingModeChange.bind(this), + onCodecSwitchChange: this.onCodecSwitchChange.bind(this), }; if (!this.props.showOptions) { @@ -215,10 +253,12 @@ class Settings extends React.Component { diff --git a/demo/full/scripts/lib/defaultOptionsValues.js b/demo/full/scripts/lib/defaultOptionsValues.js index 3fa4bb3063..45dd6496c5 100644 --- a/demo/full/scripts/lib/defaultOptionsValues.js +++ b/demo/full/scripts/lib/defaultOptionsValues.js @@ -13,6 +13,8 @@ const defaultOptionsValues = { segmentRetry: 4, manifestRetry: 4, offlineRetry: Infinity, + segmentTimeout: 30000, + manifestTimeout: 30000, enableFastSwitching: true, audioTrackSwitchingMode: "reload", onCodecSwitch: "continue", From ca60a4370de431a74fe244fb2768bb05f1d28541 Mon Sep 17 00:00:00 2001 From: Paul Berberian Date: Tue, 27 Dec 2022 18:28:41 +0100 Subject: [PATCH 20/86] tests: actually seek back and forth in memory tests --- tests/memory/index.js | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/memory/index.js b/tests/memory/index.js index 0e16aa4b92..8a32f43db4 100644 --- a/tests/memory/index.js +++ b/tests/memory/index.js @@ -217,6 +217,7 @@ describe("Memory tests", () => { player.seekTo(0); } else { player.seekTo(20); + seekToBeginning = true; } const bitrateIdx = iterationIdx % videoBitrates.length; player.setVideoBitrate(videoBitrates[bitrateIdx]); From 58a7d6293d55fa0a7a3938583974f68ea054e4e4 Mon Sep 17 00:00:00 2001 From: Paul Berberian Date: Tue, 27 Dec 2022 19:46:14 +0100 Subject: [PATCH 21/86] demo: add descriptions to every option of the demo page --- .../Options/AudioAdaptiveSettings.jsx | 24 ++++++ .../components/Options/BufferOptions.jsx | 36 ++++++++- .../components/Options/NetworkConfig.jsx | 25 ++++++ .../scripts/components/Options/Playback.jsx | 28 +++++++ .../components/Options/TrackSwitch.jsx | 46 +++++++++++ .../Options/VideoAdaptiveSettings.jsx | 76 ++++++++++++++----- demo/full/styles/style.css | 7 +- 7 files changed, 218 insertions(+), 24 deletions(-) diff --git a/demo/full/scripts/components/Options/AudioAdaptiveSettings.jsx b/demo/full/scripts/components/Options/AudioAdaptiveSettings.jsx index 21c05284be..8ee7fd3adf 100644 --- a/demo/full/scripts/components/Options/AudioAdaptiveSettings.jsx +++ b/demo/full/scripts/components/Options/AudioAdaptiveSettings.jsx @@ -75,6 +75,14 @@ function AudioAdaptiveSettings({ /> + + { + parseFloat(initialAudioBr) === 0 ? + "Starts loading the lowest audio bitrate" : + `Starts with an audio bandwidth estimate of ${initialAudioBr}` + + " bits per seconds." + } +
  • @@ -116,6 +124,14 @@ function AudioAdaptiveSettings({ > Do not limit + + { + !isMinAudioBrLimited || parseFloat(minAudioBr) <= 0 ? + "Not limiting the lowest audio bitrate reachable through the adaptive logic" : + "Limiting the lowest audio bitrate reachable through the adaptive " + + `logic to ${minAudioBr} bits per seconds` + } +
  • @@ -159,6 +175,14 @@ function AudioAdaptiveSettings({ Do not limit
    + + { + !isMaxAudioBrLimited || parseFloat(maxAudioBr) === Infinity ? + "Not limiting the highest audio bitrate reachable through the adaptive logic" : + "Limiting the highest audio bitrate reachable through the adaptive " + + `logic to ${maxAudioBr} bits per seconds` + } +
  • ); diff --git a/demo/full/scripts/components/Options/BufferOptions.jsx b/demo/full/scripts/components/Options/BufferOptions.jsx index c342deefa9..9d505f76cf 100644 --- a/demo/full/scripts/components/Options/BufferOptions.jsx +++ b/demo/full/scripts/components/Options/BufferOptions.jsx @@ -56,12 +56,12 @@ function BufferOptions({ const isNotLimited = getCheckBoxValue(evt.target); if (isNotLimited){ setMaxVideoBufferSizeLimit(false); - onMaxVideoBufferSizeInput(Infinity) + onMaxVideoBufferSizeInput(Infinity); } else { setMaxVideoBufferSizeLimit(true); - onMaxVideoBufferSizeInput(DEFAULT_VALUES.maxVideoBufferSize) + onMaxVideoBufferSizeInput(DEFAULT_VALUES.maxVideoBufferSize); } - } + }; return ( @@ -96,6 +96,10 @@ function BufferOptions({ /> + + Buffering around {wantedBufferAhead} second(s) ahead of the current + position +
  • @@ -123,7 +127,7 @@ function BufferOptions({ ariaLabel="Reset option to default value" title="Reset option to default value" onClick={() => { - setMaxVideoBufferSizeLimit(DEFAULT_VALUES.maxVideoBufferSize !== + setMaxVideoBufferSizeLimit(DEFAULT_VALUES.maxVideoBufferSize !== Infinity); onMaxVideoBufferSizeInput(DEFAULT_VALUES.maxVideoBufferSize); }} @@ -140,6 +144,14 @@ function BufferOptions({ > Do not limit + + { + parseFloat(maxVideoBufferSize) === Infinity || + !isMaxVideoBufferSizeLimited ? + "Not setting a size limit to the video buffer (relying only on the wantedBufferAhead option)" : + `Buffering at most around ${maxVideoBufferSize} kilobyte(s) on the video buffer` + } +
  • @@ -182,6 +194,14 @@ function BufferOptions({ > Do not limit + + { + parseFloat(maxBufferAhead) === Infinity || + !isMaxBufferAHeadLimited ? + "Not manually cleaning buffer far ahead of the current position" : + `Manually cleaning data ${maxBufferAhead} second(s) ahead of the current position` + } +
  • @@ -224,6 +244,14 @@ function BufferOptions({ > Do not limit + + { + parseFloat(maxBufferBehind) === Infinity || + !isMaxBufferBehindLimited ? + "Not manually cleaning buffer behind the current position" : + `Manually cleaning data ${maxBufferBehind} second(s) behind the current position` + } +
  • ); diff --git a/demo/full/scripts/components/Options/NetworkConfig.jsx b/demo/full/scripts/components/Options/NetworkConfig.jsx index 972c5b09b4..91a87e90e6 100644 --- a/demo/full/scripts/components/Options/NetworkConfig.jsx +++ b/demo/full/scripts/components/Options/NetworkConfig.jsx @@ -135,6 +135,11 @@ function RequestConfig({ > Do not limit + + {parseFloat(segmentRetry) === Infinity || !isSegmentRetryLimited ? + "Retry \"retryable\" segment requests with no limit" : + `Retry "retryable" segment requests at most ${segmentRetry} time(s)`} +
  • @@ -178,6 +183,11 @@ function RequestConfig({ > Do not limit + + {parseFloat(segmentTimeout) === -1 || !isSegmentTimeoutLimited ? + "Perform segment requests without timeout" : + `Stop segment requests after ${segmentTimeout} millisecond(s)`} +
  • @@ -221,6 +231,11 @@ function RequestConfig({ > Do not limit + + {parseFloat(manifestRetry) === Infinity || !isManifestRetryLimited ? + "Retry \"retryable\" manifest requests with no limit" : + `Retry "retryable" manifest requests at most ${manifestRetry} time(s)`} +
  • @@ -263,6 +278,11 @@ function RequestConfig({ > Do not limit + + {parseFloat(offlineRetry) === Infinity || !isOfflineRetryLimited ? + "Retry \"retryable\" requests when offline with no limit" : + `Retry "retryable" requests when offline at most ${offlineRetry} time(s)`} +
  • @@ -306,6 +326,11 @@ function RequestConfig({ > Do not limit + + {parseFloat(manifestTimeout) === -1 || !isManifestTimeoutLimited ? + "Perform manifest requests without timeout" : + `Stop manifest requests after ${manifestTimeout} millisecond(s)`} +
  • ); diff --git a/demo/full/scripts/components/Options/Playback.jsx b/demo/full/scripts/components/Options/Playback.jsx index af5f64ed5e..aa207dcc87 100644 --- a/demo/full/scripts/components/Options/Playback.jsx +++ b/demo/full/scripts/components/Options/Playback.jsx @@ -14,6 +14,21 @@ function TrackSwitch({ stopAtEnd, onStopAtEndClick, }) { + let manualBitrateSwitchingModeDesc; + switch (manualBrSwitchingMode) { + case "direct": + manualBitrateSwitchingModeDesc = + "Directly visible transition when a Representation is manually changed"; + break; + case "seamless": + manualBitrateSwitchingModeDesc = + "Smooth transition when a Representation is manually changed"; + break; + default: + manualBitrateSwitchingModeDesc = + "Unknown value"; + break; + } return (
  • @@ -26,6 +41,11 @@ function TrackSwitch({ > Auto Play + + {autoPlay ? + "Playing directly when the content is loaded." : + "Staying in pause when the content is loaded."} +
  • + + {manualBitrateSwitchingModeDesc} +
  • Stop At End + + {stopAtEnd ? + "Automatically stop when reaching the end of the content." : + "Don't stop when reaching the end of the content."} +
  • ); diff --git a/demo/full/scripts/components/Options/TrackSwitch.jsx b/demo/full/scripts/components/Options/TrackSwitch.jsx index ab8412feed..ac40bead45 100644 --- a/demo/full/scripts/components/Options/TrackSwitch.jsx +++ b/demo/full/scripts/components/Options/TrackSwitch.jsx @@ -14,6 +14,41 @@ function NetworkConfig({ onAudioTrackSwitchingModeChange, onCodecSwitchChange, }) { + let audioTrackSwitchingModeDescMsg; + switch (audioTrackSwitchingMode) { + case "reload": + audioTrackSwitchingModeDescMsg = + "Reloading when the audio track is changed"; + break; + case "direct": + audioTrackSwitchingModeDescMsg = + "Directly audible transition when the audio track is changed"; + break; + case "seamless": + audioTrackSwitchingModeDescMsg = + "Smooth transition when the audio track is changed"; + break; + default: + audioTrackSwitchingModeDescMsg = + "Unknown value"; + break; + } + + let onCodecSwitchDescMsg; + switch (onCodecSwitch) { + case "reload": + onCodecSwitchDescMsg = "Reloading buffers when the codec changes"; + break; + case "continue": + onCodecSwitchDescMsg = + "Keeping the same buffers even when the codec changes"; + break; + default: + onCodecSwitchDescMsg = + "Unknown value"; + break; + } + return (
  • @@ -27,6 +62,11 @@ function NetworkConfig({ > Fast Switching + + {enableFastSwitching ? + "Fast quality switch by replacing lower qualities in the buffer by higher ones when possible." : + "Not replacing lower qualities in the buffer by an higher one when possible."} +
  • + + {audioTrackSwitchingModeDescMsg} +
  • + + {onCodecSwitchDescMsg} +
  • ); diff --git a/demo/full/scripts/components/Options/VideoAdaptiveSettings.jsx b/demo/full/scripts/components/Options/VideoAdaptiveSettings.jsx index 6d3107ca21..83c7038869 100644 --- a/demo/full/scripts/components/Options/VideoAdaptiveSettings.jsx +++ b/demo/full/scripts/components/Options/VideoAdaptiveSettings.jsx @@ -79,6 +79,14 @@ function VideoAdaptiveSettings({ /> + + { + parseFloat(initialVideoBr) === 0 ? + "Starts loading the lowest video bitrate" : + `Starts with a video bandwidth estimate of ${initialVideoBr}` + + " bits per seconds." + } +
  • @@ -120,6 +128,14 @@ function VideoAdaptiveSettings({ > Do not limit + + { + !isMinVideoBrLimited || parseFloat(minVideoBr) <= 0 ? + "Not limiting the lowest video bitrate reachable through the adaptive logic" : + "Limiting the lowest video bitrate reachable through the adaptive " + + `logic to ${minVideoBr} bits per seconds` + } +
  • @@ -163,29 +179,51 @@ function VideoAdaptiveSettings({ Do not limit
    + + { + !isMaxVideoBrLimited || parseFloat(maxVideoBr) === Infinity ? + "Not limiting the highest video bitrate reachable through the adaptive logic" : + "Limiting the highest video bitrate reachable through the adaptive " + + `logic to ${maxVideoBr} bits per seconds` + } +
  • - - Limit Video Width - +
    + + Limit Video Width + + + {limitVideoWidth ? + "Limiting video width to the current +
  • - - Throttle Video Bitrate When Hidden - +
    + + Throttle Video Bitrate When Hidden + + + {throttleVideoBitrateWhenHidden ? + "Throttling the video bitrate when the page is hidden for a time" : + "Not throttling the video bitrate when the page is hidden for a time"} + +
  • ); diff --git a/demo/full/styles/style.css b/demo/full/styles/style.css index e772e924fc..f381f6bd2b 100644 --- a/demo/full/styles/style.css +++ b/demo/full/styles/style.css @@ -425,6 +425,11 @@ body { margin: 5px; } +.option-desc { + font-weight: normal; + font-style: italic; +} + .choice-input-button { font-family: "icons", sans-serif; border: solid 1px #d1d1d1; @@ -1212,7 +1217,7 @@ input:checked + .slider:before { } .loadVideooptions li { - padding: 5px 10px; + padding: 10px; border-top: dashed 1px black; display: flex; flex-direction: column; From 720766976646f4e3440fd74a062522676d486267 Mon Sep 17 00:00:00 2001 From: Paul Berberian Date: Tue, 27 Dec 2022 20:01:31 +0100 Subject: [PATCH 22/86] demo: add title to options --- demo/full/scripts/controllers/Settings.jsx | 7 +++++++ demo/full/styles/style.css | 16 +++++++++++++++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/demo/full/scripts/controllers/Settings.jsx b/demo/full/scripts/controllers/Settings.jsx index 60099aacdb..fc2c3c81fa 100644 --- a/demo/full/scripts/controllers/Settings.jsx +++ b/demo/full/scripts/controllers/Settings.jsx @@ -248,6 +248,13 @@ class Settings extends React.Component { return (
    +
    + Content options +
    +
    + Note: Those options won't be retroactively applied to + already-loaded contents +
    - {player ? : null}
    ); From 0e3e4967dc70f0ac3f1ee3f454fd3914bb5e4e20 Mon Sep 17 00:00:00 2001 From: Paul Berberian Date: Tue, 20 Dec 2022 11:28:31 +0100 Subject: [PATCH 27/86] Completely remove RxJS from src directory --- package-lock.json | 12 +- package.json | 4 +- src/compat/event_listeners.ts | 5 - src/compat/on_height_width_change.ts | 2 +- .../adaptive_representation_selector.ts | 2 +- src/core/api/README.md | 33 --- src/core/api/playback_observer.ts | 13 +- src/core/api/public_api.ts | 27 +- .../tracks_management/track_choice_manager.ts | 87 +++--- src/core/decrypt/clear_on_stop.ts | 2 +- src/core/decrypt/create_session.ts | 2 +- src/core/decrypt/get_media_keys.ts | 2 +- src/core/decrypt/set_server_certificate.ts | 2 +- .../utils/clean_old_loaded_sessions.ts | 2 +- .../decrypt/utils/loaded_sessions_store.ts | 2 +- .../segment/segment_fetcher_creator.ts | 24 -- src/core/init/types.ts | 14 +- src/core/init/utils/create_media_source.ts | 4 +- src/core/segment_buffers/README.md | 9 +- src/core/segment_buffers/garbage_collector.ts | 1 - .../audio_video/audio_video_segment_buffer.ts | 10 +- .../image/image_segment_buffer.ts | 6 +- .../text/html/html_text_segment_buffer.ts | 2 +- .../orchestrator/stream_orchestrator.ts | 2 +- src/core/stream/period/period_stream.ts | 31 +-- src/core/stream/period/types.ts | 11 +- src/core/stream/representation/README.md | 5 +- .../utils/append_segment_to_buffer.ts | 2 +- .../remove_buffer_around_time.ts | 2 +- src/transports/README.md | 69 ++--- src/transports/dash/manifest_parser.ts | 4 +- src/transports/dash/segment_loader.ts | 2 +- src/transports/dash/text_parser.ts | 6 +- src/transports/local/segment_loader.ts | 2 +- .../__tests__/cast_to_observable.test.ts | 93 ------- src/utils/__tests__/concat_map_latest.test.ts | 221 --------------- src/utils/__tests__/event_emitter.test.ts | 77 +----- src/utils/__tests__/rx-throttle.test.ts | 252 ------------------ src/utils/__tests__/rx-try_catch.test.ts | 100 ------- src/utils/cast_to_observable.ts | 48 ---- src/utils/concat_map_latest.ts | 61 ----- src/utils/event_emitter.ts | 26 -- src/utils/reference.ts | 69 +---- src/utils/request/xhr.ts | 71 +---- src/utils/rx-next-tick.ts | 43 --- src/utils/rx-throttle.ts | 84 ------ src/utils/rx-try_catch.ts | 37 --- 47 files changed, 163 insertions(+), 1422 deletions(-) delete mode 100644 src/utils/__tests__/cast_to_observable.test.ts delete mode 100644 src/utils/__tests__/concat_map_latest.test.ts delete mode 100644 src/utils/__tests__/rx-throttle.test.ts delete mode 100644 src/utils/__tests__/rx-try_catch.test.ts delete mode 100644 src/utils/cast_to_observable.ts delete mode 100644 src/utils/concat_map_latest.ts delete mode 100644 src/utils/rx-next-tick.ts delete mode 100644 src/utils/rx-throttle.ts delete mode 100644 src/utils/rx-try_catch.ts diff --git a/package-lock.json b/package-lock.json index a48e041434..9fd5109ac6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,8 +9,7 @@ "version": "3.29.0", "license": "Apache-2.0", "dependencies": { - "next-tick": "1.1.0", - "rxjs": "7.5.7" + "next-tick": "1.1.0" }, "devDependencies": { "@babel/core": "7.20.2", @@ -54,6 +53,7 @@ "react-dom": "18.2.0", "regenerator-runtime": "0.13.10", "rimraf": "3.0.2", + "rxjs": "7.5.7", "semver": "7.3.8", "sinon": "14.0.2", "terser-webpack-plugin": "5.3.6", @@ -11640,6 +11640,7 @@ "version": "7.5.7", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.5.7.tgz", "integrity": "sha512-z9MzKh/UcOqB3i20H6rtrlaE/CgjLOvheWK/9ILrbhROGTweAi1BaFsTT9FbwZi5Trr1qNRs+MXkhmR06awzQA==", + "dev": true, "dependencies": { "tslib": "^2.1.0" } @@ -11647,7 +11648,8 @@ "node_modules/rxjs/node_modules/tslib": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.1.0.tgz", - "integrity": "sha512-hcVC3wYEziELGGmEEXue7D75zbwIIVUMWAVbHItGPx0ziyXxrOMQx4rQEVEV45Ut/1IotuEvwqPopzIOkDMf0A==" + "integrity": "sha512-hcVC3wYEziELGGmEEXue7D75zbwIIVUMWAVbHItGPx0ziyXxrOMQx4rQEVEV45Ut/1IotuEvwqPopzIOkDMf0A==", + "dev": true }, "node_modules/safe-buffer": { "version": "5.1.2", @@ -22087,6 +22089,7 @@ "version": "7.5.7", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.5.7.tgz", "integrity": "sha512-z9MzKh/UcOqB3i20H6rtrlaE/CgjLOvheWK/9ILrbhROGTweAi1BaFsTT9FbwZi5Trr1qNRs+MXkhmR06awzQA==", + "dev": true, "requires": { "tslib": "^2.1.0" }, @@ -22094,7 +22097,8 @@ "tslib": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.1.0.tgz", - "integrity": "sha512-hcVC3wYEziELGGmEEXue7D75zbwIIVUMWAVbHItGPx0ziyXxrOMQx4rQEVEV45Ut/1IotuEvwqPopzIOkDMf0A==" + "integrity": "sha512-hcVC3wYEziELGGmEEXue7D75zbwIIVUMWAVbHItGPx0ziyXxrOMQx4rQEVEV45Ut/1IotuEvwqPopzIOkDMf0A==", + "dev": true } } }, diff --git a/package.json b/package.json index 956bc46426..16af167c13 100644 --- a/package.json +++ b/package.json @@ -77,8 +77,7 @@ "url": "git://github.com/canalplus/rx-player.git" }, "dependencies": { - "next-tick": "1.1.0", - "rxjs": "7.5.7" + "next-tick": "1.1.0" }, "devDependencies": { "@babel/core": "7.20.2", @@ -122,6 +121,7 @@ "react-dom": "18.2.0", "regenerator-runtime": "0.13.10", "rimraf": "3.0.2", + "rxjs": "7.5.7", "semver": "7.3.8", "sinon": "14.0.2", "terser-webpack-plugin": "5.3.6", diff --git a/src/compat/event_listeners.ts b/src/compat/event_listeners.ts index c0bad774ea..ab1f3b83b6 100644 --- a/src/compat/event_listeners.ts +++ b/src/compat/event_listeners.ts @@ -14,11 +14,6 @@ * limitations under the License. */ -/** - * This file provides browser-agnostic event listeners under the form of - * RxJS Observables - */ - import config from "../config"; import log from "../log"; import { IEventEmitter } from "../utils/event_emitter"; diff --git a/src/compat/on_height_width_change.ts b/src/compat/on_height_width_change.ts index b4d8435a62..702c51580e 100644 --- a/src/compat/on_height_width_change.ts +++ b/src/compat/on_height_width_change.ts @@ -66,7 +66,7 @@ const _ResizeObserver : IResizeObserverConstructor | * milliseconds at which we should query that element's size. * @param {HTMLElement} element * @param {number} interval - * @returns {Observable} + * @returns {Object} */ export default function onHeightWidthChange( element : HTMLElement, diff --git a/src/core/adaptive/adaptive_representation_selector.ts b/src/core/adaptive/adaptive_representation_selector.ts index bb25b68928..4180e6e9eb 100644 --- a/src/core/adaptive/adaptive_representation_selector.ts +++ b/src/core/adaptive/adaptive_representation_selector.ts @@ -274,7 +274,7 @@ function getEstimateReference( /** Reference through which estimates are emitted. */ const innerEstimateRef = createSharedReference(getCurrentEstimate()); - // subscribe to subsequent playback observations + // Listen to playback observations playbackObserver.listen((obs) => { lastPlaybackObservation = obs; updateEstimate(); diff --git a/src/core/api/README.md b/src/core/api/README.md index 8ad5b841d4..b43fff56b2 100644 --- a/src/core/api/README.md +++ b/src/core/api/README.md @@ -9,39 +9,6 @@ As such, its main roles are to: - redirecting events to the user - -## `public_api.ts`: the largest file here ###################################### - -`public_api.ts` is at the time of writing by far the longest file in all the -RxPlayer, with more than 2000 lines. - -One of reason is that the API needs to have a considerable state because most of -the other modules rely on Observables. - -I'll explain: -The API can't just interogate at any time the concerned module as if it was a -class with methods. Here most modules are functions which send events. - -The problem is that a library user might want to have an information at any -given moment (for example, the current bitrate), which internally is only -sent as an event by some module. -It is thus the role of the API to store that information when it receives -this event to then communicate it back to the user. - - Also, as the API is a single class with a huge private state, being able - to see those state mutations in a single file allows us to better think about - how it all works. - - Another huge part of that file is actually the entire public API, as small - functions. - - Still, we did some efforts to reduce the size of that file. For example, some - long argument-parsing code has been moved out of this file, into - `core/api/option_utils`. We might find other ways to reduce that size in the - future, but that's not a main concern for now. - - - ## Subparts #################################################################### To facilitate those actions, the API relies on multiple building blocks: diff --git a/src/core/api/playback_observer.ts b/src/core/api/playback_observer.ts index f5b3120db4..c9125445c7 100644 --- a/src/core/api/playback_observer.ts +++ b/src/core/api/playback_observer.ts @@ -48,8 +48,8 @@ const SCANNED_MEDIA_ELEMENTS_EVENTS : IPlaybackObserverEventType[] = [ "canplay" * `PlaybackObserver` to know the current state of the media being played. * * You can use the PlaybackObserver to either get the last observation - * performed, get the current media state or subscribe to an Observable emitting - * regularly media conditions. + * performed, get the current media state or listen to media observation sent + * at a regular interval. * * @class {PlaybackObserver} */ @@ -116,8 +116,7 @@ export default class PlaybackObserver { /** * Stop the `PlaybackObserver` from emitting playback observations and free all - * resources reserved to emitting them such as event listeners, intervals and - * subscribing callbacks. + * resources reserved to emitting them such as event listeners and intervals. * * Once `stop` is called, no new playback observation will ever be emitted. * @@ -192,7 +191,7 @@ export default class PlaybackObserver { * produced by the `PlaybackObserver` and updated each time a new one is * produced. * - * This value can then be for example subscribed to to be notified of future + * This value can then be for example listened to to be notified of future * playback observations. * * @returns {Object} @@ -253,7 +252,7 @@ export default class PlaybackObserver { /** * Creates the `IReadOnlySharedReference` that will generate playback * observations. - * @returns {Observable} + * @returns {Object} */ private _createSharedReference() : IReadOnlySharedReference { if (this._observationRef !== undefined) { @@ -532,7 +531,7 @@ export interface IReadOnlyPlaybackObserver { * produced by the `IReadOnlyPlaybackObserver` and updated each time a new one * is produced. * - * This value can then be for example subscribed to to be notified of future + * This value can then be for example listened to to be notified of future * playback observations. * * @returns {Object} diff --git a/src/core/api/public_api.ts b/src/core/api/public_api.ts index a35a50e6bb..102ced69f9 100644 --- a/src/core/api/public_api.ts +++ b/src/core/api/public_api.ts @@ -19,7 +19,6 @@ * It also starts the different sub-parts of the player on various API calls. */ -import { Subject } from "rxjs"; import { events, exitFullscreen, @@ -403,7 +402,6 @@ class Player extends EventEmitter { // We can have two consecutive textTrackChanges with the exact same // payload when we perform multiple texttrack operations before the event // loop is freed. - // In that case we only want to fire one time the observable. if (oldTextTracks.length !== textTrackArr.length) { this._priv_onNativeTextTracksNext(textTrackArr); return; @@ -522,7 +520,7 @@ class Player extends EventEmitter { // free resources linked to the Player instance this._destroyCanceller.cancel(); - // Complete all subjects and references + // Complete all references this._priv_speed.finish(); this._priv_contentLock.finish(); this._priv_bufferOptions.wantedBufferAhead.finish(); @@ -631,10 +629,9 @@ class Player extends EventEmitter { const isDirectFile = transport === "directfile"; - /** Subject which will emit to stop the current content. */ + /** Emit to stop the current content. */ const currentContentCanceller = new TaskCanceller(); - const videoElement = this.videoElement; let initializer : ContentInitializer; @@ -2417,13 +2414,13 @@ class Player extends EventEmitter { value : { type : IBufferType; period : Period; - adaptation$ : Subject; + adaptationRef : ISharedReference; } ) : void { if (contentInfos.contentId !== this._priv_contentInfos?.contentId) { return; // Event for another content } - const { type, period, adaptation$ } = value; + const { type, period, adaptationRef } = value; const trackChoiceManager = contentInfos.trackChoiceManager; switch (type) { @@ -2431,9 +2428,9 @@ class Player extends EventEmitter { case "video": if (isNullOrUndefined(trackChoiceManager)) { log.error("API: TrackChoiceManager not instanciated for a new video period"); - adaptation$.next(null); + adaptationRef.setValue(null); } else { - trackChoiceManager.addPeriod(type, period, adaptation$); + trackChoiceManager.addPeriod(type, period, adaptationRef); trackChoiceManager.setInitialVideoTrack(period); } break; @@ -2441,9 +2438,9 @@ class Player extends EventEmitter { case "audio": if (isNullOrUndefined(trackChoiceManager)) { log.error(`API: TrackChoiceManager not instanciated for a new ${type} period`); - adaptation$.next(null); + adaptationRef.setValue(null); } else { - trackChoiceManager.addPeriod(type, period, adaptation$); + trackChoiceManager.addPeriod(type, period, adaptationRef); trackChoiceManager.setInitialAudioTrack(period); } break; @@ -2451,9 +2448,9 @@ class Player extends EventEmitter { case "text": if (isNullOrUndefined(trackChoiceManager)) { log.error(`API: TrackChoiceManager not instanciated for a new ${type} period`); - adaptation$.next(null); + adaptationRef.setValue(null); } else { - trackChoiceManager.addPeriod(type, period, adaptation$); + trackChoiceManager.addPeriod(type, period, adaptationRef); trackChoiceManager.setInitialTextTrack(period); } break; @@ -2461,9 +2458,9 @@ class Player extends EventEmitter { default: const adaptations = period.adaptations[type]; if (!isNullOrUndefined(adaptations) && adaptations.length > 0) { - adaptation$.next(adaptations[0]); + adaptationRef.setValue(adaptations[0]); } else { - adaptation$.next(null); + adaptationRef.setValue(null); } break; } diff --git a/src/core/api/tracks_management/track_choice_manager.ts b/src/core/api/tracks_management/track_choice_manager.ts index 504dfd814e..d8e90573be 100644 --- a/src/core/api/tracks_management/track_choice_manager.ts +++ b/src/core/api/tracks_management/track_choice_manager.ts @@ -19,7 +19,6 @@ * switching for an easier API management. */ -import { Subject } from "rxjs"; import log from "../../../log"; import { Adaptation, @@ -43,26 +42,35 @@ import arrayFind from "../../../utils/array_find"; import arrayIncludes from "../../../utils/array_includes"; import isNullOrUndefined from "../../../utils/is_null_or_undefined"; import normalizeLanguage from "../../../utils/languages"; +import { ISharedReference } from "../../../utils/reference"; import SortedList from "../../../utils/sorted_list"; import takeFirstSet from "../../../utils/take_first_set"; /** Audio information stored for a single Period. */ -interface ITMPeriodAudioInfos { adaptations : Adaptation[]; - adaptation$ : Subject; } +interface ITMPeriodAudioInfos { + adaptations : Adaptation[]; + adaptationRef : ISharedReference; +} /** Text information stored for a single Period. */ -interface ITMPeriodTextInfos { adaptations : Adaptation[]; - adaptation$ : Subject; } +interface ITMPeriodTextInfos { + adaptations : Adaptation[]; + adaptationRef : ISharedReference; +} /** Video information stored for a single Period. */ -interface ITMPeriodVideoInfos { adaptations : Adaptation[]; - adaptation$ : Subject; } +interface ITMPeriodVideoInfos { + adaptations : Adaptation[]; + adaptationRef : ISharedReference; +} /** Every information stored for a single Period. */ -interface ITMPeriodInfos { period : Period; - audio? : ITMPeriodAudioInfos; - text? : ITMPeriodTextInfos; - video? : ITMPeriodVideoInfos; } +interface ITMPeriodInfos { + period : Period; + audio? : ITMPeriodAudioInfos; + text? : ITMPeriodTextInfos; + video? : ITMPeriodVideoInfos; +} /** Audio track preference once normalized by the TrackChoiceManager. */ type INormalizedPreferredAudioTrack = null | @@ -249,16 +257,15 @@ export default class TrackChoiceManager { } /** - * Add Subject to choose Adaptation for new "audio" or "text" Period. + * Add shared reference to choose Adaptation for new "audio" or "text" Period. * @param {string} bufferType - The concerned buffer type * @param {Period} period - The concerned Period. - * @param {Subject.} adaptation$ - A subject through which the - * choice will be given + * @param {Object} adaptationRef */ public addPeriod( bufferType : "audio" | "text"| "video", period : Period, - adaptation$ : Subject + adaptationRef : ISharedReference ) : void { const periodItem = getPeriodItem(this._periods, period); const adaptations = period.getSupportedAdaptations(bufferType); @@ -268,17 +275,17 @@ export default class TrackChoiceManager { period.start); return; } else { - periodItem[bufferType] = { adaptations, adaptation$ }; + periodItem[bufferType] = { adaptations, adaptationRef }; } } else { this._periods.add({ period, - [bufferType]: { adaptations, adaptation$ } }); + [bufferType]: { adaptations, adaptationRef } }); } } /** - * Remove Subject to choose an "audio", "video" or "text" Adaptation for a - * Period. + * Remove shared reference to choose an "audio", "video" or "text" Adaptation + * for a Period. * @param {string} bufferType - The concerned buffer type * @param {Period} period - The concerned Period. */ @@ -326,7 +333,7 @@ export default class TrackChoiceManager { } /** - * Emit initial audio Adaptation through the given Subject based on: + * Emit initial audio Adaptation through the given shared reference based on: * - the preferred audio tracks * - the last choice for this period, if one * @param {Period} period - The concerned Period. @@ -344,7 +351,7 @@ export default class TrackChoiceManager { if (chosenAudioAdaptation === null) { // If the Period was previously without audio, keep it that way - audioInfos.adaptation$.next(null); + audioInfos.adaptationRef.setValue(null); } else if (chosenAudioAdaptation === undefined || !arrayIncludes(audioAdaptations, chosenAudioAdaptation) ) { @@ -355,14 +362,14 @@ export default class TrackChoiceManager { normalizedPref); this._audioChoiceMemory.set(period, optimalAdaptation); - audioInfos.adaptation$.next(optimalAdaptation); + audioInfos.adaptationRef.setValue(optimalAdaptation); } else { - audioInfos.adaptation$.next(chosenAudioAdaptation); // set last one + audioInfos.adaptationRef.setValue(chosenAudioAdaptation); // set last one } } /** - * Emit initial text Adaptation through the given Subject based on: + * Emit initial text Adaptation through the given shared reference based on: * - the preferred text tracks * - the last choice for this period, if one * @param {Period} period - The concerned Period. @@ -379,7 +386,7 @@ export default class TrackChoiceManager { const chosenTextAdaptation = this._textChoiceMemory.get(period); if (chosenTextAdaptation === null) { // If the Period was previously without text, keep it that way - textInfos.adaptation$.next(null); + textInfos.adaptationRef.setValue(null); } else if (chosenTextAdaptation === undefined || !arrayIncludes(textAdaptations, chosenTextAdaptation) ) { @@ -391,14 +398,14 @@ export default class TrackChoiceManager { normalizedPref, this._audioChoiceMemory.get(period)); this._textChoiceMemory.set(period, optimalAdaptation); - textInfos.adaptation$.next(optimalAdaptation); + textInfos.adaptationRef.setValue(optimalAdaptation); } else { - textInfos.adaptation$.next(chosenTextAdaptation); // set last one + textInfos.adaptationRef.setValue(chosenTextAdaptation); // set last one } } /** - * Emit initial video Adaptation through the given Subject based on: + * Emit initial video Adaptation through the given shared reference based on: * - the preferred video tracks * - the last choice for this period, if one * @param {Period} period - The concerned Period. @@ -433,7 +440,7 @@ export default class TrackChoiceManager { if (newBaseAdaptation === null) { this._videoChoiceMemory.set(period, null); - videoInfos.adaptation$.next(null); + videoInfos.adaptationRef.setValue(null); return; } @@ -441,7 +448,7 @@ export default class TrackChoiceManager { this.trickModeTrackEnabled); this._videoChoiceMemory.set(period, { baseAdaptation: newBaseAdaptation, adaptation: newVideoAdaptation }); - videoInfos.adaptation$.next(newVideoAdaptation); + videoInfos.adaptationRef.setValue(newVideoAdaptation); } /** @@ -469,7 +476,7 @@ export default class TrackChoiceManager { } this._audioChoiceMemory.set(period, wantedAdaptation); - audioInfos.adaptation$.next(wantedAdaptation); + audioInfos.adaptationRef.setValue(wantedAdaptation); } /** @@ -496,7 +503,7 @@ export default class TrackChoiceManager { } this._textChoiceMemory.set(period, wantedAdaptation); - textInfos.adaptation$.next(wantedAdaptation); + textInfos.adaptationRef.setValue(wantedAdaptation); } /** @@ -527,7 +534,7 @@ export default class TrackChoiceManager { this.trickModeTrackEnabled); this._videoChoiceMemory.set(period, { baseAdaptation: wantedBaseAdaptation, adaptation: newVideoAdaptation }); - videoInfos.adaptation$.next(newVideoAdaptation); + videoInfos.adaptationRef.setValue(newVideoAdaptation); } /** @@ -550,7 +557,7 @@ export default class TrackChoiceManager { } this._textChoiceMemory.set(period, null); - textInfos.adaptation$.next(null); + textInfos.adaptationRef.setValue(null); } /** @@ -569,7 +576,7 @@ export default class TrackChoiceManager { return; } this._videoChoiceMemory.set(period, null); - videoInfos.adaptation$.next(null); + videoInfos.adaptationRef.setValue(null); } public disableVideoTrickModeTracks(): void { @@ -920,7 +927,7 @@ export default class TrackChoiceManager { normalizedPref); this._audioChoiceMemory.set(period, optimalAdaptation); - audioItem.adaptation$.next(optimalAdaptation); + audioItem.adaptationRef.setValue(optimalAdaptation); // previous "next" call could have changed everything, start over recursiveUpdateAudioTrack(0); @@ -976,7 +983,7 @@ export default class TrackChoiceManager { this._audioChoiceMemory.get(period)); this._textChoiceMemory.set(period, optimalAdaptation); - textItem.adaptation$.next(optimalAdaptation); + textItem.adaptationRef.setValue(optimalAdaptation); // previous "next" call could have changed everything, start over recursiveUpdateTextTrack(0); @@ -1036,7 +1043,7 @@ export default class TrackChoiceManager { baseAdaptation: chosenVideoAdaptation.baseAdaptation, adaptation: wantedVideoAdaptation, }); - videoItem.adaptation$.next(wantedVideoAdaptation); + videoItem.adaptationRef.setValue(wantedVideoAdaptation); // previous "next" call could have changed everything, start over return recursiveUpdateVideoTrack(0); @@ -1047,7 +1054,7 @@ export default class TrackChoiceManager { preferredVideoTracks); if (optimalAdaptation === null) { this._videoChoiceMemory.set(period, null); - videoItem.adaptation$.next(null); + videoItem.adaptationRef.setValue(null); // previous "next" call could have changed everything, start over return recursiveUpdateVideoTrack(0); } @@ -1056,7 +1063,7 @@ export default class TrackChoiceManager { this.trickModeTrackEnabled); this._videoChoiceMemory.set(period, { baseAdaptation: optimalAdaptation, adaptation: newVideoAdaptation }); - videoItem.adaptation$.next(newVideoAdaptation); + videoItem.adaptationRef.setValue(newVideoAdaptation); // previous "next" call could have changed everything, start over return recursiveUpdateVideoTrack(0); diff --git a/src/core/decrypt/clear_on_stop.ts b/src/core/decrypt/clear_on_stop.ts index fba9700052..a845d84139 100644 --- a/src/core/decrypt/clear_on_stop.ts +++ b/src/core/decrypt/clear_on_stop.ts @@ -23,7 +23,7 @@ import MediaKeysInfosStore from "./utils/media_keys_infos_store"; * Clear DRM-related resources that should be cleared when the current content * stops its playback. * @param {HTMLMediaElement} mediaElement - * @returns {Observable} + * @returns {Promise} */ export default function clearOnStop( mediaElement : HTMLMediaElement diff --git a/src/core/decrypt/create_session.ts b/src/core/decrypt/create_session.ts index 4ae489ce4e..721d78b76b 100644 --- a/src/core/decrypt/create_session.ts +++ b/src/core/decrypt/create_session.ts @@ -151,7 +151,7 @@ async function createAndTryToRetrievePersistentSession( /** * Helper function to close and restart the current persistent session * considered, and re-create it from scratch. - * @returns {Observable} + * @returns {Promise.} */ async function recreatePersistentSession() : Promise { if (cancelSignal.cancellationError !== null) { diff --git a/src/core/decrypt/get_media_keys.ts b/src/core/decrypt/get_media_keys.ts index d1af7cf17e..c0acab5963 100644 --- a/src/core/decrypt/get_media_keys.ts +++ b/src/core/decrypt/get_media_keys.ts @@ -130,7 +130,7 @@ export default async function getMediaKeysInfos( * Create `MediaKeys` from the `MediaKeySystemAccess` given. * Throws the right formatted error if it fails. * @param {MediaKeySystemAccess} mediaKeySystemAccess - * @returns {Observable.} + * @returns {Promise.} */ async function createMediaKeys( mediaKeySystemAccess : MediaKeySystemAccess | ICustomMediaKeySystemAccess diff --git a/src/core/decrypt/set_server_certificate.ts b/src/core/decrypt/set_server_certificate.ts index 5940d3d298..ec8068c431 100644 --- a/src/core/decrypt/set_server_certificate.ts +++ b/src/core/decrypt/set_server_certificate.ts @@ -61,7 +61,7 @@ async function setServerCertificate( * and complete. * @param {MediaKeys} mediaKeys * @param {ArrayBuffer} serverCertificate - * @returns {Observable} + * @returns {Promise.} */ export default async function trySettingServerCertificate( mediaKeys : ICustomMediaKeys|MediaKeys, diff --git a/src/core/decrypt/utils/clean_old_loaded_sessions.ts b/src/core/decrypt/utils/clean_old_loaded_sessions.ts index b4d9c07625..3e489b82b8 100644 --- a/src/core/decrypt/utils/clean_old_loaded_sessions.ts +++ b/src/core/decrypt/utils/clean_old_loaded_sessions.ts @@ -23,7 +23,7 @@ import LoadedSessionsStore from "./loaded_sessions_store"; * Emit event when a MediaKeySession begin to be closed and another when the * MediaKeySession is closed. * @param {Object} loadedSessionsStore - * @returns {Observable} + * @returns {Promise} */ export default async function cleanOldLoadedSessions( loadedSessionsStore : LoadedSessionsStore, diff --git a/src/core/decrypt/utils/loaded_sessions_store.ts b/src/core/decrypt/utils/loaded_sessions_store.ts index 4b77e21931..1f1aa5643f 100644 --- a/src/core/decrypt/utils/loaded_sessions_store.ts +++ b/src/core/decrypt/utils/loaded_sessions_store.ts @@ -479,7 +479,7 @@ export interface IStoredSessionEntry { * Close a MediaKeySession and just log an error if it fails (while resolving). * Emits then complete when done. * @param {MediaKeySession} mediaKeySession - * @returns {Observable} + * @returns {Promise} */ async function safelyCloseMediaKeySession( mediaKeySession : MediaKeySession | ICustomMediaKeySession diff --git a/src/core/fetchers/segment/segment_fetcher_creator.ts b/src/core/fetchers/segment/segment_fetcher_creator.ts index dd0fb785b8..a92692b047 100644 --- a/src/core/fetchers/segment/segment_fetcher_creator.ts +++ b/src/core/fetchers/segment/segment_fetcher_creator.ts @@ -37,30 +37,6 @@ import TaskPrioritizer from "./task_prioritizer"; * priority. * * @class SegmentFetcherCreator - * - * @example - * ```js - * const creator = new SegmentFetcherCreator(transport, { - * lowLatencyMode: false, - * maxRetryRegular: Infinity, - * maxRetryOffline: Infinity, - * }); - * - * // 2 - create a new fetcher with its backoff options - * const fetcher = creator.createSegmentFetcher("audio", { - * // ... (lifecycle callbacks if wanted) - * }); - * - * // 3 - load a segment with a given priority - * fetcher.createRequest(myContent, 1) - * // 4 - parse it - * .pipe( - * filter(evt => evt.type === "chunk"), - * mergeMap(response => response.parse()); - * ) - * // 5 - use it - * .subscribe((res) => console.log("audio chunk downloaded:", res)); - * ``` */ export default class SegmentFetcherCreator { /** diff --git a/src/core/init/types.ts b/src/core/init/types.ts index ffb3dfc87d..298826e614 100644 --- a/src/core/init/types.ts +++ b/src/core/init/types.ts @@ -14,7 +14,6 @@ * limitations under the License. */ -import { Subject } from "rxjs"; import Manifest, { Adaptation, ISegment, @@ -23,6 +22,7 @@ import Manifest, { } from "../../manifest"; import { IPlayerError } from "../../public_types"; import EventEmitter from "../../utils/event_emitter"; +import { ISharedReference } from "../../utils/reference"; import { PlaybackObserver } from "../api"; import SegmentBuffersStore, { IBufferType, @@ -157,21 +157,23 @@ export interface IContentInitializerEvents { /** The `Period` linked to the `PeriodStream` we have created. */ period : Period; /** - * The subject through which any Adaptation (i.e. track) choice should be + * The Reference through which any Adaptation (i.e. track) choice should be * emitted for that `PeriodStream`. * - * The `PeriodStream` will not do anything until this subject has emitted + * The `PeriodStream` will not do anything until this Reference has emitted * at least one to give its initial choice. * You can send `null` through it to tell this `PeriodStream` that you don't * want any `Adaptation`. + * It is set to `undefined` by default, you SHOULD NOT set it to `undefined` + * yourself. */ - adaptation$ : Subject; + adaptationRef : ISharedReference; }; /** * A `PeriodStream` has been removed. * This event can be used for clean-up purposes. For example, you are free to - * remove from scope the subject that you used to choose a track for that - * `PeriodStream`. + * remove from scope the shared reference that you used to choose a track for + * that `PeriodStream`. */ periodStreamCleared: { /** diff --git a/src/core/init/utils/create_media_source.ts b/src/core/init/utils/create_media_source.ts index 919c7f15d2..2c5a15ad95 100644 --- a/src/core/init/utils/create_media_source.ts +++ b/src/core/init/utils/create_media_source.ts @@ -75,8 +75,8 @@ export function resetMediaSource( } /** - * Create, on subscription, a MediaSource instance and attach it to the given - * mediaElement element's src attribute. + * Create a MediaSource instance and attach it to the given mediaElement element's + * src attribute. * * Returns a Promise which resolves with the MediaSource when created and attached * to the `mediaElement` element. diff --git a/src/core/segment_buffers/README.md b/src/core/segment_buffers/README.md index 8d5b74bfbe..478b8ade3f 100644 --- a/src/core/segment_buffers/README.md +++ b/src/core/segment_buffers/README.md @@ -134,7 +134,7 @@ periodically perform "garbage collection" manually on a given It is based on the following building bricks: - - An observable emitting the current time (in seconds) when the garbage + - A playback observer emitting the current time (in seconds) when the garbage collection task should be performed - The `SegmentBuffer` on which the garbage collection task should run @@ -145,9 +145,10 @@ It is based on the following building bricks: - The maximum time margin authorized for the buffer ahead of the current position -Basically, each times the given Observable emits, the BufferGarbageCollector will -ensure that the volume of data before and ahead of the current position does not -grow into a larger value than what is configured. +Basically, each times the given playback observer emits, the +BufferGarbageCollector will ensure that the volume of data before and ahead +of the current position does not grow into a larger value than what is +configured. For now, its code is completely decoupled for the rest of the code in that directory. This is why it is not included in the schema included on the top of diff --git a/src/core/segment_buffers/garbage_collector.ts b/src/core/segment_buffers/garbage_collector.ts index f16b399d0e..274a70ca4e 100644 --- a/src/core/segment_buffers/garbage_collector.ts +++ b/src/core/segment_buffers/garbage_collector.ts @@ -44,7 +44,6 @@ export interface IGarbageCollectorArgument { * * @param {Object} opt * @param {Object} cancellationSignal - * @returns {Observable} */ export default function BufferGarbageCollector( { segmentBuffer, diff --git a/src/core/segment_buffers/implementations/audio_video/audio_video_segment_buffer.ts b/src/core/segment_buffers/implementations/audio_video/audio_video_segment_buffer.ts index 354043132d..a5b41866c4 100644 --- a/src/core/segment_buffers/implementations/audio_video/audio_video_segment_buffer.ts +++ b/src/core/segment_buffers/implementations/audio_video/audio_video_segment_buffer.ts @@ -49,9 +49,8 @@ import { * Item added to the AudioVideoSegmentBuffer's queue before being processed into * a task (see `IAVSBPendingTask`). * - * Here we add the `subject` property which will allow the - * AudioVideoSegmentBuffer to emit an event when the corresponding queued - * operation is completely processed. + * Here we add `resolve` and `reject` callbacks to anounce when the task is + * finished. */ type IAVSBQueueItem = ISBOperation & { resolve : () => void; @@ -361,11 +360,6 @@ export default class AudioVideoSegmentBuffer extends SegmentBuffer { } /** - * When the returned observable is subscribed: - * 1. Add your operation to the queue. - * 2. Begin the queue if not pending. - * - * Cancel queued operation on unsubscription. * @private * @param {Object} operation * @param {Object} cancellationSignal diff --git a/src/core/segment_buffers/implementations/image/image_segment_buffer.ts b/src/core/segment_buffers/implementations/image/image_segment_buffer.ts index e54ecd28de..fe0f08e798 100644 --- a/src/core/segment_buffers/implementations/image/image_segment_buffer.ts +++ b/src/core/segment_buffers/implementations/image/image_segment_buffer.ts @@ -97,9 +97,9 @@ export default class ImageSegmentBuffer extends SegmentBuffer { * Indicate that every chunks from a Segment has been given to pushChunk so * far. * This will update our internal Segment inventory accordingly. - * The returned Observable will emit and complete successively once the whole - * segment has been pushed and this indication is acknowledged. - * @param {Object} infos + * The returned Promise will resolve once the whole segment has been pushed + * and this indication is acknowledged. + * @param {Object} _infos * @returns {Promise} */ public endOfSegment(_infos : IEndOfSegmentInfos) : Promise { diff --git a/src/core/segment_buffers/implementations/text/html/html_text_segment_buffer.ts b/src/core/segment_buffers/implementations/text/html/html_text_segment_buffer.ts index e2d341fd6e..eda1ba50d4 100644 --- a/src/core/segment_buffers/implementations/text/html/html_text_segment_buffer.ts +++ b/src/core/segment_buffers/implementations/text/html/html_text_segment_buffer.ts @@ -138,7 +138,7 @@ export default class HTMLTextSegmentBuffer extends SegmentBuffer { } /** - * Push segment on Subscription. + * Push text segment to the HTMLTextSegmentBuffer. * @param {Object} infos * @returns {Promise} */ diff --git a/src/core/stream/orchestrator/stream_orchestrator.ts b/src/core/stream/orchestrator/stream_orchestrator.ts index bb74496875..b0facec7e1 100644 --- a/src/core/stream/orchestrator/stream_orchestrator.ts +++ b/src/core/stream/orchestrator/stream_orchestrator.ts @@ -65,7 +65,7 @@ import getTimeRangesForContent from "./get_time_ranges_for_content"; * - Call various callbacks to notify of its health and issues * * @param {Object} content - * @param {Observable} playbackObserver - Emit position information + * @param {Object} playbackObserver - Emit position information * @param {Object} representationEstimator - Emit bitrate estimates and best * Representation to play. * @param {Object} segmentBuffersStore - Will be used to lazily create diff --git a/src/core/stream/period/period_stream.ts b/src/core/stream/period/period_stream.ts index 28e2ac2759..3ca4be34cb 100644 --- a/src/core/stream/period/period_stream.ts +++ b/src/core/stream/period/period_stream.ts @@ -15,7 +15,6 @@ */ import nextTick from "next-tick"; -import { ReplaySubject } from "rxjs"; import config from "../../../config"; import { formatError, @@ -53,7 +52,7 @@ import { import getAdaptationSwitchStrategy from "./utils/get_adaptation_switch_strategy"; /** - * Create single PeriodStream Observable: + * Create a single PeriodStream: * - Lazily create (or reuse) a SegmentBuffer for the given type. * - Create a Stream linked to an Adaptation each time it changes, to * download and append the corresponding segments to the SegmentBuffer. @@ -99,11 +98,14 @@ export default function PeriodStream( ) : void { const { period } = content; - // Emits the chosen Adaptation for the current type. - // `null` when no Adaptation is chosen (e.g. no subtitles) - const adaptation$ = new ReplaySubject(1); + /** + * Emits the chosen Adaptation for the current type. + * `null` when no Adaptation is chosen (e.g. no subtitles) + * `undefined` at the beginning (it can be ignored.). + */ + const adaptationRef = createSharedReference(undefined); - callbacks.periodStreamReady({ type: bufferType, period, adaptation$ }); + callbacks.periodStreamReady({ type: bufferType, period, adaptationRef }); if (parentCancelSignal.isCancelled) { return; } @@ -111,9 +113,12 @@ export default function PeriodStream( let currentStreamCanceller : TaskCanceller | undefined; let isFirstAdaptationSwitch = true; - const subscription = adaptation$.subscribe((adaptation : Adaptation | null) => { - // As an IIFE to profit from async/await while respecting subscribe's signature + adaptationRef.onUpdate((adaptation : Adaptation | null | undefined) => { + // As an IIFE to profit from async/await while respecting onUpdate's signature (async () : Promise => { + if (adaptation === undefined) { + return; + } const streamCanceller = new TaskCanceller({ cancelOn: parentCancelSignal }); currentStreamCanceller?.cancel(); // Cancel oreviously created stream if one currentStreamCanceller = streamCanceller; @@ -233,16 +238,12 @@ export default function PeriodStream( currentStreamCanceller?.cancel(); callbacks.error(err); }); - }); - - parentCancelSignal.register(() => { - subscription.unsubscribe(); - }); + }, { clearSignal: parentCancelSignal, emitCurrentValue: true }); /** * @param {Object} adaptation * @param {Object} segmentBuffer - * @returns {Observable} + * @param {Object} cancelSignal */ function createAdaptationStream( adaptation : Adaptation, @@ -427,7 +428,7 @@ function createAdaptationStreamPlaybackObserver( * Create empty AdaptationStream, linked to a Period. * This AdaptationStream will never download any segment and just emit a "full" * event when reaching the end. - * @param {Observable} playbackObserver + * @param {Object} playbackObserver * @param {Object} wantedBufferAhead * @param {string} bufferType * @param {Object} content diff --git a/src/core/stream/period/types.ts b/src/core/stream/period/types.ts index f8e97754bc..95d9430a60 100644 --- a/src/core/stream/period/types.ts +++ b/src/core/stream/period/types.ts @@ -1,10 +1,9 @@ -import { Subject } from "rxjs"; import Manifest, { Adaptation, Period, } from "../../../manifest"; import { IAudioTrackSwitchingMode } from "../../../public_types"; -import { IReadOnlySharedReference } from "../../../utils/reference"; +import { IReadOnlySharedReference, ISharedReference } from "../../../utils/reference"; import { CancellationSignal } from "../../../utils/task_canceller"; import WeakMapMemory from "../../../utils/weak_map_memory"; import { IRepresentationEstimator } from "../../adaptive"; @@ -67,15 +66,17 @@ export interface IPeriodStreamReadyPayload { /** The `Period` linked to the `PeriodStream` we have created. */ period : Period; /** - * The subject through which any Adaptation (i.e. track) choice should be + * The reference through which any Adaptation (i.e. track) choice should be * emitted for that `PeriodStream`. * - * The `PeriodStream` will not do anything until this subject has emitted + * The `PeriodStream` will not do anything until this Reference has emitted * at least one to give its initial choice. * You can send `null` through it to tell this `PeriodStream` that you don't * want any `Adaptation`. + * It is set to `undefined` by default, you SHOULD NOT set it to `undefined` + * yourself. */ - adaptation$ : Subject; + adaptationRef : ISharedReference; } /** Playback observation required by the `PeriodStream`. */ diff --git a/src/core/stream/representation/README.md b/src/core/stream/representation/README.md index 6b47b78574..e7ed6e685b 100644 --- a/src/core/stream/representation/README.md +++ b/src/core/stream/representation/README.md @@ -11,7 +11,6 @@ playback conditions. It then download and push them to a linked `SegmentBuffer` (the media buffer containing the segments for later decoding). -Multiple `RepresentationStream` observables can be ran on the same -`SegmentBuffer` without problems, as long as they are linked to different -Periods of the Manifest. +Multiple `RepresentationStream` can be ran on the same `SegmentBuffer` without +problems, as long as they are linked to different Periods of the Manifest. This allows for example smooth transitions between multiple periods. diff --git a/src/core/stream/representation/utils/append_segment_to_buffer.ts b/src/core/stream/representation/utils/append_segment_to_buffer.ts index 4bd21e9bff..6848716061 100644 --- a/src/core/stream/representation/utils/append_segment_to_buffer.ts +++ b/src/core/stream/representation/utils/append_segment_to_buffer.ts @@ -32,7 +32,7 @@ import forceGarbageCollection from "./force_garbage_collection"; * Append a segment to the given segmentBuffer. * If it leads to a QuotaExceededError, try to run our custom range * _garbage collector_ then retry. - * @param {Observable} playbackObserver + * @param {Object} playbackObserver * @param {Object} segmentBuffer * @param {Object} dataInfos * @param {Object} cancellationSignal diff --git a/src/experimental/tools/VideoThumbnailLoader/remove_buffer_around_time.ts b/src/experimental/tools/VideoThumbnailLoader/remove_buffer_around_time.ts index 2f7053efdc..c4c51214f0 100644 --- a/src/experimental/tools/VideoThumbnailLoader/remove_buffer_around_time.ts +++ b/src/experimental/tools/VideoThumbnailLoader/remove_buffer_around_time.ts @@ -27,7 +27,7 @@ import { CancellationSignal } from "../../../utils/task_canceller"; * @param {Number} time * @param {Number|undefined} margin * @param {Object} cancelSignal - * @returns {Observable} + * @returns {Promise} */ export default function removeBufferAroundTime( videoElement: HTMLMediaElement, diff --git a/src/transports/README.md b/src/transports/README.md index 6b8582c3f7..0f45fa110f 100644 --- a/src/transports/README.md +++ b/src/transports/README.md @@ -94,17 +94,17 @@ Its concept can be illustrated as such: ``` As the wanted resource could be obtained asynchronously (like when an HTTP -request has to be performed), the loader returns an Observable and the resource -is then emitted through it. +request has to be performed), the loader returns a Promise which resolves once +the full resource is loaded. -This Observable will throw on any problem arising during that step, such as an +This Promise will reject on any problem arising during that step, such as an HTTP error. In some specific conditions, the loader can also emit the wanted resource in multiple sub-parts. This allows for example to play a media file while still downloading it and is at the basis of low-latency streaming. To allow such use cases, the segment loaders can also emit the wanted resource -by cutting it into chunks and emitting them through the Observable as they are +by cutting it into chunks and emitting them through a callback as it becomes available. This is better explained in the related chapter below. @@ -133,8 +133,8 @@ Its concept can be illustrated as such: Depending on the type of parser (e.g. Manifest parser or segment parser), that task can be synchronous or asynchronous. -In asynchronous cases, the parser will return an Observable emitting a unique -time the result when done and throwing if an error is encountered. +In asynchronous cases, the parser will return a Promise resolving with +the result when done and rejecting if an error is encountered. In synchronous cases, the parser returns directly the result, and can throw directly when/if an error is encountered. @@ -152,7 +152,7 @@ just for those requests. The Manifest loader is the "loader" downloading the Manifest (or MPD) file. It is a function which receives as argument the URL of the manifest and then -returns an Observable emitting a single time the corresponding Manifest when it +returns a Promise resolving with the corresponding loaded Manifest when it finished downloading it: ``` INPUT: OUTPUT: @@ -178,8 +178,8 @@ allowing it to ask for supplementary requests before completing (e.g. to fetch the current time from an URL or to load sub-parts of the Manifests only known at parse-time). -This function returns an Observable wich emits a single time the parsed -Manifest: +This function returns either the parsed Manifest object directly or wrapped in a +Promise: ``` INPUT: OUTPUT: ------ ------- @@ -208,8 +208,7 @@ It receives information linked to the segment you want to download: - The `Representation` it is linked to - The `Segment` object it is linked to -It then return an Observable which send events as it loads the corresponding -segment. +It then return a Promise resolving when the segment is loaded. ``` INPUT: OUTPUT: @@ -239,50 +238,12 @@ The latter mode is usually active under the following conditions: - the segment is in a CMAF container - the `Fetch` JS API is available -In most other cases, it will be in the regular mode. +In most other cases, it will be in the regular mode, where the segment is fully +communicated as the returned Promise resolves. -You can deduce which mode we are in simply by looking a the events the loader -sends. - -In the regular mode, any of the following events can be sent through the -Observable: - - - `"progress"`: We have new metrics on the current download (e.g. the amount - currently downloaded, the time since the beginning of the request...) - - - `"data-created"`: The segment is available without needing to perform a - network request. This is usually the case when segments are generated like - Smooth Streaming's initialization segments. - The segment's data is also communicated via this event. - - The `"data-created"` event, when sent, is the last event sent from the - loader. The loader will complete just after emitting it. - - - `"data-loaded"`: The segment has been compeletely downloaded from the - network. The segment's data is also communicated via this event. - - Like `"data-created"`, the `"data-loaded"` will be the last event sent by - the loader. - This means that you will either have a single `"data-created"` event or a - single `"data-loaded"` event with the data when the segment has been loaded - succesfully. - -In the low-latency mode, the following events can be sent instead: - - - `"progress"`: We have new metrics on the current download (e.g. the amount - currently downloaded, the time since the beginning of the request...) - - - `"data-chunk"`: A sub-segment (or chunk) of the data is currently available. - The corresponding sub-segment is communicated in the payload of this event. - - This event can be communicated multiple times until a - `"data-chunk-complete"` event is received. - - - `"data-chunk-complete"`: The segment request just finished. All - corresponding data has been sent through `"data-chunk"` events. - - If sent, this is the last event sent by a segment loader. The loader will - complete just after emitting it. +In the low-latency mode, chunks of the data are sent through a callback given +to the segment loaded and the promise only resolves once all chunks have been +communicated that way. diff --git a/src/transports/dash/manifest_parser.ts b/src/transports/dash/manifest_parser.ts index 5f25143191..cefb0daa97 100644 --- a/src/transports/dash/manifest_parser.ts +++ b/src/transports/dash/manifest_parser.ts @@ -115,7 +115,7 @@ export default function generateManifestParser( * Parse the MPD through the default JS-written parser (as opposed to the * WebAssembly one). * If it is not defined, throws. - * @returns {Observable} + * @returns {Object|Promise.} */ function runDefaultJsParser() { if (parsers.js === null) { @@ -130,7 +130,7 @@ export default function generateManifestParser( * Process return of one of the MPD parser. * If it asks for a resource, load it then continue. * @param {Object} parserResponse - Response returned from a MPD parser. - * @returns {Observable} + * @returns {Object|Promise.} */ function processMpdParserResponse( parserResponse : IDashParserResponse | IDashParserResponse diff --git a/src/transports/dash/segment_loader.ts b/src/transports/dash/segment_loader.ts index 6f450dc19b..2b999d42dd 100644 --- a/src/transports/dash/segment_loader.ts +++ b/src/transports/dash/segment_loader.ts @@ -107,7 +107,7 @@ export default function generateSegmentLoader( /** * @param {Object|null} wantedCdn - * @returns {Observable} + * @returns {Promise.} */ function segmentLoader( wantedCdn : ICdnMetadata | null, diff --git a/src/transports/dash/text_parser.ts b/src/transports/dash/text_parser.ts index 186b10d432..9e44650359 100644 --- a/src/transports/dash/text_parser.ts +++ b/src/transports/dash/text_parser.ts @@ -55,7 +55,7 @@ import { * @param {boolean} __priv_patchLastSegmentInSidx - Enable ugly Canal+-specific * fix for an issue people on the content-packaging side could not fix. * For more information on that, look at the code using it. - * @returns {Observable.} + * @returns {Object} */ function parseISOBMFFEmbeddedTextTrack( data : ArrayBuffer | Uint8Array | string, @@ -133,7 +133,7 @@ function parseISOBMFFEmbeddedTextTrack( * @param {Object} content - Object describing the context of the given * segment's data: of which segment, `Representation`, `Adaptation`, `Period`, * `Manifest` it is a part of etc. - * @returns {Observable.} + * @returns {Object} */ function parsePlainTextTrack( data : ArrayBuffer | Uint8Array | string, @@ -188,7 +188,7 @@ export default function generateTextTrackParser( * @param {Object} loadedSegment * @param {Object} content * @param {number|undefined} initTimescale - * @returns {Observable.} + * @returns {Object} */ return function textTrackParser( loadedSegment : { data : ArrayBuffer | Uint8Array | string | null; diff --git a/src/transports/local/segment_loader.ts b/src/transports/local/segment_loader.ts index 0175439961..4befbf89a9 100644 --- a/src/transports/local/segment_loader.ts +++ b/src/transports/local/segment_loader.ts @@ -102,7 +102,7 @@ function loadInitSegment( * @param {Object} segment * @param {Function} customSegmentLoader * @param {Object} cancelSignal - * @returns {Observable} + * @returns {Promise.} */ function loadSegment( segment : { time : number; duration : number; timestampOffset? : number }, diff --git a/src/utils/__tests__/cast_to_observable.test.ts b/src/utils/__tests__/cast_to_observable.test.ts deleted file mode 100644 index c266cfdbcc..0000000000 --- a/src/utils/__tests__/cast_to_observable.test.ts +++ /dev/null @@ -1,93 +0,0 @@ -/** - * 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. - */ - -/* eslint-disable @typescript-eslint/ban-types */ - -import { Observable } from "rxjs"; -import castToObservable from "../cast_to_observable"; - -describe("utils - castToObservable", () => { - it("should return the argument if already an Observable", () => { - const obs = new Observable(); - expect(castToObservable(obs)).toBe(obs); - }); - - it("should convert promise's then to next", (done) => { - let resolve : ((str : string) => void)|undefined; - const emitItem = "je n'ai plus peur de, perdre mes dents"; - const prom = new Promise((res) => { - resolve = res; - }); - - let numberOfItemEmitted = 0; - castToObservable(prom).subscribe({ - next: (x) => { - numberOfItemEmitted++; - expect(x).toBe(emitItem); - }, - complete: () => { - expect(numberOfItemEmitted).toBe(1); - done(); - }, - }); - - if (resolve === undefined) { - throw new Error(); - } - resolve(emitItem); - }); - - it("should convert promise's error to Observable's error", (done) => { - let reject : ((str : string) => void)|undefined; - const errorItem = "je n'ai plus peur de, perdre mon temps"; - const prom = new Promise((_, rej) => { - reject = rej; - }); - - let numberOfItemEmitted = 0; - castToObservable(prom).subscribe({ - next: () => { - numberOfItemEmitted++; - }, - error: (err) => { - expect(numberOfItemEmitted).toBe(0); - expect(err).toBe(errorItem); - done(); - }, - }); - if (reject === undefined) { - throw new Error(); - } - reject(errorItem); - }); - - it("should wrap other values in an rxJS Observable", (done) => { - const err = new Error("TEST"); - const obs = castToObservable(err); - let nextHasBeenCalled = 0; - obs.subscribe({ - next: (e) => { - nextHasBeenCalled++; - expect(e).toBeInstanceOf(Error); - expect(e.message).toBe("TEST"); - }, - complete: () => { - expect(nextHasBeenCalled).toBe(1); - done(); - }, - }); - }); -}); diff --git a/src/utils/__tests__/concat_map_latest.test.ts b/src/utils/__tests__/concat_map_latest.test.ts deleted file mode 100644 index 662de58549..0000000000 --- a/src/utils/__tests__/concat_map_latest.test.ts +++ /dev/null @@ -1,221 +0,0 @@ -/** - * 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. - */ - -import { - concat as observableConcat, - concatMap, - interval, - map, - merge as observableMerge, - Observable, - Subject, - of as observableOf, - take, - tap, - timer, -} from "rxjs"; -import concatMapLatest from "../concat_map_latest"; - -describe("utils - concatMapLatest", () => { - it("should act as a mergeMap for a single value", (done) => { - const counter$ : Observable = observableOf(0); - let itemReceived = false; - counter$.pipe( - concatMapLatest(observableOf) - ).subscribe({ - next(res: number) : void { - expect(res).toBe(0); - itemReceived = true; - }, - complete() { - expect(itemReceived).toBe(true); - done(); - }, - }); - }); - - /* eslint-disable max-len */ - it("should consider all values if precedent inner Observable finished synchronously", (done) => { - /* eslint-enable max-len */ - const innerValues = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]; - const innerValuesLength = innerValues.length; - let lastCount: number|undefined; - - const counter$ : Observable = observableOf(...innerValues); - counter$.pipe( - concatMapLatest((i: number, count: number) => { - lastCount = count; - return observableOf(i); - }) - ).subscribe({ - next(res: number) { - const expectedResult = innerValues.shift(); - expect(res).toBe(expectedResult); - }, - complete() { - if (innerValues.length !== 0) { - throw new Error("Not all values were received."); - } - expect(lastCount).toBe(innerValuesLength - 1); - done(); - }, - }); - }); - - /* eslint-disable max-len */ - it("should consider all values if precedent inner Observable had time to finish", (done) => { - /* eslint-enable max-len */ - const innerValues = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]; - const innerValuesLength = innerValues.length; - let lastCount: number|undefined; - - const counter$ : Observable = observableOf(...innerValues).pipe( - concatMap((v) => timer(5).pipe(map(() => v))) - ); - counter$.pipe( - concatMapLatest((i: number, count: number) => { - lastCount = count; - return observableOf(i); - }) - ).subscribe({ - next(res: number) { - const expectedResult = innerValues.shift(); - expect(res).toBe(expectedResult); - }, - complete() { - if (innerValues.length !== 0) { - throw new Error("Not all values were received."); - } - expect(lastCount).toBe(innerValuesLength - 1); - done(); - }, - }); - }); - - /* eslint-disable max-len */ - it("should skip all inner values but the last when the inner Observable completes", (done) => { - /* eslint-enable max-len */ - - const counter$ = new Subject(); - let itemEmittedCounter = 0; - let itemProcessedCounter = 0; - let lastCount: number|undefined; - - counter$.pipe( - tap(() => { itemEmittedCounter++; }), - concatMapLatest((i: number, count: number) => { - lastCount = count; - return timer(230).pipe(map(() => i)); - }) - ).subscribe({ - next(result: number) { - switch (itemProcessedCounter++) { - case 0: - expect(result).toBe(0); - counter$.next(3); // should be ignored - counter$.next(4); - break; - case 1: - expect(result).toBe(4); - counter$.complete(); - break; - default: - throw new Error("Should not have emitted that item"); - } - }, - complete() { - expect(itemEmittedCounter).toBe(5); - expect(itemProcessedCounter).toBe(2); - expect(lastCount).toBe(itemProcessedCounter - 1); - done(); - }, - }); - - counter$.next(0); - counter$.next(1); // should be ignored - counter$.next(2); // should be ignored - }); - - /* eslint-disable max-len */ - it("should increment the counter each times the callback is called", (done) => { - /* eslint-enable max-len */ - - let itemProcessed = 0; - let nextCount = 0; - const obs1$ = observableOf(1, 2, 3); - const obs2$ = observableOf(4, 5); - const obs3$ = observableOf(6, 7, 8, 9); - - observableOf( - [0, obs1$] as [number, Observable], - [1, obs2$] as [number, Observable], - [2, obs3$] as [number, Observable] - ).pipe( - concatMapLatest(([wantedCounter, obs$], counter) => { - itemProcessed++; - expect(counter).toBe(wantedCounter); - return obs$; - }) - ).subscribe({ - next() { nextCount++; }, - complete() { - expect(itemProcessed).toBe(3); - expect(nextCount).toBe(3 + 2 + 4); - done(); - }, - }); - }); - - it("should reset the counter for each subscription", async () => { - const base$ = interval(10).pipe(take(10)); - const counter$ = base$.pipe(concatMapLatest((_, i) => observableOf(i))); - - function validateThroughMerge() { - let nextCount = 0; - return new Promise(res => { - observableMerge(counter$, counter$, counter$).subscribe({ - next(item) { - expect(item).toBe(Math.floor(nextCount / 3)); - nextCount++; - }, - complete() { - expect(nextCount).toBe(30); - res(); - }, - }); - }); - } - - function validateThroughConcat() { - let nextCount = 0; - return new Promise(res => { - observableConcat(counter$, counter$, counter$).subscribe({ - next(item) { - expect(item).toBe(nextCount % 10); - nextCount++; - }, - complete() { - expect(nextCount).toBe(30); - res(); - }, - }); - }); - } - - // eslint-disable-next-line no-restricted-properties - await Promise.all([validateThroughConcat(), validateThroughMerge()]); - }); -}); diff --git a/src/utils/__tests__/event_emitter.test.ts b/src/utils/__tests__/event_emitter.test.ts index 527d658a20..b178278317 100644 --- a/src/utils/__tests__/event_emitter.test.ts +++ b/src/utils/__tests__/event_emitter.test.ts @@ -19,11 +19,8 @@ /* eslint-disable @typescript-eslint/no-unsafe-return */ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { take } from "rxjs"; import log from "../../log"; -import EventEmitter, { - fromEvent, -} from "../event_emitter"; +import EventEmitter from "../event_emitter"; describe("utils - EventEmitter", () => { it("should be able to call synchronously a callback on a given event", () => { @@ -580,75 +577,3 @@ describe("utils - EventEmitter", () => { eventEmitter.removeEventListener(); }); }); - -describe("utils - fromEvent", () => { - it("should subscribe to a given event", (done) => { - let stringItemsReceived = 0; - let numberItemsReceived = 0; - const eventEmitter = new EventEmitter<{ - test: undefined|"a"|{ a: string }; - fooba: undefined|number|"a"|"b"|"c"|{ a: string }; - }>(); - fromEvent(eventEmitter, "fooba") - .pipe(take(6)) - .subscribe({ - next(item) { - if (typeof item === "number") { - numberItemsReceived++; - } else if (typeof item === "string") { - stringItemsReceived++; - } - }, - complete() { - (eventEmitter as any).trigger("fooba", 6); - expect(numberItemsReceived).toBe(2); - expect(stringItemsReceived).toBe(3); - done(); - }, - }); - - (eventEmitter as any).trigger("test", undefined); - (eventEmitter as any).trigger("fooba", undefined); - (eventEmitter as any).trigger("fooba", 5); - (eventEmitter as any).trigger("fooba", "a"); - (eventEmitter as any).trigger("test", undefined); - (eventEmitter as any).trigger("test", undefined); - (eventEmitter as any).trigger("test", undefined); - (eventEmitter as any).trigger("fooba", "b"); - (eventEmitter as any).trigger("fooba", "c"); - (eventEmitter as any).trigger("fooba", 6); - }); - - it("should remove the event listener on unsubscription", () => { - let stringItemsReceived = 0; - let numberItemsReceived = 0; - const eventEmitter = new EventEmitter<{ - test: undefined|"a"|{ a: string }; - fooba: undefined|number|"a"|"b"|"c"|{ a: string }; - }>(); - const subscription = fromEvent(eventEmitter, "fooba") - .pipe(take(6)) - .subscribe((item) => { - if (typeof item === "number") { - numberItemsReceived++; - } else if (typeof item === "string") { - stringItemsReceived++; - } - }); - - (eventEmitter as any).trigger("test", undefined); - (eventEmitter as any).trigger("fooba", undefined); - (eventEmitter as any).trigger("fooba", 5); - (eventEmitter as any).trigger("fooba", "a"); - subscription.unsubscribe(); - (eventEmitter as any).trigger("test", undefined); - (eventEmitter as any).trigger("test", undefined); - (eventEmitter as any).trigger("test", undefined); - (eventEmitter as any).trigger("fooba", "b"); - (eventEmitter as any).trigger("fooba", "c"); - (eventEmitter as any).trigger("fooba", 6); - - expect(stringItemsReceived).toBe(1); - expect(numberItemsReceived).toBe(1); - }); -}); diff --git a/src/utils/__tests__/rx-throttle.test.ts b/src/utils/__tests__/rx-throttle.test.ts deleted file mode 100644 index 333e2775de..0000000000 --- a/src/utils/__tests__/rx-throttle.test.ts +++ /dev/null @@ -1,252 +0,0 @@ -/** - * 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. - */ - -import { - concat, - Observable, - of as observableOf, - Subject, -} from "rxjs"; -import throttle from "../rx-throttle"; - -describe("utils - throttle (RxJS)", () => { - it("should execute every Observables for synchronous Observables", (done) => { - const obsFunction = (x : number) : Observable => { - return observableOf(x); - }; - const throttledObsFunction = throttle(obsFunction); - const obs1 = throttledObsFunction(1); - const obs2 = throttledObsFunction(2); - - let receivedItemFrom1 = false; - let has1Completed = false; - let receivedItemFrom2 = false; - - obs1.subscribe({ - next() { receivedItemFrom1 = true; }, - complete() { has1Completed = true; }, - }); - obs2.subscribe({ - next() { receivedItemFrom2 = true; }, - complete() { - expect(receivedItemFrom1).toBe(true); - expect(has1Completed).toBe(true); - expect(receivedItemFrom2).toBe(true); - done(); - }, - }); - }); - - it("should complete new Observable if one is already pending", (done) => { - const sub1 = new Subject(); - const sub2 = new Subject(); - const obsFunction = (sub : Subject) : Observable => { - return concat(observableOf(undefined), sub); - }; - const throttledObsFunction = throttle(obsFunction); - const obs1 = throttledObsFunction(sub1); - const obs2 = throttledObsFunction(sub2); - - let itemsReceivedFrom1 = 0; - let itemsReceivedFrom2 = 0; - - let has2Completed = false; - - obs1.subscribe({ - next() { itemsReceivedFrom1++; }, - complete() { - expect(itemsReceivedFrom1).toBe(2); - expect(itemsReceivedFrom2).toBe(0); - done(); - }, - }); - - obs2.subscribe({ - next() { itemsReceivedFrom2++; }, - complete() { has2Completed = true; }, - }); - - expect(itemsReceivedFrom2).toBe(0); - expect(has2Completed).toBe(true); - - sub1.next(); - sub2.complete(); - sub1.complete(); - }); - - it("should execute Observable coming after the previous one has completed", (done) => { - const sub1 = new Subject(); - const sub2 = new Subject(); - const sub3 = new Subject(); - const obsFunction = (sub : Subject) : Observable => { - return concat(observableOf(undefined), sub); - }; - const throttledObsFunction = throttle(obsFunction); - const obs1 = throttledObsFunction(sub1); - const obs2 = throttledObsFunction(sub2); - const obs3 = throttledObsFunction(sub3); - - let itemsReceivedFrom1 = 0; - let itemsReceivedFrom3 = 0; - - let has1Completed = false; - - obs2.subscribe(); - sub2.complete(); - - obs1.subscribe({ - next() { itemsReceivedFrom1++; }, - complete() { has1Completed = true; }, - }); - sub1.complete(); - - obs3.subscribe({ - next() { itemsReceivedFrom3++; }, - complete() { - expect(has1Completed).toBe(true); - expect(itemsReceivedFrom1).toBe(1); - expect(itemsReceivedFrom3).toBe(1); - done(); - }, - }); - - sub3.complete(); - }); - - it("should execute Observable coming after the previous one has errored", (done) => { - const sub1 = new Subject(); - const sub2 = new Subject(); - const sub3 = new Subject(); - const obsFunction = (sub : Subject) : Observable => { - return concat(observableOf(undefined), sub); - }; - const throttledObsFunction = throttle(obsFunction); - const obs1 = throttledObsFunction(sub1); - const obs2 = throttledObsFunction(sub2); - const obs3 = throttledObsFunction(sub3); - const error = new Error("ffo"); - - let itemsReceivedFrom1 = 0; - let itemsReceivedFrom3 = 0; - - let has1Errored = false; - - obs2.subscribe(); - sub2.complete(); - - obs1.subscribe({ - next: () => { itemsReceivedFrom1++; }, - error: (e) => { - expect(e).toBe("titi"); - has1Errored = true; - }, - }); - sub1.error("titi"); - - obs3.subscribe({ - next: () => { itemsReceivedFrom3++; }, - error: (e) => { - expect(e).toBe(error); - expect(has1Errored).toBe(true); - expect(itemsReceivedFrom1).toBe(1); - expect(itemsReceivedFrom3).toBe(1); - done(); - }, - }); - - sub3.error(error); - }); - - /* eslint-disable max-len */ - it("should execute Observable coming after the previous one was unsubscribed", (done) => { - /* eslint-enable max-len */ - const sub1 = new Subject(); - const sub2 = new Subject(); - const sub3 = new Subject(); - const obsFunction = (sub : Subject) : Observable => { - return concat(observableOf(undefined), sub); - }; - const throttledObsFunction = throttle(obsFunction); - const obs1 = throttledObsFunction(sub1); - const obs2 = throttledObsFunction(sub2); - const obs3 = throttledObsFunction(sub3); - - let itemsReceivedFrom1 = 0; - let itemsReceivedFrom3 = 0; - - let has1Completed = false; - - const subscription2 = obs2.subscribe(); - subscription2.unsubscribe(); - - obs1.subscribe({ - next() { itemsReceivedFrom1++; }, - complete() { has1Completed = true; }, - }); - sub1.complete(); - - obs3.subscribe({ - next() { itemsReceivedFrom3++; }, - complete() { - expect(has1Completed).toBe(true); - expect(itemsReceivedFrom1).toBe(1); - expect(itemsReceivedFrom3).toBe(1); - sub2.complete(); - done(); - }, - }); - - sub3.complete(); - }); - - it("should allow multiple throttledObsFunction Observables in parallel", (done) => { - const sub1 = new Subject(); - const sub2 = new Subject(); - const obsFunction = (sub : Subject) : Observable => { - return concat(observableOf(undefined), sub); - }; - const throttledObsFunction1 = throttle(obsFunction); - const throttledObsFunction2 = throttle(obsFunction); - const obs1 = throttledObsFunction1(sub1); - const obs2 = throttledObsFunction2(sub2); - - let itemsReceivedFrom1 = 0; - let itemsReceivedFrom2 = 0; - - let has2Completed = false; - - obs1.subscribe({ - next() { itemsReceivedFrom1++; }, - complete() { - expect(itemsReceivedFrom1).toBe(2); - expect(itemsReceivedFrom2).toBe(1); - done(); - }, - }); - - obs2.subscribe({ - next() { itemsReceivedFrom2++; }, - complete() { has2Completed = true; }, - }); - - expect(itemsReceivedFrom2).toBe(1); - expect(has2Completed).toBe(false); - - sub1.next(); - sub2.complete(); - sub1.complete(); - }); -}); diff --git a/src/utils/__tests__/rx-try_catch.test.ts b/src/utils/__tests__/rx-try_catch.test.ts deleted file mode 100644 index d0efd11934..0000000000 --- a/src/utils/__tests__/rx-try_catch.test.ts +++ /dev/null @@ -1,100 +0,0 @@ -/** - * 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. - */ - -import { - concat, - Observable, - of as observableOf, - throwError as observableThrow, -} from "rxjs"; -import tryCatch from "../rx-try_catch"; - -describe("utils - tryCatch (RxJS)", () => { - it("should return a throwing observable if the function throws", (done) => { - function func() : Observable { - // eslint-disable-next-line no-throw-literal - throw 4; - } - - let itemsReceived = 0; - tryCatch(func, undefined).subscribe({ - next: () => { itemsReceived++; }, - error: (err) => { - expect(itemsReceived).toBe(0); - expect(err).toBe(4); - done(); - }, - }); - }); - - it("should allow giving optional arguments", (done) => { - function func(a : number) : Observable { - expect(a).toBe(4); - throw new Error(); - } - tryCatch(func, 4).subscribe({ error() { done(); } }); - }); - - /* eslint-disable max-len */ - it("should emit when the returned Observable emits and complete when it completes", (done) => { - /* eslint-enable max-len */ - function func() { - return observableOf(1, 2, 3); - } - - let itemsReceived = 0; - tryCatch(func, undefined).subscribe({ - next(i) { - switch (itemsReceived++) { - case 0: - expect(i).toBe(1); - break; - case 1: - expect(i).toBe(2); - break; - case 2: - expect(i).toBe(3); - break; - default: - throw new Error("Too much items emitted"); - } - }, - complete() { - expect(itemsReceived).toBe(3); - done(); - }, - }); - }); - - it("should throw when the returned Observable throws", (done) => { - function func() { - return concat(observableOf(1), observableThrow(() => "a")); - } - - let itemsReceived = 0; - tryCatch(func, undefined).subscribe({ - next: (i) => { - itemsReceived++; - expect(i).toBe(1); - }, - error: (err) => { - expect(itemsReceived).toBe(1); - expect(err).toBe("a"); - done(); - }, - }); - }); -}); diff --git a/src/utils/cast_to_observable.ts b/src/utils/cast_to_observable.ts deleted file mode 100644 index 903a0f76e0..0000000000 --- a/src/utils/cast_to_observable.ts +++ /dev/null @@ -1,48 +0,0 @@ -/** - * 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. - */ - -import { - from as observableFrom, - Observable, - of as observableOf, -} from "rxjs"; -import isNullOrUndefined from "./is_null_or_undefined"; - -/** - * Try to cast the given value into an observable. - * StraightForward - test first for an Observable then for a Promise. - * @param {Observable|Function|*} - * @returns {Observable} - */ -function castToObservable(value : Observable | - Promise | - Exclude>) : Observable { - if (value instanceof Observable) { - return value; - } else if ( - value instanceof Promise || - ( - !isNullOrUndefined(value) && - typeof (value as { then? : unknown }).then === "function") - ) - { - return observableFrom(value as Promise); - } - - return observableOf(value); -} - -export default castToObservable; diff --git a/src/utils/concat_map_latest.ts b/src/utils/concat_map_latest.ts deleted file mode 100644 index 21628d806d..0000000000 --- a/src/utils/concat_map_latest.ts +++ /dev/null @@ -1,61 +0,0 @@ -/** - * 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. - */ - -import { - concat as observableConcat, - defer as observableDefer, - EMPTY, - mergeMap, - Observable, - tap, -} from "rxjs"; - -/** - * Same as concatMap, but get last emitted value from source instead of unstack - * inner values. - * @param {function} callback - * @returns {function} - */ -export default function concatMapLatest( - callback: (arg: T, i: number) => Observable -): (source: Observable) => Observable { - return (source: Observable) => observableDefer(() => { - let counter = 0; - let valuePending : T; - let hasValuePending = false; - let isExhausting = false; - function next(value: T): Observable { - return observableDefer(() => { - if (isExhausting) { - valuePending = value; - hasValuePending = true; - return EMPTY; - } - hasValuePending = false; - isExhausting = true; - return callback(value, counter++).pipe( - tap({ complete: () => isExhausting = false }), - (s: Observable) => - observableConcat(s, - observableDefer(() => - hasValuePending ? next(valuePending) : - EMPTY)) - ); - }); - } - return source.pipe(mergeMap(next)); - }); -} diff --git a/src/utils/event_emitter.ts b/src/utils/event_emitter.ts index e03a9cbc89..6fb603bcd6 100644 --- a/src/utils/event_emitter.ts +++ b/src/utils/event_emitter.ts @@ -14,10 +14,6 @@ * limitations under the License. */ -import { - Observable, - Observer, -} from "rxjs"; import log from "../log"; import isNullOrUndefined from "./is_null_or_undefined"; import { CancellationSignal } from "./task_canceller"; @@ -147,25 +143,3 @@ export default class EventEmitter implements IEventEmitter { }); } } - -/** - * Simple redefinition of the fromEvent from rxjs to also work on our - * implementation of EventEmitter with type-checked strings - * @param {Object} target - * @param {string} eventName - * @returns {Observable} - */ -export function fromEvent( - target : IEventEmitter, - eventName : TEventName -) : Observable> { - return new Observable((obs : Observer>) => { - function handler(event : IArgs) { - obs.next(event); - } - target.addEventListener(eventName, handler); - return () => { - target.removeEventListener(eventName, handler); - }; - }); -} diff --git a/src/utils/reference.ts b/src/utils/reference.ts index f9bf03db73..a5ea2c4647 100644 --- a/src/utils/reference.ts +++ b/src/utils/reference.ts @@ -14,10 +14,6 @@ * limitations under the License. */ -import { - Observable, - Subscriber, -} from "rxjs"; import { CancellationSignal } from "./task_canceller"; /** @@ -79,14 +75,6 @@ export interface ISharedReference { */ setValueIfChanged(newVal : T) : void; - /** - * Returns an Observable which synchronously emits the current value (unless - * the `skipCurrentValue` argument has been set to `true`) and then each time - * a new value is set. - * @param {boolean} [skipCurrentValue] - * @returns {Observable} - */ - asObservable(skipCurrentValue? : boolean) : Observable; /** * Allows to register a callback to be called each time the value inside the * reference is updated. @@ -139,8 +127,8 @@ export interface ISharedReference { /** * Indicate that no new values will be emitted. - * Allows to automatically close all Observables and listeners subscribed to - * this `ISharedReference`. + * Allows to automatically close all listeners listening to this + * `ISharedReference`. */ finish() : void; } @@ -170,7 +158,6 @@ export interface ISharedReference { export type IReadOnlySharedReference = Pick, "getValue" | - "asObservable" | "onUpdate" | "waitUntilDefined">; @@ -180,7 +167,7 @@ export type IReadOnlySharedReference = * * @see ISharedReference * @param {*} initialValue - * @returns {Observable} + * @returns {Object} */ export default function createSharedReference(initialValue : T) : ISharedReference { /** Current value referenced by this `ISharedReference`. */ @@ -195,8 +182,8 @@ export default function createSharedReference(initialValue : T) : ISharedRefe * once it changes * - `complete`: Allows to clean-up the listener, will be called once the * reference is finished. - * - `hasBeenCleared`: becomes `true` when the Observable becomes - * unsubscribed and thus when it is removed from the `cbs` array. + * - `hasBeenCleared`: becomes `true` when the reference is + * removed from the `cbs` array. * Adding this property allows to detect when a previously-added * listener has since been removed e.g. as a side-effect during a * function call. @@ -256,44 +243,6 @@ export default function createSharedReference(initialValue : T) : ISharedRefe } }, - /** - * Returns an Observable which synchronously emits the current value (unless - * the `skipCurrentValue` argument has been set to `true`) and then each - * time a new value is set. - * @param {boolean} [skipCurrentValue] - * @returns {Observable} - */ - asObservable(skipCurrentValue? : boolean) : Observable { - return new Observable((obs : Subscriber) => { - if (skipCurrentValue !== true) { - obs.next(value); - } - if (isFinished) { - obs.complete(); - return undefined; - } - const cbObj = { trigger: obs.next.bind(obs), - complete: obs.complete.bind(obs), - hasBeenCleared: false }; - cbs.push(cbObj); - return () => { - if (cbObj.hasBeenCleared) { - return; - } - /** - * Code in here can still be running while this is happening. - * Set `hasBeenCleared` to `true` to avoid still using the - * `subscriber` from this object. - */ - cbObj.hasBeenCleared = true; - const indexOf = cbs.indexOf(cbObj); - if (indexOf >= 0) { - cbs.splice(indexOf, 1); - } - }; - }); - }, - /** * Allows to register a callback to be called each time the value inside the * reference is updated. @@ -335,11 +284,6 @@ export default function createSharedReference(initialValue : T) : ISharedRefe if (cbObj.hasBeenCleared) { return; } - /** - * Code in here can still be running while this is happening. - * Set `hasBeenCleared` to `true` to avoid still using the - * `subscriber` from this object. - */ cbObj.hasBeenCleared = true; const indexOf = cbs.indexOf(cbObj); if (indexOf >= 0) { @@ -377,8 +321,7 @@ export default function createSharedReference(initialValue : T) : ISharedRefe /** * Indicate that no new values will be emitted. - * Allows to automatically close all Observables generated from this shared - * reference. + * Allows to automatically free all listeners linked to this reference. */ finish() : void { isFinished = true; diff --git a/src/utils/request/xhr.ts b/src/utils/request/xhr.ts index e99c02bf55..d0771ac3d0 100644 --- a/src/utils/request/xhr.ts +++ b/src/utils/request/xhr.ts @@ -26,74 +26,9 @@ import { const DEFAULT_RESPONSE_TYPE : XMLHttpRequestResponseType = "json"; /** - * # request function + * Perform an HTTP request, according to the options given. * - * Translate GET requests into Rx.js Observables. - * - * ## Overview - * - * Perform the request on subscription. - * Emit zero, one or more progress event(s) and then the data if the request - * was successful. - * - * Throw if an error happened or if the status code is not in the 200 range at - * the time of the response. - * Complete after emitting the data. - * Abort the xhr on unsubscription. - * - * ## Emitted Objects - * - * The emitted objects are under the following form: - * ``` - * { - * type {string}: the type of event - * value {Object}: the event value - * } - * ``` - * - * The type of event can either be "progress" or "data-loaded". The value is - * under a different form depending on the type. - * - * For "progress" events, the value should be the following object: - * ``` - * { - * url {string}: url on which the request is being done - * sendingTime {Number}: timestamp at which the request was sent. - * currentTime {Number}: timestamp at which the progress event was - * triggered - * size {Number}: current size downloaded, in bytes (without - * overhead) - * totalSize {Number|undefined}: total size to download, in bytes - * (without overhead) - * } - * ``` - * - * For "data-loaded" events, the value should be the following object: - * ``` - * { - * status {Number}: xhr status code - * url {string}: URL on which the request was done (can be different than - * the one given in arguments when we go through - * redirections). - * responseType {string}: the responseType of the request - * (e.g. "json", "document"...). - * sendingTime {Number}: time at which the request was sent, in ms. - * receivedTime {Number}: timest at which the response was received, in ms. - * size {Number}: size of the received data, in bytes. - * responseData {*}: Data in the response. Format depends on the - * responseType. - * } - * ``` - * - * For any successful request you should have 0+ "progress" events and 1 - * "data-loaded" event. - * - * For failing request, you should have 0+ "progress" events and 0 "data-loaded" - * event (the Observable will throw before). - * - * ## Errors - * - * Several errors can be emitted (the Rx.js way). Namely: + * Several errors can be rejected. Namely: * - RequestErrorTypes.TIMEOUT_ERROR: the request timeouted (took too long to * respond). * - RequestErrorTypes.PARSE_ERROR: the browser APIs used to parse the @@ -104,7 +39,7 @@ const DEFAULT_RESPONSE_TYPE : XMLHttpRequestResponseType = "json"; * - RequestErrorTypes.ERROR_EVENT: The XHR had an error event before the * response could be fetched. * @param {Object} options - * @returns {Observable} + * @returns {Promise.} */ export default function request( options : IRequestOptions< undefined | null | "" | "text" > diff --git a/src/utils/rx-next-tick.ts b/src/utils/rx-next-tick.ts deleted file mode 100644 index aa0f270a4c..0000000000 --- a/src/utils/rx-next-tick.ts +++ /dev/null @@ -1,43 +0,0 @@ -/** - * 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. - */ - -import nextTick from "next-tick"; -import { Observable } from "rxjs"; - -/** - * Create Observable that emits and complete on the next micro-task. - * - * This Observable can be useful to prevent race conditions based on - * synchronous task being performed in the wrong order. - * By awaiting nextTickObs before performing a task, you ensure that all other - * tasks that might have run synchronously either before or after it all already - * ran. - * @returns {Observable} - */ -export default function nextTickObs(): Observable { - return new Observable((obs) => { - let isFinished = false; - nextTick(() => { - if (!isFinished) { - obs.next(); - obs.complete(); - } - }); - return () => { - isFinished = true; - }; - }); -} diff --git a/src/utils/rx-throttle.ts b/src/utils/rx-throttle.ts deleted file mode 100644 index d1e96a81ec..0000000000 --- a/src/utils/rx-throttle.ts +++ /dev/null @@ -1,84 +0,0 @@ -/** - * 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. - */ - -import { - Observable, - Observer, -} from "rxjs"; - -/** - * Throttle an asynchronous function returning an Observable to drop calls done - * before a previous one has finished or failed. - * - * @example - * ```js - * const fn = (time) => Observable.timer(time); - * const throttled = throttle(fn); - * - * const Obs1 = throttled(2000); // -> call fn(2000) and returns its Observable - * const Obs2 = throttled(1000); // -> won't do anything, Obs2 is an empty - * // observable (it directly completes) - * setTimeout(() => { - * const Obs3 = throttled(1000); // -> will call fn(1000) - * }, 2001); - * ``` - * - * @param {Function} func - * @returns {Function} - Function taking in argument the arguments you want - * to give your function, and returning an Observable. - */ -export default function throttle( - func : (arg1: T) => Observable -) : (arg1: T) => Observable; -export default function throttle( - func : (arg1: T, arg2: U) => Observable -) : (arg1: T, arg2: U) => Observable; -export default function throttle( - func : (arg1: T, arg2: U, arg3: V) => Observable -) : (arg1: T, arg2: U, arg3: V) => Observable; -export default function throttle( - func : (...args : Array) => Observable -) : (...args : Array) => Observable { - let isPending = false; - - return (...args : Array) : Observable => { - return new Observable((obs : Observer) => { - if (isPending) { - obs.complete(); - return undefined; - } - - isPending = true; - const funcSubscription = func(...args) - .subscribe({ - next: (i) => { obs.next(i); }, - error: (e) => { - isPending = false; - obs.error(e); - }, - complete: () => { - isPending = false; - obs.complete(); - }, - }); - - return () => { - funcSubscription.unsubscribe(); - isPending = false; - }; - }); - }; -} diff --git a/src/utils/rx-try_catch.ts b/src/utils/rx-try_catch.ts deleted file mode 100644 index 66c30e2585..0000000000 --- a/src/utils/rx-try_catch.ts +++ /dev/null @@ -1,37 +0,0 @@ -/** - * 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. - */ - -import { - Observable, - throwError as observableThrow, -} from "rxjs"; - -/** - * @param {Function} func - A function you want to execute - * @param {*} argsForFunc - The function's argument - * @returns {*} - If it fails, returns a throwing Observable, else the - * function's result (which should be, in most cases, an Observable). - */ -export default function tryCatch( - func : (args : T) => Observable, - argsForFunc : T -) : Observable { - try { - return func(argsForFunc); - } catch (e : unknown) { - return observableThrow(() => e); - } -} From 84c3d5f05a2e093ccd2e547a13b32d18d7028d6c Mon Sep 17 00:00:00 2001 From: Paul Berberian Date: Tue, 27 Dec 2022 20:23:28 +0100 Subject: [PATCH 28/86] demo: set better style for demo settings module --- demo/full/styles/style.css | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/demo/full/styles/style.css b/demo/full/styles/style.css index 61c4adbda1..141adfb2bd 100644 --- a/demo/full/styles/style.css +++ b/demo/full/styles/style.css @@ -428,6 +428,8 @@ body { .option-desc { font-weight: normal; font-style: italic; + color: #242424; + font-size: 0.95em; } .choice-input-button { @@ -647,10 +649,9 @@ body { } .settings-title { - font-weight: bold; text-align: center; margin-bottom: 12px; - font-size: 1.3em; + font-size: 1.5em; } .settings-note { @@ -1268,9 +1269,9 @@ select { } .settingsWrapper { - margin: 10px 0; - background-color: #e3e3e3; + border: 1px dashed #d1d1d1; padding: 10px; + margin-top: 10px; } .featureWrapperWithSelectMode { From 051726ca742c32c6adcd924b35c2bfb5c902c05e Mon Sep 17 00:00:00 2001 From: Paul Berberian Date: Tue, 20 Dec 2022 11:28:32 +0100 Subject: [PATCH 29/86] Allow to finish shared references when creating one to add memory leak prevention --- src/compat/event_listeners.ts | 27 +++------ src/compat/on_height_width_change.ts | 2 +- .../adaptive_representation_selector.ts | 27 +++++++-- src/core/api/playback_observer.ts | 3 +- src/core/api/public_api.ts | 49 ++++++++-------- src/core/api/utils.ts | 3 +- .../init/directfile_content_initializer.ts | 4 +- .../init/media_source_content_initializer.ts | 5 +- .../utils/create_stream_playback_observer.ts | 8 +-- src/core/init/utils/get_loaded_reference.ts | 4 +- src/core/init/utils/initial_seek_and_play.ts | 4 +- .../utils/initialize_content_decryption.ts | 18 +++--- src/core/init/utils/media_duration_updater.ts | 11 ++-- .../stream_events_emitter.ts | 3 +- .../utils/create_representation_estimator.ts | 5 +- src/core/stream/period/period_stream.ts | 13 ++--- .../representation/representation_stream.ts | 2 +- src/utils/reference.ts | 58 +++++++++++-------- 18 files changed, 131 insertions(+), 115 deletions(-) diff --git a/src/compat/event_listeners.ts b/src/compat/event_listeners.ts index ab1f3b83b6..c78c8932e5 100644 --- a/src/compat/event_listeners.ts +++ b/src/compat/event_listeners.ts @@ -210,17 +210,13 @@ function getDocumentVisibilityRef( "visibilitychange"; const isHidden = document[hidden as "hidden"]; - const ref = createSharedReference(!isHidden); + const ref = createSharedReference(!isHidden, stopListening); addEventListener(document, visibilityChangeEvent, () => { const isVisible = !(document[hidden as "hidden"]); ref.setValueIfChanged(isVisible); }, stopListening); - stopListening.register(() => { - ref.finish(); - }); - return ref; } @@ -237,11 +233,10 @@ function getPageActivityRef( ) : IReadOnlySharedReference { const isDocVisibleRef = getDocumentVisibilityRef(stopListening); let currentTimeout : number | undefined; - const ref = createSharedReference(true); + const ref = createSharedReference(true, stopListening); stopListening.register(() => { clearTimeout(currentTimeout); currentTimeout = undefined; - ref.finish(); }); isDocVisibleRef.onUpdate(function onDocVisibilityChange(isVisible : boolean) : void { @@ -299,14 +294,11 @@ function getPictureOnPictureStateRef( const ref = createSharedReference({ isEnabled: isWebKitPIPEnabled, pipWindow: null, - }); + }, stopListening); addEventListener(mediaElement, "webkitpresentationmodechanged", () => { const isEnabled = mediaElement.webkitPresentationMode === "picture-in-picture"; ref.setValue({ isEnabled, pipWindow: null }); }, stopListening); - stopListening.register(() => { - ref.finish(); - }); return ref; } @@ -314,7 +306,8 @@ function getPictureOnPictureStateRef( (document as ICompatDocument).pictureInPictureElement === mediaElement ); const ref = createSharedReference({ isEnabled: isPIPEnabled, - pipWindow: null }); + pipWindow: null }, + stopListening); addEventListener(mediaElement, "enterpictureinpicture", (evt) => { ref.setValue({ isEnabled: true, @@ -326,9 +319,6 @@ function getPictureOnPictureStateRef( addEventListener(mediaElement, "leavepictureinpicture", () => { ref.setValue({ isEnabled: false, pipWindow: null }); }, stopListening); - stopListening.register(() => { - ref.finish(); - }); return ref; } @@ -348,11 +338,10 @@ function getVideoVisibilityRef( ) : IReadOnlySharedReference { const isDocVisibleRef = getDocumentVisibilityRef(stopListening); let currentTimeout : number | undefined; - const ref = createSharedReference(true); + const ref = createSharedReference(true, stopListening); stopListening.register(() => { clearTimeout(currentTimeout); currentTimeout = undefined; - ref.finish(); }); isDocVisibleRef.onUpdate(checkCurrentVisibility, @@ -389,7 +378,8 @@ function getVideoWidthRef( pipStatusRef : IReadOnlySharedReference, stopListening : CancellationSignal ) : IReadOnlySharedReference { - const ref = createSharedReference(mediaElement.clientWidth * pixelRatio); + const ref = createSharedReference(mediaElement.clientWidth * pixelRatio, + stopListening); let clearPreviousEventListener = noop; pipStatusRef.onUpdate(checkVideoWidth, { clearSignal: stopListening }); addEventListener(window, "resize", checkVideoWidth, stopListening); @@ -400,7 +390,6 @@ function getVideoWidthRef( stopListening.register(function stopUpdatingVideoWidthRef() { clearPreviousEventListener(); clearInterval(interval); - ref.finish(); }); return ref; diff --git a/src/compat/on_height_width_change.ts b/src/compat/on_height_width_change.ts index 702c51580e..1fd4c44d8b 100644 --- a/src/compat/on_height_width_change.ts +++ b/src/compat/on_height_width_change.ts @@ -77,7 +77,7 @@ export default function onHeightWidthChange( const ref = createSharedReference({ height: initHeight, width: initWidth, - }); + }, cancellationSignal); let lastHeight : number = initHeight; let lastWidth : number = initWidth; diff --git a/src/core/adaptive/adaptive_representation_selector.ts b/src/core/adaptive/adaptive_representation_selector.ts index 4180e6e9eb..3baa12afb9 100644 --- a/src/core/adaptive/adaptive_representation_selector.ts +++ b/src/core/adaptive/adaptive_representation_selector.ts @@ -49,6 +49,23 @@ import PendingRequestsStore, { import RepresentationScoreCalculator from "./utils/representation_score_calculator"; import selectOptimalRepresentation from "./utils/select_optimal_representation"; +// Create default shared references + +const manualBitrateDefaultRef = createSharedReference(-1); +manualBitrateDefaultRef.finish(); + +const minAutoBitrateDefaultRef = createSharedReference(0); +minAutoBitrateDefaultRef.finish(); + +const maxAutoBitrateDefaultRef = createSharedReference(Infinity); +maxAutoBitrateDefaultRef.finish(); + +const limitWidthDefaultRef = createSharedReference(undefined); +limitWidthDefaultRef.finish(); + +const throttleBitrateDefaultRef = createSharedReference(Infinity); +throttleBitrateDefaultRef.finish(); + /** * Select the most adapted Representation according to the network and buffer * metrics it receives. @@ -99,22 +116,22 @@ export default function createAdaptiveRepresentationSelector( const bandwidthEstimator = _getBandwidthEstimator(type); const manualBitrate = takeFirstSet>( manualBitrates[type], - createSharedReference(-1)); + manualBitrateDefaultRef); const minAutoBitrate = takeFirstSet>( minAutoBitrates[type], - createSharedReference(0)); + minAutoBitrateDefaultRef); const maxAutoBitrate = takeFirstSet>( maxAutoBitrates[type], - createSharedReference(Infinity)); + maxAutoBitrateDefaultRef); const initialBitrate = takeFirstSet(initialBitrates[type], 0); const filters = { limitWidth: takeFirstSet>( throttlers.limitWidth[type], - createSharedReference(undefined)), + limitWidthDefaultRef), throttleBitrate: takeFirstSet>( throttlers.throttleBitrate[type], throttlers.throttle[type], - createSharedReference(Infinity)), + throttleBitrateDefaultRef), }; return getEstimateReference({ bandwidthEstimator, context, diff --git a/src/core/api/playback_observer.ts b/src/core/api/playback_observer.ts index c9125445c7..e124255e80 100644 --- a/src/core/api/playback_observer.ts +++ b/src/core/api/playback_observer.ts @@ -314,7 +314,8 @@ export default class PlaybackObserver { return timings; }; - const returnedSharedReference = createSharedReference(getCurrentObservation("init")); + const returnedSharedReference = createSharedReference(getCurrentObservation("init"), + this._canceller.signal); const generateObservationForEvent = (event : IPlaybackObserverEventType) => { const newObservation = getCurrentObservation(event); diff --git a/src/core/api/public_api.ts b/src/core/api/public_api.ts index 102ced69f9..01d9d9327a 100644 --- a/src/core/api/public_api.ts +++ b/src/core/api/public_api.ts @@ -424,26 +424,37 @@ class Player extends EventEmitter { destroyCanceller.signal); } - this._priv_speed = createSharedReference(videoElement.playbackRate); + this._priv_speed = createSharedReference(videoElement.playbackRate, + this._destroyCanceller.signal); this._priv_preferTrickModeTracks = false; - this._priv_contentLock = createSharedReference(false); + this._priv_contentLock = createSharedReference( + false, + this._destroyCanceller.signal); this._priv_bufferOptions = { - wantedBufferAhead: createSharedReference(wantedBufferAhead), - maxBufferAhead: createSharedReference(maxBufferAhead), - maxBufferBehind: createSharedReference(maxBufferBehind), - maxVideoBufferSize: createSharedReference(maxVideoBufferSize), + wantedBufferAhead: createSharedReference(wantedBufferAhead, + this._destroyCanceller.signal), + maxBufferAhead: createSharedReference(maxBufferAhead, + this._destroyCanceller.signal), + maxBufferBehind: createSharedReference(maxBufferBehind, + this._destroyCanceller.signal), + maxVideoBufferSize: createSharedReference(maxVideoBufferSize, + this._destroyCanceller.signal), }; this._priv_bitrateInfos = { lastBitrates: { audio: initialAudioBitrate, video: initialVideoBitrate }, - minAutoBitrates: { audio: createSharedReference(minAudioBitrate), - video: createSharedReference(minVideoBitrate) }, - maxAutoBitrates: { audio: createSharedReference(maxAudioBitrate), - video: createSharedReference(maxVideoBitrate) }, - manualBitrates: { audio: createSharedReference(-1), - video: createSharedReference(-1) }, + minAutoBitrates: { audio: createSharedReference(minAudioBitrate, + this._destroyCanceller.signal), + video: createSharedReference(minVideoBitrate, + this._destroyCanceller.signal) }, + maxAutoBitrates: { audio: createSharedReference(maxAudioBitrate, + this._destroyCanceller.signal), + video: createSharedReference(maxVideoBitrate, + this._destroyCanceller.signal) }, + manualBitrates: { audio: createSharedReference(-1, this._destroyCanceller.signal), + video: createSharedReference(-1, this._destroyCanceller.signal) }, }; this._priv_throttleWhenHidden = throttleWhenHidden; @@ -520,20 +531,6 @@ class Player extends EventEmitter { // free resources linked to the Player instance this._destroyCanceller.cancel(); - // Complete all references - this._priv_speed.finish(); - this._priv_contentLock.finish(); - this._priv_bufferOptions.wantedBufferAhead.finish(); - this._priv_bufferOptions.maxVideoBufferSize.finish(); - this._priv_bufferOptions.maxBufferAhead.finish(); - this._priv_bufferOptions.maxBufferBehind.finish(); - this._priv_bitrateInfos.manualBitrates.video.finish(); - this._priv_bitrateInfos.manualBitrates.audio.finish(); - this._priv_bitrateInfos.minAutoBitrates.video.finish(); - this._priv_bitrateInfos.minAutoBitrates.audio.finish(); - this._priv_bitrateInfos.maxAutoBitrates.video.finish(); - this._priv_bitrateInfos.maxAutoBitrates.audio.finish(); - this._priv_reloadingMetadata = {}; // un-attach video element diff --git a/src/core/api/utils.ts b/src/core/api/utils.ts index b44a628c85..b012c1a7db 100644 --- a/src/core/api/utils.ts +++ b/src/core/api/utils.ts @@ -89,7 +89,8 @@ export function constructPlayerStateReference( playbackObserver : IReadOnlyPlaybackObserver, cancelSignal : CancellationSignal ) : IReadOnlySharedReference { - const playerStateRef = createSharedReference(PLAYER_STATES.LOADING); + const playerStateRef = createSharedReference(PLAYER_STATES.LOADING, + cancelSignal); initializer.addEventListener("loaded", () => { if (playerStateRef.getValue() === PLAYER_STATES.LOADING) { playerStateRef.setValue(PLAYER_STATES.LOADED); diff --git a/src/core/init/directfile_content_initializer.ts b/src/core/init/directfile_content_initializer.ts index ceb1e4ad8e..6397e14302 100644 --- a/src/core/init/directfile_content_initializer.ts +++ b/src/core/init/directfile_content_initializer.ts @@ -66,8 +66,10 @@ export default class DirectFileContentInitializer extends ContentInitializer { throw new Error("No URL for a DirectFile content"); } + const decryptionRef = createSharedReference(null); + decryptionRef.finish(); const drmInitRef = - initializeContentDecryption(mediaElement, keySystems, createSharedReference(null), { + initializeContentDecryption(mediaElement, keySystems, decryptionRef, { onError: (err) => this._onFatalError(err), onWarning: (err : IPlayerError) => this.trigger("warning", err), }, cancelSignal); diff --git a/src/core/init/media_source_content_initializer.ts b/src/core/init/media_source_content_initializer.ts index 3f68f5be88..d6a3af63df 100644 --- a/src/core/init/media_source_content_initializer.ts +++ b/src/core/init/media_source_content_initializer.ts @@ -159,7 +159,10 @@ export default class MediaSourceContentInitializer extends ContentInitializer { this._initCanceller.signal); /** Send content protection initialization data to the decryption logic. */ - const protectionRef = createSharedReference(null); + const protectionRef = createSharedReference( + null, + this._initCanceller.signal + ); this._initializeMediaSourceAndDecryption(mediaElement, protectionRef) .then(initResult => this._onInitialMediaSourceReady(mediaElement, diff --git a/src/core/init/utils/create_stream_playback_observer.ts b/src/core/init/utils/create_stream_playback_observer.ts index 647ef012e0..263dd092ff 100644 --- a/src/core/init/utils/create_stream_playback_observer.ts +++ b/src/core/init/utils/create_stream_playback_observer.ts @@ -60,7 +60,8 @@ export default function createStreamPlaybackObserver( observationRef : IReadOnlySharedReference, cancellationSignal : CancellationSignal ) : IReadOnlySharedReference { - const newRef = createSharedReference(constructStreamPlaybackObservation()); + const newRef = createSharedReference(constructStreamPlaybackObservation(), + cancellationSignal); speed.onUpdate(emitStreamPlaybackObservation, { clearSignal: cancellationSignal, @@ -71,11 +72,6 @@ export default function createStreamPlaybackObserver( clearSignal: cancellationSignal, emitCurrentValue: false, }); - - cancellationSignal.register(() => { - newRef.finish(); - }); - return newRef; function constructStreamPlaybackObservation() { diff --git a/src/core/init/utils/get_loaded_reference.ts b/src/core/init/utils/get_loaded_reference.ts index a8ed228894..7dfa3b6817 100644 --- a/src/core/init/utils/get_loaded_reference.ts +++ b/src/core/init/utils/get_loaded_reference.ts @@ -43,10 +43,8 @@ export default function getLoadedReference( isDirectfile : boolean, cancelSignal : CancellationSignal ) : IReadOnlySharedReference { - const isLoaded = createSharedReference(false); - const listenCanceller = new TaskCanceller({ cancelOn: cancelSignal }); - listenCanceller.signal.register(() => isLoaded.finish()); + const isLoaded = createSharedReference(false, listenCanceller.signal); playbackObserver.listen((observation) => { if (observation.rebuffering !== null || observation.freezing !== null || diff --git a/src/core/init/utils/initial_seek_and_play.ts b/src/core/init/utils/initial_seek_and_play.ts index 524e8bc340..3b0a20d4de 100644 --- a/src/core/init/utils/initial_seek_and_play.ts +++ b/src/core/init/utils/initial_seek_and_play.ts @@ -82,8 +82,8 @@ export default function performInitialSeekAndPlay( rejectAutoPlay = rej; }); - const initialSeekPerformed = createSharedReference(false); - const initialPlayPerformed = createSharedReference(false); + const initialSeekPerformed = createSharedReference(false, cancelSignal); + const initialPlayPerformed = createSharedReference(false, cancelSignal); mediaElement.addEventListener("loadedmetadata", onLoadedMetadata); if (mediaElement.readyState >= READY_STATES.HAVE_METADATA) { diff --git a/src/core/init/utils/initialize_content_decryption.ts b/src/core/init/utils/initialize_content_decryption.ts index 6ce3181aa2..5a6db24ba2 100644 --- a/src/core/init/utils/initialize_content_decryption.ts +++ b/src/core/init/utils/initialize_content_decryption.ts @@ -59,9 +59,11 @@ export default function initializeContentDecryption( "EME feature not activated."); callbacks.onError(err); }, { clearSignal: cancelSignal }); - return createSharedReference({ initializationState: { type: "initialized", - value: null }, - drmSystemId: undefined }); + const ref = createSharedReference({ + initializationState: { type: "initialized" as const, value: null }, + drmSystemId: undefined }); + ref.finish(); // We know that no new value will be triggered + return ref; } else if (!hasEMEAPIs()) { protectionRef.onUpdate((data, stopListening) => { if (data === null) { // initial value @@ -73,16 +75,18 @@ export default function initializeContentDecryption( "Encryption APIs not found."); callbacks.onError(err); }, { clearSignal: cancelSignal }); - return createSharedReference({ initializationState: { type: "initialized", - value: null }, - drmSystemId: undefined }); + const ref = createSharedReference({ + initializationState: { type: "initialized" as const, value: null }, + drmSystemId: undefined }); + ref.finish(); // We know that no new value will be triggered + return ref; } const decryptorCanceller = new TaskCanceller({ cancelOn: cancelSignal }); const drmStatusRef = createSharedReference({ initializationState: { type: "uninitialized", value: null }, drmSystemId: undefined, - }); + }, cancelSignal); log.debug("Init: Creating ContentDecryptor"); const contentDecryptor = new ContentDecryptor(mediaElement, keySystems); diff --git a/src/core/init/utils/media_duration_updater.ts b/src/core/init/utils/media_duration_updater.ts index 4c2f2467ed..db905657f5 100644 --- a/src/core/init/utils/media_duration_updater.ts +++ b/src/core/init/utils/media_duration_updater.ts @@ -58,7 +58,7 @@ export default class MediaDurationUpdater { */ constructor(manifest : Manifest, mediaSource : MediaSource) { const canceller = new TaskCanceller(); - const currentKnownDuration = createSharedReference(undefined); + const currentKnownDuration = createSharedReference(undefined, canceller.signal); this._canceller = canceller; this._currentKnownDuration = currentKnownDuration; @@ -263,14 +263,13 @@ function createSourceBuffersUpdatingReference( sourceBuffers : SourceBufferList, cancelSignal : CancellationSignal ) : IReadOnlySharedReference { - // const areUpdating = createSharedReference( if (sourceBuffers.length === 0) { const notOpenedRef = createSharedReference(false); notOpenedRef.finish(); return notOpenedRef; } - const areUpdatingRef = createSharedReference(false); + const areUpdatingRef = createSharedReference(false, cancelSignal); reCheck(); for (let i = 0; i < sourceBuffers.length; i++) { @@ -308,7 +307,8 @@ function createMediaSourceOpenReference( mediaSource : MediaSource, cancelSignal : CancellationSignal ): IReadOnlySharedReference { - const isMediaSourceOpen = createSharedReference(mediaSource.readyState === "open"); + const isMediaSourceOpen = createSharedReference(mediaSource.readyState === "open", + cancelSignal); onSourceOpen(mediaSource, () => { isMediaSourceOpen.setValueIfChanged(true); }, cancelSignal); @@ -318,9 +318,6 @@ function createMediaSourceOpenReference( onSourceClose(mediaSource, () => { isMediaSourceOpen.setValueIfChanged(false); }, cancelSignal); - cancelSignal.register(() => { - isMediaSourceOpen.finish(); - }); return isMediaSourceOpen; } diff --git a/src/core/init/utils/stream_events_emitter/stream_events_emitter.ts b/src/core/init/utils/stream_events_emitter/stream_events_emitter.ts index 75c8cc8848..c414ad5f24 100644 --- a/src/core/init/utils/stream_events_emitter/stream_events_emitter.ts +++ b/src/core/init/utils/stream_events_emitter/stream_events_emitter.ts @@ -48,7 +48,8 @@ export default function streamEventsEmitter( const eventsBeingPlayed = new WeakMap(); const scheduledEventsRef = createSharedReference(refreshScheduledEventsList([], - manifest)); + manifest), + cancelSignal); manifest.addEventListener("manifestUpdate", () => { const prev = scheduledEventsRef.getValue(); scheduledEventsRef.setValue(refreshScheduledEventsList(prev, manifest)); diff --git a/src/core/stream/adaptation/utils/create_representation_estimator.ts b/src/core/stream/adaptation/utils/create_representation_estimator.ts index 3e7dd15388..1885ab74b7 100644 --- a/src/core/stream/adaptation/utils/create_representation_estimator.ts +++ b/src/core/stream/adaptation/utils/create_representation_estimator.ts @@ -66,7 +66,9 @@ export default function getRepresentationEstimate( abrCallbacks : IRepresentationEstimatorCallbacks; } { const { manifest, adaptation } = content; - const representations = createSharedReference([]); + const representations = createSharedReference( + [], + cancellationSignal); updateRepresentationsReference(); manifest.addEventListener("decipherabilityUpdate", updateRepresentationsReference); const unregisterCleanUp = cancellationSignal.register(cleanUp); @@ -102,7 +104,6 @@ export default function getRepresentationEstimate( /** Clean-up all resources taken here. */ function cleanUp() : void { manifest.removeEventListener("decipherabilityUpdate", updateRepresentationsReference); - representations.finish(); // check to protect against the case where it is not yet defined. if (typeof unregisterCleanUp !== "undefined") { diff --git a/src/core/stream/period/period_stream.ts b/src/core/stream/period/period_stream.ts index 3ca4be34cb..fc4d6b7e56 100644 --- a/src/core/stream/period/period_stream.ts +++ b/src/core/stream/period/period_stream.ts @@ -103,7 +103,10 @@ export default function PeriodStream( * `null` when no Adaptation is chosen (e.g. no subtitles) * `undefined` at the beginning (it can be ignored.). */ - const adaptationRef = createSharedReference(undefined); + const adaptationRef = createSharedReference( + undefined, + parentCancelSignal + ); callbacks.periodStreamReady({ type: bufferType, period, adaptationRef }); if (parentCancelSignal.isCancelled) { @@ -396,17 +399,13 @@ function createAdaptationStreamPlaybackObserver( observationRef : IReadOnlySharedReference, cancellationSignal : CancellationSignal ) : IReadOnlySharedReference { - const newRef = createSharedReference(constructAdaptationStreamPlaybackObservation()); + const newRef = createSharedReference(constructAdaptationStreamPlaybackObservation(), + cancellationSignal); observationRef.onUpdate(emitAdaptationStreamPlaybackObservation, { clearSignal: cancellationSignal, emitCurrentValue: false, }); - - cancellationSignal.register(() => { - newRef.finish(); - }); - return newRef; function constructAdaptationStreamPlaybackObservation( diff --git a/src/core/stream/representation/representation_stream.ts b/src/core/stream/representation/representation_stream.ts index 644f0de540..35f82231d8 100644 --- a/src/core/stream/representation/representation_stream.ts +++ b/src/core/stream/representation/representation_stream.ts @@ -119,7 +119,7 @@ export default function RepresentationStream( const lastSegmentQueue = createSharedReference({ initSegment: null, segmentQueue: [], - }); + }, segmentsLoadingCanceller.signal); /** If `true`, the current Representation has a linked initialization segment. */ const hasInitSegment = initSegmentState.segment !== null; diff --git a/src/utils/reference.ts b/src/utils/reference.ts index a5ea2c4647..9de552f2db 100644 --- a/src/utils/reference.ts +++ b/src/utils/reference.ts @@ -167,9 +167,18 @@ export type IReadOnlySharedReference = * * @see ISharedReference * @param {*} initialValue + * @param {Object|undefined} [cancelSignal] - If set, the created shared + * reference will be automatically "finished" once that signal emits. + * Finished references won't be able to update their value anymore, and will + * also automatically have their listeners (callbacks linked to value change) + * removed - as they cannot be triggered anymore, thus providing a security + * against memory leaks. * @returns {Object} */ -export default function createSharedReference(initialValue : T) : ISharedReference { +export default function createSharedReference( + initialValue : T, + cancelSignal? : CancellationSignal +) : ISharedReference { /** Current value referenced by this `ISharedReference`. */ let value = initialValue; @@ -195,6 +204,10 @@ export default function createSharedReference(initialValue : T) : ISharedRefe let isFinished = false; + if (cancelSignal !== undefined) { + cancelSignal.register(finish); + } + return { /** * Returns the current value of this shared reference. @@ -323,22 +336,23 @@ export default function createSharedReference(initialValue : T) : ISharedRefe * Indicate that no new values will be emitted. * Allows to automatically free all listeners linked to this reference. */ - finish() : void { - isFinished = true; - const clonedCbs = cbs.slice(); - for (const cbObj of clonedCbs) { - try { - if (!cbObj.hasBeenCleared) { - cbObj.complete(); - cbObj.hasBeenCleared = true; - } - } catch (_) { - /* nothing */ + finish, + }; + function finish() { + isFinished = true; + const clonedCbs = cbs.slice(); + for (const cbObj of clonedCbs) { + try { + if (!cbObj.hasBeenCleared) { + cbObj.complete(); + cbObj.hasBeenCleared = true; } + } catch (_) { + /* nothing */ } - cbs.length = 0; - }, - }; + } + cbs.length = 0; + } } /** @@ -348,27 +362,23 @@ export default function createSharedReference(initialValue : T) : ISharedRefe * over. * @param {Function} mappingFn - The mapping function which will receives * `originalRef`'s value and outputs this new reference's value. - * @param {Object | undefined} [cancellationSignal] - Optionally, a - * `CancellationSignal` which will finish that reference when it emits. + * @param {Object} cancellationSignal - Optionally, a `CancellationSignal` which + * will finish that reference when it emits. * @returns {Object} - The new, mapped, reference. */ export function createMappedReference( originalRef : IReadOnlySharedReference, mappingFn : (x : T) => U, - cancellationSignal? : CancellationSignal + cancellationSignal : CancellationSignal ) : IReadOnlySharedReference { - const newRef = createSharedReference(mappingFn(originalRef.getValue())); + const newRef = createSharedReference(mappingFn(originalRef.getValue()), + cancellationSignal); originalRef.onUpdate(function mapOriginalReference(x) { newRef.setValue(mappingFn(x)); }, { clearSignal: cancellationSignal }); // TODO nothing is done if `originalRef` is finished, though the returned // reference could also be finished in that case. To do? - if (cancellationSignal !== undefined) { - cancellationSignal.register(() => { - newRef.finish(); - }); - } return newRef; } From 6236a370b96533fd3cd54010b48512b7a4eecb93 Mon Sep 17 00:00:00 2001 From: Paul Berberian Date: Mon, 2 Jan 2023 14:48:29 +0100 Subject: [PATCH 30/86] api: safer event emitting by stopping sending events if content is stopped --- src/core/api/public_api.ts | 86 +++++++++++++++++++++++++++++++------- src/utils/event_emitter.ts | 10 +++-- 2 files changed, 77 insertions(+), 19 deletions(-) diff --git a/src/core/api/public_api.ts b/src/core/api/public_api.ts index 01d9d9327a..aa16c1326c 100644 --- a/src/core/api/public_api.ts +++ b/src/core/api/public_api.ts @@ -70,6 +70,7 @@ import { import areArraysOfNumbersEqual from "../../utils/are_arrays_of_numbers_equal"; import assert from "../../utils/assert"; import EventEmitter, { + IEventPayload, IListener, } from "../../utils/event_emitter"; import idGenerator from "../../utils/id_generator"; @@ -86,7 +87,9 @@ import createSharedReference, { IReadOnlySharedReference, ISharedReference, } from "../../utils/reference"; -import TaskCanceller from "../../utils/task_canceller"; +import TaskCanceller, { + CancellationSignal, +} from "../../utils/task_canceller"; import warnOnce from "../../utils/warn_once"; import { IABRThrottlers } from "../adaptive"; import { @@ -754,8 +757,14 @@ class Player extends EventEmitter { throw new Error("DirectFile feature not activated in your build."); } mediaElementTrackChoiceManager = - this._priv_initializeMediaElementTrackChoiceManager(defaultAudioTrack, - defaultTextTrack); + this._priv_initializeMediaElementTrackChoiceManager( + defaultAudioTrack, + defaultTextTrack, + currentContentCanceller.signal + ); + if (currentContentCanceller.isUsed) { + return; + } initializer = new features.directfile.initDirectFile({ autoPlay, keySystems, speed: this._priv_speed, @@ -2365,36 +2374,58 @@ class Player extends EventEmitter { if (this._priv_contentEventsMemory.periodChange !== period) { this._priv_contentEventsMemory.periodChange = period; this.trigger("periodChange", period); + if (contentInfos.currentContentCanceller.isUsed) { + return; + } } - this.trigger("availableAudioTracksChange", this.getAvailableAudioTracks()); - this.trigger("availableTextTracksChange", this.getAvailableTextTracks()); - this.trigger("availableVideoTracksChange", this.getAvailableVideoTracks()); + const triggerEventIfNotStopped = ( + evt : TEventName, + arg : IEventPayload + ) => { + if (!contentInfos.currentContentCanceller.isUsed) { + this.trigger(evt, arg); + } + }; + + const availableAudioTracks = this.getAvailableAudioTracks(); + triggerEventIfNotStopped("availableAudioTracksChange", availableAudioTracks); + const availableTextTracks = this.getAvailableTextTracks(); + triggerEventIfNotStopped("availableTextTracksChange", availableTextTracks); + const availableVideoTracks = this.getAvailableVideoTracks(); + triggerEventIfNotStopped("availableVideoTracksChange", availableVideoTracks); const trackChoiceManager = this._priv_contentInfos?.trackChoiceManager; // Emit intial events for the Period if (!isNullOrUndefined(trackChoiceManager)) { const audioTrack = trackChoiceManager.getChosenAudioTrack(period); + triggerEventIfNotStopped("audioTrackChange", audioTrack); const textTrack = trackChoiceManager.getChosenTextTrack(period); + triggerEventIfNotStopped("textTrackChange", textTrack); const videoTrack = trackChoiceManager.getChosenVideoTrack(period); - - this.trigger("audioTrackChange", audioTrack); - this.trigger("textTrackChange", textTrack); - this.trigger("videoTrackChange", videoTrack); + triggerEventIfNotStopped("videoTrackChange", videoTrack); } else { - this.trigger("audioTrackChange", null); - this.trigger("textTrackChange", null); - this.trigger("videoTrackChange", null); + triggerEventIfNotStopped("audioTrackChange", null); + triggerEventIfNotStopped("textTrackChange", null); + triggerEventIfNotStopped("videoTrackChange", null); } this._priv_triggerAvailableBitratesChangeEvent("availableAudioBitratesChange", this.getAvailableAudioBitrates()); + if (contentInfos.currentContentCanceller.isUsed) { + return; + } this._priv_triggerAvailableBitratesChangeEvent("availableVideoBitratesChange", this.getAvailableVideoBitrates()); - + if (contentInfos.currentContentCanceller.isUsed) { + return; + } const audioBitrate = this._priv_getCurrentRepresentations()?.audio?.bitrate ?? -1; this._priv_triggerCurrentBitrateChangeEvent("audioBitrateChange", audioBitrate); + if (contentInfos.currentContentCanceller.isUsed) { + return; + } const videoBitrate = this._priv_getCurrentRepresentations()?.video?.bitrate ?? -1; this._priv_triggerCurrentBitrateChangeEvent("videoBitrateChange", videoBitrate); @@ -2765,9 +2796,16 @@ class Player extends EventEmitter { return activeRepresentations[currentPeriod.id]; } + /** + * @param {Object} defaultAudioTrack + * @param {Object} defaultTextTrack + * @param {Object} cancelSignal + * @returns {Object} + */ private _priv_initializeMediaElementTrackChoiceManager( defaultAudioTrack : IAudioTrackPreference | null | undefined, - defaultTextTrack : ITextTrackPreference | null | undefined + defaultTextTrack : ITextTrackPreference | null | undefined, + cancelSignal : CancellationSignal ) : MediaElementTrackChoiceManager { assert(features.directfile !== null, "Initializing `MediaElementTrackChoiceManager` without Directfile feature"); @@ -2792,20 +2830,38 @@ class Player extends EventEmitter { this.trigger("availableAudioTracksChange", mediaElementTrackChoiceManager.getAvailableAudioTracks()); + if (cancelSignal.isCancelled) { + return mediaElementTrackChoiceManager; + } this.trigger("availableVideoTracksChange", mediaElementTrackChoiceManager.getAvailableVideoTracks()); + if (cancelSignal.isCancelled) { + return mediaElementTrackChoiceManager; + } this.trigger("availableTextTracksChange", mediaElementTrackChoiceManager.getAvailableTextTracks()); + if (cancelSignal.isCancelled) { + return mediaElementTrackChoiceManager; + } this.trigger("audioTrackChange", mediaElementTrackChoiceManager.getChosenAudioTrack() ?? null); + if (cancelSignal.isCancelled) { + return mediaElementTrackChoiceManager; + } this.trigger("textTrackChange", mediaElementTrackChoiceManager.getChosenTextTrack() ?? null); + if (cancelSignal.isCancelled) { + return mediaElementTrackChoiceManager; + } this.trigger("videoTrackChange", mediaElementTrackChoiceManager.getChosenVideoTrack() ?? null); + if (cancelSignal.isCancelled) { + return mediaElementTrackChoiceManager; + } mediaElementTrackChoiceManager .addEventListener("availableVideoTracksChange", (val) => diff --git a/src/utils/event_emitter.ts b/src/utils/event_emitter.ts index 6fb603bcd6..ec4250f040 100644 --- a/src/utils/event_emitter.ts +++ b/src/utils/event_emitter.ts @@ -28,12 +28,14 @@ export interface IEventEmitter { } // Type of the argument in the listener's callback -type IArgs = TEventRecord[TEventName]; // Type of the listener function -export type IListener = (args: IArgs) => void; +export type IListener< + TEventRecord, + TEventName extends keyof TEventRecord +> = (args: IEventPayload) => void; type IListeners = { [P in keyof TEventRecord]? : Array> @@ -127,7 +129,7 @@ export default class EventEmitter implements IEventEmitter { */ protected trigger( evt : TEventName, - arg : IArgs + arg : IEventPayload ) : void { const listeners = this._listeners[evt]; if (!Array.isArray(listeners)) { From 38b4760db909f0d302564ab96d8a1acf4964552e Mon Sep 17 00:00:00 2001 From: Paul Berberian Date: Tue, 20 Dec 2022 14:45:31 +0100 Subject: [PATCH 31/86] Minor archi documentation update --- src/core/stream/README.md | 2 +- src/core/stream/orchestrator/README.md | 5 +---- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/src/core/stream/README.md b/src/core/stream/README.md index 90edbe896b..32001c9cf6 100644 --- a/src/core/stream/README.md +++ b/src/core/stream/README.md @@ -32,7 +32,7 @@ The ``PeriodStream`` creates and destroys ``AdaptationStream``s for a single Manifest's Period and a single type of buffer (e.g. "audio", "video", "text" etc.). -It does so after asking through an event which Adaptation has to be chosen for +It does so after asking through a callback which Adaptation has to be chosen for that Period and type. It also takes care of creating the right "`SegmentBuffer`" for its associated diff --git a/src/core/stream/orchestrator/README.md b/src/core/stream/orchestrator/README.md index 986871b4b0..1af507aff3 100644 --- a/src/core/stream/orchestrator/README.md +++ b/src/core/stream/orchestrator/README.md @@ -346,7 +346,4 @@ At the end, we should only have _PeriodStream[s]_ for consecutive Period[s]: Any "Stream" communicates to the API about creations and destructions of _PeriodStreams_ respectively through ``"periodStreamReady"`` and -``"periodStreamCleared"`` events. - -When the currently seen Period changes, an ``activePeriodChanged`` event is -sent. +``"periodStreamCleared"`` callbacks. From 638f4d8d25d585d8ab92cd426b19473a0afc455a Mon Sep 17 00:00:00 2001 From: Paul Berberian Date: Tue, 3 Jan 2023 11:40:01 +0100 Subject: [PATCH 32/86] also send available...Tracks change events if AdaptationSets appear or disappear from the Manifest for the current Period --- doc/api/Player_Events.md | 27 +- src/core/api/public_api.ts | 207 ++++--- src/manifest/__tests__/manifest.test.ts | 379 +----------- .../__tests__/update_period_in_place.test.ts | 579 ++++++++++++++++-- src/manifest/__tests__/update_periods.test.ts | 294 +++++++-- src/manifest/manifest.ts | 10 +- src/manifest/update_period_in_place.ts | 98 ++- src/manifest/update_periods.ts | 126 +++- 8 files changed, 1126 insertions(+), 594 deletions(-) diff --git a/doc/api/Player_Events.md b/doc/api/Player_Events.md index 07db1e9180..b5612790e1 100644 --- a/doc/api/Player_Events.md +++ b/doc/api/Player_Events.md @@ -112,8 +112,13 @@ This chapter describes events linked to the current audio, video or text track. _payload type_: `Array.` -Triggered when the currently available audio tracks change (e.g.: at the -beginning of the content, when period changes...). +Triggered when the currently available audio tracks might have changed (e.g.: at +the beginning of the content, when period changes...) for the currently-playing +Period. + +_The event might also rarely be emitted even if the list of available audio +tracks did not really change - as the RxPlayer might send it in situations where +there's a chance it had without thoroughly checking it._ The array emitted contains object describing each available audio track: @@ -152,8 +157,13 @@ This event only concerns the currently-playing Period. _payload type_: `Array.` -Triggered when the currently available video tracks change (e.g.: at the -beginning of the content, when period changes...). +Triggered when the currently available video tracks might change (e.g.: at the +beginning of the content, when period changes...) for the currently-playing +Period. + +_The event might also rarely be emitted even if the list of available video +tracks did not really change - as the RxPlayer might send it in situations where +there's a chance it had without thoroughly checking it._ The array emitted contains object describing each available video track: @@ -192,8 +202,13 @@ This event only concerns the currently-playing Period. _payload type_: `Array.` -Triggered when the currently available text tracks change (e.g.: at the -beginning of the content, when period changes...). +Triggered when the currently available text tracks might change (e.g.: at the +beginning of the content, when period changes...) for the currently-playing +Period. + +_The event might also rarely be emitted even if the list of available text +tracks did not really change - as the RxPlayer might send it in situations where +there's a chance it had without thoroughly checking it._ The array emitted contains object describing each available text track: diff --git a/src/core/api/public_api.ts b/src/core/api/public_api.ts index aa16c1326c..e4129729bb 100644 --- a/src/core/api/public_api.ts +++ b/src/core/api/public_api.ts @@ -2327,6 +2327,7 @@ class Player extends EventEmitter { return; // Event for another content } contentInfos.manifest = manifest; + const cancelSignal = contentInfos.currentContentCanceller.signal; this._priv_reloadingMetadata.manifest = manifest; const { initialAudioTrack, initialTextTrack } = contentInfos; @@ -2347,11 +2348,38 @@ class Player extends EventEmitter { contentInfos.trackChoiceManager .setPreferredVideoTracks(this._priv_preferredVideoTracks, true); - manifest.addEventListener("manifestUpdate", () => { + manifest.addEventListener("manifestUpdate", (updates) => { // Update the tracks chosen if it changed if (contentInfos.trackChoiceManager !== null) { contentInfos.trackChoiceManager.update(); } + const currentPeriod = this._priv_contentInfos?.currentPeriod ?? undefined; + const trackChoiceManager = this._priv_contentInfos?.trackChoiceManager; + if (currentPeriod === undefined || isNullOrUndefined(trackChoiceManager)) { + return; + } + for (const update of updates.updatedPeriods) { + if (update.period.id === currentPeriod.id) { + if (update.result.addedAdaptations.length > 0 || + update.result.removedAdaptations.length > 0) + { + // We might have new (or less) tracks, send events just to be sure + const audioTracks = trackChoiceManager.getAvailableAudioTracks(currentPeriod); + this._priv_triggerEventIfNotStopped("availableAudioTracksChange", + audioTracks ?? [], + cancelSignal); + const textTracks = trackChoiceManager.getAvailableTextTracks(currentPeriod); + this._priv_triggerEventIfNotStopped("availableTextTracksChange", + textTracks ?? [], + cancelSignal); + const videoTracks = trackChoiceManager.getAvailableVideoTracks(currentPeriod); + this._priv_triggerEventIfNotStopped("availableVideoTracksChange", + videoTracks ?? [], + cancelSignal); + } + } + return; + } }, contentInfos.currentContentCanceller.signal); } @@ -2371,64 +2399,68 @@ class Player extends EventEmitter { } contentInfos.currentPeriod = period; + const cancelSignal = contentInfos.currentContentCanceller.signal; if (this._priv_contentEventsMemory.periodChange !== period) { this._priv_contentEventsMemory.periodChange = period; - this.trigger("periodChange", period); - if (contentInfos.currentContentCanceller.isUsed) { - return; - } + this._priv_triggerEventIfNotStopped("periodChange", period, cancelSignal); } - const triggerEventIfNotStopped = ( - evt : TEventName, - arg : IEventPayload - ) => { - if (!contentInfos.currentContentCanceller.isUsed) { - this.trigger(evt, arg); - } - }; - - const availableAudioTracks = this.getAvailableAudioTracks(); - triggerEventIfNotStopped("availableAudioTracksChange", availableAudioTracks); - const availableTextTracks = this.getAvailableTextTracks(); - triggerEventIfNotStopped("availableTextTracksChange", availableTextTracks); - const availableVideoTracks = this.getAvailableVideoTracks(); - triggerEventIfNotStopped("availableVideoTracksChange", availableVideoTracks); + this._priv_triggerEventIfNotStopped("availableAudioTracksChange", + this.getAvailableAudioTracks(), + cancelSignal); + this._priv_triggerEventIfNotStopped("availableTextTracksChange", + this.getAvailableTextTracks(), + cancelSignal); + this._priv_triggerEventIfNotStopped("availableVideoTracksChange", + this.getAvailableVideoTracks(), + cancelSignal); const trackChoiceManager = this._priv_contentInfos?.trackChoiceManager; // Emit intial events for the Period if (!isNullOrUndefined(trackChoiceManager)) { const audioTrack = trackChoiceManager.getChosenAudioTrack(period); - triggerEventIfNotStopped("audioTrackChange", audioTrack); + this._priv_triggerEventIfNotStopped("audioTrackChange", + audioTrack, + cancelSignal); const textTrack = trackChoiceManager.getChosenTextTrack(period); - triggerEventIfNotStopped("textTrackChange", textTrack); + this._priv_triggerEventIfNotStopped("textTrackChange", + textTrack, + cancelSignal); const videoTrack = trackChoiceManager.getChosenVideoTrack(period); - triggerEventIfNotStopped("videoTrackChange", videoTrack); + this._priv_triggerEventIfNotStopped("videoTrackChange", + videoTrack, + cancelSignal); } else { - triggerEventIfNotStopped("audioTrackChange", null); - triggerEventIfNotStopped("textTrackChange", null); - triggerEventIfNotStopped("videoTrackChange", null); + this._priv_triggerEventIfNotStopped("audioTrackChange", null, cancelSignal); + this._priv_triggerEventIfNotStopped("textTrackChange", null, cancelSignal); + this._priv_triggerEventIfNotStopped("videoTrackChange", null, cancelSignal); } this._priv_triggerAvailableBitratesChangeEvent("availableAudioBitratesChange", - this.getAvailableAudioBitrates()); + this.getAvailableAudioBitrates(), + cancelSignal); if (contentInfos.currentContentCanceller.isUsed) { return; } this._priv_triggerAvailableBitratesChangeEvent("availableVideoBitratesChange", - this.getAvailableVideoBitrates()); + this.getAvailableVideoBitrates(), + cancelSignal); if (contentInfos.currentContentCanceller.isUsed) { return; } const audioBitrate = this._priv_getCurrentRepresentations()?.audio?.bitrate ?? -1; - this._priv_triggerCurrentBitrateChangeEvent("audioBitrateChange", audioBitrate); + this._priv_triggerCurrentBitrateChangeEvent("audioBitrateChange", + audioBitrate, + cancelSignal); if (contentInfos.currentContentCanceller.isUsed) { return; } const videoBitrate = this._priv_getCurrentRepresentations()?.video?.bitrate ?? -1; - this._priv_triggerCurrentBitrateChangeEvent("videoBitrateChange", videoBitrate); + this._priv_triggerCurrentBitrateChangeEvent("videoBitrateChange", + videoBitrate, + cancelSignal); } /** @@ -2574,6 +2606,7 @@ class Player extends EventEmitter { } const { trackChoiceManager } = contentInfos; + const cancelSignal = contentInfos.currentContentCanceller.signal; if (trackChoiceManager !== null && currentPeriod !== null && !isNullOrUndefined(period) && period.id === currentPeriod.id) @@ -2581,23 +2614,29 @@ class Player extends EventEmitter { switch (type) { case "audio": const audioTrack = trackChoiceManager.getChosenAudioTrack(currentPeriod); - this.trigger("audioTrackChange", audioTrack); + this._priv_triggerEventIfNotStopped("audioTrackChange", + audioTrack, + cancelSignal); const availableAudioBitrates = this.getAvailableAudioBitrates(); this._priv_triggerAvailableBitratesChangeEvent("availableAudioBitratesChange", - availableAudioBitrates); + availableAudioBitrates, + cancelSignal); break; case "text": const textTrack = trackChoiceManager.getChosenTextTrack(currentPeriod); - this.trigger("textTrackChange", textTrack); + this._priv_triggerEventIfNotStopped("textTrackChange", textTrack, cancelSignal); break; case "video": const videoTrack = trackChoiceManager.getChosenVideoTrack(currentPeriod); - this.trigger("videoTrackChange", videoTrack); + this._priv_triggerEventIfNotStopped("videoTrackChange", + videoTrack, + cancelSignal); const availableVideoBitrates = this.getAvailableVideoBitrates(); this._priv_triggerAvailableBitratesChangeEvent("availableVideoBitratesChange", - availableVideoBitrates); + availableVideoBitrates, + cancelSignal); break; } } @@ -2640,10 +2679,15 @@ class Player extends EventEmitter { currentPeriod !== null && currentPeriod.id === period.id) { + const cancelSignal = this._priv_contentInfos.currentContentCanceller.signal; if (type === "video") { - this._priv_triggerCurrentBitrateChangeEvent("videoBitrateChange", bitrate); + this._priv_triggerCurrentBitrateChangeEvent("videoBitrateChange", + bitrate, + cancelSignal); } else if (type === "audio") { - this._priv_triggerCurrentBitrateChangeEvent("audioBitrateChange", bitrate); + this._priv_triggerCurrentBitrateChangeEvent("audioBitrateChange", + bitrate, + cancelSignal); } } } @@ -2753,13 +2797,17 @@ class Player extends EventEmitter { * the previously stored value. * @param {string} event * @param {Array.} newVal + * @param {Object} currentContentCancelSignal */ private _priv_triggerAvailableBitratesChangeEvent( event : "availableAudioBitratesChange" | "availableVideoBitratesChange", - newVal : number[] + newVal : number[], + currentContentCancelSignal : CancellationSignal ) : void { const prevVal = this._priv_contentEventsMemory[event]; - if (prevVal === undefined || !areArraysOfNumbersEqual(newVal, prevVal)) { + if (!currentContentCancelSignal.isCancelled && + (prevVal === undefined || !areArraysOfNumbersEqual(newVal, prevVal))) + { this._priv_contentEventsMemory[event] = newVal; this.trigger(event, newVal); } @@ -2770,12 +2818,16 @@ class Player extends EventEmitter { * previously stored value. * @param {string} event * @param {number} newVal + * @param {Object} currentContentCancelSignal */ private _priv_triggerCurrentBitrateChangeEvent( event : "audioBitrateChange" | "videoBitrateChange", - newVal : number + newVal : number, + currentContentCancelSignal : CancellationSignal ) : void { - if (newVal !== this._priv_contentEventsMemory[event]) { + if (!currentContentCancelSignal.isCancelled && + newVal !== this._priv_contentEventsMemory[event]) + { this._priv_contentEventsMemory[event] = newVal; this.trigger(event, newVal); } @@ -2796,6 +2848,21 @@ class Player extends EventEmitter { return activeRepresentations[currentPeriod.id]; } + /** + * @param {string} evt + * @param {*} arg + * @param {Object} currentContentCancelSignal + */ + private _priv_triggerEventIfNotStopped( + evt : TEventName, + arg : IEventPayload, + currentContentCancelSignal : CancellationSignal + ) { + if (!currentContentCancelSignal.isCancelled) { + this.trigger(evt, arg); + } + } + /** * @param {Object} defaultAudioTrack * @param {Object} defaultTextTrack @@ -2828,40 +2895,30 @@ class Player extends EventEmitter { mediaElementTrackChoiceManager .setPreferredVideoTracks(this._priv_preferredVideoTracks, true); - this.trigger("availableAudioTracksChange", - mediaElementTrackChoiceManager.getAvailableAudioTracks()); - if (cancelSignal.isCancelled) { - return mediaElementTrackChoiceManager; - } - this.trigger("availableVideoTracksChange", - mediaElementTrackChoiceManager.getAvailableVideoTracks()); - if (cancelSignal.isCancelled) { - return mediaElementTrackChoiceManager; - } - this.trigger("availableTextTracksChange", - mediaElementTrackChoiceManager.getAvailableTextTracks()); - if (cancelSignal.isCancelled) { - return mediaElementTrackChoiceManager; - } - - this.trigger("audioTrackChange", - mediaElementTrackChoiceManager.getChosenAudioTrack() - ?? null); - if (cancelSignal.isCancelled) { - return mediaElementTrackChoiceManager; - } - this.trigger("textTrackChange", - mediaElementTrackChoiceManager.getChosenTextTrack() - ?? null); - if (cancelSignal.isCancelled) { - return mediaElementTrackChoiceManager; - } - this.trigger("videoTrackChange", - mediaElementTrackChoiceManager.getChosenVideoTrack() - ?? null); - if (cancelSignal.isCancelled) { - return mediaElementTrackChoiceManager; - } + this._priv_triggerEventIfNotStopped( + "availableAudioTracksChange", + mediaElementTrackChoiceManager.getAvailableAudioTracks(), + cancelSignal); + this._priv_triggerEventIfNotStopped( + "availableVideoTracksChange", + mediaElementTrackChoiceManager.getAvailableVideoTracks(), + cancelSignal); + this._priv_triggerEventIfNotStopped( + "availableTextTracksChange", + mediaElementTrackChoiceManager.getAvailableTextTracks(), + cancelSignal); + this._priv_triggerEventIfNotStopped( + "audioTrackChange", + mediaElementTrackChoiceManager.getChosenAudioTrack() ?? null, + cancelSignal); + this._priv_triggerEventIfNotStopped( + "textTrackChange", + mediaElementTrackChoiceManager.getChosenTextTrack() ?? null, + cancelSignal); + this._priv_triggerEventIfNotStopped( + "videoTrackChange", + mediaElementTrackChoiceManager.getChosenVideoTrack() ?? null, + cancelSignal); mediaElementTrackChoiceManager .addEventListener("availableVideoTracksChange", (val) => diff --git a/src/manifest/__tests__/manifest.test.ts b/src/manifest/__tests__/manifest.test.ts index cda69eb08b..9faaa611ec 100644 --- a/src/manifest/__tests__/manifest.test.ts +++ b/src/manifest/__tests__/manifest.test.ts @@ -353,18 +353,18 @@ describe("Manifest - Manifest", () => { const fakePeriod = jest.fn((period) => ({ ...period, id: `foo${period.id}`, contentWarnings: [new Error(period.id)] })); - const fakeUpdatePeriodInPlace = jest.fn((oldPeriod, newPeriod) => { - Object.keys(oldPeriod).forEach(key => { - delete oldPeriod[key]; - }); - oldPeriod.id = newPeriod.id; - oldPeriod.start = newPeriod.start; - oldPeriod.adaptations = newPeriod.adaptations; - }); + const fakeReplacePeriodsRes = { + updatedPeriods: [], + addedPeriods: [], + removedPeriods: [], + }; + const fakeReplacePeriods = jest.fn(() => fakeReplacePeriodsRes); jest.mock("../period", () => ({ __esModule: true as const, default: fakePeriod })); - jest.mock("../update_period_in_place", () => ({ __esModule: true as const, - default: fakeUpdatePeriodInPlace })); + jest.mock("../update_periods", () => ({ + __esModule: true as const, + replacePeriods: fakeReplacePeriods, + })); const oldManifestArgs = { availabilityStartTime: 5, duration: 12, @@ -391,8 +391,6 @@ describe("Manifest - Manifest", () => { const mockTrigger = jest.spyOn(manifest, "trigger").mockImplementation(jest.fn()); - const [oldPeriod1, oldPeriod2] = manifest.periods; - const newAdaptations = {}; const newPeriod1 = { id: "foo0", start: 4, adaptations: {} }; const newPeriod2 = { id: "foo1", start: 12, adaptations: {} }; @@ -416,364 +414,15 @@ describe("Manifest - Manifest", () => { uris: ["url3", "url4"] }; manifest.replace(newManifest); - expect(manifest.adaptations).toEqual(newAdaptations); - expect(manifest.availabilityStartTime).toEqual(6); - expect(manifest.id).toEqual("fakeId"); - expect(manifest.isDynamic).toEqual(true); - expect(manifest.isLive).toEqual(true); - expect(manifest.lifetime).toEqual(14); - expect(manifest.contentWarnings).toEqual([new Error("c"), new Error("d")]); - expect(manifest.getMinimumSafePosition()).toEqual(40 - 5); - expect(manifest.getMaximumSafePosition()).toEqual(40); - expect(manifest.suggestedPresentationDelay).toEqual(100); - expect(manifest.uris).toEqual(["url3", "url4"]); - - expect(manifest.periods).toEqual([newPeriod1, newPeriod2]); - - expect(fakeUpdatePeriodInPlace).toHaveBeenCalledTimes(2); - expect(fakeUpdatePeriodInPlace).toHaveBeenCalledWith(oldPeriod1, newPeriod1, 0); - expect(fakeUpdatePeriodInPlace).toHaveBeenCalledWith(oldPeriod2, newPeriod2, 0); + expect(fakeReplacePeriods).toHaveBeenCalledTimes(1); + expect(fakeReplacePeriods) + .toHaveBeenCalledWith(manifest.periods, newManifest.periods); expect(mockTrigger).toHaveBeenCalledTimes(1); - expect(mockTrigger).toHaveBeenCalledWith("manifestUpdate", null); + expect(mockTrigger).toHaveBeenCalledWith("manifestUpdate", fakeReplacePeriodsRes); expect(fakeIdGenerator).toHaveBeenCalledTimes(2); expect(fakeGenerateNewId).toHaveBeenCalledTimes(1); expect(fakeLogger.info).not.toHaveBeenCalled(); expect(fakeLogger.warn).not.toHaveBeenCalled(); mockTrigger.mockRestore(); }); - - it("should prepend older Periods when calling `replace`", () => { - const oldManifestArgs = { availabilityStartTime: 5, - duration: 12, - id: "man", - isDynamic: false, - isLive: false, - lifetime: 13, - timeBounds: { minimumSafePosition: 0, - timeshiftDepth: null, - maximumTimeData: { - isLinear: false, - maximumSafePosition: 10, - time: 10, - } }, - contentWarnings: [new Error("a"), new Error("b")], - periods: [{ id: "1", start: 4, adaptations: {} }], - suggestedPresentationDelay: 99, - uris: ["url1", "url2"] }; - - const fakePeriod = jest.fn((period) => { - return { - ...period, - id: `foo${period.id}`, - contentWarnings: [new Error(period.id)], - }; - }); - const fakeUpdatePeriodInPlace = jest.fn((oldPeriod, newPeriod) => { - Object.keys(oldPeriod).forEach(key => { - delete oldPeriod[key]; - }); - oldPeriod.id = newPeriod.id; - oldPeriod.start = newPeriod.start; - oldPeriod.adaptations = newPeriod.adaptations; - oldPeriod.contentWarnings = newPeriod.contentWarnings; - }); - jest.mock("../period", () => ({ __esModule: true as const, - default: fakePeriod })); - jest.mock("../update_period_in_place", () => ({ - __esModule: true as const, - default: fakeUpdatePeriodInPlace, - })); - const Manifest = jest.requireActual("../manifest").default; - const manifest = new Manifest(oldManifestArgs, {}); - const [oldPeriod1] = manifest.periods; - - const mockTrigger = jest.spyOn(manifest, "trigger").mockImplementation(jest.fn()); - - const newPeriod1 = { id: "pre0", - start: 0, - adaptations: {}, - contentWarnings: [] }; - const newPeriod2 = { id: "pre1", - start: 2, - adaptations: {}, - contentWarnings: [] }; - const newPeriod3 = { id: "foo1", - start: 4, - adaptations: {}, - contentWarnings: [] }; - const newManifest = { adaptations: {}, - availabilityStartTime: 6, - id: "man2", - isDynamic: false, - isLive: true, - lifetime: 14, - contentWarnings: [ new Error("c"), - new Error("d") ], - suggestedPresentationDelay: 100, - periods: [ newPeriod1, - newPeriod2, - newPeriod3 ], - timeBounds: { minimumSafePosition: 0, - timeshiftDepth: null, - maximumTimeData: { isLinear: false, - maximumSafePosition: 10, - time: 10 } }, - uris: ["url3", "url4"] }; - - manifest.replace(newManifest); - - expect(manifest.periods).toEqual([newPeriod1, newPeriod2, newPeriod3]); - - expect(fakeUpdatePeriodInPlace).toHaveBeenCalledTimes(1); - expect(fakeUpdatePeriodInPlace).toHaveBeenCalledWith(oldPeriod1, newPeriod3, 0); - expect(mockTrigger).toHaveBeenCalledTimes(1); - expect(mockTrigger).toHaveBeenCalledWith("manifestUpdate", null); - expect(fakeIdGenerator).toHaveBeenCalledTimes(2); - expect(fakeGenerateNewId).toHaveBeenCalledTimes(1); - // expect(fakeLogger.info).toHaveBeenCalledTimes(2); - // expect(fakeLogger.info).toHaveBeenCalledWith( - // "Manifest: Adding new Period pre0 after update."); - // expect(fakeLogger.info).toHaveBeenCalledWith( - // "Manifest: Adding new Period pre1 after update."); - mockTrigger.mockRestore(); - }); - - it("should append newer Periods when calling `replace`", () => { - const oldManifestArgs = { availabilityStartTime: 5, - duration: 12, - id: "man", - isDynamic: false, - isLive: false, - lifetime: 13, - contentWarnings: [new Error("a"), new Error("b")], - periods: [{ id: "1" }], - suggestedPresentationDelay: 99, - timeBounds: { minimumSafePosition: 0, - timeshiftDepth: null, - maximumTimeData: { - isLinear: false, - maximumSafePosition: 10, - time: 10, - } }, - uris: ["url1", "url2"] }; - - const fakePeriod = jest.fn((period) => { - return { ...period, - id: `foo${period.id}`, - contentWarnings: [new Error(period.id)] }; - }); - const fakeUpdatePeriodInPlace = jest.fn((oldPeriod, newPeriod) => { - Object.keys(oldPeriod).forEach(key => { - delete oldPeriod[key]; - }); - oldPeriod.id = newPeriod.id; - }); - jest.mock("../period", () => ({ __esModule: true as const, - default: fakePeriod })); - jest.mock("../update_period_in_place", () => ({ - __esModule: true as const, - default: fakeUpdatePeriodInPlace, - })); - const Manifest = jest.requireActual("../manifest").default; - const manifest = new Manifest(oldManifestArgs, {}); - const [oldPeriod1] = manifest.periods; - - const mockTrigger = jest.spyOn(manifest, "trigger").mockImplementation(jest.fn()); - - const newPeriod1 = { id: "foo1" }; - const newPeriod2 = { id: "post0" }; - const newPeriod3 = { id: "post1" }; - const newManifest = { adaptations: {}, - availabilityStartTime: 6, - id: "man2", - isDynamic: false, - isLive: true, - lifetime: 14, - contentWarnings: [new Error("c"), new Error("d")], - suggestedPresentationDelay: 100, - periods: [newPeriod1, newPeriod2, newPeriod3], - timeBounds: { minimumSafePosition: 0, - timeshiftDepth: null, - maximumTimeData: { isLinear: false, - maximumSafePosition: 10, - time: 10 } }, - uris: ["url3", "url4"] }; - - manifest.replace(newManifest); - - expect(manifest.periods).toEqual([newPeriod1, newPeriod2, newPeriod3]); - - expect(fakeUpdatePeriodInPlace).toHaveBeenCalledTimes(1); - expect(fakeUpdatePeriodInPlace).toHaveBeenCalledWith(oldPeriod1, newPeriod1, 0); - expect(mockTrigger).toHaveBeenCalledTimes(1); - expect(mockTrigger).toHaveBeenCalledWith("manifestUpdate", null); - expect(fakeIdGenerator).toHaveBeenCalledTimes(2); - expect(fakeGenerateNewId).toHaveBeenCalledTimes(1); - // expect(fakeLogger.warn).toHaveBeenCalledTimes(1); - // expect(fakeLogger.warn) - // .toHaveBeenCalledWith("Manifest: Adding new Periods after update."); - mockTrigger.mockRestore(); - }); - - it("should replace different Periods when calling `replace`", () => { - const oldManifestArgs = { availabilityStartTime: 5, - duration: 12, - id: "man", - isDynamic: false, - isLive: false, - lifetime: 13, - contentWarnings: [new Error("a"), new Error("b")], - periods: [{ id: "1" }], - suggestedPresentationDelay: 99, - timeBounds: { minimumSafePosition: 0, - timeshiftDepth: null, - maximumTimeData: { - isLinear: false, - maximumSafePosition: 10, - time: 10, - } }, - uris: ["url1", "url2"] }; - - const fakePeriod = jest.fn((period) => { - return { ...period, - id: `foo${period.id}`, - contentWarnings: [new Error(period.id)] }; - }); - const fakeUpdatePeriodInPlace = jest.fn((oldPeriod, newPeriod) => { - Object.keys(oldPeriod).forEach(key => { - delete oldPeriod[key]; - }); - oldPeriod.id = newPeriod.id; - }); - jest.mock("../period", () => ({ __esModule: true as const, - default: fakePeriod })); - jest.mock("../update_period_in_place", () => ({ - __esModule: true as const, - default: fakeUpdatePeriodInPlace, - })); - const Manifest = jest.requireActual("../manifest").default; - const manifest = new Manifest(oldManifestArgs, {}); - - const mockTrigger = jest.spyOn(manifest, "trigger").mockImplementation(jest.fn()); - - const newPeriod1 = { id: "diff0" }; - const newPeriod2 = { id: "diff1" }; - const newPeriod3 = { id: "diff2" }; - const newManifest = { adaptations: {}, - availabilityStartTime: 6, - id: "man2", - isDynamic: false, - isLive: true, - lifetime: 14, - contentWarnings: [new Error("c"), new Error("d")], - suggestedPresentationDelay: 100, - periods: [newPeriod1, newPeriod2, newPeriod3], - timeBounds: { minimumSafePosition: 0, - timeshiftDepth: null, - maximumTimeData: { isLinear: false, - maximumSafePosition: 10, - time: 10 } }, - uris: ["url3", "url4"] }; - - manifest.replace(newManifest); - - expect(manifest.periods).toEqual([newPeriod1, newPeriod2, newPeriod3]); - - expect(fakeUpdatePeriodInPlace).not.toHaveBeenCalled(); - expect(mockTrigger).toHaveBeenCalledTimes(1); - expect(mockTrigger).toHaveBeenCalledWith("manifestUpdate", null); - expect(fakeIdGenerator).toHaveBeenCalledTimes(2); - expect(fakeGenerateNewId).toHaveBeenCalledTimes(1); - // expect(fakeLogger.info).toHaveBeenCalledTimes(4); - mockTrigger.mockRestore(); - }); - - it("should merge overlapping Periods when calling `replace`", () => { - const oldManifestArgs = { availabilityStartTime: 5, - duration: 12, - id: "man", - isDynamic: false, - isLive: false, - lifetime: 13, - contentWarnings: [new Error("a"), new Error("b")], - periods: [{ id: "1", start: 2 }, - { id: "2", start: 4 }, - { id: "3", start: 6 }], - timeBounds: { minimumSafePosition: 0, - timeshiftDepth: null, - maximumTimeData: { - isLinear: false, - maximumSafePosition: 10, - time: 10, - } }, - suggestedPresentationDelay: 99, - uris: ["url1", "url2"] }; - - const fakePeriod = jest.fn((period) => { - return { ...period, - id: `foo${period.id}`, - contentWarnings: [new Error(period.id)] }; - }); - const fakeUpdatePeriodInPlace = jest.fn((oldPeriod, newPeriod) => { - Object.keys(oldPeriod).forEach(key => { - delete oldPeriod[key]; - }); - oldPeriod.id = newPeriod.id; - oldPeriod.start = newPeriod.start; - }); - jest.mock("../period", () => ({ __esModule: true as const, - default: fakePeriod })); - jest.mock("../update_period_in_place", () => ({ - __esModule: true as const, - default: fakeUpdatePeriodInPlace, - })); - const Manifest = jest.requireActual("../manifest").default; - const manifest = new Manifest(oldManifestArgs, {}); - const [oldPeriod1, oldPeriod2] = manifest.periods; - - const mockTrigger = jest.spyOn(manifest, "trigger").mockImplementation(jest.fn()); - - const newPeriod1 = { id: "pre0", start: 0 }; - const newPeriod2 = { id: "foo1", start: 2 }; - const newPeriod3 = { id: "diff0", start: 3 }; - const newPeriod4 = { id: "foo2", start: 4 }; - const newPeriod5 = { id: "post0", start: 5 }; - const newManifest = { adaptations: {}, - availabilityStartTime: 6, - id: "man2", - isDynamic: false, - isLive: true, - lifetime: 14, - contentWarnings: [new Error("c"), new Error("d")], - suggestedPresentationDelay: 100, - periods: [ newPeriod1, - newPeriod2, - newPeriod3, - newPeriod4, - newPeriod5 ], - timeBounds: { minimumSafePosition: 0, - timeshiftDepth: null, - maximumTimeData: { isLinear: false, - maximumSafePosition: 10, - time: 10 } }, - uris: ["url3", "url4"] }; - - manifest.replace(newManifest); - - expect(manifest.periods).toEqual([ newPeriod1, - newPeriod2, - newPeriod3, - newPeriod4, - newPeriod5 ]); - - expect(fakeUpdatePeriodInPlace).toHaveBeenCalledTimes(2); - expect(fakeUpdatePeriodInPlace).toHaveBeenCalledWith(oldPeriod1, newPeriod2, 0); - expect(fakeUpdatePeriodInPlace).toHaveBeenCalledWith(oldPeriod2, newPeriod4, 0); - expect(mockTrigger).toHaveBeenCalledTimes(1); - expect(mockTrigger).toHaveBeenCalledWith("manifestUpdate", null); - expect(fakeIdGenerator).toHaveBeenCalledTimes(2); - expect(fakeGenerateNewId).toHaveBeenCalledTimes(1); - // expect(fakeLogger.info).toHaveBeenCalledTimes(5); - mockTrigger.mockRestore(); - }); }); diff --git a/src/manifest/__tests__/update_period_in_place.test.ts b/src/manifest/__tests__/update_period_in_place.test.ts index f611128c67..d09606c5ed 100644 --- a/src/manifest/__tests__/update_period_in_place.test.ts +++ b/src/manifest/__tests__/update_period_in_place.test.ts @@ -155,14 +155,17 @@ describe("Manifest - updatePeriodInPlace", () => { it("should fully update the first Period given by the second one in a full update", () => { /* eslint-enable max-len */ const oldVideoAdaptation1 = { contentWarnings: [], + type: "video", id: "ada-video-1", representations: [oldVideoRepresentation1, oldVideoRepresentation2] }; const oldVideoAdaptation2 = { contentWarnings: [], + type: "video", id: "ada-video-2", representations: [oldVideoRepresentation3, oldVideoRepresentation4] }; const oldAudioAdaptation = { contentWarnings: [], + type: "audio", id: "ada-audio-1", representations: [oldAudioRepresentation] }; const oldPeriod = { @@ -180,14 +183,17 @@ describe("Manifest - updatePeriodInPlace", () => { }, }; const newVideoAdaptation1 = { contentWarnings: [], + type: "video", id: "ada-video-1", representations: [newVideoRepresentation1, newVideoRepresentation2] }; const newVideoAdaptation2 = { contentWarnings: [], + type: "video", id: "ada-video-2", representations: [newVideoRepresentation3, newVideoRepresentation4] }; const newAudioAdaptation = { contentWarnings: [], + type: "audio", id: "ada-audio-1", representations: [newAudioRepresentation] }; const newPeriod = { @@ -209,9 +215,40 @@ describe("Manifest - updatePeriodInPlace", () => { const newPeriodAdaptations = jest.spyOn(newPeriod, "getAdaptations"); const mockLog = jest.spyOn(log, "warn"); - updatePeriodInPlace(oldPeriod as unknown as Period, - newPeriod as unknown as Period, - MANIFEST_UPDATE_TYPE.Full); + const res = updatePeriodInPlace(oldPeriod as unknown as Period, + newPeriod as unknown as Period, + MANIFEST_UPDATE_TYPE.Full); + + expect(res).toEqual({ addedAdaptations: [], + removedAdaptations: [], + updatedAdaptations: [ + { + adaptation: oldVideoAdaptation1, + addedRepresentations: [], + removedRepresentations: [], + updatedRepresentations: [ + oldVideoRepresentation1, + oldVideoRepresentation2, + ], + }, + { + adaptation: oldVideoAdaptation2, + addedRepresentations: [], + removedRepresentations: [], + updatedRepresentations: [ + oldVideoRepresentation3, + oldVideoRepresentation4, + ], + }, + { + adaptation: oldAudioAdaptation, + addedRepresentations: [], + removedRepresentations: [], + updatedRepresentations: [ + oldAudioRepresentation, + ], + }, + ] }); expect(oldPeriod.start).toEqual(500); expect(oldPeriod.end).toEqual(520); @@ -260,7 +297,7 @@ describe("Manifest - updatePeriodInPlace", () => { expect(mockNewVideoRepresentation4Update).not.toHaveBeenCalled(); expect(mockNewAudioRepresentationUpdate).not.toHaveBeenCalled(); - expect(mockLog).not.toHaveBeenCalled(); + expect(mockLog).toHaveBeenCalledTimes(0); mockLog.mockRestore(); }); @@ -269,14 +306,17 @@ describe("Manifest - updatePeriodInPlace", () => { /* eslint-enable max-len */ const oldVideoAdaptation1 = { contentWarnings: [], id: "ada-video-1", + type: "video", representations: [oldVideoRepresentation1, oldVideoRepresentation2] }; const oldVideoAdaptation2 = { contentWarnings: [], id: "ada-video-2", + type: "video", representations: [oldVideoRepresentation3, oldVideoRepresentation4] }; const oldAudioAdaptation = { contentWarnings: [], id: "ada-audio-1", + type: "audio", representations: [oldAudioRepresentation] }; const oldPeriod = { contentWarnings: [], @@ -294,14 +334,17 @@ describe("Manifest - updatePeriodInPlace", () => { }; const newVideoAdaptation1 = { contentWarnings: [], id: "ada-video-1", + type: "video", representations: [newVideoRepresentation1, newVideoRepresentation2] }; const newVideoAdaptation2 = { contentWarnings: [], id: "ada-video-2", + type: "video", representations: [newVideoRepresentation3, newVideoRepresentation4] }; const newAudioAdaptation = { contentWarnings: [], id: "ada-audio-1", + type: "audio", representations: [newAudioRepresentation] }; const newPeriod = { contentWarnings: [], @@ -322,9 +365,39 @@ describe("Manifest - updatePeriodInPlace", () => { const mockNewPeriodGetAdaptations = jest.spyOn(newPeriod, "getAdaptations"); const mockLog = jest.spyOn(log, "warn"); - updatePeriodInPlace(oldPeriod as unknown as Period, - newPeriod as unknown as Period, - MANIFEST_UPDATE_TYPE.Partial); + const res = updatePeriodInPlace(oldPeriod as unknown as Period, + newPeriod as unknown as Period, + MANIFEST_UPDATE_TYPE.Partial); + expect(res).toEqual({ addedAdaptations: [], + removedAdaptations: [], + updatedAdaptations: [ + { + adaptation: oldVideoAdaptation1, + addedRepresentations: [], + removedRepresentations: [], + updatedRepresentations: [ + oldVideoRepresentation1, + oldVideoRepresentation2, + ], + }, + { + adaptation: oldVideoAdaptation2, + addedRepresentations: [], + removedRepresentations: [], + updatedRepresentations: [ + oldVideoRepresentation3, + oldVideoRepresentation4, + ], + }, + { + adaptation: oldAudioAdaptation, + addedRepresentations: [], + removedRepresentations: [], + updatedRepresentations: [ + oldAudioRepresentation, + ], + }, + ] }); expect(oldPeriod.start).toEqual(500); expect(oldPeriod.end).toEqual(520); @@ -373,16 +446,18 @@ describe("Manifest - updatePeriodInPlace", () => { expect(mockNewVideoRepresentation4Replace).not.toHaveBeenCalled(); expect(mockNewAudioRepresentationReplace).not.toHaveBeenCalled(); - expect(mockLog).not.toHaveBeenCalled(); + expect(mockLog).toHaveBeenCalledTimes(0); mockLog.mockRestore(); }); - it("should do nothing with new Adaptations", () => { + it("should add new Adaptations in Full mode", () => { const oldVideoAdaptation1 = { contentWarnings: [], + type: "video", id: "ada-video-1", representations: [oldVideoRepresentation1, oldVideoRepresentation2] }; const oldAudioAdaptation = { contentWarnings: [], + type: "audio", id: "ada-audio-1", representations: [oldAudioRepresentation] }; const oldPeriod = { @@ -399,14 +474,17 @@ describe("Manifest - updatePeriodInPlace", () => { }; const newVideoAdaptation1 = { contentWarnings: [], id: "ada-video-1", + type: "video", representations: [newVideoRepresentation1, newVideoRepresentation2] }; const newVideoAdaptation2 = { contentWarnings: [], id: "ada-video-2", + type: "video", representations: [newVideoRepresentation3, newVideoRepresentation4] }; const newAudioAdaptation = { contentWarnings: [], id: "ada-audio-1", + type: "audio", representations: [newAudioRepresentation] }; const newPeriod = { contentWarnings: [], @@ -424,36 +502,144 @@ describe("Manifest - updatePeriodInPlace", () => { }; const mockLog = jest.spyOn(log, "warn"); - updatePeriodInPlace(oldPeriod as unknown as Period, - newPeriod as unknown as Period, - MANIFEST_UPDATE_TYPE.Full); - expect(mockLog).not.toHaveBeenCalled(); - expect(oldPeriod.adaptations.video).toHaveLength(1); - updatePeriodInPlace(oldPeriod as unknown as Period, - newPeriod as unknown as Period, - MANIFEST_UPDATE_TYPE.Partial); - expect(mockLog).not.toHaveBeenCalled(); - expect(oldPeriod.adaptations.video).toHaveLength(1); + const res = updatePeriodInPlace(oldPeriod as unknown as Period, + newPeriod as unknown as Period, + MANIFEST_UPDATE_TYPE.Full); + expect(res).toEqual({ addedAdaptations: [newVideoAdaptation2], + removedAdaptations: [], + updatedAdaptations: [ + { + adaptation: oldVideoAdaptation1, + addedRepresentations: [], + removedRepresentations: [], + updatedRepresentations: [ + oldVideoRepresentation1, + oldVideoRepresentation2, + ], + }, + { + adaptation: oldAudioAdaptation, + addedRepresentations: [], + removedRepresentations: [], + updatedRepresentations: [ + oldAudioRepresentation, + ], + }, + ] }); + expect(mockLog).toHaveBeenCalled(); + expect(mockLog).toHaveBeenNthCalledWith( + 1, + "Manifest: 1 new Adaptations found when merging." + ); + expect(oldPeriod.adaptations.video).toHaveLength(2); mockLog.mockRestore(); }); - it("should warn if an old Adaptation is not found", () => { + it("should add new Adaptations in Partial mode", () => { const oldVideoAdaptation1 = { contentWarnings: [], + type: "video", id: "ada-video-1", representations: [oldVideoRepresentation1, oldVideoRepresentation2] }; - const oldVideoAdaptation2 = { contentWarnings: [], - id: "ada-video-2", - representations: [oldVideoRepresentation3, - oldVideoRepresentation4] }; const oldAudioAdaptation = { contentWarnings: [], + type: "audio", id: "ada-audio-1", representations: [oldAudioRepresentation] }; const oldPeriod = { + contentWarnings: [], + start: 5, + end: 15, + duration: 10, + adaptations: { video: [oldVideoAdaptation1], + audio: [oldAudioAdaptation] }, + getAdaptations() { + return [oldVideoAdaptation1, + oldAudioAdaptation]; + }, + }; + const newVideoAdaptation1 = { contentWarnings: [], + id: "ada-video-1", + type: "video", + representations: [newVideoRepresentation1, + newVideoRepresentation2] }; + const newVideoAdaptation2 = { contentWarnings: [], + id: "ada-video-2", + type: "video", + representations: [newVideoRepresentation3, + newVideoRepresentation4] }; + const newAudioAdaptation = { contentWarnings: [], + id: "ada-audio-1", + type: "audio", + representations: [newAudioRepresentation] }; + const newPeriod = { contentWarnings: [], start: 500, end: 520, duration: 20, + adaptations: { video: [newVideoAdaptation1, + newVideoAdaptation2], + audio: [newAudioAdaptation] }, + getAdaptations() { + return [newVideoAdaptation1, + newVideoAdaptation2, + newAudioAdaptation]; + }, + }; + + const mockLog = jest.spyOn(log, "warn"); + const res = updatePeriodInPlace(oldPeriod as unknown as Period, + newPeriod as unknown as Period, + MANIFEST_UPDATE_TYPE.Partial); + expect(res).toEqual({ addedAdaptations: [newVideoAdaptation2], + removedAdaptations: [], + updatedAdaptations: [ + { + adaptation: oldVideoAdaptation1, + addedRepresentations: [], + removedRepresentations: [], + updatedRepresentations: [ + oldVideoRepresentation1, + oldVideoRepresentation2, + ], + }, + { + adaptation: oldAudioAdaptation, + addedRepresentations: [], + removedRepresentations: [], + updatedRepresentations: [ + oldAudioRepresentation, + ], + }, + ] }); + expect(mockLog).toHaveBeenCalled(); + expect(mockLog).toHaveBeenNthCalledWith( + 1, + "Manifest: 1 new Adaptations found when merging." + ); + expect(oldPeriod.adaptations.video).toHaveLength(2); + mockLog.mockRestore(); + }); + + it("should remove unfound Adaptations in Full mode", () => { + const oldVideoAdaptation1 = { contentWarnings: [], + type: "video", + id: "ada-video-1", + representations: [oldVideoRepresentation1, + oldVideoRepresentation2] }; + const oldVideoAdaptation2 = { contentWarnings: [], + id: "ada-video-2", + type: "video", + representations: [newVideoRepresentation3, + newVideoRepresentation4] }; + const oldAudioAdaptation = { contentWarnings: [], + type: "audio", + id: "ada-audio-1", + representations: [oldAudioRepresentation] }; + const oldPeriod = { + contentWarnings: [], + start: 5, + end: 15, + duration: 10, adaptations: { video: [oldVideoAdaptation1, oldVideoAdaptation2], audio: [oldAudioAdaptation] }, @@ -465,49 +651,223 @@ describe("Manifest - updatePeriodInPlace", () => { }; const newVideoAdaptation1 = { contentWarnings: [], id: "ada-video-1", + type: "video", representations: [newVideoRepresentation1, newVideoRepresentation2] }; const newAudioAdaptation = { contentWarnings: [], id: "ada-audio-1", + type: "audio", representations: [newAudioRepresentation] }; const newPeriod = { + contentWarnings: [], + start: 500, + end: 520, + duration: 20, + adaptations: { video: [newVideoAdaptation1], + audio: [newAudioAdaptation] }, + getAdaptations() { + return [newVideoAdaptation1, + newAudioAdaptation]; + }, + }; + + const mockLog = jest.spyOn(log, "warn"); + const res = updatePeriodInPlace(oldPeriod as unknown as Period, + newPeriod as unknown as Period, + MANIFEST_UPDATE_TYPE.Full); + expect(res).toEqual({ addedAdaptations: [], + removedAdaptations: [oldVideoAdaptation2], + updatedAdaptations: [ + { + adaptation: oldVideoAdaptation1, + addedRepresentations: [], + removedRepresentations: [], + updatedRepresentations: [ + oldVideoRepresentation1, + oldVideoRepresentation2, + ], + }, + { + adaptation: oldAudioAdaptation, + addedRepresentations: [], + removedRepresentations: [], + updatedRepresentations: [ + oldAudioRepresentation, + ], + }, + ] }); + expect(mockLog).toHaveBeenCalled(); + expect(mockLog).toHaveBeenNthCalledWith( + 1, + "Manifest: Adaptation \"ada-video-2\" not found when merging." + ); + expect(oldPeriod.adaptations.video).toHaveLength(2); + mockLog.mockRestore(); + }); + + it("should remove unfound Adaptations in Partial mode", () => { + const oldVideoAdaptation1 = { contentWarnings: [], + type: "video", + id: "ada-video-1", + representations: [oldVideoRepresentation1, + oldVideoRepresentation2] }; + const oldVideoAdaptation2 = { contentWarnings: [], + id: "ada-video-2", + type: "video", + representations: [newVideoRepresentation3, + newVideoRepresentation4] }; + const oldAudioAdaptation = { contentWarnings: [], + type: "audio", + id: "ada-audio-1", + representations: [oldAudioRepresentation] }; + const oldPeriod = { contentWarnings: [], start: 5, end: 15, duration: 10, + adaptations: { video: [oldVideoAdaptation1, + oldVideoAdaptation2], + audio: [oldAudioAdaptation] }, + getAdaptations() { + return [oldVideoAdaptation1, + oldVideoAdaptation2, + oldAudioAdaptation]; + }, + }; + const newVideoAdaptation1 = { contentWarnings: [], + id: "ada-video-1", + type: "video", + representations: [newVideoRepresentation1, + newVideoRepresentation2] }; + const newAudioAdaptation = { contentWarnings: [], + id: "ada-audio-1", + type: "audio", + representations: [newAudioRepresentation] }; + const newPeriod = { + contentWarnings: [], + start: 500, + end: 520, + duration: 20, adaptations: { video: [newVideoAdaptation1], audio: [newAudioAdaptation] }, getAdaptations() { - return [newVideoAdaptation1, newAudioAdaptation]; + return [newVideoAdaptation1, + newAudioAdaptation]; }, }; const mockLog = jest.spyOn(log, "warn"); - updatePeriodInPlace(oldPeriod as unknown as Period, - newPeriod as unknown as Period, - MANIFEST_UPDATE_TYPE.Full); - expect(mockLog).toHaveBeenCalledTimes(1); - expect(mockLog).toHaveBeenCalledWith( + const res = updatePeriodInPlace(oldPeriod as unknown as Period, + newPeriod as unknown as Period, + MANIFEST_UPDATE_TYPE.Partial); + expect(res).toEqual({ addedAdaptations: [], + removedAdaptations: [oldVideoAdaptation2], + updatedAdaptations: [ + { + adaptation: oldVideoAdaptation1, + addedRepresentations: [], + removedRepresentations: [], + updatedRepresentations: [ + oldVideoRepresentation1, + oldVideoRepresentation2, + ], + }, + { + adaptation: oldAudioAdaptation, + addedRepresentations: [], + removedRepresentations: [], + updatedRepresentations: [ + oldAudioRepresentation, + ], + }, + ] }); + expect(mockLog).toHaveBeenCalled(); + expect(mockLog).toHaveBeenNthCalledWith( + 1, "Manifest: Adaptation \"ada-video-2\" not found when merging." ); expect(oldPeriod.adaptations.video).toHaveLength(2); - mockLog.mockClear(); - updatePeriodInPlace(oldPeriod as unknown as Period, - newPeriod as unknown as Period, - MANIFEST_UPDATE_TYPE.Partial); + mockLog.mockRestore(); + }); + + it("should add new Representations in Full mode", () => { + const oldVideoAdaptation1 = { contentWarnings: [], + type: "video", + id: "ada-video-1", + representations: [oldVideoRepresentation1] }; + const oldAudioAdaptation = { contentWarnings: [], + type: "audio", + id: "ada-audio-1", + representations: [oldAudioRepresentation] }; + const oldPeriod = { contentWarnings: [], + start: 5, + end: 15, + duration: 10, + adaptations: { video: [oldVideoAdaptation1], + audio: [oldAudioAdaptation] }, + getAdaptations() { return [oldVideoAdaptation1, + oldAudioAdaptation]; } }; + + const newVideoAdaptation1 = { contentWarnings: [], + id: "ada-video-1", + type: "video", + representations: [newVideoRepresentation1, + newVideoRepresentation2] }; + const newAudioAdaptation = { contentWarnings: [], + id: "ada-audio-1", + type: "audio", + representations: [newAudioRepresentation] }; + const newPeriod = { + contentWarnings: [], + start: 500, + end: 520, + duration: 20, + adaptations: { video: [newVideoAdaptation1], + audio: [newAudioAdaptation] }, + getAdaptations() { + return [newVideoAdaptation1, newAudioAdaptation]; + }, + }; + + const mockLog = jest.spyOn(log, "warn"); + const res = updatePeriodInPlace(oldPeriod as unknown as Period, + newPeriod as unknown as Period, + MANIFEST_UPDATE_TYPE.Full); + expect(res).toEqual({ addedAdaptations: [], + removedAdaptations: [], + updatedAdaptations: [ + { + adaptation: oldVideoAdaptation1, + addedRepresentations: [newVideoRepresentation2], + removedRepresentations: [], + updatedRepresentations: [ + oldVideoRepresentation1, + ], + }, + { + adaptation: oldAudioAdaptation, + addedRepresentations: [], + removedRepresentations: [], + updatedRepresentations: [ + oldAudioRepresentation, + ], + }, + ] }); expect(mockLog).toHaveBeenCalledTimes(1); expect(mockLog).toHaveBeenCalledWith( - "Manifest: Adaptation \"ada-video-2\" not found when merging." + "Manifest: 1 new Representations found when merging." ); - expect(oldPeriod.adaptations.video).toHaveLength(2); + expect(oldVideoAdaptation1.representations).toHaveLength(2); mockLog.mockRestore(); }); - it("should do nothing with new Representations", () => { + it("should add new Representations in Partial mode", () => { const oldVideoAdaptation1 = { contentWarnings: [], + type: "video", id: "ada-video-1", representations: [oldVideoRepresentation1] }; const oldAudioAdaptation = { contentWarnings: [], + type: "audio", id: "ada-audio-1", representations: [oldAudioRepresentation] }; const oldPeriod = { contentWarnings: [], @@ -521,10 +881,12 @@ describe("Manifest - updatePeriodInPlace", () => { const newVideoAdaptation1 = { contentWarnings: [], id: "ada-video-1", + type: "video", representations: [newVideoRepresentation1, newVideoRepresentation2] }; const newAudioAdaptation = { contentWarnings: [], id: "ada-audio-1", + type: "audio", representations: [newAudioRepresentation] }; const newPeriod = { contentWarnings: [], @@ -539,26 +901,46 @@ describe("Manifest - updatePeriodInPlace", () => { }; const mockLog = jest.spyOn(log, "warn"); - updatePeriodInPlace(oldPeriod as unknown as Period, - newPeriod as unknown as Period, - MANIFEST_UPDATE_TYPE.Full); - expect(mockLog).not.toHaveBeenCalled(); - expect(oldVideoAdaptation1.representations).toHaveLength(1); - updatePeriodInPlace(oldPeriod as unknown as Period, - newPeriod as unknown as Period, - MANIFEST_UPDATE_TYPE.Partial); - expect(mockLog).not.toHaveBeenCalled(); - expect(oldVideoAdaptation1.representations).toHaveLength(1); + const res = updatePeriodInPlace(oldPeriod as unknown as Period, + newPeriod as unknown as Period, + MANIFEST_UPDATE_TYPE.Partial); + expect(res).toEqual({ addedAdaptations: [], + removedAdaptations: [], + updatedAdaptations: [ + { + adaptation: oldVideoAdaptation1, + addedRepresentations: [newVideoRepresentation2], + removedRepresentations: [], + updatedRepresentations: [ + oldVideoRepresentation1, + ], + }, + { + adaptation: oldAudioAdaptation, + addedRepresentations: [], + removedRepresentations: [], + updatedRepresentations: [ + oldAudioRepresentation, + ], + }, + ] }); + expect(mockLog).toHaveBeenCalledTimes(1); + expect(mockLog).toHaveBeenCalledWith( + "Manifest: 1 new Representations found when merging." + ); + expect(oldVideoAdaptation1.representations).toHaveLength(2); mockLog.mockRestore(); }); - it("should warn if an old Representation is not found", () => { + it("should remove an old Representation that is not found in Full mode", () => { const oldVideoAdaptation1 = { contentWarnings: [], id: "ada-video-1", + type: "video", representations: [oldVideoRepresentation1, oldVideoRepresentation2] }; const oldAudioAdaptation = { contentWarnings: [], id: "ada-audio-1", + type: "audio", representations: [oldAudioRepresentation] }; const oldPeriod = { contentWarnings: [], start: 500, @@ -570,9 +952,11 @@ describe("Manifest - updatePeriodInPlace", () => { oldAudioAdaptation]; } }; const newVideoAdaptation1 = { contentWarnings: [], id: "ada-video-1", + type: "video", representations: [newVideoRepresentation1] }; const newAudioAdaptation = { contentWarnings: [], id: "ada-audio-1", + type: "audio", representations: [newAudioRepresentation] }; const newPeriod = { contentWarnings: [], start: 5, @@ -587,23 +971,104 @@ describe("Manifest - updatePeriodInPlace", () => { }; const mockLog = jest.spyOn(log, "warn"); - updatePeriodInPlace(oldPeriod as unknown as Period, - newPeriod as unknown as Period, - MANIFEST_UPDATE_TYPE.Full); + const res = updatePeriodInPlace(oldPeriod as unknown as Period, + newPeriod as unknown as Period, + MANIFEST_UPDATE_TYPE.Full); + expect(res).toEqual({ addedAdaptations: [], + removedAdaptations: [], + updatedAdaptations: [ + { + adaptation: oldVideoAdaptation1, + addedRepresentations: [], + removedRepresentations: [oldVideoRepresentation2], + updatedRepresentations: [ + oldVideoRepresentation1, + ], + }, + { + adaptation: oldAudioAdaptation, + addedRepresentations: [], + removedRepresentations: [], + updatedRepresentations: [ + oldAudioRepresentation, + ], + }, + ] }); expect(mockLog).toHaveBeenCalledTimes(1); expect(mockLog).toHaveBeenCalledWith( "Manifest: Representation \"rep-video-2\" not found when merging." ); - expect(oldVideoAdaptation1.representations).toHaveLength(2); - mockLog.mockClear(); - updatePeriodInPlace(oldPeriod as unknown as Period, - newPeriod as unknown as Period, - MANIFEST_UPDATE_TYPE.Partial); + expect(oldVideoAdaptation1.representations).toHaveLength(1); + mockLog.mockRestore(); + }); + + it("should remove an old Representation that is not found in Partial mode", () => { + const oldVideoAdaptation1 = { contentWarnings: [], + id: "ada-video-1", + type: "video", + representations: [oldVideoRepresentation1, + oldVideoRepresentation2] }; + const oldAudioAdaptation = { contentWarnings: [], + id: "ada-audio-1", + type: "audio", + representations: [oldAudioRepresentation] }; + const oldPeriod = { contentWarnings: [], + start: 500, + end: 520, + duration: 20, + adaptations: { video: [oldVideoAdaptation1], + audio: [oldAudioAdaptation] }, + getAdaptations() { return [oldVideoAdaptation1, + oldAudioAdaptation]; } }; + const newVideoAdaptation1 = { contentWarnings: [], + id: "ada-video-1", + type: "video", + representations: [newVideoRepresentation1] }; + const newAudioAdaptation = { contentWarnings: [], + id: "ada-audio-1", + type: "audio", + representations: [newAudioRepresentation] }; + const newPeriod = { contentWarnings: [], + start: 5, + end: 15, + duration: 10, + adaptations: { video: [newVideoAdaptation1], + audio: [newAudioAdaptation], + }, + getAdaptations() { + return [newVideoAdaptation1, newAudioAdaptation]; + }, + }; + + const mockLog = jest.spyOn(log, "warn"); + const res = updatePeriodInPlace(oldPeriod as unknown as Period, + newPeriod as unknown as Period, + MANIFEST_UPDATE_TYPE.Partial); + expect(res).toEqual({ addedAdaptations: [], + removedAdaptations: [], + updatedAdaptations: [ + { + adaptation: oldVideoAdaptation1, + addedRepresentations: [], + removedRepresentations: [oldVideoRepresentation2], + updatedRepresentations: [ + oldVideoRepresentation1, + ], + }, + { + adaptation: oldAudioAdaptation, + addedRepresentations: [], + removedRepresentations: [], + updatedRepresentations: [ + oldAudioRepresentation, + ], + }, + ] }); expect(mockLog).toHaveBeenCalledTimes(1); expect(mockLog).toHaveBeenCalledWith( "Manifest: Representation \"rep-video-2\" not found when merging." ); - expect(oldVideoAdaptation1.representations).toHaveLength(2); + expect(oldVideoAdaptation1.representations).toHaveLength(1); mockLog.mockRestore(); }); }); diff --git a/src/manifest/__tests__/update_periods.test.ts b/src/manifest/__tests__/update_periods.test.ts index 10869937d4..c4f0b018c3 100644 --- a/src/manifest/__tests__/update_periods.test.ts +++ b/src/manifest/__tests__/update_periods.test.ts @@ -27,6 +27,12 @@ const MANIFEST_UPDATE_TYPE = { Partial: 1, }; +const fakeUpdatePeriodInPlaceRes = { + updatedAdaptations: [], + removedAdaptations: [], + addedAdaptations: [], +}; + describe("Manifest - replacePeriods", () => { beforeEach(() => { jest.resetModules(); @@ -37,7 +43,9 @@ describe("Manifest - replacePeriods", () => { // old periods : p1, p2 // new periods : p2 it("should remove old period", () => { - const fakeUpdatePeriodInPlace = jest.fn(() => { return; }); + const fakeUpdatePeriodInPlace = jest.fn(() => { + return fakeUpdatePeriodInPlaceRes; + }); jest.mock("../update_period_in_place", () => ({ __esModule: true as const, default: fakeUpdatePeriodInPlace, @@ -50,7 +58,14 @@ describe("Manifest - replacePeriods", () => { { id: "p2" }, ] as any; const replacePeriods = jest.requireActual("../update_periods").replacePeriods; - replacePeriods(oldPeriods, newPeriods); + const res = replacePeriods(oldPeriods, newPeriods); + expect(res).toEqual({ + addedPeriods: [], + removedPeriods: [{ id: "p1" }], + updatedPeriods: [ + { period: { id: "p2" }, result: fakeUpdatePeriodInPlaceRes }, + ], + }); expect(oldPeriods.length).toBe(1); expect(oldPeriods[0].id).toBe("p2"); expect(fakeUpdatePeriodInPlace).toHaveBeenCalledTimes(1); @@ -65,7 +80,9 @@ describe("Manifest - replacePeriods", () => { // old periods : p1 // new periods : p1, p2 it("should add new period", () => { - const fakeUpdatePeriodInPlace = jest.fn(() => { return; }); + const fakeUpdatePeriodInPlace = jest.fn(() => { + return fakeUpdatePeriodInPlaceRes; + }); jest.mock("../update_period_in_place", () => ({ __esModule: true as const, default: fakeUpdatePeriodInPlace, @@ -78,7 +95,14 @@ describe("Manifest - replacePeriods", () => { { id: "p3" }, ] as any; const replacePeriods = jest.requireActual("../update_periods").replacePeriods; - replacePeriods(oldPeriods, newPeriods); + const res = replacePeriods(oldPeriods, newPeriods); + expect(res).toEqual({ + addedPeriods: [{ id: "p3" }], + removedPeriods: [], + updatedPeriods: [ + { period: { id: "p2" }, result: fakeUpdatePeriodInPlaceRes }, + ], + }); expect(oldPeriods.length).toBe(2); expect(oldPeriods[0].id).toBe("p2"); expect(oldPeriods[1].id).toBe("p3"); @@ -94,7 +118,9 @@ describe("Manifest - replacePeriods", () => { // old periods: p1 // new periods: p2 it("should replace period", () => { - const fakeUpdatePeriodInPlace = jest.fn(() => { return; }); + const fakeUpdatePeriodInPlace = jest.fn(() => { + return fakeUpdatePeriodInPlaceRes; + }); jest.mock("../update_period_in_place", () => ({ __esModule: true as const, default: fakeUpdatePeriodInPlace, @@ -106,7 +132,12 @@ describe("Manifest - replacePeriods", () => { { id: "p2" }, ] as any; const replacePeriods = jest.requireActual("../update_periods").replacePeriods; - replacePeriods(oldPeriods, newPeriods); + const res = replacePeriods(oldPeriods, newPeriods); + expect(res).toEqual({ + addedPeriods: [{ id: "p2" }], + removedPeriods: [{ id: "p1" }], + updatedPeriods: [], + }); expect(oldPeriods.length).toBe(1); expect(oldPeriods[0].id).toBe("p2"); expect(fakeUpdatePeriodInPlace).toHaveBeenCalledTimes(0); @@ -117,7 +148,9 @@ describe("Manifest - replacePeriods", () => { // old periods: p0, p1, p2 // new periods: p1, a, b, p2, p3 it("should handle more complex period replacement", () => { - const fakeUpdatePeriodInPlace = jest.fn(() => { return; }); + const fakeUpdatePeriodInPlace = jest.fn(() => { + return fakeUpdatePeriodInPlaceRes; + }); jest.mock("../update_period_in_place", () => ({ __esModule: true as const, default: fakeUpdatePeriodInPlace, @@ -135,7 +168,19 @@ describe("Manifest - replacePeriods", () => { { id: "p3" }, ] as any; const replacePeriods = jest.requireActual("../update_periods").replacePeriods; - replacePeriods(oldPeriods, newPeriods); + const res = replacePeriods(oldPeriods, newPeriods); + expect(res).toEqual({ + addedPeriods: [ + { id: "a" }, + { id: "b" }, + { id: "p3" }, + ], + removedPeriods: [{ id: "p0" }], + updatedPeriods: [ + { period: { id: "p1" }, result: fakeUpdatePeriodInPlaceRes }, + { period: { id: "p2", start: 0 }, result: fakeUpdatePeriodInPlaceRes }, + ], + }); expect(oldPeriods.length).toBe(5); expect(oldPeriods[0].id).toBe("p1"); @@ -159,7 +204,9 @@ describe("Manifest - replacePeriods", () => { // old periods : p2 // new periods : p1, p2 it("should add new period before", () => { - const fakeUpdatePeriodInPlace = jest.fn(() => { return; }); + const fakeUpdatePeriodInPlace = jest.fn(() => { + return fakeUpdatePeriodInPlaceRes; + }); jest.mock("../update_period_in_place", () => ({ __esModule: true as const, default: fakeUpdatePeriodInPlace, @@ -172,7 +219,16 @@ describe("Manifest - replacePeriods", () => { { id: "p2" }, ] as any; const replacePeriods = jest.requireActual("../update_periods").replacePeriods; - replacePeriods(oldPeriods, newPeriods); + const res = replacePeriods(oldPeriods, newPeriods); + expect(res).toEqual({ + addedPeriods: [ + { id: "p1" }, + ], + removedPeriods: [], + updatedPeriods: [ + { period: { id: "p2" }, result: fakeUpdatePeriodInPlaceRes }, + ], + }); expect(oldPeriods.length).toBe(2); expect(oldPeriods[0].id).toBe("p1"); expect(oldPeriods[1].id).toBe("p2"); @@ -188,7 +244,9 @@ describe("Manifest - replacePeriods", () => { // old periods : p1, p2 // new periods : No periods it("should remove all periods", () => { - const fakeUpdatePeriodInPlace = jest.fn(() => { return; }); + const fakeUpdatePeriodInPlace = jest.fn(() => { + return fakeUpdatePeriodInPlaceRes; + }); jest.mock("../update_period_in_place", () => ({ __esModule: true as const, default: fakeUpdatePeriodInPlace, @@ -199,7 +257,15 @@ describe("Manifest - replacePeriods", () => { ] as any; const newPeriods = [] as any; const replacePeriods = jest.requireActual("../update_periods").replacePeriods; - replacePeriods(oldPeriods, newPeriods); + const res = replacePeriods(oldPeriods, newPeriods); + expect(res).toEqual({ + addedPeriods: [], + removedPeriods: [ + { id: "p1" }, + { id: "p2" }, + ], + updatedPeriods: [], + }); expect(oldPeriods.length).toBe(0); expect(fakeUpdatePeriodInPlace).toHaveBeenCalledTimes(0); }); @@ -209,7 +275,9 @@ describe("Manifest - replacePeriods", () => { // old periods : No periods // new periods : p1, p2 it("should add all periods to empty array", () => { - const fakeUpdatePeriodInPlace = jest.fn(() => { return; }); + const fakeUpdatePeriodInPlace = jest.fn(() => { + return fakeUpdatePeriodInPlaceRes; + }); jest.mock("../update_period_in_place", () => ({ __esModule: true as const, default: fakeUpdatePeriodInPlace, @@ -220,7 +288,15 @@ describe("Manifest - replacePeriods", () => { { id: "p2" }, ] as any; const replacePeriods = jest.requireActual("../update_periods").replacePeriods; - replacePeriods(oldPeriods, newPeriods); + const res = replacePeriods(oldPeriods, newPeriods); + expect(res).toEqual({ + addedPeriods: [ + { id: "p1" }, + { id: "p2" }, + ], + removedPeriods: [], + updatedPeriods: [], + }); expect(oldPeriods.length).toBe(2); expect(oldPeriods[0].id).toBe("p1"); expect(oldPeriods[1].id).toBe("p2"); @@ -228,7 +304,7 @@ describe("Manifest - replacePeriods", () => { }); }); -describe("updatePeriods", () => { +describe("Manifest - updatePeriods", () => { beforeEach(() => { jest.resetModules(); }); @@ -238,7 +314,9 @@ describe("updatePeriods", () => { // old periods : p1, p2 // new periods : p2 it("should not remove old period", () => { - const fakeUpdatePeriodInPlace = jest.fn(() => { return; }); + const fakeUpdatePeriodInPlace = jest.fn(() => { + return fakeUpdatePeriodInPlaceRes; + }); jest.mock("../update_period_in_place", () => ({ __esModule: true as const, default: fakeUpdatePeriodInPlace, @@ -248,7 +326,14 @@ describe("updatePeriods", () => { const newPeriods = [ { id: "p2", start: 60 } ] as any; const updatePeriods = jest.requireActual("../update_periods").updatePeriods; - updatePeriods(oldPeriods, newPeriods); + const res = updatePeriods(oldPeriods, newPeriods); + expect(res).toEqual({ + addedPeriods: [], + removedPeriods: [], + updatedPeriods: [ + { period: { id: "p2", start: 60 }, result: fakeUpdatePeriodInPlaceRes }, + ], + }); expect(oldPeriods.length).toBe(2); expect(oldPeriods[0].id).toBe("p1"); expect(fakeUpdatePeriodInPlace).toHaveBeenCalledTimes(1); @@ -263,7 +348,9 @@ describe("updatePeriods", () => { // old periods : p1 // new periods : p1, p2 it("should add new period", () => { - const fakeUpdatePeriodInPlace = jest.fn(() => { return; }); + const fakeUpdatePeriodInPlace = jest.fn(() => { + return fakeUpdatePeriodInPlaceRes; + }); jest.mock("../update_period_in_place", () => ({ __esModule: true as const, default: fakeUpdatePeriodInPlace, @@ -272,7 +359,14 @@ describe("updatePeriods", () => { const newPeriods = [ { id: "p2", start: 60, end: 80 }, { id: "p3", start: 80 } ] as any; const updatePeriods = jest.requireActual("../update_periods").updatePeriods; - updatePeriods(oldPeriods, newPeriods); + const res = updatePeriods(oldPeriods, newPeriods); + expect(res).toEqual({ + addedPeriods: [{ id: "p3", start: 80 }], + removedPeriods: [], + updatedPeriods: [ + { period: { id: "p2", start: 60 }, result: fakeUpdatePeriodInPlaceRes }, + ], + }); expect(oldPeriods.length).toBe(2); expect(oldPeriods[0].id).toBe("p2"); expect(oldPeriods[1].id).toBe("p3"); @@ -289,7 +383,9 @@ describe("updatePeriods", () => { // old periods: p1 // new periods: p3 it("should throw when encountering two distant Periods", () => { - const fakeUpdatePeriodInPlace = jest.fn(() => { return; }); + const fakeUpdatePeriodInPlace = jest.fn(() => { + return fakeUpdatePeriodInPlaceRes; + }); jest.mock("../update_period_in_place", () => ({ __esModule: true as const, default: fakeUpdatePeriodInPlace, @@ -326,11 +422,14 @@ describe("updatePeriods", () => { // old periods: p0, p1, p2 // new periods: p1, a, b, p2, p3 it("should handle more complex period replacement", () => { - const fakeUpdatePeriodInPlace = jest.fn(() => { return; }); + const fakeUpdatePeriodInPlace = jest.fn(() => { + return fakeUpdatePeriodInPlaceRes; + }); jest.mock("../update_period_in_place", () => ({ __esModule: true as const, default: fakeUpdatePeriodInPlace })); const oldPeriods = [ { id: "p0", start: 50, end: 60 }, - { id: "p1", start: 60, end: 70 }, + { id: "p1", start: 60, end: 69 }, + { id: "p1.5", start: 69, end: 70 }, { id: "p2", start: 70 } ] as any; const newPeriods = [ { id: "p1", start: 60, end: 65 }, { id: "a", start: 65, end: 68 }, @@ -338,7 +437,27 @@ describe("updatePeriods", () => { { id: "p2", start: 70, end: 80 }, { id: "p3", start: 80 } ] as any; const updatePeriods = jest.requireActual("../update_periods").updatePeriods; - updatePeriods(oldPeriods, newPeriods); + const res = updatePeriods(oldPeriods, newPeriods); + expect(res).toEqual({ + addedPeriods: [ + { id: "a", start: 65, end: 68 }, + { id: "b", start: 68, end: 70 }, + { id: "p3", start: 80 }, + ], + removedPeriods: [ + { id: "p1.5", start: 69, end: 70 }, + ], + updatedPeriods: [ + { + period: { id: "p1", start: 60, end: 69 }, + result: fakeUpdatePeriodInPlaceRes, + }, + { + period: { id: "p2", start: 70 }, + result: fakeUpdatePeriodInPlaceRes, + }, + ], + }); expect(oldPeriods.length).toBe(6); expect(oldPeriods[0].id).toBe("p0"); @@ -347,12 +466,17 @@ describe("updatePeriods", () => { expect(oldPeriods[3].id).toBe("b"); expect(oldPeriods[4].id).toBe("p2"); expect(oldPeriods[5].id).toBe("p3"); - expect(fakeUpdatePeriodInPlace).toHaveBeenCalledTimes(1); + expect(fakeUpdatePeriodInPlace).toHaveBeenCalledTimes(2); expect(fakeUpdatePeriodInPlace) .toHaveBeenNthCalledWith(1, - { id: "p1", start: 60, end: 70 }, + { id: "p1", start: 60, end: 69 }, { id: "p1", start: 60, end: 65 }, MANIFEST_UPDATE_TYPE.Partial); + expect(fakeUpdatePeriodInPlace) + .toHaveBeenNthCalledWith(2, + { id: "p2", start: 70 }, + { id: "p2", start: 70, end: 80 }, + MANIFEST_UPDATE_TYPE.Full); }); // Case 5 : @@ -360,7 +484,9 @@ describe("updatePeriods", () => { // old periods : p2 // new periods : p1, p2 it("should throw when the first period is not encountered", () => { - const fakeUpdatePeriodInPlace = jest.fn(() => { return; }); + const fakeUpdatePeriodInPlace = jest.fn(() => { + return fakeUpdatePeriodInPlaceRes; + }); jest.mock("../update_period_in_place", () => ({ __esModule: true as const, default: fakeUpdatePeriodInPlace })); const oldPeriods = [ { id: "p2", start: 70 } ] as any; @@ -397,13 +523,20 @@ describe("updatePeriods", () => { // old periods : p1, p2 // new periods : No periods it("should keep old periods if no new Period is available", () => { - const fakeUpdatePeriodInPlace = jest.fn(() => { return; }); + const fakeUpdatePeriodInPlace = jest.fn(() => { + return fakeUpdatePeriodInPlaceRes; + }); jest.mock("../update_period_in_place", () => ({ __esModule: true as const, default: fakeUpdatePeriodInPlace })); const oldPeriods = [ { id: "p1" }, { id: "p2" } ] as any; const newPeriods = [] as any; const updatePeriods = jest.requireActual("../update_periods").updatePeriods; - updatePeriods(oldPeriods, newPeriods); + const res = updatePeriods(oldPeriods, newPeriods); + expect(res).toEqual({ + addedPeriods: [], + removedPeriods: [], + updatedPeriods: [], + }); expect(oldPeriods.length).toBe(2); expect(oldPeriods[0].id).toBe("p1"); expect(oldPeriods[1].id).toBe("p2"); @@ -415,13 +548,23 @@ describe("updatePeriods", () => { // old periods : No periods // new periods : p1, p2 it("should set only new Periods if none were available before", () => { - const fakeUpdatePeriodInPlace = jest.fn(() => { return; }); + const fakeUpdatePeriodInPlace = jest.fn(() => { + return fakeUpdatePeriodInPlaceRes; + }); jest.mock("../update_period_in_place", () => ({ __esModule: true as const, default: fakeUpdatePeriodInPlace })); const oldPeriods = [] as any; const newPeriods = [ { id: "p1" }, { id: "p2" } ] as any; const updatePeriods = jest.requireActual("../update_periods").updatePeriods; - updatePeriods(oldPeriods, newPeriods); + const res = updatePeriods(oldPeriods, newPeriods); + expect(res).toEqual({ + addedPeriods: [ + { id: "p1" }, + { id: "p2" }, + ], + removedPeriods: [], + updatedPeriods: [], + }); expect(oldPeriods.length).toBe(2); expect(oldPeriods[0].id).toBe("p1"); expect(oldPeriods[1].id).toBe("p2"); @@ -433,7 +576,9 @@ describe("updatePeriods", () => { // old periods : p0, p1 // new periods : p4, p5 it("should throw if the new periods come strictly after", () => { - const fakeUpdatePeriodInPlace = jest.fn(() => { return; }); + const fakeUpdatePeriodInPlace = jest.fn(() => { + return fakeUpdatePeriodInPlaceRes; + }); jest.mock("../update_period_in_place", () => ({ __esModule: true as const, default: fakeUpdatePeriodInPlace })); const updatePeriods = jest.requireActual("../update_periods").updatePeriods; @@ -470,7 +615,9 @@ describe("updatePeriods", () => { // old periods: p1 // new periods: p2 it("should concatenate consecutive periods", () => { - const fakeUpdatePeriodInPlace = jest.fn(() => { return; }); + const fakeUpdatePeriodInPlace = jest.fn(() => { + return fakeUpdatePeriodInPlaceRes; + }); jest.mock("../update_period_in_place", () => ({ __esModule: true as const, default: fakeUpdatePeriodInPlace, @@ -479,7 +626,14 @@ describe("updatePeriods", () => { const newPeriods = [ { id: "p2", start: 60, end: 80 } ] as any; const updatePeriods = jest.requireActual("../update_periods").updatePeriods; - updatePeriods(oldPeriods, newPeriods); + const res = updatePeriods(oldPeriods, newPeriods); + expect(res).toEqual({ + addedPeriods: [ + { id: "p2", start: 60, end: 80 }, + ], + removedPeriods: [], + updatedPeriods: [], + }); expect(oldPeriods.length).toBe(2); expect(oldPeriods[0].id).toBe("p1"); expect(oldPeriods[1].id).toBe("p2"); @@ -493,7 +647,9 @@ describe("updatePeriods", () => { /* eslint-disable max-len */ it("should throw when encountering two completely different Periods with the same start", () => { /* eslint-enable max-len */ - const fakeUpdatePeriodInPlace = jest.fn(() => { return; }); + const fakeUpdatePeriodInPlace = jest.fn(() => { + return fakeUpdatePeriodInPlaceRes; + }); jest.mock("../update_period_in_place", () => ({ __esModule: true as const, default: fakeUpdatePeriodInPlace, @@ -530,7 +686,9 @@ describe("updatePeriods", () => { // old periods: p0, p1, p2 // new periods: p1, p2, p3 it("should handle more complex period replacement", () => { - const fakeUpdatePeriodInPlace = jest.fn(() => { return; }); + const fakeUpdatePeriodInPlace = jest.fn(() => { + return fakeUpdatePeriodInPlaceRes; + }); jest.mock("../update_period_in_place", () => ({ __esModule: true as const, default: fakeUpdatePeriodInPlace })); const oldPeriods = [ { id: "p0", start: 50, end: 60 }, @@ -540,7 +698,24 @@ describe("updatePeriods", () => { { id: "p2", start: 65, end: 80 }, { id: "p3", start: 80 } ] as any; const updatePeriods = jest.requireActual("../update_periods").updatePeriods; - updatePeriods(oldPeriods, newPeriods); + const res = updatePeriods(oldPeriods, newPeriods); + expect(res).toEqual({ + addedPeriods: [ + { id: "p3", start: 80 }, + ], + removedPeriods: [ + ], + updatedPeriods: [ + { + period: { id: "p1", start: 60, end: 70 }, + result: fakeUpdatePeriodInPlaceRes, + }, + { + period: { id: "p2", start: 70 }, + result: fakeUpdatePeriodInPlaceRes, + }, + ], + }); expect(oldPeriods.length).toBe(4); expect(oldPeriods[0].id).toBe("p0"); @@ -565,7 +740,9 @@ describe("updatePeriods", () => { // old periods: p0, p1, p2, p3 // new periods: p1, p3 it("should handle more complex period replacement", () => { - const fakeUpdatePeriodInPlace = jest.fn(() => { return; }); + const fakeUpdatePeriodInPlace = jest.fn(() => { + return fakeUpdatePeriodInPlaceRes; + }); jest.mock("../update_period_in_place", () => ({ __esModule: true as const, default: fakeUpdatePeriodInPlace })); const oldPeriods = [ { id: "p0", start: 50, end: 60 }, @@ -575,7 +752,24 @@ describe("updatePeriods", () => { const newPeriods = [ { id: "p1", start: 60, end: 70 }, { id: "p3", start: 80 } ] as any; const updatePeriods = jest.requireActual("../update_periods").updatePeriods; - updatePeriods(oldPeriods, newPeriods); + const res = updatePeriods(oldPeriods, newPeriods); + expect(res).toEqual({ + addedPeriods: [ + ], + removedPeriods: [ + { id: "p2", start: 70, end: 80 }, + ], + updatedPeriods: [ + { + period: { id: "p1", start: 60, end: 70 }, + result: fakeUpdatePeriodInPlaceRes, + }, + { + period: { id: "p3", start: 80 }, + result: fakeUpdatePeriodInPlaceRes, + }, + ], + }); expect(oldPeriods.length).toBe(3); expect(oldPeriods[0].id).toBe("p0"); @@ -599,7 +793,9 @@ describe("updatePeriods", () => { // old periods: p0, p1, p2, p3, p4 // new periods: p1, p3 it("should remove periods not included in the new Periods", () => { - const fakeUpdatePeriodInPlace = jest.fn(() => { return; }); + const fakeUpdatePeriodInPlace = jest.fn(() => { + return fakeUpdatePeriodInPlaceRes; + }); jest.mock("../update_period_in_place", () => ({ __esModule: true as const, default: fakeUpdatePeriodInPlace })); const oldPeriods = [ { id: "p0", start: 50, end: 60 }, @@ -610,7 +806,25 @@ describe("updatePeriods", () => { const newPeriods = [ { id: "p1", start: 60, end: 70 }, { id: "p3", start: 80, end: 90 } ] as any; const updatePeriods = jest.requireActual("../update_periods").updatePeriods; - updatePeriods(oldPeriods, newPeriods); + const res = updatePeriods(oldPeriods, newPeriods); + expect(res).toEqual({ + addedPeriods: [ + ], + removedPeriods: [ + { id: "p2", start: 70, end: 80 }, + { id: "p4", start: 90 }, + ], + updatedPeriods: [ + { + period: { id: "p1", start: 60, end: 70 }, + result: fakeUpdatePeriodInPlaceRes, + }, + { + period: { id: "p3", start: 80, end: 90 }, + result: fakeUpdatePeriodInPlaceRes, + }, + ], + }); expect(oldPeriods.length).toBe(3); expect(oldPeriods[0].id).toBe("p0"); diff --git a/src/manifest/manifest.ts b/src/manifest/manifest.ts index 0894bd2f71..6df93f3fd3 100644 --- a/src/manifest/manifest.ts +++ b/src/manifest/manifest.ts @@ -36,6 +36,7 @@ import { MANIFEST_UPDATE_TYPE, } from "./types"; import { + IPeriodsUpdateResult, replacePeriods, updatePeriods, } from "./update_periods"; @@ -104,7 +105,7 @@ export interface IDecipherabilityUpdateElement { manifest : Manifest; /** Events emitted by a `Manifest` instance */ export interface IManifestEvents { /** The Manifest has been updated */ - manifestUpdate : null; + manifestUpdate : IPeriodsUpdateResult; /** Some Representation's decipherability status has been updated */ decipherabilityUpdate : IDecipherabilityUpdateElement[]; } @@ -726,14 +727,15 @@ export default class Manifest extends EventEmitter { this.transport = newManifest.transport; this.publishTime = newManifest.publishTime; + let updatedPeriodsResult; if (updateType === MANIFEST_UPDATE_TYPE.Full) { this._timeBounds = newManifest._timeBounds; this.uris = newManifest.uris; - replacePeriods(this.periods, newManifest.periods); + updatedPeriodsResult = replacePeriods(this.periods, newManifest.periods); } else { this._timeBounds.maximumTimeData = newManifest._timeBounds.maximumTimeData; this.updateUrl = newManifest.uris[0]; - updatePeriods(this.periods, newManifest.periods); + updatedPeriodsResult = updatePeriods(this.periods, newManifest.periods); // Partial updates do not remove old Periods. // This can become a memory problem when playing a content long enough. @@ -758,7 +760,7 @@ export default class Manifest extends EventEmitter { // Let's trigger events at the end, as those can trigger side-effects. // We do not want the current Manifest object to be incomplete when those // happen. - this.trigger("manifestUpdate", null); + this.trigger("manifestUpdate", updatedPeriodsResult); } } diff --git a/src/manifest/update_period_in_place.ts b/src/manifest/update_period_in_place.ts index 2cb8889f75..baa2abdacf 100644 --- a/src/manifest/update_period_in_place.ts +++ b/src/manifest/update_period_in_place.ts @@ -15,8 +15,10 @@ */ import log from "../log"; -import arrayFind from "../utils/array_find"; +import arrayFindIndex from "../utils/array_find_index"; +import Adaptation from "./adaptation"; import Period from "./period"; +import Representation from "./representation"; import { MANIFEST_UPDATE_TYPE } from "./types"; /** @@ -24,11 +26,19 @@ import { MANIFEST_UPDATE_TYPE } from "./types"; * the Manifest). * @param {Object} oldPeriod * @param {Object} newPeriod + * @param {number} updateType + * @returns {Object} */ -export default function updatePeriodInPlace(oldPeriod : Period, - newPeriod : Period, - updateType : MANIFEST_UPDATE_TYPE) : void -{ +export default function updatePeriodInPlace( + oldPeriod : Period, + newPeriod : Period, + updateType : MANIFEST_UPDATE_TYPE +) : IUpdatedPeriodResult { + const res : IUpdatedPeriodResult = { + updatedAdaptations: [], + removedAdaptations: [], + addedAdaptations: [], + }; oldPeriod.start = newPeriod.start; oldPeriod.end = newPeriod.end; oldPeriod.duration = newPeriod.duration; @@ -39,26 +49,43 @@ export default function updatePeriodInPlace(oldPeriod : Period, for (let j = 0; j < oldAdaptations.length; j++) { const oldAdaptation = oldAdaptations[j]; - const newAdaptation = arrayFind(newAdaptations, - a => a.id === oldAdaptation.id); - if (newAdaptation === undefined) { + const newAdaptationIdx = arrayFindIndex(newAdaptations, + a => a.id === oldAdaptation.id); + + if (newAdaptationIdx === -1) { log.warn("Manifest: Adaptation \"" + oldAdaptations[j].id + "\" not found when merging."); + const [removed] = oldAdaptations.splice(j, 1); + j--; + res.removedAdaptations.push(removed); } else { - const oldRepresentations = oldAdaptations[j].representations; - const newRepresentations = newAdaptation.representations; + const [newAdaptation] = newAdaptations.splice(newAdaptationIdx, 1); + const updatedRepresentations : Representation[] = []; + const addedRepresentations : Representation[] = []; + const removedRepresentations : Representation[] = []; + res.updatedAdaptations.push({ adaptation: oldAdaptation, + updatedRepresentations, + addedRepresentations, + removedRepresentations }); + + const oldRepresentations = oldAdaptation.representations; + const newRepresentations = newAdaptation.representations.slice(); for (let k = 0; k < oldRepresentations.length; k++) { const oldRepresentation = oldRepresentations[k]; - const newRepresentation = - arrayFind(newRepresentations, - representation => representation.id === oldRepresentation.id); + const newRepresentationIdx = arrayFindIndex(newRepresentations, representation => + representation.id === oldRepresentation.id); - if (newRepresentation === undefined) { + if (newRepresentationIdx === -1) { log.warn(`Manifest: Representation "${oldRepresentations[k].id}" ` + "not found when merging."); + const [removed] = oldRepresentations.splice(k, 1); + k--; + removedRepresentations.push(removed); } else { + const [newRepresentation] = newRepresentations.splice(newRepresentationIdx, 1); + updatedRepresentations.push(oldRepresentation); oldRepresentation.cdnMetadata = newRepresentation.cdnMetadata; if (updateType === MANIFEST_UPDATE_TYPE.Full) { oldRepresentation.index._replace(newRepresentation.index); @@ -67,6 +94,49 @@ export default function updatePeriodInPlace(oldPeriod : Period, } } } + + if (newRepresentations.length > 0) { + log.warn(`Manifest: ${newRepresentations.length} new Representations ` + + "found when merging."); + oldAdaptation.representations.push(...newRepresentations); + addedRepresentations.push(...newRepresentations); + } } } + if (newAdaptations.length > 0) { + log.warn(`Manifest: ${newAdaptations.length} new Adaptations ` + + "found when merging."); + for (const adap of newAdaptations) { + const prevAdaps = oldPeriod.adaptations[adap.type]; + if (prevAdaps === undefined) { + oldPeriod.adaptations[adap.type] = [adap]; + } else { + prevAdaps.push(adap); + } + res.addedAdaptations.push(adap); + } + } + return res; +} + +/** + * Object describing the updates performed by `updatePeriodInPlace` on a single + * Period. + */ +export interface IUpdatedPeriodResult { + // XXX TODO doc + /** `true` if at least one Adaptation has been updated. */ + updatedAdaptations : Array<{ + adaptation: Adaptation; + /** `true` if at least one Representation has been updated. */ + updatedRepresentations : Representation[]; + /** `true` if at least one Representation has been removed. */ + removedRepresentations : Representation[]; + /** `true` if at least one Representation has been added. */ + addedRepresentations : Representation[]; + }>; + /** `true` if at least one Adaptation has been removed. */ + removedAdaptations : Adaptation[]; + /** `true` if at least one Adaptation has been added. */ + addedAdaptations : Adaptation[]; } diff --git a/src/manifest/update_periods.ts b/src/manifest/update_periods.ts index 13f8427582..45734e7f03 100644 --- a/src/manifest/update_periods.ts +++ b/src/manifest/update_periods.ts @@ -19,18 +19,26 @@ import log from "../log"; import arrayFindIndex from "../utils/array_find_index"; import Period from "./period"; import { MANIFEST_UPDATE_TYPE } from "./types"; -import updatePeriodInPlace from "./update_period_in_place"; +import updatePeriodInPlace, { + IUpdatedPeriodResult, +} from "./update_period_in_place"; /** * Update old periods by adding new periods and removing * not available ones. * @param {Array.} oldPeriods * @param {Array.} newPeriods + * @returns {Object} */ export function replacePeriods( oldPeriods: Period[], newPeriods: Period[] -) : void { +) : IPeriodsUpdateResult { + const res : IPeriodsUpdateResult = { + updatedPeriods: [], + addedPeriods: [], + removedPeriods: [], + }; let firstUnhandledPeriodIdx = 0; for (let i = 0; i < newPeriods.length; i++) { const newPeriod = newPeriods[i]; @@ -41,29 +49,35 @@ export function replacePeriods( oldPeriod = oldPeriods[j]; } if (oldPeriod != null) { - updatePeriodInPlace(oldPeriod, newPeriod, MANIFEST_UPDATE_TYPE.Full); + const result = updatePeriodInPlace(oldPeriod, newPeriod, MANIFEST_UPDATE_TYPE.Full); + res.updatedPeriods.push({ period: oldPeriod, result }); const periodsToInclude = newPeriods.slice(firstUnhandledPeriodIdx, i); const nbrOfPeriodsToRemove = j - firstUnhandledPeriodIdx; - oldPeriods.splice(firstUnhandledPeriodIdx, - nbrOfPeriodsToRemove, - ...periodsToInclude); + const removed = oldPeriods.splice(firstUnhandledPeriodIdx, + nbrOfPeriodsToRemove, + ...periodsToInclude); + res.removedPeriods.push(...removed); + res.addedPeriods.push(...periodsToInclude); firstUnhandledPeriodIdx = i + 1; } } if (firstUnhandledPeriodIdx > oldPeriods.length) { log.error("Manifest: error when updating Periods"); - return; + return res; } if (firstUnhandledPeriodIdx < oldPeriods.length) { - oldPeriods.splice(firstUnhandledPeriodIdx, - oldPeriods.length - firstUnhandledPeriodIdx); + const removed = oldPeriods.splice(firstUnhandledPeriodIdx, + oldPeriods.length - firstUnhandledPeriodIdx); + res.removedPeriods.push(...removed); } const remainingNewPeriods = newPeriods.slice(firstUnhandledPeriodIdx, newPeriods.length); if (remainingNewPeriods.length > 0) { oldPeriods.push(...remainingNewPeriods); + res.addedPeriods.push(...remainingNewPeriods); } + return res; } /** @@ -71,17 +85,24 @@ export function replacePeriods( * not available ones. * @param {Array.} oldPeriods * @param {Array.} newPeriods + * @returns {Object} */ export function updatePeriods( oldPeriods: Period[], newPeriods: Period[] -) : void { +) : IPeriodsUpdateResult { + const res : IPeriodsUpdateResult = { + updatedPeriods: [], + addedPeriods: [], + removedPeriods: [], + }; if (oldPeriods.length === 0) { oldPeriods.splice(0, 0, ...newPeriods); - return; + res.addedPeriods.push(...newPeriods); + return res; } if (newPeriods.length === 0) { - return; + return res; } const oldLastPeriod = oldPeriods[oldPeriods.length - 1]; if (oldLastPeriod.start < newPeriods[0].start) { @@ -90,8 +111,11 @@ export function updatePeriods( "Cannot perform partial update: not enough data"); } oldPeriods.push(...newPeriods); - return; + res.addedPeriods.push(...newPeriods); + return res; } + + /** Index, in `oldPeriods` of the first element of `newPeriods` */ const indexOfNewFirstPeriod = arrayFindIndex(oldPeriods, ({ id }) => id === newPeriods[0].id); if (indexOfNewFirstPeriod < 0) { @@ -100,10 +124,14 @@ export function updatePeriods( } // The first updated Period can only be a partial part - updatePeriodInPlace(oldPeriods[indexOfNewFirstPeriod], - newPeriods[0], - MANIFEST_UPDATE_TYPE.Partial); + const updateRes = updatePeriodInPlace(oldPeriods[indexOfNewFirstPeriod], + newPeriods[0], + MANIFEST_UPDATE_TYPE.Partial); + res.updatedPeriods.push({ period: oldPeriods[indexOfNewFirstPeriod], + result: updateRes }); + // Search each consecutive elements of `newPeriods` - after the initial one already + // processed - in `oldPeriods`, removing and adding unfound Periods in the process let prevIndexOfNewPeriod = indexOfNewFirstPeriod + 1; for (let i = 1; i < newPeriods.length; i++) { const newPeriod = newPeriods[i]; @@ -114,28 +142,60 @@ export function updatePeriods( break; // end the loop } } - if (indexOfNewPeriod < 0) { - oldPeriods.splice(prevIndexOfNewPeriod, - oldPeriods.length - prevIndexOfNewPeriod, - ...newPeriods.slice(i, newPeriods.length)); - return; - } - if (indexOfNewPeriod > prevIndexOfNewPeriod) { - oldPeriods.splice(prevIndexOfNewPeriod, - indexOfNewPeriod - prevIndexOfNewPeriod); - indexOfNewPeriod = prevIndexOfNewPeriod; - } + if (indexOfNewPeriod < 0) { // Next element of `newPeriods` not found: insert it + let toRemoveUntil = -1; + for (let j = prevIndexOfNewPeriod; j < oldPeriods.length; j++) { + if (newPeriod.start < oldPeriods[j].start) { + toRemoveUntil = j; + break; // end the loop + } + } + const nbElementsToRemove = toRemoveUntil - prevIndexOfNewPeriod; + const removed = oldPeriods.splice(prevIndexOfNewPeriod, + nbElementsToRemove, + newPeriod); + res.addedPeriods.push(newPeriod); + res.removedPeriods.push(...removed); + } else { + if (indexOfNewPeriod > prevIndexOfNewPeriod) { + // Some old periods were not found: remove + log.warn("Manifest: old Periods not found in new when updating, removing"); + const removed = oldPeriods.splice(prevIndexOfNewPeriod, + indexOfNewPeriod - prevIndexOfNewPeriod); + res.removedPeriods.push(...removed); + indexOfNewPeriod = prevIndexOfNewPeriod; + } - // Later Periods can be fully replaced - updatePeriodInPlace(oldPeriods[indexOfNewPeriod], - newPeriod, - MANIFEST_UPDATE_TYPE.Full); + // Later Periods can be fully replaced + const result = updatePeriodInPlace(oldPeriods[indexOfNewPeriod], + newPeriod, + MANIFEST_UPDATE_TYPE.Full); + res.updatedPeriods.push({ period: oldPeriods[indexOfNewPeriod], result }); + } prevIndexOfNewPeriod++; } if (prevIndexOfNewPeriod < oldPeriods.length) { - oldPeriods.splice(prevIndexOfNewPeriod, - oldPeriods.length - prevIndexOfNewPeriod); + log.warn("Manifest: Ending Periods not found in new when updating, removing"); + const removed = oldPeriods.splice(prevIndexOfNewPeriod, + oldPeriods.length - prevIndexOfNewPeriod); + res.removedPeriods.push(...removed); } + return res; +} + +/** Object describing a Manifest update at the Periods level. */ +export interface IPeriodsUpdateResult { + /** Information on Periods that have been updated. */ + updatedPeriods : Array<{ + /** The concerned Period. */ + period : Period; + /** The updates performed. */ + result : IUpdatedPeriodResult; + }>; + /** Periods that have been added. */ + addedPeriods : Period[]; + /** Periods that have been removed. */ + removedPeriods : Period[]; } From bfc6b6f63d7fa70659950631e200f07a3f68d495 Mon Sep 17 00:00:00 2001 From: Paul Berberian Date: Tue, 20 Dec 2022 14:45:55 +0100 Subject: [PATCH 33/86] Raise timer in playback rate integration tests --- tests/integration/utils/launch_tests_for_content.js | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/tests/integration/utils/launch_tests_for_content.js b/tests/integration/utils/launch_tests_for_content.js index 1ea1b6544f..4f96f789db 100644 --- a/tests/integration/utils/launch_tests_for_content.js +++ b/tests/integration/utils/launch_tests_for_content.js @@ -1072,7 +1072,7 @@ export default function launchTestsForContent(manifestInfos) { describe("setPlaybackRate", () => { // TODO handle live contents it("should update the speed accordingly", async function() { - this.timeout(7000); + this.timeout(15000); player.loadVideo({ url: manifestInfos.url, transport, @@ -1081,16 +1081,20 @@ export default function launchTestsForContent(manifestInfos) { await waitForLoadedStateAfterLoadVideo(player); expect(player.getPosition()).to.be.closeTo(minimumPosition, 0.001); player.setPlaybackRate(1); + const before1 = performance.now(); player.play(); - await sleep(3000); + await sleep(2000); + const duration1 = (performance.now() - before1) / 1000; const initialPosition = player.getPosition(); - expect(initialPosition).to.be.closeTo(minimumPosition + 3, 1); + expect(initialPosition).to.be.closeTo(minimumPosition + duration1, 2); + const before2 = performance.now(); player.setPlaybackRate(3); await sleep(2000); + const duration2 = (performance.now() - before2) / 1000; const secondPosition = player.getPosition(); expect(secondPosition).to.be - .closeTo(initialPosition + 3 * 2, 1.5); + .closeTo(initialPosition + duration2 * 3, 2); }); }); From 1ba169e3efd7a1d77fc75397011e68b4a5f02ad7 Mon Sep 17 00:00:00 2001 From: Paul Berberian Date: Tue, 27 Dec 2022 17:39:57 +0100 Subject: [PATCH 34/86] Avoid creating multiple times the same RepresentationStream if it calls terminating callbacks multiple times --- src/core/stream/adaptation/adaptation_stream.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/core/stream/adaptation/adaptation_stream.ts b/src/core/stream/adaptation/adaptation_stream.ts index 75623fd593..ef41620758 100644 --- a/src/core/stream/adaptation/adaptation_stream.ts +++ b/src/core/stream/adaptation/adaptation_stream.ts @@ -269,6 +269,9 @@ export default function AdaptationStream( callbacks.addedSegment(segmentInfo); }, terminating() { + if (repStreamTerminatingCanceller.isUsed) { + return; // Already handled + } repStreamTerminatingCanceller.cancel(); return recursivelyCreateRepresentationStreams(false); }, From 7c86bde58a4a63230ed1d11cf586b1adf133057f Mon Sep 17 00:00:00 2001 From: Paul Berberian Date: Tue, 20 Dec 2022 11:28:33 +0100 Subject: [PATCH 35/86] Add more memory tests --- tests/memory/index.js | 118 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 118 insertions(+) diff --git a/tests/memory/index.js b/tests/memory/index.js index 958aa06dab..0e16aa4b92 100644 --- a/tests/memory/index.js +++ b/tests/memory/index.js @@ -120,6 +120,124 @@ describe("Memory tests", () => { expect(heapDifference).to.be.below(3e6); }); + it("should not have a sensible memory leak after 1000 instances of the RxPlayer", async function() { + if (window.performance == null || + window.performance.memory == null || + window.gc == null) + { + // eslint-disable-next-line no-console + console.warn("API not available. Skipping test."); + return; + } + window.gc(); + await sleep(1000); + const initialMemory = window.performance.memory; + this.timeout(5 * 60 * 1000); + for (let i = 0; i < 1000; i++) { + player = new RxPlayer({ initialVideoBitrate: Infinity, + initialAudiobitrate: Infinity, + preferredtexttracks: [{ language: "fra", + closedcaption: true }] }); + player.loadVideo({ url: manifestInfos.url, + transport: manifestInfos.transport, + supplementaryTextTracks: [{ url: textTrackInfos.url, + language: "fra", + mimeType: "application/ttml+xml", + closedCaption: true }], + supplementaryImageTracks: [{ mimeType: "application/bif", + url: imageInfos.url }], + autoPlay: true }); + await waitForLoadedStateAfterLoadVideo(player); + player.dispose(); + } + await sleep(100); + window.gc(); + await sleep(1000); + const newMemory = window.performance.memory; + const heapDifference = newMemory.usedJSHeapSize - + initialMemory.usedJSHeapSize; + + // eslint-disable-next-line no-console + console.log(` + =========================================================== + | Current heap usage (B) | ${newMemory.usedJSHeapSize} + | Initial heap usage (B) | ${initialMemory.usedJSHeapSize} + | Difference (B) | ${heapDifference} + `); + expect(heapDifference).to.be.below(4e6); + }); + + it("should not have a sensible memory leak after many video quality switches", async function() { + if (window.performance == null || + window.performance.memory == null || + window.gc == null) + { + // eslint-disable-next-line no-console + console.warn("API not available. Skipping test."); + return; + } + this.timeout(15 * 60 * 1000); + player = new RxPlayer({ initialVideoBitrate: Infinity, + initialAudiobitrate: Infinity, + preferredtexttracks: [{ language: "fra", + closedcaption: true }] }); + await sleep(1000); + player.setWantedBufferAhead(5); + player.setMaxBufferBehind(5); + player.setMaxBufferAhead(15); + player.loadVideo({ url: manifestInfos.url, + transport: manifestInfos.transport, + supplementaryTextTracks: [{ url: textTrackInfos.url, + language: "fra", + mimeType: "application/ttml+xml", + closedCaption: true }], + supplementaryImageTracks: [{ mimeType: "application/bif", + url: imageInfos.url }], + autoPlay: false }); + await waitForLoadedStateAfterLoadVideo(player); + const videoBitrates = player.getAvailableVideoBitrates(); + if (videoBitrates.length <= 1) { + throw new Error( + "Not enough video bitrates to perform sufficiently pertinent tests" + ); + } + await sleep(1000); + + window.gc(); + const initialMemory = window.performance.memory; + + // Allows to alternate between two positions + let seekToBeginning = false; + for ( + let iterationIdx = 0; + iterationIdx < 500; + iterationIdx++ + ) { + if (seekToBeginning) { + player.seekTo(0); + } else { + player.seekTo(20); + } + const bitrateIdx = iterationIdx % videoBitrates.length; + player.setVideoBitrate(videoBitrates[bitrateIdx]); + await sleep(1000); + } + window.gc(); + await sleep(1000); + const newMemory = window.performance.memory; + const heapDifference = newMemory.usedJSHeapSize - + initialMemory.usedJSHeapSize; + + // eslint-disable-next-line no-console + console.log(` + =========================================================== + | Current heap usage (B) | ${newMemory.usedJSHeapSize} + | Initial heap usage (B) | ${initialMemory.usedJSHeapSize} + | Difference (B) | ${heapDifference} + `); + expect(heapDifference).to.be.below(9e6); + }); + it("should not have a sensible memory leak after 1000 setTime calls of VideoThumbnailLoader", async function() { if (window.performance == null || window.performance.memory == null || From ce60f24af8f0da61e8b01421148e8481df8958ce Mon Sep 17 00:00:00 2001 From: Paul Berberian Date: Thu, 5 Jan 2023 11:26:59 +0100 Subject: [PATCH 36/86] Minor documentation fixes --- src/manifest/update_period_in_place.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/manifest/update_period_in_place.ts b/src/manifest/update_period_in_place.ts index baa2abdacf..b6ff88b4cf 100644 --- a/src/manifest/update_period_in_place.ts +++ b/src/manifest/update_period_in_place.ts @@ -124,19 +124,19 @@ export default function updatePeriodInPlace( * Period. */ export interface IUpdatedPeriodResult { - // XXX TODO doc - /** `true` if at least one Adaptation has been updated. */ + /** Information on Adaptations that have been updated. */ updatedAdaptations : Array<{ + /** The concerned Adaptation. */ adaptation: Adaptation; - /** `true` if at least one Representation has been updated. */ + /** Representations that have been updated. */ updatedRepresentations : Representation[]; - /** `true` if at least one Representation has been removed. */ + /** Representations that have been removed from the Adaptation. */ removedRepresentations : Representation[]; - /** `true` if at least one Representation has been added. */ + /** Representations that have been added to the Adaptation. */ addedRepresentations : Representation[]; }>; - /** `true` if at least one Adaptation has been removed. */ + /** Adaptation that have been removed from the Period. */ removedAdaptations : Adaptation[]; - /** `true` if at least one Adaptation has been added. */ + /** Adaptation that have been added to the Period. */ addedAdaptations : Adaptation[]; } From a5602796e1e2f5fa7c2d32fa153910a22436ba97 Mon Sep 17 00:00:00 2001 From: Paul Berberian Date: Tue, 17 Jan 2023 10:43:14 +0100 Subject: [PATCH 37/86] Fix race condition leading to a JS error instead of a NO_PLAYABLE_REPRESENTATION --- .../adaptive/adaptive_representation_selector.ts | 14 +++++++++++++- src/core/stream/adaptation/adaptation_stream.ts | 7 ++++++- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/src/core/adaptive/adaptive_representation_selector.ts b/src/core/adaptive/adaptive_representation_selector.ts index 3baa12afb9..3274956d43 100644 --- a/src/core/adaptive/adaptive_representation_selector.ts +++ b/src/core/adaptive/adaptive_representation_selector.ts @@ -234,6 +234,16 @@ function getEstimateReference( representations : Representation[], innerCancellationSignal : CancellationSignal ) : ISharedReference { + if (representations.length === 0) { + // No Representation given, return `null` as documented + return createSharedReference({ + representation: null, + bitrate: undefined, + knownStableBitrate: undefined, + manual: false, + urgent: true, + }); + } if (manualBitrateVal >= 0) { // A manual bitrate has been set. Just choose Representation according to it. const manualRepresentation = selectOptimalRepresentation(representations, @@ -583,8 +593,10 @@ export interface IABREstimate { /** * The Representation considered as the most adapted to the current network * and playback conditions. + * `null` in the rare occurence where there is no `Representation` to choose + * from. */ - representation: Representation; + representation: Representation | null; /** * If `true`, the current `representation` suggested should be switched to as * soon as possible. For example, you might want to interrupt all pending diff --git a/src/core/stream/adaptation/adaptation_stream.ts b/src/core/stream/adaptation/adaptation_stream.ts index ef41620758..fadf185b3b 100644 --- a/src/core/stream/adaptation/adaptation_stream.ts +++ b/src/core/stream/adaptation/adaptation_stream.ts @@ -166,6 +166,9 @@ export default function AdaptationStream( cancelOn: adapStreamCanceller.signal, }); const { representation, manual } = estimateRef.getValue(); + if (representation === null) { + return; + } // A manual bitrate switch might need an immediate feedback. // To do that properly, we need to reload the MediaSource @@ -210,7 +213,9 @@ export default function AdaptationStream( /** Allows to stop listening to estimateRef on the following line. */ estimateRef.onUpdate((estimate) => { - if (estimate.representation.id === representation.id) { + if (estimate.representation === null || + estimate.representation.id === representation.id) + { return; } if (estimate.urgent) { From f947c420642e8eab875b8c2e9aef09bcb5939228 Mon Sep 17 00:00:00 2001 From: Paul Berberian Date: Mon, 23 Jan 2023 18:13:07 +0100 Subject: [PATCH 38/86] Add the `getKeySystemConfiguration` method to the RxPlayer Encryption-related issues are the most frequent category of issues currently investigated by the RxPlayer team and its associated applications at least made by Canal+ and our partners. In that context, we missed a crucial API allowing to facilitate encryption-related debugging on various platforms: One that would allow to indicate easily the current key system configuration relied on. Particularly the robustness level: PlayReady SL3000 or SL2000? Widevine L1, L2 or L3? And so on. The present PR adds the `getKeySystemConfiguration` API which returns both the **actual** key system string and the [`MediaKeySystemConfiguration`](https://www.w3.org/TR/encrypted-media/#dom-mediakeysystemconfiguration) currently relied on. I put there an emphasis on "actual" because, in opposition to the `getCurrentKeySystem` API (which I now chose to deprecate, in favor of the new method), it is [the name actually reported by the MediaKeySystemAccess](https://www.w3.org/TR/encrypted-media/#dom-mediakeysystemaccess-keysystem) that is here reported, whereas `getCurrentKeySystem` returned the exact same string than the `type` originally set on the `keySystems` option - an arguably less useful value when the RxPlayer could have made translations in between. --- .../getKeySystemConfiguration.md | 45 +++++++++++++++++++ .../getCurrentKeySystem.md | 6 +++ doc/api/Miscellaneous/Deprecated_APIs.md | 44 ++++++++++++++++++ doc/reference/API_Reference.md | 8 +++- ...a_source_on_decipherability_update.test.ts | 2 +- ..._media_source_on_decipherability_update.ts | 6 +-- src/core/api/public_api.ts | 24 ++++++++++ ...tem.ts => get_key_system_configuration.ts} | 25 ++++++++++- src/core/decrypt/index.ts | 5 ++- .../init/media_source_content_initializer.ts | 6 +-- src/public_types.ts | 8 ++++ 11 files changed, 167 insertions(+), 12 deletions(-) create mode 100644 doc/api/Content_Information/getKeySystemConfiguration.md rename doc/api/{Content_Information => Deprecated}/getCurrentKeySystem.md (54%) rename src/core/decrypt/{get_current_key_system.ts => get_key_system_configuration.ts} (56%) diff --git a/doc/api/Content_Information/getKeySystemConfiguration.md b/doc/api/Content_Information/getKeySystemConfiguration.md new file mode 100644 index 0000000000..13f810e658 --- /dev/null +++ b/doc/api/Content_Information/getKeySystemConfiguration.md @@ -0,0 +1,45 @@ +# getKeySystemConfiguration + +## Description + +Returns information on the key system configuration currently associated to the +HTMLMediaElement (e.g. `