From 6fff9610faaf0917873025ec1ee9ff07f8f57026 Mon Sep 17 00:00:00 2001 From: Paul Berberian Date: Mon, 9 Oct 2023 18:34:08 +0200 Subject: [PATCH 01/46] Add getLivePosition API and better handle availabilityTimeOffset for DASH --- demo/full/scripts/controllers/ControlBar.tsx | 8 +- demo/full/scripts/controllers/ProgressBar.tsx | 3 +- demo/full/scripts/modules/player/catchUp.ts | 10 +- demo/full/scripts/modules/player/events.ts | 11 +- demo/full/scripts/modules/player/index.ts | 2 + src/core/api/public_api.ts | 23 + .../init/media_source_content_initializer.ts | 12 +- .../utils/content_time_boundaries_observer.ts | 45 +- .../representation/utils/get_buffer_status.ts | 6 +- src/manifest/representation_index/static.ts | 4 +- src/manifest/representation_index/types.ts | 8 +- .../manifest_bounds_calculator.test.ts | 218 +++++++-- .../manifest/dash/common/indexes/base.ts | 16 +- .../indexes/get_segments_from_timeline.ts | 24 +- .../manifest/dash/common/indexes/index.ts | 20 +- .../manifest/dash/common/indexes/list.ts | 4 +- .../manifest/dash/common/indexes/template.ts | 92 ++-- .../dash/common/indexes/timeline/index.ts | 5 +- .../timeline/timeline_representation_index.ts | 432 +++++++++++++++--- .../dash/common/manifest_bounds_calculator.ts | 88 +++- .../dash/common/parse_adaptation_sets.ts | 15 +- src/parsers/manifest/dash/common/parse_mpd.ts | 43 +- .../manifest/dash/common/parse_periods.ts | 34 +- .../dash/common/parse_representation_index.ts | 82 ++-- .../dash/common/parse_representations.ts | 12 +- .../manifest/local/representation_index.ts | 6 +- .../metaplaylist/representation_index.ts | 4 +- .../manifest/smooth/representation_index.ts | 32 +- .../get_first_time_from_adaptations.test.ts | 2 +- .../get_last_time_from_adaptation.test.ts | 2 +- src/parsers/manifest/utils/index_helpers.ts | 2 +- .../utils/is_segment_still_available.ts | 58 --- 32 files changed, 947 insertions(+), 376 deletions(-) delete mode 100644 src/parsers/manifest/utils/is_segment_still_available.ts diff --git a/demo/full/scripts/controllers/ControlBar.tsx b/demo/full/scripts/controllers/ControlBar.tsx index 02e002e6db..081f0542e1 100644 --- a/demo/full/scripts/controllers/ControlBar.tsx +++ b/demo/full/scripts/controllers/ControlBar.tsx @@ -39,6 +39,7 @@ function ControlBar({ const isStopped = useModuleState(player, "isStopped"); const liveGap = useModuleState(player, "liveGap"); const lowLatencyMode = useModuleState(player, "lowLatencyMode"); + const livePosition = useModuleState(player, "livePosition"); const maximumPosition = useModuleState(player, "maximumPosition"); const playbackRate = useModuleState(player, "playbackRate"); @@ -79,15 +80,16 @@ function ControlBar({ const isAtLiveEdge = isLive && isCloseToLive && !isCatchingUp; const onLiveDotClick = React.useCallback(() => { - if (maximumPosition == null) { + const livePos = livePosition ?? maximumPosition; + if (livePos == null) { /* eslint-disable-next-line no-console */ console.error("Cannot go back to live: live position not found"); return; } if (!isAtLiveEdge) { - player.actions.seek(maximumPosition - (lowLatencyMode ? 4 : 10)); + player.actions.seek(livePos - (lowLatencyMode ? 4 : 10)); } - }, [isAtLiveEdge, player, maximumPosition, lowLatencyMode]); + }, [isAtLiveEdge, player, livePosition, maximumPosition, lowLatencyMode]); return (
diff --git a/demo/full/scripts/controllers/ProgressBar.tsx b/demo/full/scripts/controllers/ProgressBar.tsx index b6ceb621cb..b0475299a1 100644 --- a/demo/full/scripts/controllers/ProgressBar.tsx +++ b/demo/full/scripts/controllers/ProgressBar.tsx @@ -21,6 +21,7 @@ function ProgressBar({ const isContentLoaded = useModuleState(player, "isContentLoaded"); const isLive = useModuleState(player, "isLive"); const minimumPosition = useModuleState(player, "minimumPosition"); + const livePosition = useModuleState(player, "livePosition"); const maximumPosition = useModuleState(player, "maximumPosition"); const [timeIndicatorVisible, setTimeIndicatorVisible] = React.useState(false); @@ -189,7 +190,7 @@ function ProgressBar({ onMouseMove={onMouseMove} position={currentTime} minimumPosition={minimumPosition} - maximumPosition={maximumPosition} + maximumPosition={livePosition ?? maximumPosition} bufferGap={bufferGap} /> } diff --git a/demo/full/scripts/modules/player/catchUp.ts b/demo/full/scripts/modules/player/catchUp.ts index b8d7fb3fa1..3b66b99192 100644 --- a/demo/full/scripts/modules/player/catchUp.ts +++ b/demo/full/scripts/modules/player/catchUp.ts @@ -69,19 +69,19 @@ export default class CatchUpModeController { this._state.updateBulk({ isCatchingUp: false, playbackRate: 1 }); } else { const checkCatchUp = () => { - const maximumPosition = this._rxPlayer.getMaximumPosition(); - if (maximumPosition === null) { + const livePos = this._rxPlayer.getLivePosition() ?? + this._rxPlayer.getMaximumPosition(); + if (livePos === null) { this._rxPlayer.setPlaybackRate(1); this._state.updateBulk({ isCatchingUp: false, playbackRate: 1 }); return; } const position = this._rxPlayer.getPosition(); - const liveGap = maximumPosition - position; + const liveGap = livePos - position; if (liveGap >= CATCH_UP_SEEKING_STEP) { // If we're too far from the live to just change the playback rate, // seek directly close to live - this._rxPlayer - .seekTo(maximumPosition - LIVE_GAP_GOAL_WHEN_CATCHING_UP); + this._rxPlayer.seekTo(livePos - LIVE_GAP_GOAL_WHEN_CATCHING_UP); this._rxPlayer.setPlaybackRate(1); this._state.updateBulk({ isCatchingUp: false, playbackRate: 1 }); return; diff --git a/demo/full/scripts/modules/player/events.ts b/demo/full/scripts/modules/player/events.ts index d7726bb66b..a50a12d250 100644 --- a/demo/full/scripts/modules/player/events.ts +++ b/demo/full/scripts/modules/player/events.ts @@ -57,20 +57,24 @@ function linkPlayerEventsToState( const position = player.getPosition(); const duration = player.getVideoDuration(); const videoTrack = player.getVideoTrack(); + const livePosition = player.getLivePosition(); const maximumPosition = player.getMaximumPosition(); let bufferGap = player.getVideoBufferGap(); bufferGap = !isFinite(bufferGap) || isNaN(bufferGap) ? 0 : bufferGap; + + const livePos = livePosition ?? maximumPosition; state.updateBulk({ currentTime: player.getPosition(), wallClockDiff: player.getWallClockTime() - position, bufferGap, duration: Number.isNaN(duration) ? undefined : duration, + livePosition, minimumPosition: player.getMinimumPosition(), - maximumPosition: player.getMaximumPosition(), - liveGap: typeof maximumPosition === "number" ? - maximumPosition - player.getPosition() : + maximumPosition, + liveGap: typeof livePos === "number" ? + livePos - player.getPosition() : undefined, playbackRate: player.getPlaybackRate(), videoTrackHasTrickMode: videoTrack !== null && @@ -210,6 +214,7 @@ function linkPlayerEventsToState( stateUpdates.duration = undefined; stateUpdates.minimumPosition = undefined; stateUpdates.maximumPosition = undefined; + stateUpdates.livePosition = undefined; break; } diff --git a/demo/full/scripts/modules/player/index.ts b/demo/full/scripts/modules/player/index.ts index da4e6e09e1..3b53894589 100644 --- a/demo/full/scripts/modules/player/index.ts +++ b/demo/full/scripts/modules/player/index.ts @@ -134,6 +134,7 @@ export interface IPlayerModuleState { liveGap: number | undefined; loadedVideo: ILoadVideoOptions | null; lowLatencyMode: boolean; + livePosition: null | undefined | number; maximumPosition: null | undefined | number; minimumPosition: null | undefined | number; playbackRate: number; @@ -185,6 +186,7 @@ const PlayerModule = declareModule( liveGap: undefined, loadedVideo: null, lowLatencyMode: false, + livePosition: undefined, maximumPosition: undefined, minimumPosition: undefined, playbackRate: 1, diff --git a/src/core/api/public_api.ts b/src/core/api/public_api.ts index 8ed776709e..ee8c621f1d 100644 --- a/src/core/api/public_api.ts +++ b/src/core/api/public_api.ts @@ -2386,6 +2386,29 @@ class Player extends EventEmitter { return null; } + /** + * Returns the current position for live contents. + * + * Returns `null` if no content is loaded or if the current loaded content is + * not considered as a live content. + * Returns `undefined` if that live position is currently unknown. + * @returns {number} + */ + getLivePosition() : number | undefined |null { + if (this._priv_contentInfos === null) { + return null; + } + + const { isDirectFile, manifest } = this._priv_contentInfos; + if (isDirectFile) { + return undefined; + } + if (manifest?.isLive !== true) { + return null; + } + return manifest.getLivePosition(); + } + /** * Get maximum seek-able position. * @returns {number} diff --git a/src/core/init/media_source_content_initializer.ts b/src/core/init/media_source_content_initializer.ts index d89a39828e..1cc84a4d5d 100644 --- a/src/core/init/media_source_content_initializer.ts +++ b/src/core/init/media_source_content_initializer.ts @@ -723,9 +723,10 @@ export default class MediaSourceContentInitializer extends ContentInitializer { contentTimeBoundariesObserver.addEventListener("periodChange", (period) => { this.trigger("activePeriodChanged", { period }); }); - contentTimeBoundariesObserver.addEventListener("durationUpdate", (newDuration) => { - mediaSourceDurationUpdater.updateDuration(newDuration.duration, newDuration.isEnd); - }); + contentTimeBoundariesObserver.addEventListener( + "endingPositionChange", + (x) => mediaSourceDurationUpdater.updateDuration(x.endingPosition, x.isEnd) + ); contentTimeBoundariesObserver.addEventListener("endOfStream", () => { if (endOfStreamCanceller === null) { endOfStreamCanceller = new TaskCanceller(); @@ -741,9 +742,8 @@ export default class MediaSourceContentInitializer extends ContentInitializer { endOfStreamCanceller = null; } }); - const currentDuration = contentTimeBoundariesObserver.getCurrentDuration(); - mediaSourceDurationUpdater.updateDuration(currentDuration.duration, - currentDuration.isEnd); + const endInfo = contentTimeBoundariesObserver.getCurrentEndingTime(); + mediaSourceDurationUpdater.updateDuration(endInfo.endingPosition, endInfo.isEnd); return contentTimeBoundariesObserver; } diff --git a/src/core/init/utils/content_time_boundaries_observer.ts b/src/core/init/utils/content_time_boundaries_observer.ts index 922e990d59..219ca86d7f 100644 --- a/src/core/init/utils/content_time_boundaries_observer.ts +++ b/src/core/init/utils/content_time_boundaries_observer.ts @@ -32,8 +32,8 @@ import { IStreamOrchestratorPlaybackObservation } from "../../stream"; /** * 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 a `endingPositionChange` when the known maximum playable position + * 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 @@ -111,7 +111,7 @@ export default class ContentTimeBoundariesObserver }, { includeLastObservation: true, clearSignal: cancelSignal }); manifest.addEventListener("manifestUpdate", () => { - this.trigger("durationUpdate", this._getManifestDuration()); + this.trigger("endingPositionChange", this._getManifestEndTime()); if (cancelSignal.isCancelled()) { return; } @@ -120,11 +120,12 @@ export default class ContentTimeBoundariesObserver } /** - * Returns an estimate of the current duration of the content. + * Returns an estimate of the current last position which may be played in + * the content at the moment. * @returns {Object} */ - public getCurrentDuration() : IDurationItem { - return this._getManifestDuration(); + public getCurrentEndingTime() : IEndingPositionInformation { + return this._getManifestEndTime(); } /** @@ -157,12 +158,13 @@ export default class ContentTimeBoundariesObserver .updateLastVideoAdaptation(adaptation); } const endingPosition = this._maximumPositionCalculator.getEndingPosition(); - const newDuration = endingPosition !== undefined ? + const newEndingPosition = endingPosition !== undefined ? { isEnd: true, - duration: endingPosition } : + endingPosition } : { isEnd: false, - duration: this._maximumPositionCalculator.getMaximumAvailablePosition() }; - this.trigger("durationUpdate", newDuration); + endingPosition: this._maximumPositionCalculator + .getMaximumAvailablePosition() }; + this.trigger("endingPositionChange", newEndingPosition); } } } @@ -311,13 +313,13 @@ export default class ContentTimeBoundariesObserver } } - private _getManifestDuration() : IDurationItem { + private _getManifestEndTime() : IEndingPositionInformation { const endingPosition = this._maximumPositionCalculator.getEndingPosition(); return endingPosition !== undefined ? { isEnd: true, - duration: endingPosition } : + endingPosition } : { isEnd: false, - duration: this._maximumPositionCalculator.getMaximumAvailablePosition() }; + endingPosition: this._maximumPositionCalculator.getMaximumAvailablePosition() }; } private _lazilyCreateActiveStreamInfo(bufferType : IBufferType) : IActiveStreamsInfo { @@ -348,16 +350,16 @@ export default class ContentTimeBoundariesObserver } } -export interface IDurationItem { +export interface IEndingPositionInformation { /** * The new maximum known position (note that this is the ending position * currently known of the current content, it might be superior to the last * position at which segments are available and it might also evolve over * time), in seconds. */ - duration : number; + endingPosition : number; /** - * If `true`, the communicated `duration` is the actual end of the content. + * If `true`, the communicated `endingPosition` is the actual end of the content. * It may still be updated due to a track change or to add precision, but it * is still a (rough) estimate of the maximum position that content should * have. @@ -365,7 +367,7 @@ export interface IDurationItem { * If `false`, this is the currently known maximum position associated to * the content, but the content is still evolving (typically, new media * segments are still being generated) and as such it can still have a - * longer duration in the future. + * longer `endingPosition` in the future. */ isEnd : boolean; } @@ -380,10 +382,10 @@ export interface IContentTimeBoundariesObserverEvent { /** Triggered when a new `Period` is currently playing. */ periodChange : Period; /** - * Triggered when the duration of the currently-playing content became known - * or changed. + * Triggered when the ending position of the currently-playing content became + * known or changed. */ - durationUpdate : IDurationItem; + endingPositionChange : IEndingPositionInformation; /** * Triggered when the last possible chronological segment for all types of * buffers has either been pushed or is being pushed to the corresponding @@ -460,8 +462,7 @@ class MaximumPositionCalculator { */ public getMaximumAvailablePosition() : number { if (this._manifest.isDynamic) { - return this._manifest.getLivePosition() ?? - this._manifest.getMaximumSafePosition(); + return this._manifest.getMaximumSafePosition(); } if (this._lastVideoAdaptation === undefined || this._lastAudioAdaptation === undefined) diff --git a/src/core/stream/representation/utils/get_buffer_status.ts b/src/core/stream/representation/utils/get_buffer_status.ts index 87ee3a6e63..38b6ca3281 100644 --- a/src/core/stream/representation/utils/get_buffer_status.ts +++ b/src/core/stream/representation/utils/get_buffer_status.ts @@ -153,7 +153,7 @@ export default function getBufferStatus( * needed segments for this Representation until the end of the Period. */ const hasFinishedLoading = representation.index.isInitialized() && - representation.index.isFinished() && + !representation.index.isStillAwaitingFutureSegments() && neededRange.hasReachedPeriodEnd && prioritizedNeededSegments.length === 0 && segmentsOnHold.length === 0; @@ -221,7 +221,7 @@ function getRangeOfNeededSegments( SegmentBuffersStore.isNative(content.adaptation.type) && initialWantedTime >= lastIndexPosition && representationIndex.isInitialized() && - representationIndex.isFinished() && + !representationIndex.isStillAwaitingFutureSegments() && isPeriodTheCurrentAndLastOne(manifest, period, initialWantedTime)) { wantedStartPosition = lastIndexPosition - 1; @@ -233,7 +233,7 @@ function getRangeOfNeededSegments( let hasReachedPeriodEnd; if (!representation.index.isInitialized() || - !representation.index.isFinished() || + representation.index.isStillAwaitingFutureSegments() || period.end === undefined) { hasReachedPeriodEnd = false; diff --git a/src/manifest/representation_index/static.ts b/src/manifest/representation_index/static.ts index 4b47ccc4b5..0835e7cb99 100644 --- a/src/manifest/representation_index/static.ts +++ b/src/manifest/representation_index/static.ts @@ -137,8 +137,8 @@ export default class StaticRepresentationIndex implements IRepresentationIndex { /** * @returns {Boolean} */ - isFinished() : true { - return true; + isStillAwaitingFutureSegments() : false { + return false; } /** diff --git a/src/manifest/representation_index/types.ts b/src/manifest/representation_index/types.ts index ca9ad29fa1..46611d75a5 100644 --- a/src/manifest/representation_index/types.ts +++ b/src/manifest/representation_index/types.ts @@ -332,7 +332,7 @@ export interface IRepresentationIndex { /** * Returns the ending time, in seconds, of the Representation once it is - * "finished" (@see isFinished). + * "finished" (@see isStillAwaitingFutureSegments). * Should thus be equivalent to `getLastAvailablePosition` once finished. * * Returns `null` if nothing is in the index @@ -384,13 +384,13 @@ export interface IRepresentationIndex { checkDiscontinuity(time : number) : number | null; /** - * Returns `true` if the last segments in this index have already been + * Returns `false` 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 + * Returns `true` if the index is still waiting on future segments to be * generated. * @returns {boolean} */ - isFinished() : boolean; + isStillAwaitingFutureSegments() : boolean; /** * Returns `true` if this index has all the data it needs to give the list diff --git a/src/parsers/manifest/dash/common/__tests__/manifest_bounds_calculator.test.ts b/src/parsers/manifest/dash/common/__tests__/manifest_bounds_calculator.test.ts index 724d935dff..cd5a56722a 100644 --- a/src/parsers/manifest/dash/common/__tests__/manifest_bounds_calculator.test.ts +++ b/src/parsers/manifest/dash/common/__tests__/manifest_bounds_calculator.test.ts @@ -18,39 +18,46 @@ import ManifestBoundsCalculator from "../manifest_bounds_calculator"; describe("DASH parsers - ManifestBoundsCalculator", () => { /* eslint-disable max-len */ - it("should return undefined through `estimateMinimumBound` if the live edge was never set for a dynamic content with a timeShiftBufferDepth", () => { + it("should return undefined through `getEstimatedMinimumSegmentTime` if the live edge was never set for a dynamic content with a timeShiftBufferDepth", () => { /* eslint-enable max-len */ const manifestBoundsCalculator = new ManifestBoundsCalculator({ isDynamic: true, timeShiftBufferDepth: 5, + availabilityStartTime: 0, + serverTimestampOffset: undefined, }); - expect(manifestBoundsCalculator.estimateMinimumBound()).toEqual(undefined); - expect(manifestBoundsCalculator.estimateMinimumBound()).toEqual(undefined); - expect(manifestBoundsCalculator.estimateMinimumBound()).toEqual(undefined); + expect(manifestBoundsCalculator.getEstimatedMinimumSegmentTime()).toEqual(undefined); + expect(manifestBoundsCalculator.getEstimatedMinimumSegmentTime()).toEqual(undefined); + expect(manifestBoundsCalculator.getEstimatedMinimumSegmentTime()).toEqual(undefined); }); /* eslint-disable max-len */ - it("should return 0 through `estimateMinimumBound` if the live edge was never set for a static content", () => { + it("should return 0 through `getEstimatedMinimumSegmentTime` for a static content", () => { /* eslint-enable max-len */ const manifestBoundsCalculator = new ManifestBoundsCalculator({ isDynamic: false, timeShiftBufferDepth: 5, + availabilityStartTime: 0, + serverTimestampOffset: 555555, }); - expect(manifestBoundsCalculator.estimateMinimumBound()).toEqual(0); - expect(manifestBoundsCalculator.estimateMinimumBound()).toEqual(0); - expect(manifestBoundsCalculator.estimateMinimumBound()).toEqual(0); + expect(manifestBoundsCalculator.getEstimatedMinimumSegmentTime()).toEqual(0); + manifestBoundsCalculator.setLastPosition(5555, 2135); + expect(manifestBoundsCalculator.getEstimatedMinimumSegmentTime()).toEqual(0); + expect(manifestBoundsCalculator.getEstimatedMinimumSegmentTime()).toEqual(0); }); /* eslint-disable max-len */ - it("should return 0 through `estimateMinimumBound` if the live edge was never set for a dynamic content with no timeShiftBufferDepth", () => { + it("should return 0 through `getEstimatedMinimumSegmentTime` if the `serverTimestampOffset` was never set nor the last position for a dynamic content with no timeShiftBufferDepth", () => { /* eslint-enable max-len */ const manifestBoundsCalculator = new ManifestBoundsCalculator({ isDynamic: false, timeShiftBufferDepth: undefined, + availabilityStartTime: 0, + serverTimestampOffset: undefined, }); - expect(manifestBoundsCalculator.estimateMinimumBound()).toEqual(0); - expect(manifestBoundsCalculator.estimateMinimumBound()).toEqual(0); - expect(manifestBoundsCalculator.estimateMinimumBound()).toEqual(0); + expect(manifestBoundsCalculator.getEstimatedMinimumSegmentTime()).toEqual(0); + expect(manifestBoundsCalculator.getEstimatedMinimumSegmentTime()).toEqual(0); + expect(manifestBoundsCalculator.getEstimatedMinimumSegmentTime()).toEqual(0); }); /* eslint-disable max-len */ @@ -59,9 +66,11 @@ describe("DASH parsers - ManifestBoundsCalculator", () => { const manifestBoundsCalculator = new ManifestBoundsCalculator({ isDynamic: true, timeShiftBufferDepth: 5, + availabilityStartTime: 0, + serverTimestampOffset: undefined, }); expect(manifestBoundsCalculator.lastPositionIsKnown()).toEqual(false); - expect(manifestBoundsCalculator.estimateMinimumBound()).toEqual(undefined); + expect(manifestBoundsCalculator.getEstimatedMinimumSegmentTime()).toEqual(undefined); expect(manifestBoundsCalculator.lastPositionIsKnown()).toEqual(false); }); @@ -71,6 +80,8 @@ describe("DASH parsers - ManifestBoundsCalculator", () => { const manifestBoundsCalculator = new ManifestBoundsCalculator({ isDynamic: true, timeShiftBufferDepth: 5, + availabilityStartTime: 0, + serverTimestampOffset: undefined, }); manifestBoundsCalculator.setLastPosition(1000, 0); expect(manifestBoundsCalculator.lastPositionIsKnown()).toEqual(true); @@ -82,63 +93,198 @@ describe("DASH parsers - ManifestBoundsCalculator", () => { const manifestBoundsCalculator = new ManifestBoundsCalculator({ isDynamic: false, timeShiftBufferDepth: 5, + availabilityStartTime: 0, + serverTimestampOffset: undefined, }); manifestBoundsCalculator.setLastPosition(1000, 0); expect(manifestBoundsCalculator.lastPositionIsKnown()).toEqual(true); }); /* eslint-disable max-len */ - it("should return how much time has elapsed through `estimateMinimumBound` since the live edge was set for a dynamic content", () => { + it("should return how much time has elapsed through `getEstimatedMinimumSegmentTime` since the last position was set for a dynamic content", () => { /* eslint-enable max-len */ - let date = 5000; + let performanceNow = 5000; const mockPerformanceNow = jest.spyOn(performance, "now") - .mockImplementation(jest.fn(() => date)); + .mockImplementation(jest.fn(() => performanceNow)); const manifestBoundsCalculator = new ManifestBoundsCalculator({ isDynamic: true, timeShiftBufferDepth: 5, + availabilityStartTime: 0, + serverTimestampOffset: undefined, }); manifestBoundsCalculator.setLastPosition(1000, 10); - date = 25000; - expect(manifestBoundsCalculator.estimateMinimumBound()).toEqual(1010); - date = 35000; - expect(manifestBoundsCalculator.estimateMinimumBound()).toEqual(1020); + performanceNow = 25000; + expect(manifestBoundsCalculator.getEstimatedMinimumSegmentTime()).toEqual(1010); + performanceNow = 35000; + expect(manifestBoundsCalculator.getEstimatedMinimumSegmentTime()).toEqual(1020); mockPerformanceNow.mockRestore(); }); /* eslint-disable max-len */ - it("should return 0 even when a last position has been set for a static content", () => { + it("should prefer relying on the live edge for `getEstimatedMinimumSegmentTime` if it was set", () => { /* eslint-enable max-len */ - let date = 5000; + let performanceNow = 5000; const mockPerformanceNow = jest.spyOn(performance, "now") - .mockImplementation(jest.fn(() => date)); + .mockImplementation(jest.fn(() => performanceNow)); const manifestBoundsCalculator = new ManifestBoundsCalculator({ - isDynamic: false, - timeShiftBufferDepth: 5, + isDynamic: true, + timeShiftBufferDepth: 3, + availabilityStartTime: 4, + serverTimestampOffset: 7000, }); - manifestBoundsCalculator.setLastPosition(1000, 0); - date = 25000; - expect(manifestBoundsCalculator.estimateMinimumBound()).toEqual(0); - date = 35000; - expect(manifestBoundsCalculator.estimateMinimumBound()).toEqual(0); + manifestBoundsCalculator.setLastPosition(3000, 10); + expect(manifestBoundsCalculator.getEstimatedMinimumSegmentTime()) + .toEqual(7 + 5 - 4 - 3); + performanceNow = 25000; + expect(manifestBoundsCalculator.getEstimatedMinimumSegmentTime()) + .toEqual(7 + 25 - 4 - 3); + performanceNow = 35000; + manifestBoundsCalculator.setLastPosition(84546464, 5642); + expect(manifestBoundsCalculator.getEstimatedMinimumSegmentTime()) + .toEqual(7 + 35 - 4 - 3); mockPerformanceNow.mockRestore(); }); /* eslint-disable max-len */ it("should authorize and handle multiple `setLastPositionOffset` calls for dynamic contents", () => { /* eslint-enable max-len */ - let date = 5000; + let performanceNow = 5000; const mockPerformanceNow = jest.spyOn(performance, "now") - .mockImplementation(jest.fn(() => date)); + .mockImplementation(jest.fn(() => performanceNow)); const manifestBoundsCalculator = new ManifestBoundsCalculator({ isDynamic: true, timeShiftBufferDepth: 5, + availabilityStartTime: 0, + serverTimestampOffset: undefined, }); manifestBoundsCalculator.setLastPosition(1000, 0); - date = 50000; - expect(manifestBoundsCalculator.estimateMinimumBound()).toEqual(1045); + performanceNow = 50000; + expect(manifestBoundsCalculator.getEstimatedMinimumSegmentTime()).toEqual(1045); manifestBoundsCalculator.setLastPosition(0, 0); - date = 55000; - expect(manifestBoundsCalculator.estimateMinimumBound()).toEqual(50); + performanceNow = 55000; + expect(manifestBoundsCalculator.getEstimatedMinimumSegmentTime()).toEqual(50); + mockPerformanceNow.mockRestore(); + }); + + /* eslint-disable max-len */ + it("`getEstimatedMaximumPosition` should be based on the last position on on-dynamic manifest", () => { + /* eslint-enable max-len */ + let performanceNow = 5000; + const mockPerformanceNow = jest.spyOn(performance, "now") + .mockImplementation(jest.fn(() => performanceNow)); + const manifestBoundsCalculator1 = new ManifestBoundsCalculator({ + isDynamic: false, + timeShiftBufferDepth: 5, + availabilityStartTime: 0, + serverTimestampOffset: undefined, + }); + const manifestBoundsCalculator2 = new ManifestBoundsCalculator({ + isDynamic: false, + timeShiftBufferDepth: 5, + availabilityStartTime: 0, + serverTimestampOffset: 10, + }); + manifestBoundsCalculator1.setLastPosition(1000, 0); + manifestBoundsCalculator2.setLastPosition(1000, 0); + performanceNow = 50000; + expect(manifestBoundsCalculator1.getEstimatedMaximumPosition(10)).toEqual(1000); + expect(manifestBoundsCalculator2.getEstimatedMaximumPosition(19)).toEqual(1000); + performanceNow = 55000; + expect(manifestBoundsCalculator1.getEstimatedMaximumPosition(98)).toEqual(1000); + expect(manifestBoundsCalculator2.getEstimatedMaximumPosition(93)).toEqual(1000); + manifestBoundsCalculator1.setLastPosition(0, 0); + manifestBoundsCalculator2.setLastPosition(0, 0); + expect(manifestBoundsCalculator1.getEstimatedMaximumPosition(43)).toEqual(0); + expect(manifestBoundsCalculator2.getEstimatedMaximumPosition(421)).toEqual(0); + mockPerformanceNow.mockRestore(); + }); + + /* eslint-disable max-len */ + it("`getEstimatedMaximumPosition` should evolve based on the last position on dynamic manifest without `serverTimestampOffset`", () => { + /* eslint-enable max-len */ + let performanceNow = 5000; + const mockPerformanceNow = jest.spyOn(performance, "now") + .mockImplementation(jest.fn(() => performanceNow)); + const manifestBoundsCalculator = new ManifestBoundsCalculator({ + isDynamic: true, + timeShiftBufferDepth: 5, + availabilityStartTime: 7, + serverTimestampOffset: undefined, + }); + manifestBoundsCalculator.setLastPosition(1050, 0); + performanceNow = 50000; + expect(manifestBoundsCalculator.getEstimatedMaximumPosition(10)).toEqual(1050 + 50); + performanceNow = 55000; + expect(manifestBoundsCalculator.getEstimatedMaximumPosition(98)).toEqual(1050 + 55); + manifestBoundsCalculator.setLastPosition(0, 10); + expect(manifestBoundsCalculator.getEstimatedMaximumPosition(43)).toEqual(0 + 55 - 10); + mockPerformanceNow.mockRestore(); + }); + + it("should not return a live edge if `serverTimestampOffset` isn't set", () => { + const manifestBoundsCalculator = new ManifestBoundsCalculator({ + isDynamic: true, + timeShiftBufferDepth: 5, + availabilityStartTime: 0, + serverTimestampOffset: undefined, + }); + manifestBoundsCalculator.setLastPosition(1000, 0); + expect(manifestBoundsCalculator.getEstimatedLiveEdge()).toEqual(undefined); + }); + + it("should not return a live edge if the manifest is not dynamic", () => { + const manifestBoundsCalculator = new ManifestBoundsCalculator({ + isDynamic: false, + timeShiftBufferDepth: 5, + availabilityStartTime: 0, + serverTimestampOffset: 100, + }); + manifestBoundsCalculator.setLastPosition(1000, 0); + expect(manifestBoundsCalculator.getEstimatedLiveEdge()).toEqual(undefined); + }); + + it("should rely on `serverTimestampOffset` to produce live edge if set", () => { + let performanceNow = 3000; + const mockPerformanceNow = jest.spyOn(performance, "now") + .mockImplementation(jest.fn(() => performanceNow)); + const manifestBoundsCalculator = new ManifestBoundsCalculator({ + isDynamic: true, + timeShiftBufferDepth: 5, + availabilityStartTime: 2, + serverTimestampOffset: 5000, + }); + expect(manifestBoundsCalculator.getEstimatedLiveEdge()).toEqual(3 + 5 - 2); + manifestBoundsCalculator.setLastPosition(1000, 0); + expect(manifestBoundsCalculator.getEstimatedLiveEdge()).toEqual(3 + 5 - 2); + performanceNow = 9000; + expect(manifestBoundsCalculator.getEstimatedLiveEdge()).toEqual(9 + 5 - 2); + mockPerformanceNow.mockRestore(); + }); + + /* eslint-disable max-len */ + it("`getEstimatedMaximumPosition` should evolve based on the live edge position on dynamic manifest with `serverTimestampOffset`", () => { + /* eslint-enable max-len */ + let performanceNow = 5000; + const mockPerformanceNow = jest.spyOn(performance, "now") + .mockImplementation(jest.fn(() => performanceNow)); + const manifestBoundsCalculator = new ManifestBoundsCalculator({ + isDynamic: true, + timeShiftBufferDepth: 3, + availabilityStartTime: 7, + serverTimestampOffset: 1000, + }); + expect(manifestBoundsCalculator.getEstimatedMaximumPosition(4)) + .toEqual(5 + 1 - 7 + 4); + manifestBoundsCalculator.setLastPosition(1050, 0); + performanceNow = 70000; + expect(manifestBoundsCalculator.getEstimatedMaximumPosition(11)) + .toEqual(5 + 1 - 7 + 11 + 70 - 5); + performanceNow = 85000; + expect(manifestBoundsCalculator.getEstimatedMaximumPosition(98)) + .toEqual(5 + 1 - 7 + 98 + 85 - 5); + manifestBoundsCalculator.setLastPosition(0, 10); + expect(manifestBoundsCalculator.getEstimatedMaximumPosition(43)) + .toEqual(5 + 1 - 7 + 43 + 85 - 5); mockPerformanceNow.mockRestore(); }); }); diff --git a/src/parsers/manifest/dash/common/indexes/base.ts b/src/parsers/manifest/dash/common/indexes/base.ts index 405bd533cc..e20350fae8 100644 --- a/src/parsers/manifest/dash/common/indexes/base.ts +++ b/src/parsers/manifest/dash/common/indexes/base.ts @@ -26,6 +26,7 @@ import { IIndexSegment, toIndexTime, } from "../../../utils/index_helpers"; +import ManifestBoundsCalculator from "../manifest_bounds_calculator"; import getInitSegment from "./get_init_segment"; import getSegmentsFromTimeline from "./get_segments_from_timeline"; import { constructRepresentationUrl } from "./tokens"; @@ -120,6 +121,8 @@ export interface IBaseIndexContextArgument { representationId? : string | undefined; /** Bitrate of the Representation concerned. */ representationBitrate? : number | undefined; + /** Allows to obtain the minimum and maximum positions of a content. */ + manifestBoundsCalculator : ManifestBoundsCalculator; /* Function that tells if an EMSG is whitelisted by the manifest */ isEMSGWhitelisted: (inbandEvent: IEMSG) => boolean; } @@ -179,6 +182,9 @@ export default class BaseRepresentationIndex implements IRepresentationIndex { /** Absolute end of the period, timescaled and converted to index time. */ private _scaledPeriodEnd : number | undefined; + /** Allows to obtain the minimum and maximum positions of a content. */ + private _manifestBoundsCalculator : ManifestBoundsCalculator; + /* Function that tells if an EMSG is whitelisted by the manifest */ private _isEMSGWhitelisted: (inbandEvent: IEMSG) => boolean; @@ -228,6 +234,7 @@ export default class BaseRepresentationIndex implements IRepresentationIndex { endNumber: index.endNumber, timeline: index.timeline ?? [], timescale }; + this._manifestBoundsCalculator = context.manifestBoundsCalculator; this._scaledPeriodStart = toIndexTime(periodStart, this._index); this._scaledPeriodEnd = periodEnd == null ? undefined : toIndexTime(periodEnd, this._index); @@ -261,8 +268,9 @@ export default class BaseRepresentationIndex implements IRepresentationIndex { return getSegmentsFromTimeline(this._index, from, dur, - this._isEMSGWhitelisted, - this._scaledPeriodEnd); + this._manifestBoundsCalculator, + this._scaledPeriodEnd, + this._isEMSGWhitelisted); } /** @@ -379,8 +387,8 @@ export default class BaseRepresentationIndex implements IRepresentationIndex { * should become available in the future. * @returns {Boolean} */ - isFinished() : true { - return true; + isStillAwaitingFutureSegments() : false { + return false; } /** 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 f0839e7ac4..940333bc19 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 @@ -21,6 +21,7 @@ import { IIndexSegment, toIndexTime, } from "../../../utils/index_helpers"; +import ManifestBoundsCalculator from "../manifest_bounds_calculator"; import { createDashUrlDetokenizer } from "./tokens"; /** @@ -47,12 +48,14 @@ function getWantedRepeatIndex( * @param {Object} index - index object, constructed by parsing the manifest. * @param {number} from - starting timestamp wanted, in seconds * @param {number} durationWanted - duration wanted, in seconds + * @param {Object} manifestBoundsCalculator + * @param {number|undefined} scaledPeriodEnd * @param {function} isEMSGWhitelisted - * @param {number|undefined} maximumTime * @returns {Array.} */ export default function getSegmentsFromTimeline( index : { availabilityTimeComplete? : boolean | undefined; + availabilityTimeOffset? : number | undefined; segmentUrlTemplate : string | null; startNumber? : number | undefined; endNumber? : number | undefined; @@ -61,11 +64,16 @@ export default function getSegmentsFromTimeline( indexTimeOffset : number; }, from : number, durationWanted : number, - isEMSGWhitelisted: (inbandEvent: IEMSG) => boolean, - maximumTime? : number + manifestBoundsCalculator : ManifestBoundsCalculator, + scaledPeriodEnd : number | undefined, + isEMSGWhitelisted: (inbandEvent: IEMSG) => boolean ) : ISegment[] { + const maximumTime = manifestBoundsCalculator.getEstimatedMaximumPosition( + index.availabilityTimeOffset ?? 0 + ); + const wantedMaximum = Math.min(from + durationWanted, maximumTime ?? Infinity); const scaledUp = toIndexTime(from, index); - const scaledTo = toIndexTime(from + durationWanted, index); + const scaledTo = toIndexTime(wantedMaximum, index); const { timeline, timescale, segmentUrlTemplate, startNumber, endNumber } = index; let currentNumber = startNumber ?? 1; @@ -77,7 +85,13 @@ export default function getSegmentsFromTimeline( const timelineItem = timeline[i]; const { duration, start, range } = timelineItem; - const repeat = calculateRepeat(timelineItem, timeline[i + 1], maximumTime); + let maxRepeatTime; + if (maximumTime === undefined) { + maxRepeatTime = scaledPeriodEnd; + } else { + maxRepeatTime = Math.min(maximumTime * timescale, scaledPeriodEnd ?? Infinity); + } + const repeat = calculateRepeat(timelineItem, timeline[i + 1], maxRepeatTime); const complete = index.availabilityTimeComplete !== false || i !== timelineLength - 1 && repeat !== 0; diff --git a/src/parsers/manifest/dash/common/indexes/index.ts b/src/parsers/manifest/dash/common/indexes/index.ts index 97c92ee934..f44be837c7 100644 --- a/src/parsers/manifest/dash/common/indexes/index.ts +++ b/src/parsers/manifest/dash/common/indexes/index.ts @@ -14,14 +14,26 @@ * limitations under the License. */ -import BaseRepresentationIndex from "./base"; -import ListRepresentationIndex from "./list"; -import TemplateRepresentationIndex from "./template"; -import TimelineRepresentationIndex from "./timeline"; +import BaseRepresentationIndex, { + IBaseIndexContextArgument, +} from "./base"; +import ListRepresentationIndex, { + IListIndexContextArgument, +} from "./list"; +import TemplateRepresentationIndex, { + ITemplateIndexContextArgument, +} from "./template"; +import TimelineRepresentationIndex, { + ITimelineIndexContextArgument, +} from "./timeline"; export { BaseRepresentationIndex, ListRepresentationIndex, TemplateRepresentationIndex, TimelineRepresentationIndex, + IBaseIndexContextArgument, + IListIndexContextArgument, + ITemplateIndexContextArgument, + ITimelineIndexContextArgument, }; diff --git a/src/parsers/manifest/dash/common/indexes/list.ts b/src/parsers/manifest/dash/common/indexes/list.ts index a73ce5dbce..3eb6f6ef7f 100644 --- a/src/parsers/manifest/dash/common/indexes/list.ts +++ b/src/parsers/manifest/dash/common/indexes/list.ts @@ -314,8 +314,8 @@ export default class ListRepresentationIndex implements IRepresentationIndex { /** * @returns {Boolean} */ - isFinished() : true { - return true; + isStillAwaitingFutureSegments() : false { + return false; } /** diff --git a/src/parsers/manifest/dash/common/indexes/template.ts b/src/parsers/manifest/dash/common/indexes/template.ts index 191935e22f..f95432cecc 100644 --- a/src/parsers/manifest/dash/common/indexes/template.ts +++ b/src/parsers/manifest/dash/common/indexes/template.ts @@ -118,9 +118,15 @@ export interface ITemplateIndexIndexArgument { /** Aditional context needed by a SegmentTemplate RepresentationIndex. */ export interface ITemplateIndexContextArgument { - aggressiveMode : boolean; - /** Minimum availabilityTimeOffset concerning the segments of this Representation. */ - availabilityTimeOffset : number; + /** + * availability time offset of the concerned Adaptation. + * + * If `undefined`, the corresponding property was not set in the MPD and it is + * thus assumed to be equal to `0`. + * It might however be semantically different than `0` in the RxPlayer as it + * means that the packager didn't include that information in the MPD. + */ + availabilityTimeOffset : number | undefined; /** Allows to obtain the minimum and maximum positions of a content. */ manifestBoundsCalculator : ManifestBoundsCalculator; /** Start of the period concerned by this RepresentationIndex, in seconds. */ @@ -145,11 +151,6 @@ export interface ITemplateIndexContextArgument { export default class TemplateRepresentationIndex implements IRepresentationIndex { /** Underlying structure to retrieve segment information. */ private _index : ITemplateIndex; - /** - * Whether the "aggressiveMode" is enabled. If enabled, segments can be - * requested in advance. - */ - private _aggressiveMode : boolean; /** Retrieve the maximum and minimum position of the whole content. */ private _manifestBoundsCalculator : ManifestBoundsCalculator; /** Absolute start of the Period, in seconds. */ @@ -171,8 +172,7 @@ export default class TemplateRepresentationIndex implements IRepresentationIndex index : ITemplateIndexIndexArgument, context : ITemplateIndexContextArgument ) { - const { aggressiveMode, - availabilityTimeOffset, + const { availabilityTimeOffset, manifestBoundsCalculator, isDynamic, periodEnd, @@ -185,7 +185,6 @@ export default class TemplateRepresentationIndex implements IRepresentationIndex this._availabilityTimeOffset = availabilityTimeOffset; this._manifestBoundsCalculator = manifestBoundsCalculator; - this._aggressiveMode = aggressiveMode; const presentationTimeOffset = index.presentationTimeOffset != null ? index.presentationTimeOffset : 0; @@ -389,14 +388,26 @@ export default class TemplateRepresentationIndex implements IRepresentationIndex const { timescale } = this._index; const segmentTimeRounding = getSegmentTimeRoundingError(timescale); const scaledPeriodStart = this._periodStart * timescale; + const scaledRelativeStart = start * timescale - scaledPeriodStart; const scaledRelativeEnd = end * timescale - scaledPeriodStart; + const lastSegmentStart = this._getLastSegmentStart(); + if (isNullOrUndefined(lastSegmentStart)) { + const relativeScaledIndexEnd = this._estimateRelativeScaledEnd(); + if (relativeScaledIndexEnd === undefined) { + return scaledRelativeEnd + segmentTimeRounding >= 0; + } + return scaledRelativeEnd + segmentTimeRounding >= 0 && + scaledRelativeStart < relativeScaledIndexEnd - segmentTimeRounding; + + } + const lastSegmentEnd = lastSegmentStart + this._index.duration; const relativeScaledIndexEnd = this._estimateRelativeScaledEnd(); if (relativeScaledIndexEnd === undefined) { - return (scaledRelativeEnd + segmentTimeRounding) >= 0; + return scaledRelativeEnd > lastSegmentEnd - segmentTimeRounding; } - const scaledRelativeStart = start * timescale - scaledPeriodStart; - return (scaledRelativeStart - segmentTimeRounding) < relativeScaledIndexEnd; + return scaledRelativeEnd > lastSegmentEnd - segmentTimeRounding && + scaledRelativeStart < relativeScaledIndexEnd - segmentTimeRounding; } /** @@ -446,20 +457,20 @@ export default class TemplateRepresentationIndex implements IRepresentationIndex } /** - * Returns `true` if the last segments in this index have already been + * Returns `false` 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 + * Returns `true` if the index is still waiting on future segments to be * generated. * @returns {Boolean} */ - isFinished() : boolean { + isStillAwaitingFutureSegments() : boolean { if (!this._isDynamic) { - return true; + return false; } const scaledRelativeIndexEnd = this._estimateRelativeScaledEnd(); if (scaledRelativeIndexEnd === undefined) { - return false; + return true; } const { timescale } = this._index; @@ -468,11 +479,11 @@ 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 (isNullOrUndefined(lastSegmentStart)) { - return false; + return true; } const lastSegmentEnd = lastSegmentStart + this._index.duration; const segmentTimeRounding = getSegmentTimeRoundingError(timescale); - return (lastSegmentEnd + segmentTimeRounding) >= scaledRelativeIndexEnd; + return (lastSegmentEnd + segmentTimeRounding) < scaledRelativeIndexEnd; } /** @@ -487,7 +498,6 @@ export default class TemplateRepresentationIndex implements IRepresentationIndex */ _replace(newIndex : TemplateRepresentationIndex) : void { this._index = newIndex._index; - this._aggressiveMode = newIndex._aggressiveMode; this._isDynamic = newIndex._isDynamic; this._periodStart = newIndex._periodStart; this._scaledRelativePeriodEnd = newIndex._scaledRelativePeriodEnd; @@ -520,8 +530,11 @@ export default class TemplateRepresentationIndex implements IRepresentationIndex // /!\ The scaled max position augments continuously and might not // reflect exactly the real server-side value. As segments are // generated discretely. - const maximumBound = this._manifestBoundsCalculator.estimateMaximumBound(); - if (maximumBound !== undefined && maximumBound < this._periodStart) { + const maximumSegmentTime = + this._manifestBoundsCalculator.getEstimatedMaximumPosition( + this._availabilityTimeOffset ?? 0 + ); + if (maximumSegmentTime !== undefined && maximumSegmentTime < this._periodStart) { // Maximum position is before this period. // No segment is yet available here return null; @@ -529,7 +542,7 @@ export default class TemplateRepresentationIndex implements IRepresentationIndex } const { duration, timescale } = this._index; - const firstPosition = this._manifestBoundsCalculator.estimateMinimumBound(); + const firstPosition = this._manifestBoundsCalculator.getEstimatedMinimumSegmentTime(); if (firstPosition === undefined) { return undefined; } @@ -551,15 +564,12 @@ export default class TemplateRepresentationIndex implements IRepresentationIndex const { duration, timescale, endNumber, startNumber = 1 } = this._index; if (this._isDynamic) { - const lastPos = this._manifestBoundsCalculator.estimateMaximumBound(); - if (lastPos === undefined) { - return undefined; - } - const agressiveModeOffset = this._aggressiveMode ? (duration / timescale) : - 0; - if (this._scaledRelativePeriodEnd !== undefined && + const liveEdge = this._manifestBoundsCalculator.getEstimatedLiveEdge(); + if (liveEdge !== undefined && + this._scaledRelativePeriodEnd !== undefined && this._scaledRelativePeriodEnd < - (lastPos + agressiveModeOffset - this._periodStart) * this._index.timescale) { + liveEdge - this._periodStart * this._index.timescale) + { let numberOfSegments = Math.ceil(this._scaledRelativePeriodEnd / duration); if (endNumber !== undefined && (endNumber - startNumber + 1) < numberOfSegments) { @@ -567,10 +577,16 @@ export default class TemplateRepresentationIndex implements IRepresentationIndex } return (numberOfSegments - 1) * duration; } + const maxPosition = this._manifestBoundsCalculator + .getEstimatedMaximumPosition(this._availabilityTimeOffset ?? 0); + if (maxPosition === undefined) { + return undefined; + } + // /!\ The scaled last position augments continuously and might not // reflect exactly the real server-side value. As segments are // generated discretely. - const scaledLastPosition = (lastPos - this._periodStart) * timescale; + const scaledLastPosition = (maxPosition - this._periodStart) * timescale; // Maximum position is before this period. // No segment is yet available here @@ -578,13 +594,7 @@ export default class TemplateRepresentationIndex implements IRepresentationIndex return null; } - const availabilityTimeOffset = - ((this._availabilityTimeOffset !== undefined ? this._availabilityTimeOffset : 0) + - agressiveModeOffset) * timescale; - - let numberOfSegmentsAvailable = - Math.floor((scaledLastPosition + availabilityTimeOffset) / duration); - + let numberOfSegmentsAvailable = Math.floor(scaledLastPosition / duration); if (endNumber !== undefined && (endNumber - startNumber + 1) < numberOfSegmentsAvailable) { numberOfSegmentsAvailable = endNumber - startNumber + 1; diff --git a/src/parsers/manifest/dash/common/indexes/timeline/index.ts b/src/parsers/manifest/dash/common/indexes/timeline/index.ts index d059c0bfef..05367c2fc3 100644 --- a/src/parsers/manifest/dash/common/indexes/timeline/index.ts +++ b/src/parsers/manifest/dash/common/indexes/timeline/index.ts @@ -14,5 +14,8 @@ * limitations under the License. */ -import TimelineRepresentationIndex from "./timeline_representation_index"; +import TimelineRepresentationIndex, { + ITimelineIndexContextArgument, +} from "./timeline_representation_index"; export default TimelineRepresentationIndex; +export { ITimelineIndexContextArgument }; 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 0d105ee865..be4ec19b2d 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 @@ -33,7 +33,6 @@ import { IIndexSegment, toIndexTime, } from "../../../../utils/index_helpers"; -import isSegmentStillAvailable from "../../../../utils/is_segment_still_available"; import updateSegmentTimeline from "../../../../utils/update_segment_timeline"; import { ISegmentTimelineElement } from "../../../node_parser_types"; import ManifestBoundsCalculator from "../../manifest_bounds_calculator"; @@ -53,6 +52,8 @@ import constructTimelineFromPreviousTimeline from "./construct_timeline_from_pre export interface ITimelineIndex { /** If `false`, the last segment anounced might be still incomplete. */ availabilityTimeComplete : boolean; + /** Minimum availabilityTimeOffset concerning the segments of this Representation. */ + availabilityTimeOffset : number; /** Byte range for a possible index of segments in the server. */ indexRange?: [number, number] | undefined; /** @@ -151,8 +152,25 @@ export interface ITimelineIndexIndexArgument { /** Aditional context needed by a SegmentTimeline RepresentationIndex. */ export interface ITimelineIndexContextArgument { - /** If `false`, the last segment anounced might be still incomplete. */ - availabilityTimeComplete : boolean; + /** + * If `false`, declared segments in the MPD might still be not completely generated. + * If `true`, they are completely generated. + * + * If `undefined`, the corresponding property was not set in the MPD and it is + * thus assumed that they are all generated. + * It might however be semantically different than `true` in the RxPlayer as it + * means that the packager didn't include that information in the MPD. + */ + availabilityTimeComplete : boolean | undefined; + /** + * availability time offset of the concerned Adaptation. + * + * If `undefined`, the corresponding property was not set in the MPD and it is + * thus assumed to be equal to `0`. + * It might however be semantically different than `0` in the RxPlayer as it + * means that the packager didn't include that information in the MPD. + */ + availabilityTimeOffset : number | undefined; /** Allows to obtain the minimum and maximum positions of a content. */ manifestBoundsCalculator : ManifestBoundsCalculator; /** Start of the period linked to this RepresentationIndex, in seconds. */ @@ -196,6 +214,11 @@ export interface ILastSegmentInformation { time : number; } +/** + * `IRepresentationIndex` implementation for a DASH `SegmentTimeline` segment + * indexing scheme. + * @class TimelineRepresentationIndex + */ export default class TimelineRepresentationIndex implements IRepresentationIndex { /** Underlying structure to retrieve segment information. */ protected _index : ITimelineIndex; @@ -250,6 +273,7 @@ export default class TimelineRepresentationIndex implements IRepresentationIndex "TimelineRepresentationIndex."); } const { availabilityTimeComplete, + availabilityTimeOffset, manifestBoundsCalculator, isDynamic, isLastPeriod, @@ -298,7 +322,29 @@ export default class TimelineRepresentationIndex implements IRepresentationIndex const segmentUrlTemplate = index.media === undefined ? null : constructRepresentationUrl(index.media, representationId, representationBitrate); - this._index = { availabilityTimeComplete, + + let actualAvailabilityTimeOffset; + // Technically, it seems (although it is not clear) that an MPD may contain + // future segments and it's the job of a player to not request segments later + // than the time at which they should be available. + // In practice, we don't do that for various reasons: precision issues, + // various DASH spec interpretations by packagers and players... + // + // So as a compromise, if nothing in the MPD indicates that future segments + // may be announced (see code below), we will act as if ALL segments in this + // TimelineRepresentationIndex are requestable + if ( + availabilityTimeOffset === undefined && + availabilityTimeComplete === undefined + ) { + actualAvailabilityTimeOffset = Infinity; // Meaning: we can request + // everything in the index + } else { + actualAvailabilityTimeOffset = availabilityTimeOffset ?? 0; + } + + this._index = { availabilityTimeComplete: availabilityTimeComplete ?? true, + availabilityTimeOffset: actualAvailabilityTimeOffset, indexRange: index.indexRange, indexTimeOffset, initialization: index.initialization == null ? @@ -316,6 +362,7 @@ export default class TimelineRepresentationIndex implements IRepresentationIndex index.startNumber, index.endNumber), timescale }; + this._scaledPeriodStart = toIndexTime(periodStart, this._index); this._scaledPeriodEnd = periodEnd === undefined ? undefined : toIndexTime(periodEnd, this._index); @@ -356,8 +403,9 @@ export default class TimelineRepresentationIndex implements IRepresentationIndex indexTimeOffset }, from, duration, - this._isEMSGWhitelisted, - this._scaledPeriodEnd); + this._manifestBoundsCalculator, + this._scaledPeriodEnd, + this._isEMSGWhitelisted); } /** @@ -399,10 +447,19 @@ export default class TimelineRepresentationIndex implements IRepresentationIndex if (this._index.timeline === null) { this._index.timeline = this._getTimeline(); } - const lastTime = TimelineRepresentationIndex.getIndexEnd(this._index.timeline, - this._scaledPeriodEnd); - return lastTime === null ? null : - fromIndexTime(lastTime, this._index); + + const lastReqSegInfo = getLastRequestableSegmentInfo( + // Needed typecast for TypeScript + this._index as typeof this._index & { timeline: IIndexSegment[] }, + this._manifestBoundsCalculator, + this._scaledPeriodEnd + ); + if (lastReqSegInfo === null) { + return null; + } + const lastScaledPosition = Math.min(lastReqSegInfo.end, + this._scaledPeriodEnd ?? Infinity); + return fromIndexTime(lastScaledPosition, this._index); } /** @@ -411,10 +468,23 @@ export default class TimelineRepresentationIndex implements IRepresentationIndex * @returns {number|null|undefined} */ getEnd(): number | undefined | null { - if (!this._isDynamic || !this._isLastPeriod) { // @see isFinished - return this.getLastAvailablePosition(); + if (this._isDynamic && !this._isLastPeriod) { + return undefined; + } + + this._refreshTimeline(); + if (this._index.timeline === null) { + this._index.timeline = this._getTimeline(); + } + if (this._index.timeline.length <= 0) { + return null; } - return undefined; + const lastSegment = this._index.timeline[this._index.timeline.length - 1]; + const lastTime = Math.min(getIndexSegmentEnd(lastSegment, + null, + this._scaledPeriodEnd), + this._scaledPeriodEnd ?? Infinity); + return fromIndexTime(lastTime, this._index); } /** @@ -430,33 +500,67 @@ export default class TimelineRepresentationIndex implements IRepresentationIndex */ awaitSegmentBetween(start: number, end: number): boolean | undefined { assert(start <= end); - if (!this._isDynamic || !this._isLastPeriod) { - return false; + if (!this._isDynamic) { + return false; // No segment will be newly available in the future } + this._refreshTimeline(); if (this._index.timeline === null) { this._index.timeline = this._getTimeline(); } - const { timeline, timescale } = this._index; + const { timescale, timeline } = this._index; const segmentTimeRounding = getSegmentTimeRoundingError(timescale); - const scaledEnd = toIndexTime(end, this._index); - if (timeline.length > 0) { - const lastTimelineElement = timeline[timeline.length - 1]; - const lastSegmentEnd = getIndexSegmentEnd(lastTimelineElement, + const scaledWantedEnd = toIndexTime(end, this._index); + const lastReqSegInfo = getLastRequestableSegmentInfo( + // Needed typecast for TypeScript + this._index as typeof this._index & { timeline: IIndexSegment[] }, + this._manifestBoundsCalculator, + this._scaledPeriodEnd + ); + if (lastReqSegInfo !== null) { + const lastReqSegmentEnd = Math.min(lastReqSegInfo.end, + this._scaledPeriodEnd ?? Infinity); + const roundedReqSegmentEnd = lastReqSegmentEnd + segmentTimeRounding; + if (roundedReqSegmentEnd >= Math.min(scaledWantedEnd, + this._scaledPeriodEnd ?? Infinity)) + { + return false; // everything up to that point is already requestable + } + } + + const scaledWantedStart = toIndexTime(start, this._index); + if (timeline.length > 0 && + lastReqSegInfo !== null && + !lastReqSegInfo.isLastOfTimeline) + { + // There are some future segments already anounced in the MPD + + const lastSegment = timeline[timeline.length - 1]; + const lastSegmentEnd = getIndexSegmentEnd(lastSegment, null, this._scaledPeriodEnd); - const roundedEnd = lastSegmentEnd + segmentTimeRounding; - if (roundedEnd >= Math.min(scaledEnd, this._scaledPeriodEnd ?? Infinity)) { - return false; // already loaded + const roundedLastSegEnd = lastSegmentEnd + segmentTimeRounding; + if (scaledWantedStart < roundedLastSegEnd + segmentTimeRounding) { + return true; // The MPD's timeline already contains one such element, + // It is just not requestable yet } } + + if (!this._isLastPeriod) { + // Let's consider - perhaps wrongly, that Periods which aren't the last + // one have all of their segments announced. + return false; + } + if (this._scaledPeriodEnd === undefined) { - return (scaledEnd + segmentTimeRounding) > this._scaledPeriodStart ? undefined : - false; + return (scaledWantedEnd + segmentTimeRounding) > this._scaledPeriodStart ? + undefined : // There may be future segments at this point + false; // Before the current Period } - const scaledStart = toIndexTime(start, this._index); - return (scaledStart - segmentTimeRounding) < this._scaledPeriodEnd && - (scaledEnd + segmentTimeRounding) > this._scaledPeriodStart; + + // `true` if within the boundaries of this Period. `false` otherwise. + return (scaledWantedStart - segmentTimeRounding) < this._scaledPeriodEnd && + (scaledWantedEnd + segmentTimeRounding) > this._scaledPeriodStart; } /** @@ -475,8 +579,13 @@ export default class TimelineRepresentationIndex implements IRepresentationIndex if (this._index.timeline === null) { this._index.timeline = this._getTimeline(); } - const { timeline, timescale, indexTimeOffset } = this._index; - return isSegmentStillAvailable(segment, timeline, timescale, indexTimeOffset); + return isSegmentStillAvailable(segment, + // Needed typecast for TypeScript + this._index as typeof this._index & { + timeline: IIndexSegment[]; + }, + this._manifestBoundsCalculator, + this._scaledPeriodEnd); } /** @@ -546,6 +655,8 @@ export default class TimelineRepresentationIndex implements IRepresentationIndex if (hasReplaced) { this._index.startNumber = newIndex._index.startNumber; } + this._index.availabilityTimeOffset = newIndex._index.availabilityTimeOffset; + this._index.availabilityTimeComplete = newIndex._index.availabilityTimeComplete; this._index.endNumber = newIndex._index.endNumber; this._isDynamic = newIndex._isDynamic; this._scaledPeriodStart = newIndex._scaledPeriodStart; @@ -555,35 +666,80 @@ export default class TimelineRepresentationIndex implements IRepresentationIndex } /** - * Returns `true` if this RepresentationIndex currently contains its last + * Returns `false` if this RepresentationIndex currently contains its last * segment. - * Returns `false` if it's still pending. + * Returns `true` if it's still pending. * @returns {Boolean} */ - isFinished() : boolean { - if (!this._isDynamic || !this._isLastPeriod) { - // Either the content is not dynamic, in which case no new segment will - // be generated, either it is but this index is not linked to the current - // last Period in the MPD, in which case it is inferred that it has been - // completely generated. Note that this second condition might break very - // very rare use cases where old Periods are still being generated, yet it - // should fix more cases than it breaks. - return true; + isStillAwaitingFutureSegments() : boolean { + if (!this._isDynamic) { + return false; } + this._refreshTimeline(); if (this._index.timeline === null) { this._index.timeline = this._getTimeline(); } + const { timeline } = this._index; - if (this._scaledPeriodEnd === undefined || timeline.length === 0) { - return false; + if (timeline.length === 0) { + // No segment announced in this Period + if (this._scaledPeriodEnd !== undefined) { + const liveEdge = this._manifestBoundsCalculator.getEstimatedLiveEdge(); + if (liveEdge !== undefined && + toIndexTime(liveEdge, this._index) > this._scaledPeriodEnd) + { + // This Period is over, we're not awaiting anything + return false; + } + } + // Let's just consider that we're awaiting only for when this is the last Period. + return this._isLastPeriod; } - const lastTimelineElement = timeline[timeline.length - 1]; - const lastTime = getIndexSegmentEnd(lastTimelineElement, - null, - this._scaledPeriodEnd); + const segmentTimeRounding = getSegmentTimeRoundingError(this._index.timescale); - return (lastTime + segmentTimeRounding) >= this._scaledPeriodEnd; + const lastReqSegInfo = getLastRequestableSegmentInfo( + // Needed typecast for TypeScript + this._index as typeof this._index & { timeline: IIndexSegment[] }, + this._manifestBoundsCalculator, + this._scaledPeriodEnd + ); + + if (lastReqSegInfo !== null && !lastReqSegInfo.isLastOfTimeline) { + // There might be non-yet requestable segments in the manifest + const lastReqSegmentEnd = Math.min(lastReqSegInfo.end, + this._scaledPeriodEnd ?? Infinity); + if (this._scaledPeriodEnd !== undefined && + lastReqSegmentEnd + segmentTimeRounding >= this._scaledPeriodEnd) + { + // The last requestable segment ends after the end of the Period anyway + return false; + } + return true; // There are not-yet requestable segments + } + + if (!this._isLastPeriod) { + // This index is not linked to the current last Period in the MPD, in + // which case it is inferred that all segments have been announced. + // + // Note that this condition might break very very rare use cases where old + // Periods are still being generated, yet it should fix more cases than it + // breaks. + return false; + } + + if (this._scaledPeriodEnd === undefined) { + // This is the last Period of a dynamic content whose end is unknown. + // Just return true. + return true; + } + const lastSegment = timeline[timeline.length - 1]; + const lastSegmentEnd = getIndexSegmentEnd(lastSegment, + null, + this._scaledPeriodEnd); + // We're awaiting future segments only if the current end is before the end + // of the Period + return (lastSegmentEnd + segmentTimeRounding) < this._scaledPeriodEnd; } /** @@ -615,7 +771,8 @@ export default class TimelineRepresentationIndex implements IRepresentationIndex if (!this._isDynamic) { return; } - const firstPosition = this._manifestBoundsCalculator.estimateMinimumBound(); + const firstPosition = this._manifestBoundsCalculator + .getEstimatedMinimumSegmentTime(); if (firstPosition == null) { return; // we don't know yet } @@ -629,17 +786,6 @@ export default class TimelineRepresentationIndex implements IRepresentationIndex } } - static getIndexEnd(timeline : IIndexSegment[], - scaledPeriodEnd : number | undefined) : number | null { - if (timeline.length <= 0) { - return null; - } - return Math.min(getIndexSegmentEnd(timeline[timeline.length - 1], - null, - scaledPeriodEnd), - scaledPeriodEnd ?? Infinity); - } - /** * Allows to generate the "timeline" for this RepresentationIndex. * Call this function when the timeline is unknown. @@ -741,3 +887,169 @@ function updateTimelineFromEndNumber( } return timeline; } + +/** + * Returns true if a Segment returned by the corresponding index is still + * considered available. + * Returns false if it is not available anymore. + * Returns undefined if we cannot know whether it is still available or not. + * /!\ We do not check the mediaURLs of the segment. + * @param {Object} segment + * @param {Object} index + * @param {Object} manifestBoundsCalculator + * @param {number|undefined} scaledPeriodEnd + * @returns {Boolean|undefined} + */ +export function isSegmentStillAvailable( + segment : ISegment, + index: { availabilityTimeOffset : number; + timeline : IIndexSegment[]; + indexTimeOffset: number; + timescale : number; }, + manifestBoundsCalculator : ManifestBoundsCalculator, + scaledPeriodEnd : number | undefined +) : boolean | undefined { + const lastReqSegInfo = getLastRequestableSegmentInfo(index, + manifestBoundsCalculator, + scaledPeriodEnd); + if (lastReqSegInfo === null) { + return false; + } + + for (let i = 0; i < index.timeline.length; i++) { + if (lastReqSegInfo.timelineIdx < i) { + return false; + } + const tSegment = index.timeline[i]; + const tSegmentTime = (tSegment.start - index.indexTimeOffset) / index.timescale; + if (tSegmentTime > segment.time) { + return false; // We went over it without finding it + } else if (tSegmentTime === segment.time) { + if (tSegment.range === undefined) { + return segment.range === undefined; + } + return segment.range != null && + tSegment.range[0] === segment.range[0] && + tSegment.range[1] === segment.range[1]; + } else { // tSegment.start < segment.time + if (tSegment.repeatCount >= 0 && tSegment.duration !== undefined) { + const timeDiff = tSegmentTime - tSegment.start; + const repeat = (timeDiff / tSegment.duration) - 1; + return repeat % 1 === 0 && repeat <= lastReqSegInfo.newRepeatCount; + } + } + } + return false; +} + +/** + * Returns from the given RepresentationIndex information on the last segment + * that may be requested currently. + * + * Returns `null` if there's no such segment. + * @param {Object} index + * @param {Object} manifestBoundsCalculator + * @param {number|undefined} scaledPeriodEnd + * @returns {number|null} + */ +export function getLastRequestableSegmentInfo( + index: { availabilityTimeOffset : number; + timeline : IIndexSegment[]; + timescale : number; }, + manifestBoundsCalculator : ManifestBoundsCalculator, + scaledPeriodEnd : number | undefined +) : ILastRequestableSegmentInfo | null { + if (index.timeline.length <= 0) { + return null; + } + + if (index.availabilityTimeOffset === Infinity) { + // availabilityTimeOffset to Infinity == Everything is requestable in the timeline. + const lastIndex = index.timeline.length - 1; + const lastElem = index.timeline[lastIndex]; + return { isLastOfTimeline: true, + timelineIdx: lastIndex, + newRepeatCount: lastElem.repeatCount, + end: getIndexSegmentEnd(lastElem, null, scaledPeriodEnd) }; + } + + const adjustedMaxSeconds = manifestBoundsCalculator.getEstimatedMaximumPosition( + index.availabilityTimeOffset + ); + if (adjustedMaxSeconds === undefined) { + const lastIndex = index.timeline.length - 1; + const lastElem = index.timeline[lastIndex]; + return { isLastOfTimeline: true, + timelineIdx: lastIndex, + newRepeatCount: lastElem.repeatCount, + end: getIndexSegmentEnd(lastElem, null, scaledPeriodEnd) }; + } + for (let i = index.timeline.length - 1; i >= index.timeline.length; i--) { + const element = index.timeline[i]; + const endOfFirstOccurence = element.start + element.duration; + if (fromIndexTime(endOfFirstOccurence, index) <= adjustedMaxSeconds) { + if (element.repeatCount <= 1) { + return { isLastOfTimeline: i === index.timeline.length - 1, + timelineIdx: i, + newRepeatCount: element.repeatCount, + end: endOfFirstOccurence }; + } + const endTime = getIndexSegmentEnd(element, index.timeline[i + 1], scaledPeriodEnd); + if (fromIndexTime(endTime, index) <= adjustedMaxSeconds) { + return { isLastOfTimeline: i === index.timeline.length - 1, + timelineIdx: i, + newRepeatCount: element.repeatCount, + end: endOfFirstOccurence }; + } else { + // We have to find the right repeatCount + const maxIndexTime = toIndexTime(adjustedMaxSeconds, index); + const diffToSegStart = maxIndexTime - element.start; + const nbOfSegs = Math.floor(diffToSegStart / element.duration); + assert(nbOfSegs >= 1); + return { isLastOfTimeline: false, + timelineIdx: i, + newRepeatCount: nbOfSegs - 1, + end: element.start + nbOfSegs * element.duration }; + } + } + } + return null; +} + +/** + * Information on the last requestable segment deduced from a timeline array of + * segment information. + */ +export interface ILastRequestableSegmentInfo { + /** + * If `true`, we know that the last requestable segment is equal to the last + * segment that can be deduced from the corresponding given timeline. + * Written another way, there seem to be no segment announced in the timeline + * that are not yet requestable. + * + * If `false`, we know that the last requestable segment is not the last + * segment that can be deduced from the corresponding timeline. + * Written another way, there are supplementary segments in the timeline which + * are not yet requestable. + * + * Note that if the last requestable segment has its information from the last + * element from the timeline but it's not the last segment that would be + * deduced from the `repeatCount` property, then this value is set to `false`. + */ + isLastOfTimeline: boolean; + /** + * End time at which the last requestable segment ends, in the corresponding + * index timescale (__NOT__ in seconds). + */ + end: number; + /** + * The index in `timeline` of the last requestable segment. + * Note that its `repeatCount` may be updated and put as `newRepeatCount`. + */ + timelineIdx: number; + /** + * The new `repeatCount` value for that last segment. May be equal or + * different from the timeline element found at `timelineIdx`. + */ + newRepeatCount: number; +} diff --git a/src/parsers/manifest/dash/common/manifest_bounds_calculator.ts b/src/parsers/manifest/dash/common/manifest_bounds_calculator.ts index cf7a423d7e..2be89900e4 100644 --- a/src/parsers/manifest/dash/common/manifest_bounds_calculator.ts +++ b/src/parsers/manifest/dash/common/manifest_bounds_calculator.ts @@ -18,16 +18,6 @@ * This class allows to easily calculate the first and last available positions * in a content at any time. * - * That task can be an hard for dynamic DASH contents: it depends on a - * `timeShiftBufferDepth` defined in the MPD and on the maximum possible - * position. - * - * The latter can come from either a clock synchronization mechanism or the - * indexing schemes (e.g. SegmentTemplate, SegmentTimeline etc.) of the last - * Periods. - * As such, it might only be known once a large chunk of the MPD has already - * been parsed. - * * By centralizing the manifest bounds calculation in this class and by giving * an instance of it to each parsed elements which might depend on it, we * ensure that we can provide it once it is known to every one of those @@ -35,26 +25,46 @@ * @class ManifestBoundsCalculator */ export default class ManifestBoundsCalculator { - /** Value of MPD@timeShiftBufferDepth. */ + /** + * Value of MPD@timeShiftBufferDepth. + * `null` if not defined. + */ private _timeShiftBufferDepth : number | null; + /** + * Value of MPD@availabilityStartTime as an unix timestamp in seconds. + * `0` if it wasn't defined. + */ + private _availabilityStartTime : number; + /** `true` if MPD@type is equal to "dynamic". */ + private _isDynamic : boolean; /** Value of `performance.now` at the time `lastPosition` was calculated. */ private _positionTime : number | undefined; /** Last position calculated at a given moment (itself indicated by `_positionTime`. */ private _lastPosition : number | undefined; - /** `true` if MPD@type is equal to "dynamic". */ - private _isDynamic : boolean; + /** + * Offset to add to `performance.now` to obtain a good estimation of the + * server-side unix timestamp. + * + * `undefined` if unknown. + */ + private _serverTimestampOffset : number | undefined; /** * @param {Object} args */ - constructor(args : { timeShiftBufferDepth : number | undefined; - isDynamic : boolean; } - ) { + constructor(args : { + availabilityStartTime : number; + timeShiftBufferDepth : number | undefined; + isDynamic : boolean; + serverTimestampOffset: number | undefined; + }) { this._isDynamic = args.isDynamic; this._timeShiftBufferDepth = !args.isDynamic || args.timeShiftBufferDepth === undefined ? null : args.timeShiftBufferDepth; + this._serverTimestampOffset = args.serverTimestampOffset; + this._availabilityStartTime = args.availabilityStartTime; } /** @@ -96,11 +106,12 @@ export default class ManifestBoundsCalculator { * Consider that it is only an estimation, not the real value. * @return {number|undefined} */ - estimateMinimumBound(): number | undefined { + getEstimatedMinimumSegmentTime(): number | undefined { if (!this._isDynamic || this._timeShiftBufferDepth === null) { return 0; } - const maximumBound = this.estimateMaximumBound(); + const maximumBound = this.getEstimatedLiveEdge() ?? + this.getEstimatedMaximumPosition(0); if (maximumBound === undefined) { return undefined; } @@ -109,15 +120,42 @@ export default class ManifestBoundsCalculator { } /** - * Estimate a maximum bound for the content from the last set segment time. - * Consider that it is only an estimation, not the real value. + * Estimate the segment time in seconds that corresponds to what could be + * considered the live edge (or `undefined` for non-live contents). + * + * Note that for some contents which just anounce segments in advance, this + * value might be very different than the maximum position that is + * requestable. * @return {number|undefined} */ - estimateMaximumBound() : number | undefined { - if (this._isDynamic && - this._positionTime != null && - this._lastPosition != null) - { + getEstimatedLiveEdge() : number | undefined { + if (!this._isDynamic || this._serverTimestampOffset === undefined) { + return undefined; + } + return (performance.now() + this._serverTimestampOffset) / 1000 - + this._availabilityStartTime; + } + + /** + * Produce a rough estimate of the ending time of the last requestable segment + * in that content. + * + * This value is only an estimate and may be far from reality. + * + * The `availabilityTimeOffset` in argument is the corresponding + * `availabilityTimeOffset` that applies to the current wanted segment, or `0` + * if none exist. It will be applied on live content to deduce the maximum + * segment time available. + */ + getEstimatedMaximumPosition(availabilityTimeOffset: number) : number | undefined { + if (!this._isDynamic) { + return this._lastPosition; + } + + const liveEdge = this.getEstimatedLiveEdge(); + if (liveEdge !== undefined && availabilityTimeOffset !== Infinity) { + return liveEdge + availabilityTimeOffset; + } else if (this._positionTime !== undefined && this._lastPosition !== undefined) { return Math.max((this._lastPosition - this._positionTime) + (performance.now() / 1000), 0); diff --git a/src/parsers/manifest/dash/common/parse_adaptation_sets.ts b/src/parsers/manifest/dash/common/parse_adaptation_sets.ts index 2fc070d1cc..5c14eea8de 100644 --- a/src/parsers/manifest/dash/common/parse_adaptation_sets.ts +++ b/src/parsers/manifest/dash/common/parse_adaptation_sets.ts @@ -282,12 +282,19 @@ export default function parseAdaptationSets( roles.some((role) => role.schemeIdUri === "urn:mpeg:dash:role:2011"); const representationsIR = adaptation.children.representations; + const availabilityTimeComplete = adaptation.attributes.availabilityTimeComplete ?? context.availabilityTimeComplete; - const availabilityTimeOffset = - (adaptation.attributes.availabilityTimeOffset ?? 0) + - context.availabilityTimeOffset; + + let availabilityTimeOffset; + if ( + adaptation.attributes.availabilityTimeOffset !== undefined || + context.availabilityTimeOffset !== undefined + ) { + availabilityTimeOffset = (adaptation.attributes.availabilityTimeOffset ?? 0) + + (context.availabilityTimeOffset ?? 0); + } const adaptationMimeType = adaptation.attributes.mimeType; const adaptationCodecs = adaptation.attributes.codecs; @@ -317,7 +324,6 @@ export default function parseAdaptationSets( } const reprCtxt : IRepresentationContext = { - aggressiveMode: context.aggressiveMode, availabilityTimeComplete, availabilityTimeOffset, baseURLs: resolveBaseURLs(context.baseURLs, adaptationChildren.baseURLs), @@ -329,7 +335,6 @@ export default function parseAdaptationSets( parentSegmentTemplates, receivedTime: context.receivedTime, start: context.start, - timeShiftBufferDepth: context.timeShiftBufferDepth, unsafelyBaseOnPreviousAdaptation: null, }; diff --git a/src/parsers/manifest/dash/common/parse_mpd.ts b/src/parsers/manifest/dash/common/parse_mpd.ts index 03e472826e..4236410cf2 100644 --- a/src/parsers/manifest/dash/common/parse_mpd.ts +++ b/src/parsers/manifest/dash/common/parse_mpd.ts @@ -29,6 +29,7 @@ import { IResponseData } from "../parsers_types"; import getClockOffset from "./get_clock_offset"; import getHTTPUTCTimingURL from "./get_http_utc-timing_url"; import getMinimumAndMaximumPositions from "./get_minimum_and_maximum_positions"; +import ManifestBoundsCalculator from "./manifest_bounds_calculator"; import parseAvailabilityStartTime from "./parse_availability_start_time"; import parsePeriods, { IXLinkInfos, @@ -250,12 +251,20 @@ function parseCompleteIntermediateRepresentation( const { externalClockOffset: clockOffset, unsafelyBaseOnPreviousManifest } = args; + const { externalClockOffset } = args; + const manifestBoundsCalculator = new ManifestBoundsCalculator({ + availabilityStartTime, + isDynamic, + timeShiftBufferDepth, + serverTimestampOffset: externalClockOffset, + }); const manifestInfos = { aggressiveMode: args.aggressiveMode, availabilityStartTime, baseURLs: mpdBaseUrls, clockOffset, duration: rootAttributes.duration, isDynamic, + manifestBoundsCalculator, manifestProfiles: mpdIR.attributes.profiles, receivedTime: args.manifestReceivedTime, timeShiftBufferDepth, @@ -312,31 +321,33 @@ function parseCompleteIntermediateRepresentation( livePosition: undefined, time: now }; } else { - minimumTime = minimumSafePosition; - timeshiftDepth = timeShiftBufferDepth ?? null; + // Determine the maximum seekable position let finalMaximumSafePosition : number; - let livePosition; - - if (maximumUnsafePosition !== undefined) { - livePosition = maximumUnsafePosition; - } - if (maximumSafePosition !== undefined) { finalMaximumSafePosition = maximumSafePosition; } else { - const ast = availabilityStartTime ?? 0; - const { externalClockOffset } = args; if (externalClockOffset === undefined) { log.warn("DASH Parser: use system clock to define maximum position"); - finalMaximumSafePosition = (Date.now() / 1000) - ast; + finalMaximumSafePosition = (Date.now() / 1000) - availabilityStartTime; } else { const serverTime = performance.now() + externalClockOffset; - finalMaximumSafePosition = (serverTime / 1000) - ast; + finalMaximumSafePosition = (serverTime / 1000) - availabilityStartTime; } } + + // Determine live edge (what position corresponds to live content, can be + // inferior or superior to the maximum anounced position in some specific + // scenarios). However, the `timeShiftBufferDepth` should be based on it. + let livePosition = manifestBoundsCalculator.getEstimatedLiveEdge(); if (livePosition === undefined) { - livePosition = finalMaximumSafePosition; + if (maximumUnsafePosition !== undefined) { + livePosition = maximumUnsafePosition; + } else { + livePosition = finalMaximumSafePosition; + } + // manifestBoundsCalculator.forceLiveEdge(livePosition); } + maximumTimeData = { isLinear: true, maximumSafePosition: finalMaximumSafePosition, livePosition, @@ -344,10 +355,12 @@ function parseCompleteIntermediateRepresentation( // if the minimum calculated time is even below the buffer depth, perhaps we // can go even lower in terms of depth + minimumTime = minimumSafePosition; + timeshiftDepth = timeShiftBufferDepth ?? null; if (timeshiftDepth !== null && minimumTime !== undefined && - finalMaximumSafePosition - minimumTime > timeshiftDepth) + livePosition - minimumTime > timeshiftDepth) { - timeshiftDepth = finalMaximumSafePosition - minimumTime; + timeshiftDepth = livePosition - minimumTime; } } diff --git a/src/parsers/manifest/dash/common/parse_periods.ts b/src/parsers/manifest/dash/common/parse_periods.ts index 8c55e67a3d..3f0c29d6e1 100644 --- a/src/parsers/manifest/dash/common/parse_periods.ts +++ b/src/parsers/manifest/dash/common/parse_periods.ts @@ -18,6 +18,7 @@ import log from "../../../../log"; import Manifest from "../../../../manifest"; import flatMap from "../../../../utils/flat_map"; import idGenerator from "../../../../utils/id_generator"; +import isNullOrUndefined from "../../../../utils/is_null_or_undefined"; import objectValues from "../../../../utils/object_values"; import { utf8ToStr } from "../../../../utils/string_parsing"; import { @@ -33,7 +34,6 @@ import { // eslint-disable-next-line max-len import flattenOverlappingPeriods from "./flatten_overlapping_periods"; import getPeriodsTimeInformation from "./get_periods_time_infos"; -import ManifestBoundsCalculator from "./manifest_bounds_calculator"; import parseAdaptationSets, { IAdaptationSetContext, } from "./parse_adaptation_sets"; @@ -67,12 +67,9 @@ export default function parsePeriods( throw new Error("MPD parsing error: the time information are incoherent."); } - const { isDynamic, - timeShiftBufferDepth } = context; - const manifestBoundsCalculator = new ManifestBoundsCalculator({ isDynamic, - timeShiftBufferDepth }); + const { isDynamic, manifestBoundsCalculator } = context; - if (!isDynamic && context.duration != null) { + if (!isDynamic && !isNullOrUndefined(context.duration)) { manifestBoundsCalculator.setLastPosition(context.duration); } @@ -90,7 +87,7 @@ export default function parsePeriods( periodEnd } = periodsTimeInformation[i]; let periodID : string; - if (periodIR.attributes.id == null) { + if (isNullOrUndefined(periodIR.attributes.id)) { log.warn("DASH: No usable id found in the Period. Generating one."); periodID = "gen-dash-period-" + generatePeriodID(); } else { @@ -108,12 +105,11 @@ export default function parsePeriods( const unsafelyBaseOnPreviousPeriod = context .unsafelyBaseOnPreviousManifest?.getPeriod(periodID) ?? null; - const availabilityTimeComplete = periodIR.attributes.availabilityTimeComplete ?? true; - const availabilityTimeOffset = periodIR.attributes.availabilityTimeOffset ?? 0; - const { aggressiveMode, manifestProfiles } = context; + const availabilityTimeComplete = periodIR.attributes.availabilityTimeComplete; + const availabilityTimeOffset = periodIR.attributes.availabilityTimeOffset; + const { manifestProfiles } = context; const { segmentTemplate } = periodIR.children; - const adapCtxt : IAdaptationSetContext = { aggressiveMode, - availabilityTimeComplete, + const adapCtxt : IAdaptationSetContext = { availabilityTimeComplete, availabilityTimeOffset, baseURLs: periodBaseURLs, manifestBoundsCalculator, @@ -124,7 +120,6 @@ export default function parsePeriods( receivedTime, segmentTemplate, start: periodStart, - timeShiftBufferDepth, unsafelyBaseOnPreviousPeriod }; const adaptations = parseAdaptationSets(periodIR.children.adaptations, adapCtxt); @@ -207,7 +202,7 @@ function guessLastPositionFromClock( context : IPeriodContext, minimumTime : number ) : [number, number] | undefined { - if (context.clockOffset != null) { + if (!isNullOrUndefined(context.clockOffset)) { const lastPosition = context.clockOffset / 1000 - context.availabilityStartTime; const positionTime = performance.now() / 1000; @@ -244,7 +239,7 @@ function getMaximumLastPosition( let maxEncounteredPosition : number | null = null; let allIndexAreEmpty = true; const adaptationsVal = objectValues(adaptationsPerType) - .filter((ada) : ada is IParsedAdaptation[] => ada != null); + .filter((ada) : ada is IParsedAdaptation[] => !isNullOrUndefined(ada)); const allAdaptations = flatMap(adaptationsVal, (adaptationsForType) => adaptationsForType); for (const adaptation of allAdaptations) { @@ -255,15 +250,15 @@ function getMaximumLastPosition( allIndexAreEmpty = false; if (typeof position === "number") { maxEncounteredPosition = - maxEncounteredPosition == null ? position : - Math.max(maxEncounteredPosition, - position); + isNullOrUndefined(maxEncounteredPosition) ? position : + Math.max(maxEncounteredPosition, + position); } } } } - if (maxEncounteredPosition != null) { + if (!isNullOrUndefined(maxEncounteredPosition)) { return maxEncounteredPosition; } else if (allIndexAreEmpty) { return null; @@ -364,6 +359,5 @@ type IInheritedAdaptationContext = Omit; diff --git a/src/parsers/manifest/dash/common/parse_representation_index.ts b/src/parsers/manifest/dash/common/parse_representation_index.ts index b1a107409a..606ffa10b4 100644 --- a/src/parsers/manifest/dash/common/parse_representation_index.ts +++ b/src/parsers/manifest/dash/common/parse_representation_index.ts @@ -31,6 +31,10 @@ import { ListRepresentationIndex, TemplateRepresentationIndex, TimelineRepresentationIndex, + IBaseIndexContextArgument, + IListIndexContextArgument, + ITemplateIndexContextArgument, + ITimelineIndexContextArgument, } from "./indexes"; import ManifestBoundsCalculator from "./manifest_bounds_calculator"; import { IResolvedBaseUrl } from "./resolve_base_urls"; @@ -46,14 +50,12 @@ export default function parseRepresentationIndex( representation : IRepresentationIntermediateRepresentation, context : IRepresentationIndexContext ) : IRepresentationIndex { - const { aggressiveMode, - availabilityTimeOffset, + const { availabilityTimeOffset, manifestBoundsCalculator, isDynamic, end: periodEnd, start: periodStart, receivedTime, - timeShiftBufferDepth, unsafelyBaseOnPreviousRepresentation, inbandEventStreams, isLastPeriod } = context; @@ -65,20 +67,24 @@ export default function parseRepresentationIndex( return inbandEventStreams .some(({ schemeIdUri }) => schemeIdUri === inbandEvent.schemeIdUri); }; - const reprIndexCtxt = { aggressiveMode, - availabilityTimeComplete: true, - availabilityTimeOffset, - unsafelyBaseOnPreviousRepresentation, - isEMSGWhitelisted, - isLastPeriod, - manifestBoundsCalculator, - isDynamic, - periodEnd, - periodStart, - receivedTime, - representationBitrate: representation.attributes.bitrate, - representationId: representation.attributes.id, - timeShiftBufferDepth }; + const reprIndexCtxt: ITimelineIndexContextArgument | + ITemplateIndexContextArgument | + IListIndexContextArgument | + IBaseIndexContextArgument = + { + availabilityTimeComplete: undefined, + availabilityTimeOffset, + unsafelyBaseOnPreviousRepresentation, + isEMSGWhitelisted, + isLastPeriod, + manifestBoundsCalculator, + isDynamic, + periodEnd, + periodStart, + receivedTime, + representationBitrate: representation.attributes.bitrate, + representationId: representation.attributes.id, + }; let representationIndex : IRepresentationIndex; if (representation.children.segmentBase !== undefined) { const { segmentBase } = representation.children; @@ -99,12 +105,15 @@ export default function parseRepresentationIndex( ...segmentTemplates as [ ISegmentTemplateIntermediateRepresentation ] /* Ugly TS Hack */); - reprIndexCtxt.availabilityTimeComplete = - segmentTemplate.availabilityTimeComplete ?? - context.availabilityTimeComplete; - reprIndexCtxt.availabilityTimeOffset = - (segmentTemplate.availabilityTimeOffset ?? 0) + - context.availabilityTimeOffset; + if ( + segmentTemplate.availabilityTimeOffset !== undefined || + context.availabilityTimeOffset !== undefined + ) { + reprIndexCtxt.availabilityTimeOffset = + (segmentTemplate.availabilityTimeOffset ?? 0) + + (context.availabilityTimeOffset ?? 0); + } + representationIndex = TimelineRepresentationIndex .isTimelineIndexArgument(segmentTemplate) ? new TimelineRepresentationIndex(segmentTemplate, reprIndexCtxt) : @@ -133,12 +142,25 @@ export default function parseRepresentationIndex( export interface IRepresentationIndexContext { /** Parsed AdaptationSet which contains the Representation. */ adaptation : IAdaptationSetIntermediateRepresentation; - /** Whether we should request new segments even if they are not yet finished. */ - aggressiveMode : boolean; - /** If false, declared segments in the MPD might still be not completely generated. */ - availabilityTimeComplete : boolean; - /** availability time offset of the concerned Adaptation. */ - availabilityTimeOffset : number; + /** + * If `false`, declared segments in the MPD might still be not completely generated. + * If `true`, they are completely generated. + * + * If `undefined`, the corresponding property was not set in the MPD and it is + * thus assumed that they are all generated. + * It might however be semantically different than `true` in the RxPlayer as it + * means that the packager didn't include that information in the MPD. + */ + availabilityTimeComplete : boolean | undefined; + /** + * availability time offset of the concerned Adaptation. + * + * If `undefined`, the corresponding property was not set in the MPD and it is + * thus assumed to be equal to `0`. + * It might however be semantically different than `0` in the RxPlayer as it + * means that the packager didn't include that information in the MPD. + */ + availabilityTimeOffset : number | undefined; /** Eventual URLs from which every relative URL will be based on. */ baseURLs : IResolvedBaseUrl[]; /** End time of the current Period, in seconds. */ @@ -167,8 +189,6 @@ export interface IRepresentationIndexContext { receivedTime? : number | undefined; /** Start time of the current period, in seconds. */ start : number; - /** Depth of the buffer for the whole content, in seconds. */ - timeShiftBufferDepth? : number | undefined; /** * The parser should take this Representation - which is the same as this one * parsed at an earlier time - as a base to speed-up the parsing process. diff --git a/src/parsers/manifest/dash/common/parse_representations.ts b/src/parsers/manifest/dash/common/parse_representations.ts index 8379a7a3f2..1e172a5c74 100644 --- a/src/parsers/manifest/dash/common/parse_representations.ts +++ b/src/parsers/manifest/dash/common/parse_representations.ts @@ -152,9 +152,15 @@ export default function parseRepresentations( const availabilityTimeComplete = representation.attributes.availabilityTimeComplete ?? context.availabilityTimeComplete; - const availabilityTimeOffset = - (representation.attributes.availabilityTimeOffset ?? 0) + - context.availabilityTimeOffset; + + let availabilityTimeOffset; + if ( + representation.attributes.availabilityTimeOffset !== undefined || + context.availabilityTimeOffset !== undefined + ) { + availabilityTimeOffset = (representation.attributes.availabilityTimeOffset ?? 0) + + (context.availabilityTimeOffset ?? 0); + } const reprIndexCtxt = objectAssign({}, context, { availabilityTimeOffset, diff --git a/src/parsers/manifest/local/representation_index.ts b/src/parsers/manifest/local/representation_index.ts index 49ba6d36e8..8a71ad6233 100644 --- a/src/parsers/manifest/local/representation_index.ts +++ b/src/parsers/manifest/local/representation_index.ts @@ -149,7 +149,7 @@ export default class LocalRepresentationIndex implements IRepresentationIndex { * @returns {boolean|undefined} */ awaitSegmentBetween(start: number, end: number): boolean | undefined { - if (this.isFinished()) { + if (this.isStillAwaitingFutureSegments()) { return false; } if (this._index.incomingRanges === undefined) { @@ -173,8 +173,8 @@ export default class LocalRepresentationIndex implements IRepresentationIndex { return true; } - isFinished() : boolean { - return this._index.isFinished; + isStillAwaitingFutureSegments() : boolean { + return !this._index.isFinished; } /** diff --git a/src/parsers/manifest/metaplaylist/representation_index.ts b/src/parsers/manifest/metaplaylist/representation_index.ts index 4bf4f0bb27..36230ab653 100644 --- a/src/parsers/manifest/metaplaylist/representation_index.ts +++ b/src/parsers/manifest/metaplaylist/representation_index.ts @@ -196,8 +196,8 @@ export default class MetaRepresentationIndex implements IRepresentationIndex { /** * @returns {Boolean} */ - public isFinished() : boolean { - return this._wrappedIndex.isFinished(); + public isStillAwaitingFutureSegments() : boolean { + return this._wrappedIndex.isStillAwaitingFutureSegments(); } /** diff --git a/src/parsers/manifest/smooth/representation_index.ts b/src/parsers/manifest/smooth/representation_index.ts index 91537aa91b..65b28743c6 100644 --- a/src/parsers/manifest/smooth/representation_index.ts +++ b/src/parsers/manifest/smooth/representation_index.ts @@ -27,7 +27,6 @@ import { checkDiscontinuity, getIndexSegmentEnd, } from "../utils/index_helpers"; -import isSegmentStillAvailable from "../utils/is_segment_still_available"; import updateSegmentTimeline from "../utils/update_segment_timeline"; import addSegmentInfos from "./utils/add_segment_infos"; import { replaceSegmentSmoothTokens } from "./utils/tokens"; @@ -493,7 +492,7 @@ export default class SmoothRepresentationIndex implements IRepresentationIndex { */ awaitSegmentBetween(start: number, end: number): boolean | undefined { assert(start <= end); - if (this.isFinished()) { + if (this.isStillAwaitingFutureSegments()) { return false; } const lastAvailablePosition = this.getLastAvailablePosition(); @@ -532,7 +531,22 @@ export default class SmoothRepresentationIndex implements IRepresentationIndex { } this._refreshTimeline(); const { timeline, timescale } = this._index; - return isSegmentStillAvailable(segment, timeline, timescale, 0); + for (let i = 0; i < timeline.length; i++) { + const tSegment = timeline[i]; + const tSegmentTime = tSegment.start / timescale; + if (tSegmentTime > segment.time) { + return false; // We went over it without finding it + } else if (tSegmentTime === segment.time) { + return true; + } else { // tSegment.start < segment.time + if (tSegment.repeatCount >= 0 && tSegment.duration !== undefined) { + const timeDiff = tSegmentTime - tSegment.start; + const repeat = (timeDiff / tSegment.duration) - 1; + return repeat % 1 === 0 && repeat <= tSegment.repeatCount; + } + } + } + return false; } /** @@ -628,9 +642,9 @@ export default class SmoothRepresentationIndex implements IRepresentationIndex { } /** - * Returns `true` if the last segments in this index have already been + * Returns `false` if the last segments in this index have already been * generated. - * Returns `false` if the index is still waiting on future segments to be + * Returns `true` if the index is still waiting on future segments to be * generated. * * For Smooth, it should only depend on whether the content is a live content @@ -638,8 +652,8 @@ export default class SmoothRepresentationIndex implements IRepresentationIndex { * TODO What about Smooth live content that finishes at some point? * @returns {boolean} */ - isFinished() : boolean { - return !this._isLive; + isStillAwaitingFutureSegments() : boolean { + return this._isLive; } /** @@ -652,8 +666,8 @@ export default class SmoothRepresentationIndex implements IRepresentationIndex { /** * Add new segments to a `SmoothRepresentationIndex`. * @param {Array.} nextSegments - The segment information parsed. - * @param {Object} segment - Information on the segment which contained that - * new segment information. + * @param {Object} currentSegment - Information on the segment which contained + * that new segment information. */ addNewSegments( nextSegments : Array<{ duration : number; time : number; timescale : number }>, diff --git a/src/parsers/manifest/utils/__tests__/get_first_time_from_adaptations.test.ts b/src/parsers/manifest/utils/__tests__/get_first_time_from_adaptations.test.ts index 99e184f8e1..ce39772e64 100644 --- a/src/parsers/manifest/utils/__tests__/get_first_time_from_adaptations.test.ts +++ b/src/parsers/manifest/utils/__tests__/get_first_time_from_adaptations.test.ts @@ -30,7 +30,7 @@ function generateRepresentationIndex( awaitSegmentBetween() : undefined { return ; }, checkDiscontinuity() : number | null { return null; }, isSegmentStillAvailable() : undefined { return ; }, - isFinished() { return false; }, + isStillAwaitingFutureSegments() { return true; }, canBeOutOfSyncError() : true { return true; }, isInitialized() : true { return true; }, _replace() { /* noop */ }, diff --git a/src/parsers/manifest/utils/__tests__/get_last_time_from_adaptation.test.ts b/src/parsers/manifest/utils/__tests__/get_last_time_from_adaptation.test.ts index ea401d4821..4e88ae8168 100644 --- a/src/parsers/manifest/utils/__tests__/get_last_time_from_adaptation.test.ts +++ b/src/parsers/manifest/utils/__tests__/get_last_time_from_adaptation.test.ts @@ -30,7 +30,7 @@ function generateRepresentationIndex( awaitSegmentBetween() : undefined { return ; }, checkDiscontinuity() : number | null { return null; }, isSegmentStillAvailable() : undefined { return ; }, - isFinished() { return false; }, + isStillAwaitingFutureSegments() { return true; }, isInitialized() : true { return true; }, canBeOutOfSyncError() : true { return true; }, _replace() { /* noop */ }, diff --git a/src/parsers/manifest/utils/index_helpers.ts b/src/parsers/manifest/utils/index_helpers.ts index a79f432c67..78ceb6ae8b 100644 --- a/src/parsers/manifest/utils/index_helpers.ts +++ b/src/parsers/manifest/utils/index_helpers.ts @@ -136,7 +136,7 @@ export function getTimescaledRange( * timescaled time. * Returns -1 if the given time is lower than the start of the first available * segment. - * @param {Object} index + * @param {Object} timeline * @param {Number} timeTScaled * @returns {Number} */ diff --git a/src/parsers/manifest/utils/is_segment_still_available.ts b/src/parsers/manifest/utils/is_segment_still_available.ts deleted file mode 100644 index ab8601db26..0000000000 --- a/src/parsers/manifest/utils/is_segment_still_available.ts +++ /dev/null @@ -1,58 +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 { ISegment } from "../../../manifest"; -import { IIndexSegment } from "./index_helpers"; - -/** - * Returns true if a Segment returned by the corresponding index is still - * considered available. - * Returns false if it is not available anymore. - * Returns undefined if we cannot know whether it is still available or not. - * /!\ We do not check the mediaURLs of the segment. - * @param {Object} segment - * @param {Array.} timescale - * @param {number} timeline - * @returns {Boolean|undefined} - */ -export default function isSegmentStillAvailable( - segment : ISegment, - timeline : IIndexSegment[], - timescale : number, - indexTimeOffset : number -) : boolean | undefined { - for (let i = 0; i < timeline.length; i++) { - const tSegment = timeline[i]; - const tSegmentTime = (tSegment.start - indexTimeOffset) / timescale; - if (tSegmentTime > segment.time) { - return false; - } else if (tSegmentTime === segment.time) { - if (tSegment.range === undefined) { - return segment.range === undefined; - } - return segment.range != null && - tSegment.range[0] === segment.range[0] && - tSegment.range[1] === segment.range[1]; - } else { // tSegment.start < segment.time - if (tSegment.repeatCount >= 0 && tSegment.duration !== undefined) { - const timeDiff = tSegmentTime - tSegment.start; - const repeat = (timeDiff / tSegment.duration) - 1; - return repeat % 1 === 0 && repeat <= tSegment.repeatCount; - } - } - } - return false; -} From b6fbea9ce69bfe802a0cae78aff70e7311501715 Mon Sep 17 00:00:00 2001 From: Paul Berberian Date: Thu, 2 Nov 2023 19:00:59 +0100 Subject: [PATCH 02/46] Add fromLivePosition to the startAt loadVideo option --- doc/api/Loading_a_Content.md | 10 ++++++ doc/api/Miscellaneous/Low_Latency.md | 2 +- src/core/api/option_utils.ts | 1 + .../init/directfile_content_initializer.ts | 30 ++++++++++++---- src/core/init/utils/get_initial_time.ts | 30 ++++++++++------ src/public_types.ts | 14 ++++++-- .../scenarios/loadVideo_options.js | 34 +++++++++++++++++++ 7 files changed, 100 insertions(+), 21 deletions(-) diff --git a/doc/api/Loading_a_Content.md b/doc/api/Loading_a_Content.md index b4c890f77e..0cae7663bc 100644 --- a/doc/api/Loading_a_Content.md +++ b/doc/api/Loading_a_Content.md @@ -202,6 +202,16 @@ can be either: - for VoD contents, it is the difference between the starting position and the end position of the content. + +- **fromLivePosition** relative position relative to the content's live edge + (for live contents, it is the position that is intended to be broadcasted + at the current time) if it makes sense, in seconds. Should be a negative + number. + + If the live edge is unknown or if it does not make sense for the current + content (for example, it won't make sense for a VoD content), that setting + repeats the same behavior than **fromLastPosition**. + - **percentage** (`Number`): percentage of the wanted position. `0` being the minimum position possible (0 for static content, buffer depth for dynamic contents) and `100` being the maximum position possible diff --git a/doc/api/Miscellaneous/Low_Latency.md b/doc/api/Miscellaneous/Low_Latency.md index fcc010dfae..d9f0e5bf17 100644 --- a/doc/api/Miscellaneous/Low_Latency.md +++ b/doc/api/Miscellaneous/Low_Latency.md @@ -70,7 +70,7 @@ rxPlayer.loadVideo({ url: "https://www.example.com/content.mpd", transport: "dash", lowLatencyMode: true, - startAt: { fromLastPosition: 2 }, // Play 2 seconds from the live edge instead + startAt: { fromLivePosition: 2 }, // Play 2 seconds from the live edge instead // (beware of much more frequent rebuffering // risks) }); diff --git a/src/core/api/option_utils.ts b/src/core/api/option_utils.ts index 1b00fd1778..501eff2a32 100644 --- a/src/core/api/option_utils.ts +++ b/src/core/api/option_utils.ts @@ -56,6 +56,7 @@ export type IParsedStartAtOption = { position : number } | { wallClockTime : number } | { percentage : number } | { fromLastPosition : number } | + { fromLivePosition : number } | { fromFirstPosition : number }; export interface IParsedTransportOptions { diff --git a/src/core/init/directfile_content_initializer.ts b/src/core/init/directfile_content_initializer.ts index c9aa31d7a3..68a24d897e 100644 --- a/src/core/init/directfile_content_initializer.ts +++ b/src/core/init/directfile_content_initializer.ts @@ -27,6 +27,7 @@ import { IPlayerError, } from "../../public_types"; import assert from "../../utils/assert"; +import isNullOrUndefined from "../../utils/is_null_or_undefined"; import SharedReference, { IReadOnlySharedReference, } from "../../utils/reference"; @@ -240,11 +241,11 @@ function getDirectFileInitialTime( mediaElement : HTMLMediaElement, startAt? : IInitialTimeOptions ) : number { - if (startAt == null) { + if (isNullOrUndefined(startAt)) { return 0; } - if (startAt.position != null) { + if (!isNullOrUndefined(startAt.position)) { return startAt.position; } else if (startAt.wallClockTime != null) { return startAt.wallClockTime; @@ -253,15 +254,30 @@ function getDirectFileInitialTime( } 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") { + if (isNullOrUndefined(duration) || !isFinite(duration)) { + log.warn("startAt.fromLastPosition set but no known duration, " + + "beginning at 0."); + return 0; + } return Math.max(0, duration + startAt.fromLastPosition); + } else if (typeof startAt.fromLivePosition === "number") { + const livePosition = mediaElement.seekable.length > 0 ? + mediaElement.seekable.end(0) : + duration; + if (isNullOrUndefined(livePosition)) { + log.warn("startAt.fromLivePosition set but no known live position, " + + "beginning at 0."); + return 0; + } + return Math.max(0, livePosition + startAt.fromLivePosition); } else if (startAt.percentage != null) { + if (isNullOrUndefined(duration) || !isFinite(duration)) { + log.warn("startAt.percentage set but no known duration, " + + "beginning at 0."); + return 0; + } const { percentage } = startAt; if (percentage >= 100) { return duration; diff --git a/src/core/init/utils/get_initial_time.ts b/src/core/init/utils/get_initial_time.ts index 1c682937c6..2edd438574 100644 --- a/src/core/init/utils/get_initial_time.ts +++ b/src/core/init/utils/get_initial_time.ts @@ -42,10 +42,19 @@ export interface IInitialTimeOptions { */ fromFirstPosition? : number | null | undefined; /** - * If set, we should begin at this position relative to the content's end, - * in seconds. + * If set, we should begin at this position relative to the content's maximum + * seekable position, in seconds. */ fromLastPosition? : number | null | undefined; + /** + * If set, we should begin at this position relative to the content's live + * edge if it makes sense, in seconds. + * + * If the live edge is unknown or if it does not make sense for the current + * content, that position is relative to the content's maximum position + * instead. + */ + fromLivePosition? : number | null | undefined; /** If set, we should begin at this position relative to the whole duration of * the content, in percentage. */ @@ -72,13 +81,7 @@ export default function getInitialTime( ) : number { if (!isNullOrUndefined(startAt)) { const min = manifest.getMinimumSafePosition(); - let max; - if (manifest.isLive) { - max = manifest.getLivePosition(); - } - if (max === undefined) { - max = manifest.getMaximumSafePosition(); - } + const max = manifest.getMaximumSafePosition(); if (!isNullOrUndefined(startAt.position)) { log.debug("Init: using startAt.minimumPosition"); return Math.max(Math.min(startAt.position, max), min); @@ -96,12 +99,17 @@ export default function getInitialTime( const { fromFirstPosition } = startAt; return fromFirstPosition <= 0 ? min : Math.min(max, min + fromFirstPosition); - } - else if (!isNullOrUndefined(startAt.fromLastPosition)) { + } else if (!isNullOrUndefined(startAt.fromLastPosition)) { log.debug("Init: using startAt.fromLastPosition"); const { fromLastPosition } = startAt; return fromLastPosition >= 0 ? max : Math.max(min, max + fromLastPosition); + } else if (!isNullOrUndefined(startAt.fromLivePosition)) { + log.debug("Init: using startAt.fromLivePosition"); + const livePosition = manifest.getLivePosition() ?? max; + const { fromLivePosition } = startAt; + return fromLivePosition >= 0 ? livePosition : + Math.max(min, livePosition + fromLivePosition); } else if (!isNullOrUndefined(startAt.percentage)) { log.debug("Init: using startAt.percentage"); const { percentage } = startAt; diff --git a/src/public_types.ts b/src/public_types.ts index 60359211d4..a5d4f09109 100644 --- a/src/public_types.ts +++ b/src/public_types.ts @@ -356,10 +356,20 @@ export type IStartAtOption = percentage : number; } | { /** - * If set, we should begin at this position relative to the content's end, - * in seconds. + * If set, we should begin at this position relative to the content's maximum + * seekable position, in seconds. */ fromLastPosition : number; + } | { + /** + * If set, we should begin at this position relative to the content's live + * edge if it makes sense, in seconds. + * + * If the live edge is unknown or if it does not make sense for the current + * content, that position is relative to the content's maximum position + * instead. + */ + fromLivePosition : number; } | { /** * If set, we should begin at this position relative to the content's start, diff --git a/tests/integration/scenarios/loadVideo_options.js b/tests/integration/scenarios/loadVideo_options.js index fce29d62a4..49f2de9c6d 100644 --- a/tests/integration/scenarios/loadVideo_options.js +++ b/tests/integration/scenarios/loadVideo_options.js @@ -186,6 +186,23 @@ describe("loadVideo Options", () => { expect(player.getPosition()).to.equal(initialPosition); }); + it("should seek at the right position if startAt.fromLivePosition is set", async function () { + const startAt = 10; + player.loadVideo({ + transport: manifestInfos.transport, + url: manifestInfos.url, + autoPlay: false, + startAt: { fromLivePosition: - startAt }, + }); + await waitForLoadedStateAfterLoadVideo(player); + expect(player.getPlayerState()).to.equal("LOADED"); + const initialPosition = player.getPosition(); + expect(initialPosition).to.be + .closeTo(player.getMaximumPosition() - startAt, 0.5); + await sleep(500); + expect(player.getPosition()).to.equal(initialPosition); + }); + it("should seek at the right position if startAt.percentage is set", async function () { player.loadVideo({ transport: manifestInfos.transport, @@ -268,6 +285,23 @@ describe("loadVideo Options", () => { expect(player.getPosition()).to.be.above(initialPosition); }); + it("should seek at the right position then play if startAt.fromLivePosition and autoPlay is set", async function () { + const startAt = 10; + player.loadVideo({ + transport: manifestInfos.transport, + url: manifestInfos.url, + autoPlay: true, + startAt: { fromLivePosition: - startAt }, + }); + await waitForLoadedStateAfterLoadVideo(player); + expect(player.getPlayerState()).to.equal("PLAYING"); + const initialPosition = player.getPosition(); + expect(initialPosition).to.be + .closeTo(player.getMaximumPosition() - startAt, 0.5); + await sleep(500); + expect(player.getPosition()).to.be.above(initialPosition); + }); + it("should seek at the right position then play if startAt.percentage and autoPlay is set", async function () { player.loadVideo({ transport: manifestInfos.transport, From 8857d012ddd1a7bd0adedba1fe500b9be6a094c6 Mon Sep 17 00:00:00 2001 From: Florent Date: Mon, 13 Nov 2023 18:40:22 +0100 Subject: [PATCH 03/46] fix bug: subtitle no longer blink in low latency --- .../text/html/__tests__/utils.test.ts | 15 +++++++ .../text/html/text_track_cues_store.ts | 45 ++++++++++++++++--- .../implementations/text/html/utils.ts | 21 ++++++++- 3 files changed, 73 insertions(+), 8 deletions(-) diff --git a/src/core/segment_buffers/implementations/text/html/__tests__/utils.test.ts b/src/core/segment_buffers/implementations/text/html/__tests__/utils.test.ts index 697f38a8e5..2e9c2b8299 100644 --- a/src/core/segment_buffers/implementations/text/html/__tests__/utils.test.ts +++ b/src/core/segment_buffers/implementations/text/html/__tests__/utils.test.ts @@ -261,6 +261,21 @@ describe("HTML Text buffer utils - areNearlyEqual", () => { it("should return true if input number are equals", () => { expect(areNearlyEqual(5, 5)).toBe(true); }); + it( + "should return false if input number are not nearly equals with delta parameter", + () => { + expect(areNearlyEqual(5, 5.1, 0.02)).toBe(false); + }); + it( + "should return true if input number are nearly equals with delta parameter", + () => { + expect(areNearlyEqual(5, 5.01, 0.02)).toBe(true); + }); + it( + "should return true if input number are equals with delta parameter", + () => { + expect(areNearlyEqual(5, 5, 0.02)).toBe(true); + }); }); describe("HTML Text buffer utils - removeCuesInfosBetween", () => { diff --git a/src/core/segment_buffers/implementations/text/html/text_track_cues_store.ts b/src/core/segment_buffers/implementations/text/html/text_track_cues_store.ts index c73670fb4f..fcdb96a90f 100644 --- a/src/core/segment_buffers/implementations/text/html/text_track_cues_store.ts +++ b/src/core/segment_buffers/implementations/text/html/text_track_cues_store.ts @@ -26,6 +26,11 @@ import { removeCuesInfosBetween, } from "./utils"; +const DELTA_CUES_GROUP = 1e-3; // 1ms + +// segment_duration / RELATIVE_DELTA_RATIO = relative_delta +// relative_delta is the tolerance to determine if two segements are the same +const RELATIVE_DELTA_RATIO = 5; /** * Manage the buffer of the HTMLTextSegmentBuffer. * Allows to add, remove and recuperate cues at given times. @@ -72,6 +77,17 @@ export default class TextTrackCuesStore { ret.push(cues[j].element); } } + // first or last IHTMLCue in a group can have a slighlty different start + // or end time than the start or end time of the ICuesGroup due to parsing + // approximation. + // Add a tolerance of 1ms to fix this issue + if (ret.length === 0 && cues.length) { + if (areNearlyEqual(time, cues[0].start, DELTA_CUES_GROUP)) { + ret.push(cues[0].element); + } else if (areNearlyEqual(time, cues[cues.length - 1].end, DELTA_CUES_GROUP)) { + ret.push(cues[cues.length - 1].element); + } + } return ret; } } @@ -163,6 +179,11 @@ export default class TextTrackCuesStore { insert(cues : IHTMLCue[], start : number, end : number) : void { const cuesBuffer = this._cuesBuffer; const cuesInfosToInsert = { start, end, cues }; + // it's preferable to have a delta depending on the duration of the segment + // if the delta is one fifth of the length of the segment: + // a segment of [0, 2] is the "same" segment as [0, 2.1] + // but [0, 0.04] is not the "same" segement as [0,04, 0.08] + const relativeDelta = Math.abs(start - end) / RELATIVE_DELTA_RATIO; /** * Called when we found the index of the next cue relative to the cue we @@ -175,7 +196,7 @@ export default class TextTrackCuesStore { function onIndexOfNextCueFound(indexOfNextCue : number) : void { const nextCue = cuesBuffer[indexOfNextCue]; if (nextCue === undefined || // no cue - areNearlyEqual(cuesInfosToInsert.end, nextCue.end)) // samey end + areNearlyEqual(cuesInfosToInsert.end, nextCue.end, relativeDelta)) // samey end { // ours: |AAAAA| // the current one: |BBBBB| @@ -210,8 +231,8 @@ export default class TextTrackCuesStore { for (let cueIdx = 0; cueIdx < cuesBuffer.length; cueIdx++) { let cuesInfos = cuesBuffer[cueIdx]; if (start < cuesInfos.end) { - if (areNearlyEqual(start, cuesInfos.start)) { - if (areNearlyEqual(end, cuesInfos.end)) { + if (areNearlyEqual(start, cuesInfos.start, relativeDelta)) { + if (areNearlyEqual(end, cuesInfos.end, relativeDelta)) { // exact same segment // ours: |AAAAA| // the current one: |BBBBB| @@ -257,7 +278,7 @@ export default class TextTrackCuesStore { // - add ours before the current one cuesBuffer.splice(cueIdx, 0, cuesInfosToInsert); return; - } else if (areNearlyEqual(end, cuesInfos.start)) { + } else if (areNearlyEqual(end, cuesInfos.start, relativeDelta)) { // our cue goes just before the current one: // ours: |AAAAAAA| // the current one: |BBBB| @@ -268,7 +289,7 @@ export default class TextTrackCuesStore { cuesInfos.start = end; cuesBuffer.splice(cueIdx, 0, cuesInfosToInsert); return; - } else if (areNearlyEqual(end, cuesInfos.end)) { + } else if (areNearlyEqual(end, cuesInfos.end, relativeDelta)) { // ours: |AAAAAAA| // the current one: |BBBB| // Result: |AAAAAAA| @@ -297,7 +318,7 @@ export default class TextTrackCuesStore { } // else -> start > cuesInfos.start - if (areNearlyEqual(cuesInfos.end, end)) { + if (areNearlyEqual(cuesInfos.end, end, relativeDelta)) { // ours: |AAAAAA| // the current one: |BBBBBBBB| // Result: |BBAAAAAA| @@ -333,6 +354,18 @@ export default class TextTrackCuesStore { } } } + + if (cuesBuffer.length) { + const lastCue = cuesBuffer[cuesBuffer.length - 1]; + if (areNearlyEqual(lastCue.end, start, relativeDelta)) { + // Match the end of the previous cue to the start of + // the following one if they are close enough + // ours: |AAAAA| + // the current one: |BBBBB|... + // Result: |BBBBBBBAAAAA| + lastCue.end = start; + } + } // no cues group has the end after our current start. // These cues should be the last one cuesBuffer.push(cuesInfosToInsert); diff --git a/src/core/segment_buffers/implementations/text/html/utils.ts b/src/core/segment_buffers/implementations/text/html/utils.ts index 82e4ee280e..285e61c6a5 100644 --- a/src/core/segment_buffers/implementations/text/html/utils.ts +++ b/src/core/segment_buffers/implementations/text/html/utils.ts @@ -50,6 +50,21 @@ import { * Setting a value too high might lead to two segments targeting different times * to be wrongly believed to target the same time. In worst case scenarios, this * could lead to wanted text tracks being removed. + * + * When comparing 2 segments s1 and s2, you may want to take into account the duration + * of the segments: + * - if s1 is [0, 2] and s2 is [0, 2.1] s1 and s2 can be considered as nearly equal as + * there is a relative difference of: (2.1-2) / 2 = 5%; + * Formula: (end_s1 - end_s2) / duration_s2 = relative_difference + * - if s1 is [0, 0.04] and s2 is [0.04, 0.08] s1 and s2 may not considered as nearly + * equal as there is a relative difference of: (0.04-0.08) / 0.04 = 100% + * + * To compare relatively to the duration of a segment you can provide and additional + * parameter "delta" that remplace MAX_DELTA_BUFFER_TIME. + * If parameter "delta" is higher than MAX_DELTA_BUFFER_TIME, MAX_DELTA_BUFFER_TIME + * is used instead of delta. This ensure that segments are nearly equal when comparing + * relatively AND absolutely. + * * @type Number */ const MAX_DELTA_BUFFER_TIME = 0.2; @@ -58,10 +73,12 @@ const MAX_DELTA_BUFFER_TIME = 0.2; * @see MAX_DELTA_BUFFER_TIME * @param {Number} a * @param {Number} b + * @param {Number} delta * @returns {Boolean} */ -export function areNearlyEqual(a : number, b : number) : boolean { - return Math.abs(a - b) <= MAX_DELTA_BUFFER_TIME; +export function areNearlyEqual( + a : number, b : number, delta: number = MAX_DELTA_BUFFER_TIME) : boolean { + return Math.abs(a - b) <= Math.min(delta, MAX_DELTA_BUFFER_TIME); } /** From 6ee2433c19138cbcf11c623b9e455aa1b958bdf0 Mon Sep 17 00:00:00 2001 From: Paul Berberian Date: Thu, 2 Nov 2023 14:10:41 +0100 Subject: [PATCH 04/46] Add the possibility to set a new `keySystems` option on the `reload` API A majority of the bigger issues we encounter in production is DRM-related, leading us to either work-around those in the RxPlayer, or to facilitate a work-around on the application-side through a better DRM-related API. Recently, we've seen that many Windows/Edge users (but still a minority of them) could encounter an issue on the `generateRequest` EME call when relying on PlayReady SL3000 (hardware-backed decryption and playback, seen as one the most secure DRM mechanism for OTT contents) which would lead to a failure to play the content. When this happens, fallbacking to a different key system like PlayReady SL2000 (where decryption happens in software) or Widevine usually (though not always) seems to avoid the issue, even if it might lead to less protection and thus might lead generally to only lower video qualities (as higher security requirements are in practice generally just enforced for the higher video qualities, depending on license policies). After brainstorming whether this fallback should be done on the RxPlayer-side, or on the application-side, we're for now implementing the easier way (for us :p) of just providing here an API allowing to just let the application replay the last loaded content (whose loading may have failed due to the aforementioned `generateRequest` error) with a different `keySystems` configuration, thus allowing an application to reload the last loaded content after blacklisting the current key system if the error appears to be linked to that issue. --- doc/api/Basic_Methods/reload.md | 11 +++++++++++ src/core/api/option_utils.ts | 8 ++++++++ src/core/api/public_api.ts | 14 +++++++++++++- 3 files changed, 32 insertions(+), 1 deletion(-) diff --git a/doc/api/Basic_Methods/reload.md b/doc/api/Basic_Methods/reload.md index cc3534ba03..d64547a486 100644 --- a/doc/api/Basic_Methods/reload.md +++ b/doc/api/Basic_Methods/reload.md @@ -35,6 +35,17 @@ The options argument is an object containing : content was playing the last time it was played and stay in the `"LOADED"` state (and paused) if it was paused last time it was played. +- _keySystems_ (`Array. | undefined`): If set, a new configuration will + be set on this reloaded content regarding its decryption. + + The value of this property follows the exact same structure than for the + original `loadVideo` call, it is described in the [decryption options + documentation page](../Decryption_Options.md). + + You might for example want to update that way the `keySystems` option compared + to the one of the original `loadVideo` call when you suspect that there is a + decryption-related issue with the original `keySystems` given. + Note that despite this method's name, the player will not go through the `RELOADING` state while reloading the content but through the regular `LOADING` state - as if `loadVideo` was called on that same content again. diff --git a/src/core/api/option_utils.ts b/src/core/api/option_utils.ts index 1b00fd1778..ed7c230437 100644 --- a/src/core/api/option_utils.ts +++ b/src/core/api/option_utils.ts @@ -397,6 +397,8 @@ function parseConstructorOptions( */ function checkReloadOptions(options?: { reloadAt?: { position?: number; relative?: number }; + keySystems?: IKeySystemOption[]; + autoPlay?: boolean; }): void { if (options === null || (typeof options !== "object" && options !== undefined)) { @@ -414,6 +416,12 @@ function checkReloadOptions(options?: { options?.reloadAt?.relative !== undefined) { throw new Error("API: reload - Invalid 'reloadAt.relative' option format."); } + if (!Array.isArray(options?.keySystems) && options?.keySystems !== undefined) { + throw new Error("API: reload - Invalid 'keySystems' option format."); + } + if (options?.autoPlay !== undefined && typeof options.autoPlay !== "boolean") { + throw new Error("API: reload - Invalid 'autoPlay' option format."); + } } /** diff --git a/src/core/api/public_api.ts b/src/core/api/public_api.ts index 8d1ff7682b..40d044e4ca 100644 --- a/src/core/api/public_api.ts +++ b/src/core/api/public_api.ts @@ -59,6 +59,7 @@ import { IConstructorOptions, IDecipherabilityUpdateContent, IKeySystemConfigurationOutput, + IKeySystemOption, ILoadVideoOptions, IPeriod, IPlayerError, @@ -577,6 +578,7 @@ class Player extends EventEmitter { */ reload(reloadOpts?: { reloadAt?: { position?: number; relative?: number }; + keySystems?: IKeySystemOption[]; autoPlay?: boolean; }): void { const { options, @@ -609,6 +611,13 @@ class Player extends EventEmitter { autoPlay = !reloadInPause; } + let keySystems : IKeySystemOption[] | undefined; + if (reloadOpts?.keySystems !== undefined) { + keySystems = reloadOpts.keySystems; + } else if (this._priv_reloadingMetadata.options?.keySystems !== undefined) { + keySystems = this._priv_reloadingMetadata.options.keySystems; + } + const newOptions = { ...options, initialManifest: manifest }; if (startAt !== undefined) { @@ -617,6 +626,9 @@ class Player extends EventEmitter { if (autoPlay !== undefined) { newOptions.autoPlay = autoPlay; } + if (keySystems !== undefined) { + newOptions.keySystems = keySystems; + } this._priv_initializeContentPlayback(newOptions); } @@ -626,7 +638,7 @@ class Player extends EventEmitter { if (features.createDebugElement === null) { throw new Error("Feature `DEBUG_ELEMENT` not added to the RxPlayer"); } - const canceller = new TaskCanceller() ; + const canceller = new TaskCanceller(); features.createDebugElement(element, this, canceller.signal); return { dispose() { From dd71e574e18947dda8d22e56b1e0d9a1c16605fa Mon Sep 17 00:00:00 2001 From: Florent Date: Tue, 14 Nov 2023 13:30:22 +0100 Subject: [PATCH 05/46] add comments and format --- .../text/html/text_track_cues_store.ts | 32 ++++++++++++++----- 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/src/core/segment_buffers/implementations/text/html/text_track_cues_store.ts b/src/core/segment_buffers/implementations/text/html/text_track_cues_store.ts index fcdb96a90f..4448c0748b 100644 --- a/src/core/segment_buffers/implementations/text/html/text_track_cues_store.ts +++ b/src/core/segment_buffers/implementations/text/html/text_track_cues_store.ts @@ -26,10 +26,22 @@ import { removeCuesInfosBetween, } from "./utils"; -const DELTA_CUES_GROUP = 1e-3; // 1ms +/** + * first or last IHTMLCue in a group can have a slighlty different start + * or end time than the start or end time of the ICuesGroup due to parsing + * approximation. + * DELTA_CUES_GROUP defines the tolerance level when comparing the start/end + * of a IHTMLCue to the start/end of a ICuesGroup. + * Having this value too high may lead to have unwanted subtitle displayed + * Having this value too low may lead to have subtitles not displayed + */ +const DELTA_CUES_GROUP = 1e-3; -// segment_duration / RELATIVE_DELTA_RATIO = relative_delta -// relative_delta is the tolerance to determine if two segements are the same +/** + * segment_duration / RELATIVE_DELTA_RATIO = relative_delta + * + * relative_delta is the tolerance to determine if two segements are the same + */ const RELATIVE_DELTA_RATIO = 5; /** * Manage the buffer of the HTMLTextSegmentBuffer. @@ -358,11 +370,15 @@ export default class TextTrackCuesStore { if (cuesBuffer.length) { const lastCue = cuesBuffer[cuesBuffer.length - 1]; if (areNearlyEqual(lastCue.end, start, relativeDelta)) { - // Match the end of the previous cue to the start of - // the following one if they are close enough - // ours: |AAAAA| - // the current one: |BBBBB|... - // Result: |BBBBBBBAAAAA| + // Match the end of the previous cue to the start of the following one + // if they are close enough. If there is a small gap between two segments + // it can lead to having no subtitles for a short time, this is noticeable when + // two successive segments displays the same text, making it diseappear + // and reappear quickly, which gives the impression of blinking + // + // ours: |AAAAA| + // the current one: |BBBBB|... + // Result: |BBBBBBBAAAAA| lastCue.end = start; } } From 5e36a77dd3aedebee39d7a41bbe0fe424bdff2e3 Mon Sep 17 00:00:00 2001 From: Florent Date: Tue, 14 Nov 2023 13:30:49 +0100 Subject: [PATCH 06/46] handle the case where multiple cues can target the same time --- .../text/html/text_track_cues_store.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/core/segment_buffers/implementations/text/html/text_track_cues_store.ts b/src/core/segment_buffers/implementations/text/html/text_track_cues_store.ts index 4448c0748b..16d2942039 100644 --- a/src/core/segment_buffers/implementations/text/html/text_track_cues_store.ts +++ b/src/core/segment_buffers/implementations/text/html/text_track_cues_store.ts @@ -93,11 +93,13 @@ export default class TextTrackCuesStore { // or end time than the start or end time of the ICuesGroup due to parsing // approximation. // Add a tolerance of 1ms to fix this issue - if (ret.length === 0 && cues.length) { - if (areNearlyEqual(time, cues[0].start, DELTA_CUES_GROUP)) { - ret.push(cues[0].element); - } else if (areNearlyEqual(time, cues[cues.length - 1].end, DELTA_CUES_GROUP)) { - ret.push(cues[cues.length - 1].element); + if (ret.length === 0 && cues.length > 0) { + for (let j = 0; j < cues.length; j++) { + if (areNearlyEqual(time, cues[j].start, DELTA_CUES_GROUP) + || areNearlyEqual(time, cues[j].end, DELTA_CUES_GROUP) + ) { + ret.push(cues[j].element); + } } } return ret; From febbf9512359aebc6dc7c802d1cdff8fff8091d7 Mon Sep 17 00:00:00 2001 From: Florent Bouisset <58945185+Florent-Bouisset@users.noreply.github.com> Date: Wed, 15 Nov 2023 11:17:36 +0100 Subject: [PATCH 07/46] remplace typescript cast by runtime check (#1315) --- src/core/api/option_utils.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/core/api/option_utils.ts b/src/core/api/option_utils.ts index 501eff2a32..1bfe444424 100644 --- a/src/core/api/option_utils.ts +++ b/src/core/api/option_utils.ts @@ -646,9 +646,8 @@ function parseLoadVideoOptions( } if (!isNullOrUndefined(options.startAt)) { - // TODO Better way to express that in TypeScript? - if ((options.startAt as { wallClockTime? : Date|number }).wallClockTime - instanceof Date + if ("wallClockTime" in options.startAt + && options.startAt.wallClockTime instanceof Date ) { const wallClockTime = (options.startAt as { wallClockTime : Date }) .wallClockTime.getTime() / 1000; From 098385f4ca6671de4993074d641b0f701cdf6abf Mon Sep 17 00:00:00 2001 From: Paul Berberian Date: Fri, 17 Nov 2023 17:22:15 +0100 Subject: [PATCH 08/46] Debug: update buffer graph maximum size so it becomes more readable for long contents When displaying the RxPlayer's debug element, a graph describing the audio, video and text qualities in the various buffers might be displayed. At maximum, that graph could display 10000 seconds of content, which made in that case the graph poorly readable. I now chose to reduce it to maximum 30 minutes, as you rarely intend to see more when displaying debug information, making it much more readable. --- src/core/api/debug/buffer_graph.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/core/api/debug/buffer_graph.ts b/src/core/api/debug/buffer_graph.ts index 44e5da72e4..cb026b03ef 100644 --- a/src/core/api/debug/buffer_graph.ts +++ b/src/core/api/debug/buffer_graph.ts @@ -1,7 +1,7 @@ import { Representation } from "../../../manifest"; import { IBufferedChunk } from "../../segment_buffers"; -const BUFFER_WIDTH_IN_SECONDS = 10000; +const BUFFER_WIDTH_IN_SECONDS = 30 * 60; const COLORS = [ "#2ab7ca", @@ -96,8 +96,8 @@ export default class SegmentBufferGraph { let maximumPosition; if (maximumPoint - minimumPoint > BUFFER_WIDTH_IN_SECONDS) { if (currentTime === undefined) { - minimumPosition = minimumPoint; maximumPosition = maximumPoint; + minimumPosition = maximumPoint - BUFFER_WIDTH_IN_SECONDS; } else if (maximumPoint - currentTime < BUFFER_WIDTH_IN_SECONDS / 2) { maximumPosition = maximumPoint; minimumPosition = maximumPoint - BUFFER_WIDTH_IN_SECONDS; From d429e55b5aa70937c50cf64ead95485c0aca82f9 Mon Sep 17 00:00:00 2001 From: Paul Berberian Date: Mon, 20 Nov 2023 18:02:19 +0100 Subject: [PATCH 09/46] Debug: always synchronize SegmentInventory before reporting it through the API We have a minor issue with an undocumented API letting applications know the content of the various buffers (audio, video and text), which may show in some rare cases to an unsynchronized view into one of those buffers. As this API is not documented and put behind an unwelcoming method name, the only impact that unsynchronized status could have are: - information shown in the RxPlayer's debug element (through the `createDebugElement API`) could not reflect the exact reality. The only case I've seen now is that when enabling then disabling text tracks, we may still see a view making it seems that the since-remove text segments pushed were still here (it is in reality not, as should be expected). - Likewise, our demo page's buffer graph, which rely on the same API, could show an unsynchronized view into the buffer in the same situation. The solution I found was just to make sure the `SegmentInventory`, the module actually storing that buffer information, is always synchronized to the buffer at the time that hidden API is called. This could mean unnecessary calls when the buffer is already synchronized, but we do not care much as that API is only called for debug anyway and not even performance-sensitive for the moment. --- src/core/api/public_api.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/core/api/public_api.ts b/src/core/api/public_api.ts index 8d1ff7682b..765c752d00 100644 --- a/src/core/api/public_api.ts +++ b/src/core/api/public_api.ts @@ -2429,9 +2429,11 @@ class Player extends EventEmitter { } const segmentBufferStatus = this._priv_contentInfos .segmentBuffersStore.getStatus(bufferType); - return segmentBufferStatus.type === "initialized" ? - segmentBufferStatus.value.getInventory() : - null; + if (segmentBufferStatus.type === "initialized") { + segmentBufferStatus.value.synchronizeInventory(); + return segmentBufferStatus.value.getInventory(); + } + return null; } /** From d3ccc85f02613bd47e4b3e823c153d8915d44480 Mon Sep 17 00:00:00 2001 From: Paul Berberian Date: Tue, 21 Nov 2023 18:33:31 +0100 Subject: [PATCH 10/46] debug: remove impossible case --- src/core/api/debug/buffer_graph.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/core/api/debug/buffer_graph.ts b/src/core/api/debug/buffer_graph.ts index cb026b03ef..e587b4c1a4 100644 --- a/src/core/api/debug/buffer_graph.ts +++ b/src/core/api/debug/buffer_graph.ts @@ -95,10 +95,7 @@ export default class SegmentBufferGraph { let minimumPosition; let maximumPosition; if (maximumPoint - minimumPoint > BUFFER_WIDTH_IN_SECONDS) { - if (currentTime === undefined) { - maximumPosition = maximumPoint; - minimumPosition = maximumPoint - BUFFER_WIDTH_IN_SECONDS; - } else if (maximumPoint - currentTime < BUFFER_WIDTH_IN_SECONDS / 2) { + if (maximumPoint - currentTime < BUFFER_WIDTH_IN_SECONDS / 2) { maximumPosition = maximumPoint; minimumPosition = maximumPoint - BUFFER_WIDTH_IN_SECONDS; } else if (currentTime - minimumPoint < BUFFER_WIDTH_IN_SECONDS / 2) { From da7efb6f3eebfdd34cdf0e1b94baf26df74305c7 Mon Sep 17 00:00:00 2001 From: Paul Berberian Date: Wed, 22 Nov 2023 12:43:39 +0100 Subject: [PATCH 11/46] Replace docgen.ico by in-canal readme.doc --- package-lock.json | 84 +++++++++++++++++++++++------------------------ package.json | 4 +-- 2 files changed, 44 insertions(+), 44 deletions(-) diff --git a/package-lock.json b/package-lock.json index bce1ea0190..d1123e1f11 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "@babel/plugin-transform-runtime": "7.22.15", "@babel/preset-env": "7.22.20", "@babel/preset-react": "7.22.15", + "@canalplus/readme.doc": "^0.3.0", "@types/chai": "4.3.6", "@types/jest": "29.5.5", "@types/mocha": "10.0.1", @@ -29,7 +30,6 @@ "babel-loader": "9.1.3", "chai": "4.3.8", "core-js": "3.32.2", - "docgen.ico": "^0.2.3", "esbuild": "0.19.3", "eslint": "8.50.0", "eslint-plugin-ban": "1.6.0", @@ -1937,6 +1937,27 @@ "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", "dev": true }, + "node_modules/@canalplus/readme.doc": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@canalplus/readme.doc/-/readme.doc-0.3.0.tgz", + "integrity": "sha512-MVTz6iJs8a0KucpheT37gLADqyFTTk3PRaf95g5MRduRSwangwE4zq3306QtUId8dPuTLPXxWGUyULY00jmeRw==", + "dev": true, + "dependencies": { + "cheerio": "1.0.0-rc.12", + "highlight.js": "11.7.0", + "html-entities": "2.3.3", + "markdown-it": "13.0.1" + }, + "bin": { + "readme.doc": "build/index.js" + } + }, + "node_modules/@canalplus/readme.doc/node_modules/html-entities": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.3.3.tgz", + "integrity": "sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA==", + "dev": true + }, "node_modules/@colors/colors": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", @@ -5691,27 +5712,6 @@ "node": ">=8" } }, - "node_modules/docgen.ico": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/docgen.ico/-/docgen.ico-0.2.3.tgz", - "integrity": "sha512-dfXbeTpcQwZbpXXTFOR4MCGm1aQr171c3kA0KZqvzawGm5WJe8IqojO+JHdu2xXDep/rDxC8BPfELzRKAZeK3w==", - "dev": true, - "dependencies": { - "cheerio": "1.0.0-rc.12", - "highlight.js": "11.7.0", - "html-entities": "2.3.3", - "markdown-it": "13.0.1" - }, - "bin": { - "docgen.ico": "build/index.js" - } - }, - "node_modules/docgen.ico/node_modules/html-entities": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.3.3.tgz", - "integrity": "sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA==", - "dev": true - }, "node_modules/doctrine": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", @@ -16174,6 +16174,26 @@ "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", "dev": true }, + "@canalplus/readme.doc": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@canalplus/readme.doc/-/readme.doc-0.3.0.tgz", + "integrity": "sha512-MVTz6iJs8a0KucpheT37gLADqyFTTk3PRaf95g5MRduRSwangwE4zq3306QtUId8dPuTLPXxWGUyULY00jmeRw==", + "dev": true, + "requires": { + "cheerio": "1.0.0-rc.12", + "highlight.js": "11.7.0", + "html-entities": "2.3.3", + "markdown-it": "13.0.1" + }, + "dependencies": { + "html-entities": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.3.3.tgz", + "integrity": "sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA==", + "dev": true + } + } + }, "@colors/colors": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", @@ -18928,26 +18948,6 @@ "path-type": "^4.0.0" } }, - "docgen.ico": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/docgen.ico/-/docgen.ico-0.2.3.tgz", - "integrity": "sha512-dfXbeTpcQwZbpXXTFOR4MCGm1aQr171c3kA0KZqvzawGm5WJe8IqojO+JHdu2xXDep/rDxC8BPfELzRKAZeK3w==", - "dev": true, - "requires": { - "cheerio": "1.0.0-rc.12", - "highlight.js": "11.7.0", - "html-entities": "2.3.3", - "markdown-it": "13.0.1" - }, - "dependencies": { - "html-entities": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.3.3.tgz", - "integrity": "sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA==", - "dev": true - } - } - }, "doctrine": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", diff --git a/package.json b/package.json index 562ba965ec..730fbc2de7 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,7 @@ "demo": "node ./scripts/generate_full_demo.js --production-mode", "demo:min": "node ./scripts/generate_full_demo.js --production-mode --minify", "demo:watch": "node ./scripts/generate_full_demo.js --watch --production-mode", - "doc": "docgen.ico doc/ doc/generated \"$(cat VERSION)\"", + "doc": "readme.doc doc/ doc/generated \"$(cat VERSION)\"", "lint": "eslint src -c .eslintrc.js", "lint:demo": "eslint -c demo/full/.eslintrc.js demo/full/scripts", "lint:tests": "eslint tests/**/*.js --ignore-pattern '/tests/performance/bundle*'", @@ -84,6 +84,7 @@ "@babel/plugin-transform-runtime": "7.22.15", "@babel/preset-env": "7.22.20", "@babel/preset-react": "7.22.15", + "@canalplus/readme.doc": "^0.3.0", "@types/chai": "4.3.6", "@types/jest": "29.5.5", "@types/mocha": "10.0.1", @@ -97,7 +98,6 @@ "babel-loader": "9.1.3", "chai": "4.3.8", "core-js": "3.32.2", - "docgen.ico": "^0.2.3", "esbuild": "0.19.3", "eslint": "8.50.0", "eslint-plugin-ban": "1.6.0", From 2a6ca4899a66a0ae31c628c7f9f3100ba0209a02 Mon Sep 17 00:00:00 2001 From: Florent Date: Wed, 22 Nov 2023 18:36:05 +0100 Subject: [PATCH 12/46] fix bug lineheight was not correctly applied --- src/parsers/texttracks/ttml/html/apply_line_height.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/parsers/texttracks/ttml/html/apply_line_height.ts b/src/parsers/texttracks/ttml/html/apply_line_height.ts index 1897e9ab44..4f727229a1 100644 --- a/src/parsers/texttracks/ttml/html/apply_line_height.ts +++ b/src/parsers/texttracks/ttml/html/apply_line_height.ts @@ -27,10 +27,12 @@ export default function applyLineHeight( lineHeight : string ) : void { const trimmedLineHeight = lineHeight.trim(); + const splittedLineHeight = trimmedLineHeight.split(" "); + if (trimmedLineHeight === "auto") { return; } - const firstLineHeight = REGXP_LENGTH.exec(trimmedLineHeight[0]); + const firstLineHeight = REGXP_LENGTH.exec(splittedLineHeight[0]); if (firstLineHeight === null) { return; } From 31a8fadfbcda785285287c9f0b861d90e4a684da Mon Sep 17 00:00:00 2001 From: Paul Berberian Date: Thu, 23 Nov 2023 14:21:07 +0100 Subject: [PATCH 13/46] Add unsupported and undecipherable bitrates to the debug element The debug element that might be displayed with the `createDebugElement` method filtered from listed video and audio bitrates those that were not for decipherable or decodable Representation. This makes sense, but it bothered us when debugging why some qualities were not advertised anymore through that window on some difficult-to-debug devices lately: was it an RxPlayer bug, a DRM policy not respected, or codecs that were unsupported. This commit adds audio and video bitrates which are not actually playable, suffixing them by `E!` when it is because it's not decipherable, by `U!` when it's because the codec is not supported, or both (`U! E!`) when it's both. --- doc/api/Miscellaneous/Debug_Element.md | 16 ++++++++++++++-- src/core/api/debug/modules/general_info.ts | 21 +++++++++++++++------ src/public_types.ts | 3 +++ 3 files changed, 32 insertions(+), 8 deletions(-) diff --git a/doc/api/Miscellaneous/Debug_Element.md b/doc/api/Miscellaneous/Debug_Element.md index 34a89d5a15..c77c2aa267 100644 --- a/doc/api/Miscellaneous/Debug_Element.md +++ b/doc/api/Miscellaneous/Debug_Element.md @@ -104,8 +104,20 @@ reflect exactly what's going on at a particular point in time. - **vt**: _Video tracks_. List of the video tracks' `id` property. The line begins with a number indicating the number of available video tracks, followed by `:`, followed by each video track's id separated by a space. The current video track is prepended by a `*` character. - **at**: _Audio tracks_. List of the audio tracks' `id` property. The line begins with a number indicating the number of available audio tracks, followed by `:`, followed by each audio track's id separated by a space. The current audio track is prepended by a `*` character. - **tt**: _Text tracks_. List of the text tracks' `id` property. The line begins with a number indicating the number of available text tracks, followed by `:`, followed by each text track's id separated by a space. The current text track is prepended by a `*` character. - - **vb**: _Video Bitrates_. The available video bitrates in the current video track, separated by a space. - - **ab**: _Audio Bitrates_. The available audio bitrates in the current audio track, separated by a space. + - **vb**: _Video Bitrates_. The available video bitrates in the current + video track, separated by a space. + Each bitrate value can optionally be followed by an "`U!`", in which case + the codec of the corresponding Representation is unsupported, and/or be + followed by an "`E!`", in which case it is undecipherable currently. + In both of those cases the corresponding video Representation won't be + played by the RxPlayer. + - **ab**: _Audio Bitrates_. The available audio bitrates in the current + audio track, separated by a space. + Each bitrate value can optionally be followed by an "`U!`", in which case + the codec of the corresponding Representation is unsupported, and/or be + followed by an "`E!`", in which case it is undecipherable currently. + In both of those cases the corresponding audio Representation won't be + played by the RxPlayer. - Buffer information - **vbuf**: _Graphical representation of the video buffer_. The red rectangle indicates the current position, the different colors indicate different video qualities in the buffer. diff --git a/src/core/api/debug/modules/general_info.ts b/src/core/api/debug/modules/general_info.ts index cd8491fa1d..d576d6bb70 100644 --- a/src/core/api/debug/modules/general_info.ts +++ b/src/core/api/debug/modules/general_info.ts @@ -181,19 +181,28 @@ export default function constructDebugGeneralInfo( ]); adaptationsElt.appendChild(textAdaps); } - const videoBitrates = instance.getAvailableVideoBitrates(); - const audioBitrates = instance.getAvailableAudioBitrates(); + const adaptations = instance.getCurrentAdaptations(); + const videoBitratesStr = adaptations?.video?.representations.map((r) => { + return String(r.bitrate) + + (r.isSupported ? "" : " U!") + + (r.decipherable === false ? "" : " E!"); + }) ?? []; + const audioBitratesStr = adaptations?.video?.representations.map((r) => { + return String(r.bitrate) + + (r.isSupported ? "" : " U!") + + (r.decipherable === false ? "" : " E!"); + }) ?? []; representationsElt.innerHTML = ""; - if (videoBitrates.length > 0) { + if (videoBitratesStr.length > 0) { representationsElt.appendChild(createMetricTitle("vb")); representationsElt.appendChild(createElement("span", { - textContent: videoBitrates.join(" ") + " ", + textContent: videoBitratesStr.join(" ") + " ", })); } - if (audioBitrates.length > 0) { + if (audioBitratesStr.length > 0) { representationsElt.appendChild(createMetricTitle("ab")); representationsElt.appendChild(createElement("span", { - textContent: audioBitrates.join(" ") + " ", + textContent: audioBitratesStr.join(" ") + " ", })); } } else { diff --git a/src/public_types.ts b/src/public_types.ts index 60359211d4..8f900b9fed 100644 --- a/src/public_types.ts +++ b/src/public_types.ts @@ -313,6 +313,9 @@ export interface IRepresentation { /** If the track is HDR, gives the HDR characteristics of the content */ hdrInfo? : IHDRInformation; index : IRepresentationIndex; + + /** NOTE: not part of the API. */ + isSupported: boolean; } export interface IHDRInformation { From 19e76c264a616360171f69caefa597c2ba69c8e5 Mon Sep 17 00:00:00 2001 From: Paul Berberian Date: Mon, 27 Nov 2023 18:28:27 +0100 Subject: [PATCH 14/46] Remove unneeded tslint dependency --- package-lock.json | 17 +++++++++++++---- package.json | 1 - 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index d1123e1f11..1a6f6eb72d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -59,7 +59,6 @@ "terser-webpack-plugin": "5.3.9", "ts-jest": "29.1.1", "ts-loader": "9.4.4", - "tslint": "6.1.3", "typescript": "5.2.2", "webpack": "5.88.2", "webpack-bundle-analyzer": "4.9.1", @@ -4925,6 +4924,7 @@ "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-1.1.1.tgz", "integrity": "sha1-Jw8HbFpywC9bZaR9+Uxf46J4iS8=", "dev": true, + "peer": true, "engines": { "node": ">=0.10.0" } @@ -5687,6 +5687,7 @@ "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", "dev": true, + "peer": true, "engines": { "node": ">=0.3.1" } @@ -13841,6 +13842,7 @@ "resolved": "https://registry.npmjs.org/tslint/-/tslint-6.1.3.tgz", "integrity": "sha512-IbR4nkT96EQOvKE2PW/djGz8iGNeJ4rF2mBfiYaR/nvUWYKJhLwimoJKgjIFEIDibBtOevj7BqCRL4oHeWWUCg==", "dev": true, + "peer": true, "dependencies": { "@babel/code-frame": "^7.0.0", "builtin-modules": "^1.1.1", @@ -13868,6 +13870,7 @@ "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", "dev": true, + "peer": true, "bin": { "semver": "bin/semver" } @@ -13877,6 +13880,7 @@ "resolved": "https://registry.npmjs.org/tsutils/-/tsutils-2.29.0.tgz", "integrity": "sha512-g5JVHCIJwzfISaXpXE1qvNalca5Jwob6FjI4AoPlqMusJ6ftFE7IkkFoMhVLRgK+4Kx3gkzb8UZK5t5yTTvEmA==", "dev": true, + "peer": true, "dependencies": { "tslib": "^1.8.1" } @@ -18367,7 +18371,8 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-1.1.1.tgz", "integrity": "sha1-Jw8HbFpywC9bZaR9+Uxf46J4iS8=", - "dev": true + "dev": true, + "peer": true }, "bytes": { "version": "3.1.2", @@ -18931,7 +18936,8 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", - "dev": true + "dev": true, + "peer": true }, "diff-sequences": { "version": "29.6.3", @@ -24983,6 +24989,7 @@ "resolved": "https://registry.npmjs.org/tslint/-/tslint-6.1.3.tgz", "integrity": "sha512-IbR4nkT96EQOvKE2PW/djGz8iGNeJ4rF2mBfiYaR/nvUWYKJhLwimoJKgjIFEIDibBtOevj7BqCRL4oHeWWUCg==", "dev": true, + "peer": true, "requires": { "@babel/code-frame": "^7.0.0", "builtin-modules": "^1.1.1", @@ -25003,13 +25010,15 @@ "version": "5.7.2", "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", - "dev": true + "dev": true, + "peer": true }, "tsutils": { "version": "2.29.0", "resolved": "https://registry.npmjs.org/tsutils/-/tsutils-2.29.0.tgz", "integrity": "sha512-g5JVHCIJwzfISaXpXE1qvNalca5Jwob6FjI4AoPlqMusJ6ftFE7IkkFoMhVLRgK+4Kx3gkzb8UZK5t5yTTvEmA==", "dev": true, + "peer": true, "requires": { "tslib": "^1.8.1" } diff --git a/package.json b/package.json index 730fbc2de7..c50facab7e 100644 --- a/package.json +++ b/package.json @@ -127,7 +127,6 @@ "terser-webpack-plugin": "5.3.9", "ts-jest": "29.1.1", "ts-loader": "9.4.4", - "tslint": "6.1.3", "typescript": "5.2.2", "webpack": "5.88.2", "webpack-bundle-analyzer": "4.9.1", From 267c2bd0ad5a5bd90c38d7f8a93004aa0190a960 Mon Sep 17 00:00:00 2001 From: Paul Berberian Date: Thu, 30 Nov 2023 11:08:30 +0100 Subject: [PATCH 15/46] Add maxBufferAhead exception for text garbage collection The `maxBufferAhead` RxPlayer option allows to manually garbage collect media data that is too far ahead from the playing position. It can be set to a number of seconds, and was previously applied to audio, video and text media segments. For example when setting `maxBufferAhead` to `30`, and playing at position `20`, we would remove if present media data already buffered a position `50` or more (which could for example mostly happen when seeking back in the content). We found out however that applying that same value to the text buffer may not always be sensible: - `maxBufferAhead` is most likely defined by an application to restrict memory usage. The space taken by the text cues have much less weight than audio and video - We encounter several contents where loaded text segments span for 1 minute because of how small those segments are. Here we risk progressively loading the same segment as its end will be GCed multiple times. Due to both of these situations, I decided for now to have a lower bound on the `maxBufferAhead` applied on the text buffer to 2 minutes. We also could have made the `maxBufferAhead` API more configurable by letting an application set a different setting per media type, but I thought that an application may not easily realize the issue. --- .../orchestrator/stream_orchestrator.ts | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/src/core/stream/orchestrator/stream_orchestrator.ts b/src/core/stream/orchestrator/stream_orchestrator.ts index b2ffa609c0..d4d1c36a9c 100644 --- a/src/core/stream/orchestrator/stream_orchestrator.ts +++ b/src/core/stream/orchestrator/stream_orchestrator.ts @@ -116,10 +116,10 @@ export default function StreamOrchestrator( const garbageCollectors = new WeakMapMemory((segmentBuffer : SegmentBuffer) => { const { bufferType } = segmentBuffer; - const defaultMaxBehind = MAXIMUM_MAX_BUFFER_BEHIND[bufferType] != null ? + const defaultMaxBehind = MAXIMUM_MAX_BUFFER_BEHIND[bufferType] !== undefined ? MAXIMUM_MAX_BUFFER_BEHIND[bufferType] as number : Infinity; - const defaultMaxAhead = MAXIMUM_MAX_BUFFER_AHEAD[bufferType] != null ? + const defaultMaxAhead = MAXIMUM_MAX_BUFFER_AHEAD[bufferType] !== undefined ? MAXIMUM_MAX_BUFFER_AHEAD[bufferType] as number : Infinity; return (gcCancelSignal : CancellationSignal) => { @@ -130,10 +130,17 @@ export default function StreamOrchestrator( (val) => Math.min(val, defaultMaxBehind), gcCancelSignal), - maxBufferAhead: createMappedReference(maxBufferAhead, - (val) => - Math.min(val, defaultMaxAhead), - gcCancelSignal) }, + maxBufferAhead: createMappedReference(maxBufferAhead, (val) => { + const actualMaxBuff = bufferType === "text" ? + // Text segments are both much lighter on resources and might + // actually be much larger than other types of segments in terms + // of duration. Let's make an exception here by authorizing a + // larger text buffer ahead, to avoid unnecesarily reloading the + // same text track. + Math.max(val, 2 * 60) : + val; + return Math.min(actualMaxBuff, defaultMaxAhead); + }, gcCancelSignal) }, gcCancelSignal ); }; From 8ec3ca75bc3aac9a34447a24b2417e2f675a454e Mon Sep 17 00:00:00 2001 From: Paul Berberian Date: Thu, 30 Nov 2023 11:25:37 +0100 Subject: [PATCH 16/46] Remove unneeded object-assign typing file --- src/typings/object-assign.d.ts | 48 ---------------------------------- 1 file changed, 48 deletions(-) delete mode 100644 src/typings/object-assign.d.ts diff --git a/src/typings/object-assign.d.ts b/src/typings/object-assign.d.ts deleted file mode 100644 index 08544f31a3..0000000000 --- a/src/typings/object-assign.d.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. - */ - -declare module "object-assign" { - function objectAssign(target : T, source : U) : T & U; - function objectAssign( - target : T, - source1 : U, - source2 : V - ) : T & U & V; - function objectAssign( - target : T, - source1 : U, - source2 : V, - source3 : W - ) : T & U & V & W; - function objectAssign( - target : T, - source1 : U, - source2 : V, - source3 : W, - source4 : X - ) : T & U & V & W & X; - function objectAssign( - target : T, - source1 : U, - source2 : V, - source3 : W, - source4 : X, - source5 : Y - ) : T & U & V & W & Y; - // eslint-disable-next-line @typescript-eslint/ban-types - function objectAssign(target : object, ...sources : T[]) : T; - export default objectAssign; -} From f85997150eedae507f606e284fba0690aec1f41b Mon Sep 17 00:00:00 2001 From: Paul Berberian Date: Thu, 30 Nov 2023 11:26:42 +0100 Subject: [PATCH 17/46] Stream: Fix end GC log --- src/core/stream/representation/utils/get_needed_segments.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/core/stream/representation/utils/get_needed_segments.ts b/src/core/stream/representation/utils/get_needed_segments.ts index c139532f14..a2681a8cae 100644 --- a/src/core/stream/representation/utils/get_needed_segments.ts +++ b/src/core/stream/representation/utils/get_needed_segments.ts @@ -493,8 +493,8 @@ function doesEndSeemGarbageCollected( currentSeg.end - currentSeg.bufferedEnd > MAX_TIME_MISSING_FROM_COMPLETE_SEGMENT) { log.info("Stream: The end of the wanted segment has been garbage collected", - currentSeg.start, - currentSeg.bufferedStart); + currentSeg.end, + currentSeg.bufferedEnd); return true; } From 6bfaf7c0589ad47515d7f280a1a2dacf0b764e1f Mon Sep 17 00:00:00 2001 From: Paul Berberian Date: Wed, 15 Nov 2023 11:35:00 +0100 Subject: [PATCH 18/46] Make some parts of the code clearer --- src/core/init/utils/get_initial_time.ts | 7 ++++++- .../manifest/dash/common/indexes/template.ts | 14 +++++++------- .../timeline/timeline_representation_index.ts | 6 ------ .../manifest/dash/common/parse_representations.ts | 2 +- 4 files changed, 14 insertions(+), 15 deletions(-) diff --git a/src/core/init/utils/get_initial_time.ts b/src/core/init/utils/get_initial_time.ts index 2edd438574..781c63f4df 100644 --- a/src/core/init/utils/get_initial_time.ts +++ b/src/core/init/utils/get_initial_time.ts @@ -44,18 +44,23 @@ export interface IInitialTimeOptions { /** * If set, we should begin at this position relative to the content's maximum * seekable position, in seconds. + * + * It should consequently in most cases be a negative value. */ fromLastPosition? : number | null | undefined; /** * If set, we should begin at this position relative to the content's live * edge if it makes sense, in seconds. * + * It should consequently in most cases be a negative value. + * * If the live edge is unknown or if it does not make sense for the current * content, that position is relative to the content's maximum position * instead. */ fromLivePosition? : number | null | undefined; - /** If set, we should begin at this position relative to the whole duration of + /** + * If set, we should begin at this position relative to the whole duration of * the content, in percentage. */ percentage? : number | null | undefined; diff --git a/src/parsers/manifest/dash/common/indexes/template.ts b/src/parsers/manifest/dash/common/indexes/template.ts index f95432cecc..dae6842c79 100644 --- a/src/parsers/manifest/dash/common/indexes/template.ts +++ b/src/parsers/manifest/dash/common/indexes/template.ts @@ -388,8 +388,8 @@ export default class TemplateRepresentationIndex implements IRepresentationIndex const { timescale } = this._index; const segmentTimeRounding = getSegmentTimeRoundingError(timescale); const scaledPeriodStart = this._periodStart * timescale; - const scaledRelativeStart = start * timescale - scaledPeriodStart; - const scaledRelativeEnd = end * timescale - scaledPeriodStart; + const scaledRelativeStart = (start * timescale) - scaledPeriodStart; + const scaledRelativeEnd = (end * timescale) - scaledPeriodStart; const lastSegmentStart = this._getLastSegmentStart(); if (isNullOrUndefined(lastSegmentStart)) { const relativeScaledIndexEnd = this._estimateRelativeScaledEnd(); @@ -567,8 +567,8 @@ export default class TemplateRepresentationIndex implements IRepresentationIndex const liveEdge = this._manifestBoundsCalculator.getEstimatedLiveEdge(); if (liveEdge !== undefined && this._scaledRelativePeriodEnd !== undefined && - this._scaledRelativePeriodEnd < - liveEdge - this._periodStart * this._index.timescale) + this._scaledRelativePeriodEnd < liveEdge - (this._periodStart * + this._index.timescale)) { let numberOfSegments = Math.ceil(this._scaledRelativePeriodEnd / duration); @@ -577,16 +577,16 @@ export default class TemplateRepresentationIndex implements IRepresentationIndex } return (numberOfSegments - 1) * duration; } - const maxPosition = this._manifestBoundsCalculator + const lastPosition = this._manifestBoundsCalculator .getEstimatedMaximumPosition(this._availabilityTimeOffset ?? 0); - if (maxPosition === undefined) { + if (lastPosition === undefined) { return undefined; } // /!\ The scaled last position augments continuously and might not // reflect exactly the real server-side value. As segments are // generated discretely. - const scaledLastPosition = (maxPosition - this._periodStart) * timescale; + const scaledLastPosition = (lastPosition - this._periodStart) * timescale; // Maximum position is before this period. // No segment is yet available here 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 be4ec19b2d..e176e2535b 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 @@ -988,12 +988,6 @@ export function getLastRequestableSegmentInfo( const element = index.timeline[i]; const endOfFirstOccurence = element.start + element.duration; if (fromIndexTime(endOfFirstOccurence, index) <= adjustedMaxSeconds) { - if (element.repeatCount <= 1) { - return { isLastOfTimeline: i === index.timeline.length - 1, - timelineIdx: i, - newRepeatCount: element.repeatCount, - end: endOfFirstOccurence }; - } const endTime = getIndexSegmentEnd(element, index.timeline[i + 1], scaledPeriodEnd); if (fromIndexTime(endTime, index) <= adjustedMaxSeconds) { return { isLastOfTimeline: i === index.timeline.length - 1, diff --git a/src/parsers/manifest/dash/common/parse_representations.ts b/src/parsers/manifest/dash/common/parse_representations.ts index 1e172a5c74..4913b787fa 100644 --- a/src/parsers/manifest/dash/common/parse_representations.ts +++ b/src/parsers/manifest/dash/common/parse_representations.ts @@ -153,7 +153,7 @@ export default function parseRepresentations( representation.attributes.availabilityTimeComplete ?? context.availabilityTimeComplete; - let availabilityTimeOffset; + let availabilityTimeOffset: number | undefined; if ( representation.attributes.availabilityTimeOffset !== undefined || context.availabilityTimeOffset !== undefined From 89fd4155b49af301f1fce632d71a67ceb2a88c17 Mon Sep 17 00:00:00 2001 From: Florent Date: Tue, 5 Dec 2023 10:23:16 +0100 Subject: [PATCH 19/46] doc: corrects a bad documentation about /dist directory --- FILES.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/FILES.md b/FILES.md index b383a6dc2b..4529509f3e 100644 --- a/FILES.md +++ b/FILES.md @@ -20,7 +20,7 @@ At the time of writing, there are two distinct demos: ## `dist/`: Builds -The `demo/` directory stores the player builds of the last version released. +The `dist/` directory stores the player builds of the last version released. Contains the minified (``rx-player.min.js``) and the non-minified files (``rx-player.js``). Both are automatically generated with scripts at every new From f3b775d905edb59ad63bfcdacda2805c8b8e0b30 Mon Sep 17 00:00:00 2001 From: Paul Berberian Date: Tue, 5 Dec 2023 18:26:30 +0100 Subject: [PATCH 20/46] Fix decipherability in debug window --- src/core/api/debug/modules/general_info.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/core/api/debug/modules/general_info.ts b/src/core/api/debug/modules/general_info.ts index d576d6bb70..b4429c42cd 100644 --- a/src/core/api/debug/modules/general_info.ts +++ b/src/core/api/debug/modules/general_info.ts @@ -185,12 +185,12 @@ export default function constructDebugGeneralInfo( const videoBitratesStr = adaptations?.video?.representations.map((r) => { return String(r.bitrate) + (r.isSupported ? "" : " U!") + - (r.decipherable === false ? "" : " E!"); + (r.decipherable !== false ? "" : " E!"); }) ?? []; const audioBitratesStr = adaptations?.video?.representations.map((r) => { return String(r.bitrate) + (r.isSupported ? "" : " U!") + - (r.decipherable === false ? "" : " E!"); + (r.decipherable !== false ? "" : " E!"); }) ?? []; representationsElt.innerHTML = ""; if (videoBitratesStr.length > 0) { From 7e276667b23662ee6801b17b3998b880b7c93b97 Mon Sep 17 00:00:00 2001 From: Paul Berberian Date: Wed, 6 Dec 2023 16:26:47 +0100 Subject: [PATCH 21/46] update CHANGELOG.md in update-version script --- scripts/update-version | 2 ++ 1 file changed, 2 insertions(+) diff --git a/scripts/update-version b/scripts/update-version index 0a8e02b029..c94ae2e9bc 100755 --- a/scripts/update-version +++ b/scripts/update-version @@ -22,7 +22,9 @@ set -e version=$1 +date_iso=$(date "+%Y-%m-%d") +sed -i.bak -e "s/^\#\# Unreleased/\#\# v${version} \(${date_iso}\)/gi" CHANGELOG.md && rm CHANGELOG.md.bak sed -i.bak -e "s/\/\\* PLAYER_VERSION \\*\/\"\(.*\)\";/\/* PLAYER_VERSION *\/\"${version}\";/g" src/core/api/public_api.ts && rm src/core/api/public_api.ts.bak sed -i.bak -e "s/\"version\":\s*\"[0-9]\+\.[0-9]\+\.[0-9]\+[^\"]*\"/\"version\": \"${version}\"/g" package.json && rm package.json.bak sed -i.bak -e "s/sonar\.projectVersion= *.*/sonar.projectVersion=${version}/g" sonar-project.properties && rm sonar-project.properties.bak From 61a78caf5630559858b88fa59e332c40ca654e2a Mon Sep 17 00:00:00 2001 From: Paul Berberian Date: Wed, 6 Dec 2023 16:40:47 +0100 Subject: [PATCH 22/46] Add canal-release.patch to make a canal release more easily --- scripts/canal-release.patch | 320 ++++++++++++++++++++++++++++++++++++ 1 file changed, 320 insertions(+) create mode 100644 scripts/canal-release.patch diff --git a/scripts/canal-release.patch b/scripts/canal-release.patch new file mode 100644 index 0000000000..d60ca7c09e --- /dev/null +++ b/scripts/canal-release.patch @@ -0,0 +1,320 @@ +diff --git a/src/core/init/directfile_content_initializer.ts b/src/core/init/directfile_content_initializer.ts +index c9aa31d7a..b64dfc4b3 100644 +--- a/src/core/init/directfile_content_initializer.ts ++++ b/src/core/init/directfile_content_initializer.ts +@@ -121,6 +121,7 @@ export default class DirectFileContentInitializer extends ContentInitializer { + * events when it cannot, as well as "unstalled" events when it get out of one. + */ + const rebufferingController = new RebufferingController(playbackObserver, ++ null, + null, + speed); + rebufferingController.addEventListener("stalled", (evt) => +diff --git a/src/core/init/media_source_content_initializer.ts b/src/core/init/media_source_content_initializer.ts +index d89a39828..8fd859cd7 100644 +--- a/src/core/init/media_source_content_initializer.ts ++++ b/src/core/init/media_source_content_initializer.ts +@@ -462,9 +462,20 @@ export default class MediaSourceContentInitializer extends ContentInitializer { + + const rebufferingController = this._createRebufferingController(playbackObserver, + manifest, ++ segmentBuffersStore, + speed, + cancelSignal); +- ++ rebufferingController.addEventListener("needsReload", () => { ++ // NOTE couldn't both be always calculated at event destination? ++ // Maybe there are exceptions? ++ const position = initialSeekPerformed.getValue() ? ++ playbackObserver.getCurrentTime() : ++ initialTime; ++ const autoplay = initialPlayPerformed.getValue() ? ++ !playbackObserver.getIsPaused() : ++ autoPlay; ++ onReloadOrder({ position, autoPlay: autoplay }); ++ }, cancelSignal); + const contentTimeBoundariesObserver = this + ._createContentTimeBoundariesObserver(manifest, + mediaSource, +@@ -768,11 +779,13 @@ export default class MediaSourceContentInitializer extends ContentInitializer { + private _createRebufferingController( + playbackObserver : PlaybackObserver, + manifest : Manifest, ++ segmentBuffersStore : SegmentBuffersStore, + speed : IReadOnlySharedReference, + cancelSignal : CancellationSignal + ) : RebufferingController { + const rebufferingController = new RebufferingController(playbackObserver, + manifest, ++ segmentBuffersStore, + speed); + // Bubble-up events + rebufferingController.addEventListener("stalled", +diff --git a/src/core/init/utils/rebuffering_controller.ts b/src/core/init/utils/rebuffering_controller.ts +index f1753b8c2..93471ccaf 100644 +--- a/src/core/init/utils/rebuffering_controller.ts ++++ b/src/core/init/utils/rebuffering_controller.ts +@@ -30,7 +30,7 @@ import { + IPlaybackObservation, + PlaybackObserver, + } from "../../api"; +-import { IBufferType } from "../../segment_buffers"; ++import SegmentBuffersStore, { IBufferType } from "../../segment_buffers"; + import { IStallingSituation } from "../types"; + + +@@ -54,6 +54,7 @@ export default class RebufferingController + /** Emit the current playback conditions */ + private _playbackObserver : PlaybackObserver; + private _manifest : Manifest | null; ++ private _segmentBuffersStore : SegmentBuffersStore | null; + private _speed : IReadOnlySharedReference; + private _isStarted : boolean; + +@@ -65,6 +66,18 @@ export default class RebufferingController + + private _canceller : TaskCanceller; + ++ /** ++ * If set to something else than `null`, this is the DOMHighResTimestamp as ++ * outputed by `performance.now()` when playback begin to seem to not start ++ * despite having decipherable data in the buffer(s). ++ * ++ * If enough time in that condition is spent, special considerations are ++ * taken at which point `_currentFreezeTimestamp` is reset to `null`. ++ * ++ * It is also reset to `null` when and if there is no such issue anymore. ++ */ ++ private _currentFreezeTimestamp : number | null; ++ + /** + * @param {object} playbackObserver - emit the current playback conditions. + * @param {Object} manifest - The Manifest of the currently-played content. +@@ -72,16 +85,19 @@ export default class RebufferingController + */ + constructor( + playbackObserver : PlaybackObserver, +- manifest: Manifest | null, ++ manifest : Manifest | null, ++ segmentBuffersStore : SegmentBuffersStore | null, + speed : IReadOnlySharedReference + ) { + super(); + this._playbackObserver = playbackObserver; + this._manifest = manifest; ++ this._segmentBuffersStore = segmentBuffersStore; + this._speed = speed; + this._discontinuitiesStore = []; + this._isStarted = false; + this._canceller = new TaskCanceller(); ++ this._currentFreezeTimestamp = null; + } + + public start() : void { +@@ -154,6 +170,10 @@ export default class RebufferingController + Math.max(observation.pendingInternalSeek ?? 0, observation.position) : + null; + ++ if (this._checkDecipherabilityFreeze(observation)) { ++ return ; ++ } ++ + if (freezing !== null) { + const now = performance.now(); + +@@ -215,7 +235,7 @@ export default class RebufferingController + this.trigger("stalled", stalledReason); + return ; + } else { +- log.warn("Init: ignored stall for too long, checking discontinuity", ++ log.warn("Init: ignored stall for too long, considering it", + now - ignoredStallTimeStamp); + } + } +@@ -358,6 +378,96 @@ export default class RebufferingController + public destroy() : void { + this._canceller.cancel(); + } ++ ++ /** ++ * Support of contents with DRM on all the platforms out there is a pain in ++ * the *ss considering all the DRM-related bugs there are. ++ * ++ * We found out a frequent issue which is to be unable to play despite having ++ * all the decryption keys to play what is currently buffered. ++ * When this happens, re-creating the buffers from scratch, with a reload, is ++ * usually sufficient to unlock the situation. ++ * ++ * Although we prefer providing more targeted fixes or telling to platform ++ * developpers to fix their implementation, it's not always possible. ++ * We thus resorted to developping an heuristic which detects such situation ++ * and reload in that case. ++ * ++ * @param {Object} observation - The last playback observation produced, it ++ * has to be recent (just triggered for example). ++ * @returns {boolean} - Returns `true` if it seems to be such kind of ++ * decipherability freeze, in which case this method already performed the ++ * right handling steps. ++ */ ++ private _checkDecipherabilityFreeze( ++ observation : IPlaybackObservation ++ ): boolean { ++ const { readyState, ++ rebuffering, ++ freezing } = observation; ++ const bufferGap = observation.bufferGap !== undefined && ++ isFinite(observation.bufferGap) ? observation.bufferGap : ++ 0; ++ if ( ++ this._segmentBuffersStore === null || ++ bufferGap < 6 || ++ (rebuffering === null && freezing === null) || ++ readyState > 1 ++ ) { ++ this._currentFreezeTimestamp = null; ++ return false; ++ } ++ ++ const now = performance.now(); ++ if (this._currentFreezeTimestamp === null) { ++ this._currentFreezeTimestamp = now; ++ } ++ const rebufferingForTooLong = ++ rebuffering !== null && now - rebuffering.timestamp > 4000; ++ const frozenForTooLong = ++ freezing !== null && now - freezing.timestamp > 4000; ++ ++ if ( ++ (rebufferingForTooLong || frozenForTooLong) && ++ this._currentFreezeTimestamp > 4000 ++ ) { ++ const statusAudio = this._segmentBuffersStore.getStatus("audio"); ++ const statusVideo = this._segmentBuffersStore.getStatus("video"); ++ let hasOnlyDecipherableSegments = true; ++ let isClear = true; ++ for (const status of [statusAudio, statusVideo]) { ++ if (status.type === "initialized") { ++ for (const segment of status.value.getInventory()) { ++ const { representation } = segment.infos; ++ if (representation.decipherable === false) { ++ log.warn( ++ "Init: we have undecipherable segments left in the buffer, reloading" ++ ); ++ this._currentFreezeTimestamp = null; ++ this.trigger("needsReload", null); ++ return true; ++ } else if (representation.contentProtections !== undefined) { ++ isClear = false; ++ if (representation.decipherable !== true) { ++ hasOnlyDecipherableSegments = false; ++ } ++ } ++ } ++ } ++ } ++ ++ if (!isClear && hasOnlyDecipherableSegments) { ++ log.warn( ++ "Init: we are frozen despite only having decipherable " + ++ "segments left in the buffer, reloading" ++ ); ++ this._currentFreezeTimestamp = null; ++ this.trigger("needsReload", null); ++ return true; ++ } ++ } ++ return false; ++ } + } + + /** +@@ -581,6 +691,7 @@ class PlaybackRateUpdater { + export interface IRebufferingControllerEvent { + stalled : IStallingSituation; + unstalled : null; ++ needsReload : null; + warning : IPlayerError; + } + +diff --git a/src/parsers/texttracks/ttml/html/apply_extent.ts b/src/parsers/texttracks/ttml/html/apply_extent.ts +index 5772fa8fb..eb3a051e4 100644 +--- a/src/parsers/texttracks/ttml/html/apply_extent.ts ++++ b/src/parsers/texttracks/ttml/html/apply_extent.ts +@@ -54,7 +54,14 @@ export default function applyExtent( + secondExtent[2] === "%" || + secondExtent[2] === "em") + { +- element.style.height = secondExtent[1] + secondExtent[2]; ++ const toNum = Number(secondExtent[1]); ++ if (secondExtent[2] === "%" && !isNaN(toNum) && ++ (toNum < 0 || toNum > 100)) ++ { ++ element.style.width = "80%"; ++ } else { ++ element.style.height = secondExtent[1] + secondExtent[2]; ++ } + } else if (secondExtent[2] === "c") { + addClassName(element, "proportional-style"); + element.setAttribute("data-proportional-height", secondExtent[1]); +diff --git a/src/parsers/texttracks/ttml/html/apply_line_height.ts b/src/parsers/texttracks/ttml/html/apply_line_height.ts +index 1897e9ab4..c7c118ac3 100644 +--- a/src/parsers/texttracks/ttml/html/apply_line_height.ts ++++ b/src/parsers/texttracks/ttml/html/apply_line_height.ts +@@ -19,30 +19,13 @@ import log from "../../../../log"; + import { REGXP_LENGTH } from "../regexps"; + + /** +- * @param {HTMLElement} element +- * @param {string} lineHeight ++ * @param {HTMLElement} _element ++ * @param {string} _lineHeight + */ + export default function applyLineHeight( +- element : HTMLElement, +- lineHeight : string ++ _element : HTMLElement, ++ _lineHeight : string + ) : void { +- const trimmedLineHeight = lineHeight.trim(); +- if (trimmedLineHeight === "auto") { +- return; +- } +- const firstLineHeight = REGXP_LENGTH.exec(trimmedLineHeight[0]); +- if (firstLineHeight === null) { +- return; +- } +- if (firstLineHeight[2] === "px" || +- firstLineHeight[2] === "%" || +- firstLineHeight[2] === "em") +- { +- element.style.lineHeight = firstLineHeight[1] + firstLineHeight[2]; +- } else if (firstLineHeight[2] === "c") { +- addClassName(element, "proportional-style"); +- element.setAttribute("data-proportional-line-height", firstLineHeight[1]); +- } else { +- log.warn("TTML Parser: unhandled lineHeight unit:", firstLineHeight[2]); +- } ++ // Ignore lineHeight in this as it is often set content-side to an unwanted value ++ return; + } +diff --git a/src/parsers/texttracks/ttml/html/apply_origin.ts b/src/parsers/texttracks/ttml/html/apply_origin.ts +index 01a205aad..91d69fa3c 100644 +--- a/src/parsers/texttracks/ttml/html/apply_origin.ts ++++ b/src/parsers/texttracks/ttml/html/apply_origin.ts +@@ -53,7 +53,15 @@ export default function applyOrigin( + secondOrigin[2] === "%" || + secondOrigin[2] === "em") + { +- element.style.top = secondOrigin[1] + secondOrigin[2]; ++ const toNum = Number(secondOrigin[1]); ++ if (secondOrigin[2] === "%" && !isNaN(toNum) && ++ (toNum < 0 || toNum > 100)) ++ { ++ element.style.bottom = "5%"; ++ element.style.left = "10%"; ++ } else { ++ element.style.top = secondOrigin[1] + secondOrigin[2]; ++ } + } else if (secondOrigin[2] === "c") { + addClassName(element, "proportional-style"); + element.setAttribute("data-proportional-top", secondOrigin[1]); From 38c788d12832c865fd22a5cbf84401c5272272d6 Mon Sep 17 00:00:00 2001 From: Paul Berberian Date: Wed, 6 Dec 2023 17:54:41 +0100 Subject: [PATCH 23/46] Fix canal-release.patch --- scripts/canal-release.patch | 52 +++++++++++++++++-------------------- 1 file changed, 24 insertions(+), 28 deletions(-) diff --git a/scripts/canal-release.patch b/scripts/canal-release.patch index d60ca7c09e..e170100e7f 100644 --- a/scripts/canal-release.patch +++ b/scripts/canal-release.patch @@ -1,8 +1,8 @@ diff --git a/src/core/init/directfile_content_initializer.ts b/src/core/init/directfile_content_initializer.ts -index c9aa31d7a..b64dfc4b3 100644 +index 68a24d897..80f05baf7 100644 --- a/src/core/init/directfile_content_initializer.ts +++ b/src/core/init/directfile_content_initializer.ts -@@ -121,6 +121,7 @@ export default class DirectFileContentInitializer extends ContentInitializer { +@@ -122,6 +122,7 @@ export default class DirectFileContentInitializer extends ContentInitializer { * events when it cannot, as well as "unstalled" events when it get out of one. */ const rebufferingController = new RebufferingController(playbackObserver, @@ -11,7 +11,7 @@ index c9aa31d7a..b64dfc4b3 100644 speed); rebufferingController.addEventListener("stalled", (evt) => diff --git a/src/core/init/media_source_content_initializer.ts b/src/core/init/media_source_content_initializer.ts -index d89a39828..8fd859cd7 100644 +index 1cc84a4d5..ce3522ef0 100644 --- a/src/core/init/media_source_content_initializer.ts +++ b/src/core/init/media_source_content_initializer.ts @@ -462,9 +462,20 @@ export default class MediaSourceContentInitializer extends ContentInitializer { @@ -257,46 +257,42 @@ index 5772fa8fb..eb3a051e4 100644 addClassName(element, "proportional-style"); element.setAttribute("data-proportional-height", secondExtent[1]); diff --git a/src/parsers/texttracks/ttml/html/apply_line_height.ts b/src/parsers/texttracks/ttml/html/apply_line_height.ts -index 1897e9ab4..c7c118ac3 100644 +index 4f727229a..253aa1a72 100644 --- a/src/parsers/texttracks/ttml/html/apply_line_height.ts +++ b/src/parsers/texttracks/ttml/html/apply_line_height.ts -@@ -19,30 +19,13 @@ import log from "../../../../log"; +@@ -14,16 +14,15 @@ + * limitations under the License. + */ + +-import { addClassName } from "../../../../compat"; + import log from "../../../../log"; import { REGXP_LENGTH } from "../regexps"; /** - * @param {HTMLElement} element -- * @param {string} lineHeight + * @param {HTMLElement} _element -+ * @param {string} _lineHeight + * @param {string} lineHeight */ export default function applyLineHeight( - element : HTMLElement, -- lineHeight : string + _element : HTMLElement, -+ _lineHeight : string + lineHeight : string ) : void { -- const trimmedLineHeight = lineHeight.trim(); -- if (trimmedLineHeight === "auto") { -- return; -- } -- const firstLineHeight = REGXP_LENGTH.exec(trimmedLineHeight[0]); -- if (firstLineHeight === null) { -- return; -- } -- if (firstLineHeight[2] === "px" || -- firstLineHeight[2] === "%" || -- firstLineHeight[2] === "em") -- { + const trimmedLineHeight = lineHeight.trim(); +@@ -40,10 +39,10 @@ export default function applyLineHeight( + firstLineHeight[2] === "%" || + firstLineHeight[2] === "em") + { - element.style.lineHeight = firstLineHeight[1] + firstLineHeight[2]; -- } else if (firstLineHeight[2] === "c") { ++ // element.style.lineHeight = firstLineHeight[1] + firstLineHeight[2]; + } else if (firstLineHeight[2] === "c") { - addClassName(element, "proportional-style"); - element.setAttribute("data-proportional-line-height", firstLineHeight[1]); -- } else { -- log.warn("TTML Parser: unhandled lineHeight unit:", firstLineHeight[2]); -- } -+ // Ignore lineHeight in this as it is often set content-side to an unwanted value -+ return; - } ++ // addClassName(element, "proportional-style"); ++ // element.setAttribute("data-proportional-line-height", firstLineHeight[1]); + } else { + log.warn("TTML Parser: unhandled lineHeight unit:", firstLineHeight[2]); + } diff --git a/src/parsers/texttracks/ttml/html/apply_origin.ts b/src/parsers/texttracks/ttml/html/apply_origin.ts index 01a205aad..91d69fa3c 100644 --- a/src/parsers/texttracks/ttml/html/apply_origin.ts From 25b3af5b26213774e5c49e77fcdf527ecf89d2af Mon Sep 17 00:00:00 2001 From: Paul Berberian Date: Wed, 6 Dec 2023 18:08:47 +0100 Subject: [PATCH 24/46] Add make-dev-releases script --- scripts/make-dev-releases | 44 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100755 scripts/make-dev-releases diff --git a/scripts/make-dev-releases b/scripts/make-dev-releases new file mode 100755 index 0000000000..5eda192021 --- /dev/null +++ b/scripts/make-dev-releases @@ -0,0 +1,44 @@ +#!/bin/bash + +set -e + +current_branch=$(git branch | sed -n -e 's/^\* \(.*\)/\1/p') +version=$1 +date=$(date "+%Y%m%d") +dev_branch="release/v${version}-dev.${date}00" +canal_branch="release/v${version}-canal.${date}00" + +git checkout -b ${dev_branch} +./scripts/update-version $1-dev.${date}00 +git add --all +git commit -m "update version" +while true; do + read -n1 -p "Do you wish to push and publish the dev build? [y/n] " yn + echo "" + case $yn in + [Yy]* ) break;; + [Nn]* ) exit;; + * ) echo "Please answer y or n.";; + esac +done +git push origin ${dev_branch} +npm publish --tag dev + +git checkout $current_branch + +git checkout -b ${canal_branch} +git apply ./scripts/canal-release.patch +./scripts/update-version $1-canal.${date}00 +git add --all +git commit -m "update version" +git push origin ${canal_branch} +while true; do + read -n1 -p "Do you wish to push and publish the dev build? [y/n] " yn + echo "" + case $yn in + [Yy]* ) break;; + [Nn]* ) exit;; + * ) echo "Please answer y or n.";; + esac +done +npm publish --tag canal From b556b0a3397e0b547a70617d12ba6e8f82fbd857 Mon Sep 17 00:00:00 2001 From: Paul Berberian Date: Wed, 6 Dec 2023 18:58:30 +0100 Subject: [PATCH 25/46] Add npm script, guard, and comment to the make-dev-releases script --- package.json | 4 ++++ scripts/make-dev-releases | 28 +++++++++++++++++++++++++++- scripts/update-version | 5 +++++ 3 files changed, 36 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index c50facab7e..bf3aa225d9 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "lint:tests": "eslint tests/**/*.js --ignore-pattern '/tests/performance/bundle*'", "list": "node scripts/list-npm-scripts.js", "prepublishOnly": "npm run build:modular", + "releases:dev": "./scripts/make-dev-releases", "standalone": "node ./scripts/run_standalone_demo.js", "start": "node ./scripts/start_demo_web_server.js", "start:wasm": "node ./scripts/start_demo_web_server.js --include-wasm", @@ -199,6 +200,9 @@ "doc": "Generate the HTML documentation in doc/generated/pages" }, "Update the RxPlayer's version": { + }, + "Make a release": { + "releases:dev": "Produce dev npm releases (which are tagged pre-releases on npm) with the code in the current branch", "update-version": "Update the version to the string given in argument (example: `npm run update-version 3.8.0`). Will update the codebase and perform every builds." } } diff --git a/scripts/make-dev-releases b/scripts/make-dev-releases index 5eda192021..9c3c8669ba 100755 --- a/scripts/make-dev-releases +++ b/scripts/make-dev-releases @@ -1,7 +1,33 @@ #!/bin/bash +# make-dev-releases +# ================= +# +# This script produces pre-releases on top of the current branch for the +# `dev` and `canal` versions (as per their npm tags). +# +# To use it: +# +# 1. Be sure that you're on the branch corresponding to the pre-release you +# want to publish, at the repository's root directory. +# +# 2. Call this script followed with the corresponding version number it would +# have as an official release in the `MAJOR.MINOR.PATCH` format (e.g. +# `./update-version 4.1.3`). Special suffix corresponding to the date and +# tag will be added automatically by this script. +# +# 3. When the script ask you to confirm, check that the preceding commands did +# not output any issue and if it didn't you can confirm. +# +# 4. That's it! + set -e +if [ $# -eq 0 ]; then + echo "no version in argument" + exit 1 +fi + current_branch=$(git branch | sed -n -e 's/^\* \(.*\)/\1/p') version=$1 date=$(date "+%Y%m%d") @@ -33,7 +59,7 @@ git add --all git commit -m "update version" git push origin ${canal_branch} while true; do - read -n1 -p "Do you wish to push and publish the dev build? [y/n] " yn + read -n1 -p "Do you wish to push and publish the canal build? [y/n] " yn echo "" case $yn in [Yy]* ) break;; diff --git a/scripts/update-version b/scripts/update-version index c94ae2e9bc..288d13048e 100755 --- a/scripts/update-version +++ b/scripts/update-version @@ -21,6 +21,11 @@ set -e +if [ $# -eq 0 ]; then + echo "no version in argument" + exit 1 +fi + version=$1 date_iso=$(date "+%Y-%m-%d") From 79ebbfc78732f10e3f98045da503d78ae45a1e1c Mon Sep 17 00:00:00 2001 From: Paul Berberian Date: Fri, 24 Nov 2023 15:38:58 +0100 Subject: [PATCH 26/46] Do not add failed push operations in the BufferedHistory This commit fixes a kind of rare issue that is tricky to reproduce that may happen after a `QuotaExceededError` is encountered when pushing a chunk to the audio and video `SourceBuffer` due to memory limitations. In that situation, we could be left in the following scenario: 1. We're loading a chunk and pushing it to the `SourceBuffer` 2. The `SourceBuffer` throws a `QuotaExceededError`, generally this means that the device needs more memory to add that chunk 3. The SegmentBuffer code still adds the chunk to its `SegmentInventory` (as it may still have been partially pushed to the SourceBuffer, we're not 100% sure of what happened on the lower-level code) and to that `SegmentInventory`'s `BufferedHistory`. 4. The stream code catches the error, try to remove some buffered data and retry to push the same chunk. 5. Same QuotaExceededError 6. The SegmentBuffer code agains, re do the whole `SegmentInventory` + `BufferedHistory` insertion thing for the same reasons 7. The stream code re-catches the issue, here it takes more drastic measures, mainly reducing the amount of buffer that is built in advance. 8. The stream code then check if it re-needs the chunk. However, it sees through the `BufferedHistory` that it already tried to load it two times, and that in those two times, the browser did not buffer it (there ignoring that there was a `QuotaExceededError` involved). 9. The stream code thus assumes a browser/device issue with that segment, and loads the next one instead. 10. When it's time to decode the skipped chunk, the RxPlayer automatically seek over it, which is good, but we would have prefered to actually log it. This commit fixes the issue by clearly identifying in the `SegmentInventory` chunks whose push operation had an issue. Those are: 1. never added to the `BufferedHistory` 2. always re-loaded if they become needed again We could also have not added them to the `SegmentInventory` altogether but I still think it's better to do it, as in the very rare chance that chunk still had been at least partially pushed, our logic would be more accurate. --- .../audio_video/audio_video_segment_buffer.ts | 12 +++- .../image/image_segment_buffer.ts | 2 +- .../text/html/html_text_segment_buffer.ts | 2 +- .../text/native/native_text_segment_buffer.ts | 2 +- src/core/segment_buffers/index.ts | 2 + src/core/segment_buffers/inventory/index.ts | 2 + .../inventory/segment_inventory.ts | 66 ++++++++++++++----- .../representation/utils/get_buffer_status.ts | 3 +- 8 files changed, 68 insertions(+), 23 deletions(-) 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 70f4de37fc..4ce658a7db 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 @@ -376,7 +376,15 @@ export default class AudioVideoSegmentBuffer extends SegmentBuffer { err : new Error("An unknown error occured when doing operations " + "on the SourceBuffer"); - this._pendingTask.reject(error); + const task = this._pendingTask; + if (task.type === SegmentBufferOperation.Push && + task.data.length === 0 && + task.inventoryData !== null) + { + this._segmentInventory.insertChunk(task.inventoryData, false); + } + this._pendingTask = null; + task.reject(error); } } @@ -429,7 +437,7 @@ export default class AudioVideoSegmentBuffer extends SegmentBuffer { switch (task.type) { case SegmentBufferOperation.Push: if (task.inventoryData !== null) { - this._segmentInventory.insertChunk(task.inventoryData); + this._segmentInventory.insertChunk(task.inventoryData, true); } break; case SegmentBufferOperation.EndOfSegment: 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 d89d834b07..176d35fb96 100644 --- a/src/core/segment_buffers/implementations/image/image_segment_buffer.ts +++ b/src/core/segment_buffers/implementations/image/image_segment_buffer.ts @@ -83,7 +83,7 @@ export default class ImageSegmentBuffer extends SegmentBuffer { try { this._buffered.insert(startTime, endTime); if (infos.inventoryInfos !== null) { - this._segmentInventory.insertChunk(infos.inventoryInfos); + this._segmentInventory.insertChunk(infos.inventoryInfos, true); } } catch (err) { return Promise.reject(err); 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 069a021f10..cb1b3985c6 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 @@ -302,7 +302,7 @@ export default class HTMLTextSegmentBuffer extends SegmentBuffer { } if (infos.inventoryInfos !== null) { - this._segmentInventory.insertChunk(infos.inventoryInfos); + this._segmentInventory.insertChunk(infos.inventoryInfos, true); } this._buffer.insert(cues, start, end); this._buffered.insert(start, end); diff --git a/src/core/segment_buffers/implementations/text/native/native_text_segment_buffer.ts b/src/core/segment_buffers/implementations/text/native/native_text_segment_buffer.ts index a67d3caac5..ba8d26a59d 100644 --- a/src/core/segment_buffers/implementations/text/native/native_text_segment_buffer.ts +++ b/src/core/segment_buffers/implementations/text/native/native_text_segment_buffer.ts @@ -187,7 +187,7 @@ export default class NativeTextSegmentBuffer extends SegmentBuffer { } this._buffered.insert(start, end); if (infos.inventoryInfos !== null) { - this._segmentInventory.insertChunk(infos.inventoryInfos); + this._segmentInventory.insertChunk(infos.inventoryInfos, true); } } catch (err) { return Promise.reject(err); diff --git a/src/core/segment_buffers/index.ts b/src/core/segment_buffers/index.ts index 462a852b8c..5458352498 100644 --- a/src/core/segment_buffers/index.ts +++ b/src/core/segment_buffers/index.ts @@ -28,6 +28,7 @@ import { SegmentBufferOperation, } from "./implementations"; import { + ChunkStatus, IBufferedChunk, IChunkContext, IInsertedChunkInfos, @@ -40,6 +41,7 @@ import SegmentBuffersStore, { export default SegmentBuffersStore; export { BufferGarbageCollector, + ChunkStatus, ISegmentBufferOptions, ITextTrackSegmentBufferOptions, diff --git a/src/core/segment_buffers/inventory/index.ts b/src/core/segment_buffers/inventory/index.ts index ebfe99a227..93bf5ef395 100644 --- a/src/core/segment_buffers/inventory/index.ts +++ b/src/core/segment_buffers/inventory/index.ts @@ -15,12 +15,14 @@ */ import SegmentInventory, { + ChunkStatus, IBufferedChunk, IInsertedChunkInfos, } from "./segment_inventory"; export default SegmentInventory; export { + ChunkStatus, IBufferedChunk, IInsertedChunkInfos, }; diff --git a/src/core/segment_buffers/inventory/segment_inventory.ts b/src/core/segment_buffers/inventory/segment_inventory.ts index 5dfda903e4..0447c5f152 100644 --- a/src/core/segment_buffers/inventory/segment_inventory.ts +++ b/src/core/segment_buffers/inventory/segment_inventory.ts @@ -28,6 +28,27 @@ import BufferedHistory, { } from "./buffered_history"; import { IChunkContext } from "./types"; +/** Categorization of a given chunk in the `SegmentInventory`. */ +export const enum ChunkStatus { + /** + * This chunk is only a part of a partially-pushed segment for now, meaning + * that it is only a sub-part of a requested segment that was not yet + * fully-loaded and pushed. + * + * Once and if the corresponding segment is fully-pushed, its `ChunkStatus` + * switches to `Complete`. + */ + PartiallyPushed = 0, + /** This chunk corresponds to a fully-loaded segment. */ + Complete = 1, + /** + * This chunk's push operation failed, in this scenario there is no certitude + * about the presence of that chunk in the buffer: it may not be present, + * partially-present, or fully-present depending on why that push operation + * failed, which is generally only known by the lower-level code. + */ + Failed = 2, +} /** Information stored on a single chunk by the SegmentInventory. */ export interface IBufferedChunk { @@ -82,14 +103,10 @@ export interface IBufferedChunk { /** Information on what that chunk actually contains. */ infos : IChunkContext; /** - * If `true`, this chunk is only a partial chunk of a whole segment. - * - * Inversely, if `false`, this chunk is a whole segment whose inner chunks - * have all been fully pushed. - * In that condition, the `start` and `end` properties refer to that fully - * pushed segment. + * Status of this chunk. + * @see ChunkStatus */ - partiallyPushed : boolean; + status : ChunkStatus; /** * If `true`, the segment as a whole is divided into multiple parts in the * buffer, with other segment(s) between them. @@ -244,7 +261,11 @@ export default class SegmentInventory { log.debug(`SI: ${numberOfSegmentToDelete} segments GCed.`, bufferType); const removed = inventory.splice(indexBefore, numberOfSegmentToDelete); for (const seg of removed) { - if (seg.bufferedStart === undefined && seg.bufferedEnd === undefined) { + if ( + seg.bufferedStart === undefined && + seg.bufferedEnd === undefined && + seg.status !== ChunkStatus.Failed + ) { this._bufferedHistory.addBufferedSegment(seg.infos, null); } } @@ -318,7 +339,11 @@ export default class SegmentInventory { bufferType, inventoryIndex, inventory.length); const removed = inventory.splice(inventoryIndex, inventory.length - inventoryIndex); for (const seg of removed) { - if (seg.bufferedStart === undefined && seg.bufferedEnd === undefined) { + if ( + seg.bufferedStart === undefined && + seg.bufferedEnd === undefined && + seg.status !== ChunkStatus.Failed + ) { this._bufferedHistory.addBufferedSegment(seg.infos, null); } } @@ -343,7 +368,8 @@ export default class SegmentInventory { segment, chunkSize, start, - end } : IInsertedChunkInfos + end } : IInsertedChunkInfos, + succeed: boolean ) : void { if (segment.isInit) { return; @@ -357,7 +383,8 @@ export default class SegmentInventory { } const inventory = this._inventory; - const newSegment = { partiallyPushed: true, + const newSegment = { status: succeed ? ChunkStatus.PartiallyPushed : + ChunkStatus.Failed, chunkSize, splitted: false, start, @@ -565,7 +592,7 @@ export default class SegmentInventory { // ===> : |--|====|-| log.warn("SI: Segment pushed is contained in a previous one", bufferType, start, end, segmentI.start, segmentI.end); - const nextSegment = { partiallyPushed: segmentI.partiallyPushed, + const nextSegment = { status: segmentI.status, /** * Note: this sadly means we're doing as if * that chunk is present two times. @@ -743,7 +770,9 @@ export default class SegmentInventory { this._inventory.splice(firstI + 1, length); i -= length; } - this._inventory[firstI].partiallyPushed = false; + if (this._inventory[firstI].status === ChunkStatus.PartiallyPushed) { + this._inventory[firstI].status = ChunkStatus.Complete; + } this._inventory[firstI].chunkSize = segmentSize; this._inventory[firstI].end = lastEnd; this._inventory[firstI].bufferedEnd = lastBufferedEnd; @@ -760,8 +789,11 @@ export default class SegmentInventory { this.synchronizeBuffered(newBuffered); for (const seg of resSegments) { if (seg.bufferedStart !== undefined && seg.bufferedEnd !== undefined) { - this._bufferedHistory.addBufferedSegment(seg.infos, { start: seg.bufferedStart, - end: seg.bufferedEnd }); + if (seg.status !== ChunkStatus.Failed) { + this._bufferedHistory.addBufferedSegment(seg.infos, + { start: seg.bufferedStart, + end: seg.bufferedEnd }); + } } else { log.debug("SI: buffered range not known after sync. Skipping history.", seg.start, @@ -810,7 +842,7 @@ function bufferedStartLooksCoherent( thisSegment : IBufferedChunk ) : boolean { if (thisSegment.bufferedStart === undefined || - thisSegment.partiallyPushed) + thisSegment.status !== ChunkStatus.Complete) { return false; } @@ -837,7 +869,7 @@ function bufferedEndLooksCoherent( thisSegment : IBufferedChunk ) : boolean { if (thisSegment.bufferedEnd === undefined || - thisSegment.partiallyPushed) + thisSegment.status !== ChunkStatus.Complete) { return false; } diff --git a/src/core/stream/representation/utils/get_buffer_status.ts b/src/core/stream/representation/utils/get_buffer_status.ts index 87ee3a6e63..d18e591040 100644 --- a/src/core/stream/representation/utils/get_buffer_status.ts +++ b/src/core/stream/representation/utils/get_buffer_status.ts @@ -23,6 +23,7 @@ import Manifest, { import isNullOrUndefined from "../../../../utils/is_null_or_undefined"; import { IReadOnlyPlaybackObserver } from "../../../api"; import SegmentBuffersStore, { + ChunkStatus, IBufferedChunk, IEndOfSegmentOperation, SegmentBuffer, @@ -299,7 +300,7 @@ function getPlayableBufferedSegments( const eltInventory = segmentInventory[i]; const { representation } = eltInventory.infos; - if (!eltInventory.partiallyPushed && + if (eltInventory.status === ChunkStatus.Complete && representation.decipherable !== false && representation.isSupported) { From e20fe9e17aab2aefbc9c22fc7806f24953b1edee Mon Sep 17 00:00:00 2001 From: Paul Berberian Date: Tue, 12 Dec 2023 16:23:26 +0100 Subject: [PATCH 27/46] Update README.md: Remove target support as it doesn't make much sense and remove gitter --- README.md | 23 ----------------------- 1 file changed, 23 deletions(-) diff --git a/README.md b/README.md index 3fd88bd9ff..21fbc8de87 100644 --- a/README.md +++ b/README.md @@ -192,8 +192,6 @@ We will try our best to answer them as quickly as possible. Details on how to contribute is written in the [CONTRIBUTING.md file](./CONTRIBUTING.md) at the root of this repository. -If you need more information, you can contact us via our [gitter -room](https://gitter.im/canalplus/rx-player). ### Dependencies ############################################################### @@ -296,24 +294,3 @@ them. Amongst those: risks always low. \* In "directfile" mode, on compatible browsers - - -## Target support ############################################################## - -Here is a basic list of supported platforms: - -| | Chrome | IE [1] | Edge | Firefox | Safari | Opera | -|-------------|:-------:|:-------:|:------:|:---------:|:--------:|:-------:| -| Windows | >= 30 | >= 11 | >= 12 | >= 42 | >= 8 | >= 25 | -| OSX | >= 30 | - | - | >= 42 | >= 8 | >= 25 | -| Linux | >= 37 | - | - | >= 42 | - | >= 25 | -| Android [2] | >= 30 | - | - | >= 42 | - | >= 15 | -| iOS | No | - | - | No | No | No | - -[1] Only on Windows >= 8. - -[2] Android version >= 4.2 - -And more. A good way to know if the browser should be supported by our player is -to go on the page https://www.youtube.com/html5 and check for "Media Source -Extensions" support. From 196baad50995762eebfd4b8b98ef2162ee32292e Mon Sep 17 00:00:00 2001 From: Paul Berberian Date: Tue, 12 Dec 2023 19:17:45 +0100 Subject: [PATCH 28/46] Update README.md: Remove forgotten mention of gitter --- README.md | 8 -------- 1 file changed, 8 deletions(-) diff --git a/README.md b/README.md index 21fbc8de87..519c4862fd 100644 --- a/README.md +++ b/README.md @@ -179,14 +179,6 @@ Demo pages for our previous versions are also available -## Your questions ############################################################## - -You can ask directly your questions about the project on [our -gitter](https://gitter.im/canalplus/rx-player). -We will try our best to answer them as quickly as possible. - - - ## Contribute ################################################################## Details on how to contribute is written in the [CONTRIBUTING.md From 8d0ff77fdc78069e138a48ed9e549a81e2f7a68e Mon Sep 17 00:00:00 2001 From: Paul Berberian Date: Tue, 12 Dec 2023 19:20:01 +0100 Subject: [PATCH 29/46] Update README.md: it's more than 6 years now! --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 519c4862fd..b347702184 100644 --- a/README.md +++ b/README.md @@ -239,7 +239,7 @@ The R&D department of Canal+ Group thus started to work on a new featureful media-player: the RxPlayer. To both help and profit from the community, it also decided to share it to everyone under a permissive open-source licence. -Now, more than 6 years later, the RxPlayer continues to evolve at the same fast +Now, more than years later, the RxPlayer continues to evolve at the same fast pace to include a lot of features and improvements you may not find in other media players. You can look at our From d2ccc0223769fed236bfc318dbd2efd4e9a3b4cb Mon Sep 17 00:00:00 2001 From: Paul Berberian Date: Tue, 12 Dec 2023 19:20:36 +0100 Subject: [PATCH 30/46] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index b347702184..5fdce79c71 100644 --- a/README.md +++ b/README.md @@ -239,7 +239,7 @@ The R&D department of Canal+ Group thus started to work on a new featureful media-player: the RxPlayer. To both help and profit from the community, it also decided to share it to everyone under a permissive open-source licence. -Now, more than years later, the RxPlayer continues to evolve at the same fast +Now, more than 8 years later, the RxPlayer continues to evolve at the same fast pace to include a lot of features and improvements you may not find in other media players. You can look at our From 71814a7894c9db2de1a9f19d40644d05eb162221 Mon Sep 17 00:00:00 2001 From: Paul Berberian Date: Tue, 12 Dec 2023 19:21:25 +0100 Subject: [PATCH 31/46] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 5fdce79c71..527da2e76a 100644 --- a/README.md +++ b/README.md @@ -228,7 +228,7 @@ Canal+ Group is a media company with many advanced needs when it comes to media playback: it provides both live and VoD stream with multiple encryption requirements, supports a very large panel of devices and has many other specificities (like adult content restrictions, ad-insertion, Peer-To-Peer -integration...). +integration, low-latency live streaming...). When the time came to switch from a plugin-based web player approach to an HTML5 one back in 2015, no media player had the key features we wanted, and including From abf56b83e9a625c900c153c537cf1bda39f4fd0d Mon Sep 17 00:00:00 2001 From: Paul Berberian Date: Fri, 15 Dec 2023 14:29:19 +0100 Subject: [PATCH 32/46] Add MINIMUM_MAX_BUFFER_AHEAD config --- .../orchestrator/stream_orchestrator.ts | 23 ++++++------------- src/default_config.ts | 17 ++++++++++++++ 2 files changed, 24 insertions(+), 16 deletions(-) diff --git a/src/core/stream/orchestrator/stream_orchestrator.ts b/src/core/stream/orchestrator/stream_orchestrator.ts index d4d1c36a9c..eb42047f8d 100644 --- a/src/core/stream/orchestrator/stream_orchestrator.ts +++ b/src/core/stream/orchestrator/stream_orchestrator.ts @@ -108,7 +108,8 @@ export default function StreamOrchestrator( wantedBufferAhead, maxVideoBufferSize } = options; - const { MAXIMUM_MAX_BUFFER_AHEAD, + const { MINIMUM_MAX_BUFFER_AHEAD, + MAXIMUM_MAX_BUFFER_AHEAD, MAXIMUM_MAX_BUFFER_BEHIND } = config.getCurrent(); // Keep track of a unique BufferGarbageCollector created per @@ -116,12 +117,8 @@ export default function StreamOrchestrator( const garbageCollectors = new WeakMapMemory((segmentBuffer : SegmentBuffer) => { const { bufferType } = segmentBuffer; - const defaultMaxBehind = MAXIMUM_MAX_BUFFER_BEHIND[bufferType] !== undefined ? - MAXIMUM_MAX_BUFFER_BEHIND[bufferType] as number : - Infinity; - const defaultMaxAhead = MAXIMUM_MAX_BUFFER_AHEAD[bufferType] !== undefined ? - MAXIMUM_MAX_BUFFER_AHEAD[bufferType] as number : - Infinity; + const defaultMaxBehind = MAXIMUM_MAX_BUFFER_BEHIND[bufferType] ?? Infinity; + const maxAheadHigherBound = MAXIMUM_MAX_BUFFER_AHEAD[bufferType] ?? Infinity; return (gcCancelSignal : CancellationSignal) => { BufferGarbageCollector( { segmentBuffer, @@ -131,15 +128,9 @@ export default function StreamOrchestrator( Math.min(val, defaultMaxBehind), gcCancelSignal), maxBufferAhead: createMappedReference(maxBufferAhead, (val) => { - const actualMaxBuff = bufferType === "text" ? - // Text segments are both much lighter on resources and might - // actually be much larger than other types of segments in terms - // of duration. Let's make an exception here by authorizing a - // larger text buffer ahead, to avoid unnecesarily reloading the - // same text track. - Math.max(val, 2 * 60) : - val; - return Math.min(actualMaxBuff, defaultMaxAhead); + const lowerBound = Math.max(val, + MINIMUM_MAX_BUFFER_AHEAD[bufferType] ?? 0); + return Math.min(lowerBound, maxAheadHigherBound); }, gcCancelSignal) }, gcCancelSignal ); diff --git a/src/default_config.ts b/src/default_config.ts index 2693d4b2f4..448e415159 100644 --- a/src/default_config.ts +++ b/src/default_config.ts @@ -201,6 +201,23 @@ const DEFAULT_CONFIG = { } as Partial>, /* eslint-enable @typescript-eslint/consistent-type-assertions */ + /* eslint-disable @typescript-eslint/consistent-type-assertions */ + /** + * Minimum possible buffer ahead for each type of buffer, to avoid Garbage + * Collecting too much data when it would have adverse effects. + * Equal to `0` if not defined here. + * @type {Object} + */ + MINIMUM_MAX_BUFFER_AHEAD: { + // Text segments are both much lighter on resources and might + // actually be much larger than other types of segments in terms + // of duration. Let's make an exception here by authorizing a + // larger text buffer ahead, to avoid unnecesarily reloading the + // same text track. + text: 2 * 60, + } as Partial>, + /* eslint-enable @typescript-eslint/consistent-type-assertions */ + /* eslint-disable @typescript-eslint/consistent-type-assertions */ /** * Maximum possible buffer behind for each type of buffer, to avoid too much From 7fecd5f7b3f88899b3550952e396e8c3a94fe769 Mon Sep 17 00:00:00 2001 From: Florent Date: Tue, 19 Dec 2023 16:17:57 +0100 Subject: [PATCH 33/46] chore: replace demo manifest that is no longer available --- demo/full/scripts/contents.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/demo/full/scripts/contents.ts b/demo/full/scripts/contents.ts index 42ec62dff7..27f01bd121 100644 --- a/demo/full/scripts/contents.ts +++ b/demo/full/scripts/contents.ts @@ -114,7 +114,7 @@ const DEFAULT_CONTENTS: IDefaultContent[] = [ }, { "name": "Multi Video Tracks", - "url": "https://utils.ssl.cdn.cra.cz/dash/1/manifest.mpd", + "url": "https://storage.googleapis.com/shaka-demo-assets/angel-one/dash.mpd", "transport": "dash", "live": false, }, From 83c22f817da2015ceb54ae9a312ac2cd6f68acc2 Mon Sep 17 00:00:00 2001 From: Paul Berberian Date: Wed, 20 Dec 2023 16:27:21 +0100 Subject: [PATCH 34/46] Fix issue arising when a track at the exact last possible position of a Period with no consecutive Period We found out that an issue prevented from switching the track when the current position was exactly at the position indicated by a Period's `end` property if there was no immediately consecutive Period. For example if a content had, as a last Period, one starting at position `10` (seconds) and ending at `30`, and if the player was currently paused at position `30` a `setAudioTrack` or any track switching call wouldn't have any effect. This is because the logic handling which Period should currently be handled decides that a Period finishes as soon as we reached the position indicated by its `end` property. Because under that logic we're not playing the last Period anymore when we reached it, API updating tracks of the current Period do not have any effect. It actually makes perfect sense for the frequent usecase of having consecutive Periods, where the `end` of one is equal to the `start` of the following one (in which case the following one has priority, so finishing the previous Period is wanted here), but it begins to show weird behaviors like the one described here when a Period's `end` is not shared with a consecutive Period, in which case we could (and probably should) consider that `end` position as part of that Period instead as there's no such ambiguity. So this commit actually explicitely handle that case, which fixes the issue. --- .../orchestrator/stream_orchestrator.ts | 14 ++++++++++---- .../representation/utils/get_buffer_status.ts | 3 ++- src/manifest/manifest.ts | 12 ++++++++---- src/manifest/period.ts | 19 ++++++++++++++++--- 4 files changed, 36 insertions(+), 12 deletions(-) diff --git a/src/core/stream/orchestrator/stream_orchestrator.ts b/src/core/stream/orchestrator/stream_orchestrator.ts index eb42047f8d..3d4fe90720 100644 --- a/src/core/stream/orchestrator/stream_orchestrator.ts +++ b/src/core/stream/orchestrator/stream_orchestrator.ts @@ -192,6 +192,7 @@ export default function StreamOrchestrator( manifest.getNextPeriod(time); if (nextPeriod === undefined) { log.warn("Stream: The wanted position is not found in the Manifest."); + enableOutOfBoundsCheck = true; return; } launchConsecutiveStreamsForPeriod(nextPeriod); @@ -269,7 +270,7 @@ export default function StreamOrchestrator( /** * React to a Manifest's decipherability updates. - * @param {Array.} + * @param {Array.} updates * @returns {Promise} */ async function onDecipherabilityUpdates( @@ -436,9 +437,14 @@ export default function StreamOrchestrator( // 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) - { + const wantedPosition = position.pending ?? position.last; + if (basePeriod.end !== undefined && wantedPosition >= basePeriod.end) { + const nextPeriod = manifest.getPeriodAfter(basePeriod); + + // Handle special wantedPosition === basePeriod.end cases + if (basePeriod.containsTime(wantedPosition, nextPeriod)) { + return; + } log.info("Stream: Destroying PeriodStream as the current playhead moved above it", bufferType, basePeriod.start, diff --git a/src/core/stream/representation/utils/get_buffer_status.ts b/src/core/stream/representation/utils/get_buffer_status.ts index d18e591040..41b080ad8b 100644 --- a/src/core/stream/representation/utils/get_buffer_status.ts +++ b/src/core/stream/representation/utils/get_buffer_status.ts @@ -273,7 +273,8 @@ function isPeriodTheCurrentAndLastOne( period : Period, time : number ) : boolean { - return period.containsTime(time) && + const nextPeriod = manifest.getPeriodAfter(period); + return period.containsTime(time, nextPeriod) && manifest.isLastPeriodKnown && period.id === manifest.periods[manifest.periods.length - 1]?.id; } diff --git a/src/manifest/manifest.ts b/src/manifest/manifest.ts index 11cd1344e7..c18e078543 100644 --- a/src/manifest/manifest.ts +++ b/src/manifest/manifest.ts @@ -410,10 +410,14 @@ export default class Manifest extends EventEmitter { * @returns {Object|undefined} */ public getPeriodForTime(time : number) : Period | undefined { - return arrayFind(this.periods, (period) => { - return time >= period.start && - (period.end === undefined || period.end > time); - }); + let nextPeriod = null; + for (let i = this.periods.length - 1; i >= 0; i--) { + const period = this.periods[i]; + if (period.containsTime(time, nextPeriod)) { + return period; + } + nextPeriod = period; + } } /** diff --git a/src/manifest/period.ts b/src/manifest/period.ts index ff11856760..4bf791aa23 100644 --- a/src/manifest/period.ts +++ b/src/manifest/period.ts @@ -189,10 +189,23 @@ export default class Period { /** * Returns true if the give time is in the time boundaries of this `Period`. * @param {number} time + * @param {object|null} nextPeriod - Period coming chronologically just + * after in the same Manifest. `null` if this instance is the last `Period`. * @returns {boolean} */ - containsTime(time : number) : boolean { - return time >= this.start && (this.end === undefined || - time < this.end); + containsTime(time : number, nextPeriod : Period | null) : boolean { + if (time >= this.start && (this.end === undefined || time < this.end)) { + return true; + } else if (time === this.end && (nextPeriod === null || + nextPeriod.start > this.end)) + { + // The last possible timed position of a Period is ambiguous as it is + // frequently in common with the start of the next one: is it part of + // the current or of the next Period? + // Here we only consider it part of the current Period if it is the + // only one with that position. + return true; + } + return false; } } From fb5d3a690b1dbc8c2181052c6a93aecec06f6ee4 Mon Sep 17 00:00:00 2001 From: Florent Date: Tue, 19 Dec 2023 14:33:53 +0100 Subject: [PATCH 35/46] fix: seeking to the last frame restart the video seeking to the last frame of the video result in restarting the video from the beginning. This is because VideoElement.play() method restart the play from beginning if video is ended. According to the spec: > If the playback has ended and the direction of playback is forwards, > seek to the earliest possible position of the media resource. In this case checking if the video is ended and not call play() fixes the issue. --- src/core/init/utils/initial_seek_and_play.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/core/init/utils/initial_seek_and_play.ts b/src/core/init/utils/initial_seek_and_play.ts index d13aadd707..a223cdb13f 100644 --- a/src/core/init/utils/initial_seek_and_play.ts +++ b/src/core/init/utils/initial_seek_and_play.ts @@ -138,6 +138,16 @@ export default function performInitialSeekAndPlay( initialPlayPerformed.finish(); deregisterCancellation(); return resolveAutoPlay({ type: "skipped" as const }); + } else if (mediaElement.ended) { + // the video has ended state to true, executing VideoElement.play() will + // restart the video from the start, which is not wanted in most cases. + // returning "skipped" prevents the call to play() and fix the issue + log.warn("Init: autoplay is enabled but the video is ended. " + + "Skipping autoplay to prevent video to start again"); + initialPlayPerformed.setValue(true); + initialPlayPerformed.finish(); + deregisterCancellation(); + return resolveAutoPlay({ type: "skipped" as const }); } let playResult : Promise; From 043d69f3ed1daec5f7e636cfbcdacedb39133552 Mon Sep 17 00:00:00 2001 From: Paul Berberian Date: Thu, 21 Dec 2023 21:12:51 +0100 Subject: [PATCH 36/46] Also consider the content as LOADED when it is in fact ended --- src/core/init/utils/get_loaded_reference.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/core/init/utils/get_loaded_reference.ts b/src/core/init/utils/get_loaded_reference.ts index 1fd1e1d90b..2e6091d214 100644 --- a/src/core/init/utils/get_loaded_reference.ts +++ b/src/core/init/utils/get_loaded_reference.ts @@ -67,11 +67,13 @@ export default function getLoadedReference( const minReadyState = shouldWaitForHaveEnoughData() ? 4 : 3; - if (observation.readyState >= minReadyState && observation.currentRange !== null) { - if (!shouldValidateMetadata() || mediaElement.duration > 0) { - isLoaded.setValue(true); - listenCanceller.cancel(); - return; + if (observation.readyState >= minReadyState) { + if (observation.currentRange !== null || observation.ended) { + if (!shouldValidateMetadata() || mediaElement.duration > 0) { + isLoaded.setValue(true); + listenCanceller.cancel(); + return; + } } } }, { includeLastObservation: true, clearSignal: listenCanceller.signal }); From 1acbb357d08655f12514559a9a7d1846783a780c Mon Sep 17 00:00:00 2001 From: Paul Berberian Date: Fri, 22 Dec 2023 10:25:52 +0100 Subject: [PATCH 37/46] Add ugly `skipLog` parameter to the synchronizeInventory method The `synchronizeInventory` method is an internal method of the RxPlayer allowing to synchronize our internal representation of media buffers (representing which segments are buffered where) with the anounced buffered TimeRanges of the media buffer as indicated by the web browser, which should be more up-to-date (e.g. take into consideration garbage collection). As this internal representation of the buffer is very valuable when debugging, the `synchronizeInventory` produce logs each time it is called. Yet that method is very often called when the debug element (under the `DEBUG_ELEMENT` feature) to be sure it is mostly up-to-date with the browser's buffer as it is an element only shown in debugging contexts. Calling that method in this context of visual debugging here has consequently the side-effect of producing a large ammount of debug logs, which isn't necessary. Due to this, I decided to add an optional `skipLog` boolean parameter to the `synchronizeInventory` method. It is ugly, as it leaks debugging-related implementation details into the method signature, but it appears to me to be useful in that specific situation. --- src/core/api/public_api.ts | 2 +- src/core/segment_buffers/implementations/types.ts | 9 +++++++-- src/core/segment_buffers/inventory/segment_inventory.ts | 9 +++++++-- 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/src/core/api/public_api.ts b/src/core/api/public_api.ts index 765c752d00..77809ac1d3 100644 --- a/src/core/api/public_api.ts +++ b/src/core/api/public_api.ts @@ -2430,7 +2430,7 @@ class Player extends EventEmitter { const segmentBufferStatus = this._priv_contentInfos .segmentBuffersStore.getStatus(bufferType); if (segmentBufferStatus.type === "initialized") { - segmentBufferStatus.value.synchronizeInventory(); + segmentBufferStatus.value.synchronizeInventory(true); return segmentBufferStatus.value.getInventory(); } return null; diff --git a/src/core/segment_buffers/implementations/types.ts b/src/core/segment_buffers/implementations/types.ts index 39cc1abaa4..ebb612e58e 100644 --- a/src/core/segment_buffers/implementations/types.ts +++ b/src/core/segment_buffers/implementations/types.ts @@ -169,10 +169,15 @@ export abstract class SegmentBuffer { * This methods allow to manually trigger a synchronization. It should be * called before retrieving Segment information from it (e.g. with * `getInventory`). + * @param {boolean} [skipLog] - This method may trigger a voluminous debug + * log once synchronization is finished if debug logs are enabled. + * As this method might be called very often in some specific debugging + * situations, setting this value to `true` allows to prevent the call from + * triggering a log. */ - public synchronizeInventory() : void { + public synchronizeInventory(skipLog? : boolean) : void { // The default implementation just use the SegmentInventory - this._segmentInventory.synchronizeBuffered(this.getBufferedRanges()); + this._segmentInventory.synchronizeBuffered(this.getBufferedRanges(), skipLog); } /** diff --git a/src/core/segment_buffers/inventory/segment_inventory.ts b/src/core/segment_buffers/inventory/segment_inventory.ts index 0447c5f152..72e9c48788 100644 --- a/src/core/segment_buffers/inventory/segment_inventory.ts +++ b/src/core/segment_buffers/inventory/segment_inventory.ts @@ -204,8 +204,13 @@ export default class SegmentInventory { * at a time, so each `synchronizeBuffered` call should be given a TimeRanges * coming from the same buffer. * @param {TimeRanges} buffered + * @param {boolean|undefined} [skipLog=false] - This method normally may + * trigger a voluminous debug log if debug logs are enabled. + * As this method might be called very often in some specific debugging + * situations, setting this value to `true` allows to prevent the call from + * triggering a log. */ - public synchronizeBuffered(buffered : TimeRanges) : void { + public synchronizeBuffered(buffered : TimeRanges, skipLog : boolean = false) : void { const inventory = this._inventory; let inventoryIndex = 0; // Current index considered. let thisSegment = inventory[0]; // Current segmentInfos considered @@ -348,7 +353,7 @@ export default class SegmentInventory { } } } - if (bufferType !== undefined && log.hasLevel("DEBUG")) { + if (!skipLog && bufferType !== undefined && log.hasLevel("DEBUG")) { log.debug(`SI: current ${bufferType} inventory timeline:\n` + prettyPrintInventory(this._inventory)); } From 050cbb57d6e58862eff9ddde7f54a5ab172836be Mon Sep 17 00:00:00 2001 From: Florent Date: Thu, 28 Dec 2023 14:46:46 +0100 Subject: [PATCH 38/46] chore: add vs code files to gitignore --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 651702e49b..00185f3ecf 100644 --- a/.gitignore +++ b/.gitignore @@ -36,4 +36,6 @@ /src/parsers/manifest/dash/wasm-parser/target +# IDE files /.idea +/.vscode From b50063ecd115f5f1ad850b2bbd23545565bdd75b Mon Sep 17 00:00:00 2001 From: Florent Date: Tue, 9 Jan 2024 14:45:55 +0100 Subject: [PATCH 39/46] fix: detect safari when launching from dock safari v.17 (macOS v.14) introduce a new feature to save any webpage as a web app. The app is added to the dock and can be used independently from safari. Hower this app still use safari engine under the hood. It appears that when using safari in a webapp property "window.safari" is undefined. As the detection of safari browser rely on this property, the browser is mistakenly considered as non safari. The goal of this change is to rely on userAgent that may be more reliable. https://support.apple.com/en-us/104996 https://developer.mozilla.org/en-US/docs/Web/HTTP/Browser_detection_using_the_user_agent#which_part_of_the_user_agent_contains_the_information_you_are_looking_for --- src/compat/browser_detection.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/compat/browser_detection.ts b/src/compat/browser_detection.ts index 4215b27d2f..0b8668e7a6 100644 --- a/src/compat/browser_detection.ts +++ b/src/compat/browser_detection.ts @@ -90,9 +90,23 @@ let isPlayStation5 = false; { isSafariMobile = true; } else if ( + // the following statement check if the window.safari contains the method + // "pushNotification", this condition is not met when using web app from the dock + // on macOS, this is why we also check userAgent. Object.prototype.toString.call(window.HTMLElement).indexOf("Constructor") >= 0 || (window as ISafariWindowObject).safari?.pushNotification?.toString() === - "[object SafariRemoteNotification]" + "[object SafariRemoteNotification]" || + // browsers are lying: Chrome reports both as Chrome and Safari in user + // agent string, So to detect Safari we have to check for the Safari string + // and the absence of the Chrome string + // eslint-disable-next-line max-len + // @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Browser_detection_using_the_user_agent#which_part_of_the_user_agent_contains_the_information_you_are_looking_for + ((/Safari\/(\d+)/).test(navigator.userAgent) && + // Safari should contain Version/ in userAgent + (/Version\/(\d+)/).test(navigator.userAgent) && + (navigator.vendor?.indexOf("Apple") !== -1) && + !(/Chrome\/(\d+)/).test(navigator.userAgent) && + !(/Chromium\/(\d+)/).test(navigator.userAgent)) ) { isSafariDesktop = true; } From e47b750de503216f935142ec17c74ec391382aec Mon Sep 17 00:00:00 2001 From: Paul Berberian Date: Thu, 11 Jan 2024 12:06:22 +0100 Subject: [PATCH 40/46] wip --- src/compat/browser_detection.ts | 6 +++++ .../should_prevent_seeking_at_0_initially.ts | 18 +++++++++++++++ src/core/init/utils/initial_seek_and_play.ts | 22 +++++++++++++++---- 3 files changed, 42 insertions(+), 4 deletions(-) create mode 100644 src/compat/should_prevent_seeking_at_0_initially.ts diff --git a/src/compat/browser_detection.ts b/src/compat/browser_detection.ts index 0b8668e7a6..83aeeb1e7a 100644 --- a/src/compat/browser_detection.ts +++ b/src/compat/browser_detection.ts @@ -63,6 +63,9 @@ let isPanasonic = false; /** `true` for the PlayStation 5 game console. */ let isPlayStation5 = false; +/** `true` for the Xbox game consoles. */ +let isXbox = false; + ((function findCurrentBrowser() : void { if (isNode) { return ; @@ -142,6 +145,8 @@ let isPlayStation5 = false; } } else if (/[Pp]anasonic/.test(navigator.userAgent)) { isPanasonic = true; + } else if (navigator.userAgent.indexOf("Xbox") !== -1) { + isXbox = true; } })()); @@ -156,6 +161,7 @@ export { isFirefox, isPanasonic, isPlayStation5, + isXbox, isSafariDesktop, isSafariMobile, isSamsungBrowser, diff --git a/src/compat/should_prevent_seeking_at_0_initially.ts b/src/compat/should_prevent_seeking_at_0_initially.ts new file mode 100644 index 0000000000..ce93a31de0 --- /dev/null +++ b/src/compat/should_prevent_seeking_at_0_initially.ts @@ -0,0 +1,18 @@ +import { isXbox } from "./browser_detection"; + +/** + * We noticed that on Xbox game consoles, the browser didn't send a "seeking" + * event if we were seeking at a 0 position initially. + * + * We could theoretically never seek at 0 initially as the initial position of + * an HTMLMediaElement should be at 0 anyway, but we still do it as a safe + * solution, as many devices have a buggy integration of HTML5 media API. + * + * This function returns `true` when we should avoid doing so, for now only for + * the non-standard behavior of XBox game consoles. + * @returns {number} + */ +export default function shouldPreventSeekingAt0Initially( +): boolean { + return isXbox; +} diff --git a/src/core/init/utils/initial_seek_and_play.ts b/src/core/init/utils/initial_seek_and_play.ts index a223cdb13f..bbebbcb117 100644 --- a/src/core/init/utils/initial_seek_and_play.ts +++ b/src/core/init/utils/initial_seek_and_play.ts @@ -16,6 +16,8 @@ import { shouldValidateMetadata } from "../../../compat"; import { READY_STATES } from "../../../compat/browser_compatibility_types"; +/* eslint-disable-next-line max-len */ +import shouldPreventSeekingAt0Initially from "../../../compat/should_prevent_seeking_at_0_initially"; import { MediaError } from "../../../errors"; import log from "../../../log"; import { IPlayerError } from "../../../public_types"; @@ -99,12 +101,24 @@ export default function performInitialSeekAndPlay( function onLoadedMetadata() { mediaElement.removeEventListener("loadedmetadata", onLoadedMetadata); + // `startTime` defined as a function might depend on metadata to make its + // choice, such as the content duration, minimum and/or maximum position. + // + // The RxPlayer might already know those through the Manifest file for + // non-Directfile contents, yet only through the `HTMLMediaElement` once a + // a sufficient `readyState` has been reached for directfile contents. + // So let's divide the two possibilities here. const initialTime = typeof startTime === "function" ? startTime() : startTime; - log.info("Init: Set initial time", initialTime); - playbackObserver.setCurrentTime(initialTime); - initialSeekPerformed.setValue(true); - initialSeekPerformed.finish(); + if (shouldPreventSeekingAt0Initially() && initialTime === 0) { + initialSeekPerformed.setValue(true); + initialSeekPerformed.finish(); + } else { + 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", From 6691bf4961fcbe297b197dfd035084cd811a7839 Mon Sep 17 00:00:00 2001 From: Paul Berberian Date: Mon, 15 Jan 2024 17:10:02 +0100 Subject: [PATCH 41/46] Add forgotten doc in the reference --- doc/reference/API_Reference.md | 29 ++++++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/doc/reference/API_Reference.md b/doc/reference/API_Reference.md index b42b2409e8..115a558d64 100644 --- a/doc/reference/API_Reference.md +++ b/doc/reference/API_Reference.md @@ -104,6 +104,9 @@ properties, methods, events and so on. - [`keySystems[].licenseStorage`](../api/Decryption_Options.md#licensestorage): Allows to ask for the DRM session to persist the license. + - [`keySystems[].onKeyExpiration`](../api/Decryption_Options.md#onkeyexpiration): + Behavior when a key has an `"expired"` status. + - [`keySystems[].fallbackOn`](../api/Decryption_Options.md#fallbackon): Allows to fallback to another quality when a key is refused. @@ -235,6 +238,11 @@ properties, methods, events and so on. - [`defaultTextTrack`](../api/Loading_a_Content.md#defaulttexttrack): [Deprecated] Default characteristics wanted for the text track. +## Static methods + + - [`addFeatures`](../api/RxPlayer_Features.md): + Add features to the RxPlayer (e.g.: multithreading, offline playback etc.). + ## Methods - [`loadVideo`](../api/Loading_a_Content.md): Load a content. @@ -454,15 +462,18 @@ properties, methods, events and so on. - [`isContentLoaded`](../api/Playback_Information/isContentLoaded.md): Returns `true` if a content is loaded. - + - [`isBuffering`](../api/Playback_Information/isBuffering.md): Returns `true` if the player is buffering. - + - [`isPaused`](../api/Playback_Information/isPaused.md): Returns `true` if the `