diff --git a/api-extractor/report/hls.js.api.md b/api-extractor/report/hls.js.api.md index a0005198b97..e4165ce17ff 100644 --- a/api-extractor/report/hls.js.api.md +++ b/api-extractor/report/hls.js.api.md @@ -763,6 +763,15 @@ export type BufferInfo = { start: number; end: number; nextStart?: number; + buffered?: BufferTimeRange[]; +}; + +// Warning: (ae-missing-release-tag) "BufferTimeRange" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export type BufferTimeRange = { + start: number; + end: number; }; // Warning: (ae-missing-release-tag) "CapLevelController" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) @@ -1209,6 +1218,8 @@ export interface ErrorData { // (undocumented) buffer?: number; // (undocumented) + bufferInfo?: BufferInfo; + // (undocumented) bytes?: number; // (undocumented) chunkMeta?: ChunkMetadata; @@ -1253,6 +1264,10 @@ export interface ErrorData { // (undocumented) sourceBufferName?: SourceBufferName; // (undocumented) + stalled?: { + start: number; + }; + // (undocumented) stats?: LoaderStats; // (undocumented) type: ErrorTypes; @@ -1517,6 +1532,8 @@ export enum Events { // (undocumented) PLAYOUT_LIMIT_REACHED = "hlsPlayoutLimitReached", // (undocumented) + STALL_RESOLVED = "hlsStallResolved", + // (undocumented) STEERING_MANIFEST_LOADED = "hlsSteeringManifestLoaded", // (undocumented) SUBTITLE_FRAG_PROCESSED = "hlsSubtitleFragProcessed", @@ -2202,6 +2219,7 @@ export type HlsConfig = { progressive: boolean; lowLatencyMode: boolean; primarySessionId?: string; + detectStallWithCurrentTimeMs: number; } & ABRControllerConfig & BufferControllerConfig & CapLevelControllerConfig & EMEControllerConfig & FPSControllerConfig & LevelControllerConfig & MP4RemuxerConfig & StreamControllerConfig & SelectionPreferences & LatencyControllerConfig & MetadataControllerConfig & TimelineControllerConfig & TSDemuxerConfig & HlsLoadPolicies & FragmentLoaderConfig & PlaylistLoaderConfig; // Warning: (ae-missing-release-tag) "HlsEventEmitter" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) @@ -2359,6 +2377,8 @@ export interface HlsListeners { // (undocumented) [Events.PLAYOUT_LIMIT_REACHED]: (event: Events.PLAYOUT_LIMIT_REACHED, data: {}) => void; // (undocumented) + [Events.STALL_RESOLVED]: (event: Events.STALL_RESOLVED, data: {}) => void; + // (undocumented) [Events.STEERING_MANIFEST_LOADED]: (event: Events.STEERING_MANIFEST_LOADED, data: SteeringManifestLoadedData) => void; // (undocumented) [Events.SUBTITLE_FRAG_PROCESSED]: (event: Events.SUBTITLE_FRAG_PROCESSED, data: SubtitleFragProcessedData) => void; diff --git a/src/config.ts b/src/config.ts index f5b830e989a..ed27047fe1c 100644 --- a/src/config.ts +++ b/src/config.ts @@ -319,6 +319,7 @@ export type HlsConfig = { progressive: boolean; lowLatencyMode: boolean; primarySessionId?: string; + detectStallWithCurrentTimeMs: number; } & ABRControllerConfig & BufferControllerConfig & CapLevelControllerConfig & @@ -427,6 +428,7 @@ export const hlsDefaultConfig: HlsConfig = { progressive: false, lowLatencyMode: true, cmcd: undefined, + detectStallWithCurrentTimeMs: 1250, enableDateRangeMetadataCues: true, enableEmsgMetadataCues: true, enableEmsgKLVMetadata: false, diff --git a/src/controller/gap-controller.ts b/src/controller/gap-controller.ts index 43e5160094f..2b5f62797be 100644 --- a/src/controller/gap-controller.ts +++ b/src/controller/gap-controller.ts @@ -4,47 +4,41 @@ import { Events } from '../events'; import { PlaylistLevelType } from '../types/loader'; import { BufferHelper } from '../utils/buffer-helper'; import { Logger } from '../utils/logger'; -import type { HlsConfig } from '../config'; import type Hls from '../hls'; import type { FragmentTracker } from './fragment-tracker'; import type { Fragment } from '../loader/fragment'; import type { LevelDetails } from '../loader/level-details'; import type { BufferInfo } from '../utils/buffer-helper'; -export const STALL_MINIMUM_DURATION_MS = 250; export const MAX_START_GAP_JUMP = 2.0; export const SKIP_BUFFER_HOLE_STEP_SECONDS = 0.1; export const SKIP_BUFFER_RANGE_START = 0.05; export default class GapController extends Logger { - private config: HlsConfig; private media: HTMLMediaElement | null = null; - private fragmentTracker: FragmentTracker; - private hls: Hls; + private fragmentTracker: FragmentTracker | null = null; + private hls: Hls | null = null; private nudgeRetry: number = 0; private stallReported: boolean = false; private stalled: number | null = null; private moved: boolean = false; private seeking: boolean = false; public ended: number = 0; + public waiting: number = 0; constructor( - config: HlsConfig, media: HTMLMediaElement, fragmentTracker: FragmentTracker, hls: Hls, ) { super('gap-controller', hls.logger); - this.config = config; this.media = media; this.fragmentTracker = fragmentTracker; this.hls = hls; } public destroy() { - this.media = null; - // @ts-ignore - this.hls = this.fragmentTracker = null; + this.media = this.hls = this.fragmentTracker = null; } /** @@ -59,8 +53,9 @@ export default class GapController extends Logger { levelDetails: LevelDetails | undefined, state: string, ) { - const { config, media, stalled } = this; - if (media === null) { + const { media, stalled } = this; + + if (!media) { return; } const { currentTime, seeking } = media; @@ -78,50 +73,44 @@ export default class GapController extends Logger { if (!seeking) { this.nudgeRetry = 0; } - if (stalled !== null) { - // The playhead is now moving, but was previously stalled - if (this.stallReported) { - const stalledDuration = self.performance.now() - stalled; - this.warn( - `playback not stuck anymore @${currentTime}, after ${Math.round( - stalledDuration, - )}ms`, - ); - this.stallReported = false; - } - this.stalled = null; + if (this.waiting === 0) { + this.stallResolved(currentTime); } return; } // Clear stalled state when beginning or finishing seeking so that we don't report stalls coming out of a seek if (beginSeek || seeked) { - this.stalled = null; + if (seeked) { + this.stallResolved(currentTime); + } return; } // The playhead should not be moving - if ( - (media.paused && !seeking) || - media.ended || - media.playbackRate === 0 || - !BufferHelper.getBuffered(media).length - ) { + if ((media.paused && !seeking) || media.ended || media.playbackRate === 0) { + this.nudgeRetry = 0; + this.stallResolved(currentTime); // Fire MEDIA_ENDED to workaround event not being dispatched by browser - if (!this.ended && media.ended) { + if (!this.ended && media.ended && this.hls) { this.ended = currentTime || 1; this.hls.trigger(Events.MEDIA_ENDED, { stalled: false, }); } + return; + } + + if (!BufferHelper.getBuffered(media).length) { this.nudgeRetry = 0; return; } const bufferInfo = BufferHelper.bufferInfo(media, currentTime, 0); const nextStart = bufferInfo.nextStart || 0; + const fragmentTracker = this.fragmentTracker; - if (seeking) { + if (seeking && fragmentTracker) { // Waiting for seeking in a buffered range to complete const hasEnoughBuffer = bufferInfo.len > MAX_START_GAP_JUMP; // Next buffered range is too far ahead to jump to while still seeking @@ -129,7 +118,7 @@ export default class GapController extends Logger { !nextStart || (activeFrag && activeFrag.start <= currentTime) || (nextStart - currentTime > MAX_START_GAP_JUMP && - !this.fragmentTracker.getPartialFragment(currentTime)); + !fragmentTracker.getPartialFragment(currentTime)); if (hasEnoughBuffer || noBufferGap) { return; } @@ -139,7 +128,7 @@ export default class GapController extends Logger { // Skip start gaps if we haven't played, but the last poll detected the start of a stall // The addition poll gives the browser a chance to jump the gap for us - if (!this.moved && this.stalled !== null) { + if (!this.moved && this.stalled !== null && fragmentTracker) { // There is no playable buffer (seeked, waiting for buffer) const isBuffered = bufferInfo.len > 0; if (!isBuffered && !nextStart) { @@ -156,7 +145,7 @@ export default class GapController extends Logger { const maxStartGapJump = isLive ? levelDetails!.targetduration * 2 : MAX_START_GAP_JUMP; - const partialOrGap = this.fragmentTracker.getPartialFragment(currentTime); + const partialOrGap = fragmentTracker.getPartialFragment(currentTime); if (startJump > 0 && (startJump <= maxStartGapJump || partialOrGap)) { if (!media.paused) { this._trySkipBufferHole(partialOrGap); @@ -166,21 +155,36 @@ export default class GapController extends Logger { } // Start tracking stall time + const config = this.hls?.config; + if (!config) { + return; + } + const detectStallWithCurrentTimeMs = config.detectStallWithCurrentTimeMs; const tnow = self.performance.now(); + const tWaiting = this.waiting; if (stalled === null) { - this.stalled = tnow; + // Use time of recent "waiting" event + if (tWaiting > 0 && tnow - tWaiting < detectStallWithCurrentTimeMs) { + this.stalled = tWaiting; + } else { + this.stalled = tnow; + } return; } const stalledDuration = tnow - stalled; - if (!seeking && stalledDuration >= STALL_MINIMUM_DURATION_MS) { + if ( + !seeking && + (stalledDuration >= detectStallWithCurrentTimeMs || tWaiting) && + this.hls + ) { // Dispatch MEDIA_ENDED when media.ended/ended event is not signalled at end of stream if ( state === State.ENDED && !levelDetails?.live && Math.abs(currentTime - (levelDetails?.edge || 0)) < 1 ) { - if (stalledDuration < 1000 || this.ended) { + if (this.ended) { return; } this.ended = currentTime || 1; @@ -191,7 +195,7 @@ export default class GapController extends Logger { } // Report stalling after trying to fix this._reportStall(bufferInfo); - if (!this.media) { + if (!this.media || !this.hls) { return; } } @@ -204,6 +208,25 @@ export default class GapController extends Logger { this._tryFixBufferStall(bufferedWithHoles, stalledDuration); } + private stallResolved(currentTime: number) { + const stalled = this.stalled; + if (stalled && this.hls) { + this.stalled = null; + // The playhead is now moving, but was previously stalled + if (this.stallReported) { + const stalledDuration = self.performance.now() - stalled; + this.warn( + `playback not stuck anymore @${currentTime}, after ${Math.round( + stalledDuration, + )}ms`, + ); + this.stallReported = false; + this.waiting = 0; + this.hls.trigger(Events.STALL_RESOLVED, {}); + } + } + } + /** * Detects and attempts to fix known buffer stalling issues. * @param bufferInfo - The properties of the current buffer. @@ -214,10 +237,12 @@ export default class GapController extends Logger { bufferInfo: BufferInfo, stalledDurationMs: number, ) { - const { config, fragmentTracker, media } = this; - if (media === null) { + const { fragmentTracker, media } = this; + const config = this.hls?.config; + if (!media || !fragmentTracker || !config) { return; } + const currentTime = media.currentTime; const partial = fragmentTracker.getPartialFragment(currentTime); @@ -236,8 +261,11 @@ export default class GapController extends Logger { // we may just have to "nudge" the playlist as the browser decoding/rendering engine // needs to cross some sort of threshold covering all source-buffers content // to start playing properly. + const bufferedRanges = bufferInfo.buffered; if ( - (bufferInfo.len > config.maxBufferHole || + ((bufferedRanges && + bufferedRanges.length > 1 && + bufferInfo.len > config.maxBufferHole) || (bufferInfo.nextStart && bufferInfo.nextStart - currentTime < config.maxBufferHole)) && stalledDurationMs > config.highBufferWatchdogPeriod * 1000 @@ -245,9 +273,7 @@ export default class GapController extends Logger { this.warn('Trying to nudge playhead over buffer-hole'); // Try to nudge currentTime over a buffer hole if we've been stalling for the configured amount of seconds // We only try to jump the hole if it's under the configured size - // Reset stalled so to rearm watchdog timer - this.stalled = null; - this._tryNudgeBuffer(); + this._tryNudgeBuffer(bufferInfo); } } @@ -257,8 +283,8 @@ export default class GapController extends Logger { * @private */ private _reportStall(bufferInfo: BufferInfo) { - const { hls, media, stallReported } = this; - if (!stallReported && media) { + const { hls, media, stallReported, stalled } = this; + if (!stallReported && stalled !== null && media && hls) { // Report stalled error once this.stallReported = true; const error = new Error( @@ -273,6 +299,8 @@ export default class GapController extends Logger { fatal: false, error, buffer: bufferInfo.len, + bufferInfo, + stalled: { start: stalled }, }); } } @@ -283,8 +311,9 @@ export default class GapController extends Logger { * @private */ private _trySkipBufferHole(partial: Fragment | null): number { - const { config, hls, media } = this; - if (media === null) { + const { fragmentTracker, media } = this; + const config = this.hls?.config; + if (!media || !fragmentTracker || !config) { return 0; } @@ -301,7 +330,6 @@ export default class GapController extends Logger { if (gapLength > 0 && (bufferStarved || waiting)) { // Only allow large gaps to be skipped if it is a start gap, or all fragments in skip range are partial if (gapLength > config.maxBufferHole) { - const { fragmentTracker } = this; let startGap = false; if (currentTime === 0) { const startFrag = fragmentTracker.getAppendedFrag( @@ -345,19 +373,20 @@ export default class GapController extends Logger { `skipping hole, adjusting currentTime from ${currentTime} to ${targetTime}`, ); this.moved = true; - this.stalled = null; media.currentTime = targetTime; - if (partial && !partial.gap) { + if (partial && !partial.gap && this.hls) { const error = new Error( `fragment loaded with buffer holes, seeking from ${currentTime} to ${targetTime}`, ); - hls.trigger(Events.ERROR, { + this.hls.trigger(Events.ERROR, { type: ErrorTypes.MEDIA_ERROR, details: ErrorDetails.BUFFER_SEEK_OVER_HOLE, fatal: false, error, reason: error.message, frag: partial, + buffer: bufferInfo.len, + bufferInfo, }); } return targetTime; @@ -370,10 +399,11 @@ export default class GapController extends Logger { * Attempts to fix buffer stalls by advancing the mediaElement's current time by a small amount. * @private */ - private _tryNudgeBuffer() { - const { config, hls, media, nudgeRetry } = this; - if (media === null) { - return; + private _tryNudgeBuffer(bufferInfo: BufferInfo) { + const { hls, media, nudgeRetry } = this; + const config = hls?.config; + if (!media || !config) { + return 0; } const currentTime = media.currentTime; this.nudgeRetry++; @@ -391,6 +421,8 @@ export default class GapController extends Logger { details: ErrorDetails.BUFFER_NUDGE_ON_STALL, error, fatal: false, + buffer: bufferInfo.len, + bufferInfo, }); } else { const error = new Error( @@ -402,6 +434,8 @@ export default class GapController extends Logger { details: ErrorDetails.BUFFER_STALLED_ERROR, error, fatal: true, + buffer: bufferInfo.len, + bufferInfo, }); } } diff --git a/src/controller/stream-controller.ts b/src/controller/stream-controller.ts index 1d4af6453ee..4360b649152 100644 --- a/src/controller/stream-controller.ts +++ b/src/controller/stream-controller.ts @@ -123,7 +123,7 @@ export default class StreamController protected onHandlerDestroying() { // @ts-ignore - this.onMediaPlaying = this.onMediaSeeked = null; + this.onMediaPlaying = this.onMediaSeeked = this.onMediaWaiting = null; this.unregisterListeners(); super.onHandlerDestroying(); } @@ -535,10 +535,11 @@ export default class StreamController const media = data.media; media.removeEventListener('playing', this.onMediaPlaying); media.removeEventListener('seeked', this.onMediaSeeked); + media.removeEventListener('waiting', this.onMediaWaiting); media.addEventListener('playing', this.onMediaPlaying); media.addEventListener('seeked', this.onMediaSeeked); + media.addEventListener('waiting', this.onMediaWaiting); this.gapController = new GapController( - this.config, media, this.fragmentTracker, this.hls, @@ -553,6 +554,7 @@ export default class StreamController if (media) { media.removeEventListener('playing', this.onMediaPlaying); media.removeEventListener('seeked', this.onMediaSeeked); + media.removeEventListener('waiting', this.onMediaWaiting); } this.videoBuffer = null; this.fragPlaying = null; @@ -568,11 +570,19 @@ export default class StreamController this._hasEnoughToStart = false; } + private onMediaWaiting = () => { + const gapController = this.gapController; + if (gapController) { + gapController.waiting = self.performance.now(); + } + }; + private onMediaPlaying = () => { // tick to speed up FRAG_CHANGED triggering const gapController = this.gapController; if (gapController) { gapController.ended = 0; + gapController.waiting = 0; } this.tick(); }; @@ -1119,7 +1129,7 @@ export default class StreamController let startPosition = this.startPosition; // only adjust currentTime if different from startPosition or if startPosition not buffered // at that stage, there should be only one buffered range, as we reach that code after first fragment has been buffered - if (startPosition >= 0) { + if (startPosition >= 0 && currentTime < startPosition) { if (media.seeking) { this.log( `could not seek to ${startPosition}, already seeking at ${currentTime}`, diff --git a/src/events.ts b/src/events.ts index 4ebeed48d8e..4a13a65adb9 100644 --- a/src/events.ts +++ b/src/events.ts @@ -78,6 +78,8 @@ export enum Events { MEDIA_DETACHED = 'hlsMediaDetached', // Fired when HTMLMediaElement dispatches "ended" event, or stalls at end of VOD program MEDIA_ENDED = 'hlsMediaEnded', + // Fired after playback stall is resolved with playing, seeked, or ended event following BUFFER_STALLED_ERROR + STALL_RESOLVED = 'hlsStallResolved', // Fired when the buffer is going to be reset BUFFER_RESET = 'hlsBufferReset', // Fired when we know about the codecs that we need buffers for to push into - data: {tracks : { container, codec, levelCodec, initSegment, metadata }} @@ -244,6 +246,7 @@ export interface HlsListeners { event: Events.MEDIA_ENDED, data: MediaEndedData, ) => void; + [Events.STALL_RESOLVED]: (event: Events.STALL_RESOLVED, data: {}) => void; [Events.BUFFER_RESET]: (event: Events.BUFFER_RESET) => void; [Events.BUFFER_CODECS]: ( event: Events.BUFFER_CODECS, diff --git a/src/hls.ts b/src/hls.ts index b93ceabcaf6..f4b56aede20 100644 --- a/src/hls.ts +++ b/src/hls.ts @@ -54,7 +54,7 @@ import type { SubtitleSelectionOption, VideoSelectionOption, } from './types/media-playlist'; -import type { BufferInfo } from './utils/buffer-helper'; +import type { BufferInfo, BufferTimeRange } from './utils/buffer-helper'; import type EwmaBandWidthEstimator from './utils/ewma-bandwidth-estimator'; import type { MediaDecodingInfo } from './utils/mediacapabilities-helper'; @@ -1195,6 +1195,7 @@ export type { HlsEventEmitter, HlsConfig, BufferInfo, + BufferTimeRange, HdcpLevel, AbrController, AudioStreamController, diff --git a/src/types/events.ts b/src/types/events.ts index 12c5a4829bc..4dc42da5f48 100644 --- a/src/types/events.ts +++ b/src/types/events.ts @@ -47,6 +47,7 @@ import type { LevelDetails } from '../loader/level-details'; import type { LevelKey } from '../loader/level-key'; import type { LoadStats } from '../loader/load-stats'; import type { AttrList } from '../utils/attr-list'; +import type { BufferInfo } from '../utils/buffer-helper'; export interface MediaAttachingData { media: HTMLMediaElement; @@ -313,6 +314,7 @@ export interface ErrorData { fatal: boolean; errorAction?: IErrorAction; buffer?: number; + bufferInfo?: BufferInfo; bytes?: number; chunkMeta?: ChunkMetadata; context?: PlaylistLoaderContext; @@ -323,6 +325,7 @@ export interface ErrorData { levelRetry?: boolean; loader?: Loader; networkDetails?: any; + stalled?: { start: number }; stats?: LoaderStats; mimeType?: string; reason?: string; diff --git a/src/utils/buffer-helper.ts b/src/utils/buffer-helper.ts index ddc50d182fe..5a3673750ce 100644 --- a/src/utils/buffer-helper.ts +++ b/src/utils/buffer-helper.ts @@ -8,7 +8,7 @@ import { logger } from './logger'; -type BufferTimeRange = { +export type BufferTimeRange = { start: number; end: number; }; @@ -22,6 +22,7 @@ export type BufferInfo = { start: number; end: number; nextStart?: number; + buffered?: BufferTimeRange[]; }; const noopBuffered: TimeRanges = { @@ -61,19 +62,14 @@ export class BufferHelper { return BufferHelper.bufferedInfo(buffered, pos, maxHoleDuration); } } - return { len: 0, start: pos, end: pos, nextStart: undefined }; + return { len: 0, start: pos, end: pos }; } static bufferedInfo( buffered: BufferTimeRange[], pos: number, maxHoleDuration: number, - ): { - len: number; - start: number; - end: number; - nextStart?: number; - } { + ): BufferInfo { pos = Math.max(0, pos); // sort on buffer.start/smaller end (IE does not always return sorted buffered range) buffered.sort((a, b) => a.start - b.start || b.end - a.end); @@ -136,6 +132,7 @@ export class BufferHelper { start: bufferStart || 0, end: bufferEnd || 0, nextStart: bufferStartNext, + buffered, }; } diff --git a/tests/unit/controller/gap-controller.js b/tests/unit/controller/gap-controller.ts similarity index 68% rename from tests/unit/controller/gap-controller.js rename to tests/unit/controller/gap-controller.ts index 1b8e99cdae9..315e226e738 100644 --- a/tests/unit/controller/gap-controller.js +++ b/tests/unit/controller/gap-controller.ts @@ -1,35 +1,62 @@ -import Hls from '../../../src/hls'; +import chai from 'chai'; +import sinon from 'sinon'; +import sinonChai from 'sinon-chai'; +import { State } from '../../../src/controller/base-stream-controller'; +import { FragmentTracker } from '../../../src/controller/fragment-tracker'; import GapController, { SKIP_BUFFER_RANGE_START, } from '../../../src/controller/gap-controller'; -import { FragmentTracker } from '../../../src/controller/fragment-tracker'; +import { ErrorDetails, ErrorTypes } from '../../../src/errors'; import { Events } from '../../../src/events'; -import { ErrorTypes, ErrorDetails } from '../../../src/errors'; +import Hls from '../../../src/hls'; +import { + BufferHelper, + type BufferInfo, +} from '../../../src/utils/buffer-helper'; +import type { HlsConfig } from '../../../src/config'; +import type { Fragment } from '../../../src/loader/fragment'; + +chai.use(sinonChai); +const expect = chai.expect; + +type GapControllerTestable = Omit & { + fragmentTracker: FragmentTracker; + media: HTMLMediaElement; + moved: boolean; + nudgeRetry: number; + stalled: number; + stallReported: boolean; + waiting: number; + _reportStall(bufferInfo: BufferInfo): void; + _tryFixBufferStall(bufferInfo: BufferInfo, stalledDurationMs: number): void; + _tryNudgeBuffer(bufferInfo: BufferInfo): void; + _trySkipBufferHole(partial: Fragment | null): number; +}; describe('GapController', function () { - let hls; - let gapController; - let config; + let hls: Hls; + let config: HlsConfig; + let gapController: GapControllerTestable; let media; let triggerSpy; const sandbox = sinon.createSandbox(); beforeEach(function () { hls = new Hls({}); - hls.networkControllers.forEach((component) => component.destroy()); - hls.networkControllers.length = 0; - hls.coreComponents.forEach((component) => component.destroy()); - hls.coreComponents.length = 0; + config = hls.config; + const hlsTestable: any = hls; + hlsTestable.networkControllers.forEach((component) => component.destroy()); + hlsTestable.networkControllers.length = 0; + hlsTestable.coreComponents.forEach((component) => component.destroy()); + hlsTestable.coreComponents.length = 0; media = { currentTime: 0, }; - config = hls.config; gapController = new GapController( - config, media, - new FragmentTracker(hls), + new FragmentTracker(hls as Hls), hls, - ); + ) as unknown as GapControllerTestable; triggerSpy = sinon.spy(hls, 'trigger'); }); @@ -39,12 +66,13 @@ describe('GapController', function () { }); describe('_tryNudgeBuffer', function () { + const bufferInfo = BufferHelper.bufferedInfo([{ start: 0, end: 10 }], 0, 0); it('should increment the currentTime by a multiple of nudgeRetry and the configured nudge amount', function () { for (let i = 0; i < config.nudgeMaxRetry; i++) { triggerSpy.resetHistory(); const expected = media.currentTime + (i + 1) * config.nudgeOffset; - gapController._tryNudgeBuffer(); + gapController._tryNudgeBuffer(bufferInfo); expect(media.currentTime).to.equal(expected); expect(triggerSpy).to.have.been.calledWith(Events.ERROR, { @@ -52,11 +80,13 @@ describe('GapController', function () { details: ErrorDetails.BUFFER_NUDGE_ON_STALL, fatal: false, error: triggerSpy.getCall(0).lastArg.error, + buffer: bufferInfo.len, + bufferInfo, }); } triggerSpy.resetHistory(); - gapController._tryNudgeBuffer(); + gapController._tryNudgeBuffer(bufferInfo); expect(triggerSpy).not.to.have.been.calledWith(Events.ERROR, { type: ErrorTypes.MEDIA_ERROR, @@ -67,76 +97,109 @@ describe('GapController', function () { it('should not increment the currentTime if the max amount of nudges has been attempted', function () { config.nudgeMaxRetry = 0; - gapController._tryNudgeBuffer(); + gapController._tryNudgeBuffer(bufferInfo); expect(media.currentTime).to.equal(0); expect(triggerSpy).to.have.been.calledWith(Events.ERROR, { type: ErrorTypes.MEDIA_ERROR, details: ErrorDetails.BUFFER_STALLED_ERROR, fatal: true, error: triggerSpy.getCall(0).lastArg.error, + buffer: bufferInfo.len, + bufferInfo, }); }); }); describe('_reportStall', function () { it('should report a stall with the current buffer length if it has not already been reported', function () { - gapController._reportStall({ len: 42 }); + const bufferInfo = BufferHelper.bufferedInfo( + [{ start: 0, end: 42 }], + 0, + 0, + ); + gapController.stalled = 456; + gapController._reportStall(bufferInfo); expect(triggerSpy).to.have.been.calledWith(Events.ERROR, { type: ErrorTypes.MEDIA_ERROR, details: ErrorDetails.BUFFER_STALLED_ERROR, fatal: false, error: triggerSpy.getCall(0).lastArg.error, buffer: 42, + bufferInfo, + stalled: { start: 456 }, }); }); it('should not report a stall if it was already reported', function () { + const bufferInfo = BufferHelper.bufferedInfo( + [{ start: 0, end: 42 }], + 0, + 0, + ); gapController.stallReported = true; - gapController._reportStall({ len: 42 }); + gapController.stalled = 456; + gapController._reportStall(bufferInfo); expect(triggerSpy).to.not.have.been.called; }); }); describe('_tryFixBufferStall', function () { - it('should nudge when stalling close to the buffer end', function () { - const mockBufferInfo = { len: 1 }; + it('should nudge when stalling close to the buffer end with multiple ranges', function () { + const bufferInfo = BufferHelper.bufferedInfo( + [ + { start: 0, end: 4 }, + { start: 4.1, end: 8 }, + ], + 0, + 0, + ); const mockStallDuration = (config.highBufferWatchdogPeriod + 1) * 1000; const nudgeStub = sandbox.stub(gapController, '_tryNudgeBuffer'); - gapController._tryFixBufferStall(mockBufferInfo, mockStallDuration); + gapController._tryFixBufferStall(bufferInfo, mockStallDuration); expect(nudgeStub).to.have.been.calledOnce; }); it('should not nudge when briefly stalling close to the buffer end', function () { - const mockBufferInfo = { len: 1 }; + const bufferInfo = BufferHelper.bufferedInfo( + [{ start: 0, end: 1 }], + 0, + 0, + ); const mockStallDuration = (config.highBufferWatchdogPeriod / 2) * 1000; const nudgeStub = sandbox.stub(gapController, '_tryNudgeBuffer'); - gapController._tryFixBufferStall(mockBufferInfo, mockStallDuration); + gapController._tryFixBufferStall(bufferInfo, mockStallDuration); expect(nudgeStub).to.have.not.been.called; }); it('should not nudge when too far from the buffer end', function () { - const mockBufferInfo = { len: 0.09 }; + const bufferInfo = BufferHelper.bufferedInfo( + [{ start: 0, end: 0.09 }], + 0, + 0, + ); const mockStallDuration = (config.highBufferWatchdogPeriod + 1) * 1000; const nudgeStub = sandbox.stub(gapController, '_tryNudgeBuffer'); - gapController._tryFixBufferStall(mockBufferInfo, mockStallDuration); + gapController._tryFixBufferStall(bufferInfo, mockStallDuration); expect(nudgeStub).to.have.not.been.called; }); it('should try to jump partial fragments when detected', function () { + const bufferInfo = BufferHelper.bufferedInfo([], 0, 0); sandbox .stub(gapController.fragmentTracker, 'getPartialFragment') - .returns({}); + .returns({} as unknown as Fragment); const skipHoleStub = sandbox.stub(gapController, '_trySkipBufferHole'); - gapController._tryFixBufferStall({ len: 0 }); + gapController._tryFixBufferStall(bufferInfo, 100); expect(skipHoleStub).to.have.been.calledOnce; }); it('should not try to jump partial fragments when none are detected', function () { + const bufferInfo = BufferHelper.bufferedInfo([], 0, 0); sandbox .stub(gapController.fragmentTracker, 'getPartialFragment') .returns(null); const skipHoleStub = sandbox.stub(gapController, '_trySkipBufferHole'); - gapController._tryFixBufferStall({ len: 0 }); + gapController._tryFixBufferStall(bufferInfo, 100); expect(skipHoleStub).to.have.not.been.called; }); }); @@ -198,8 +261,9 @@ describe('GapController', function () { lastCurrentTime = mockMedia.currentTime; if (!isStalling) { mockMedia.currentTime += incrementSec; + gapController.waiting = 0; } - gapController.poll(lastCurrentTime); + gapController.poll(lastCurrentTime, null, undefined, State.IDLE); } function setStalling() { @@ -223,13 +287,13 @@ describe('GapController', function () { it('should try to fix a stall if expected to be playing', function () { const fixStallStub = sandbox.stub(gapController, '_tryFixBufferStall'); setStalling(); - gapController.poll(lastCurrentTime); + gapController.poll(lastCurrentTime, null, undefined, State.IDLE); // The first poll call made while stalling just sets stall flags expect(gapController.stalled).to.be.a('number'); expect(gapController.stallReported).to.be.false; - gapController.poll(lastCurrentTime); + gapController.poll(lastCurrentTime, null, undefined, State.IDLE); expect(fixStallStub).to.have.been.calledOnce; }); @@ -239,7 +303,7 @@ describe('GapController', function () { gapController.nudgeRetry = 1; gapController.stalled = 4200; const fixStallStub = sandbox.stub(gapController, '_tryFixBufferStall'); - gapController.poll(lastCurrentTime); + gapController.poll(lastCurrentTime, null, undefined, State.IDLE); expect(gapController.stalled).to.not.exist; expect(gapController.nudgeRetry).to.equal(0); @@ -281,7 +345,7 @@ describe('GapController', function () { it('should not detect stalls when loading an earlier fragment while seeking', function () { wallClock.tick(2 * STALL_HANDLING_RETRY_PERIOD_MS); mockMedia.currentTime += 0.1; - gapController.poll(0); + gapController.poll(0, null, undefined, State.IDLE); expect(gapController.stalled).to.equal(null, 'buffered start'); wallClock.tick(2 * STALL_HANDLING_RETRY_PERIOD_MS); @@ -289,27 +353,49 @@ describe('GapController', function () { mockMedia.seeking = true; mockTimeRangesData.length = 1; mockTimeRangesData[0] = [5.5, 10]; - gapController.poll(mockMedia.currentTime - 5); + gapController.poll( + mockMedia.currentTime - 5, + null, + undefined, + State.IDLE, + ); expect(gapController.stalled).to.equal(null, 'new seek position'); wallClock.tick(2 * STALL_HANDLING_RETRY_PERIOD_MS); - gapController.poll(mockMedia.currentTime, { - start: 5, - }); + gapController.poll( + mockMedia.currentTime, + { + start: 5, + } as unknown as Fragment, + undefined, + State.IDLE, + ); expect(gapController.stalled).to.equal( null, 'seeking while loading fragment', ); }); - it('should trigger reportStall when stalling for 250ms or longer', function () { + it('should trigger reportStall when stalling for `detectStallWithCurrentTimeMs` or longer', function () { + setStalling(); + wallClock.tick(250); + gapController.stalled = 1; + gapController.poll(lastCurrentTime, null, undefined, State.IDLE); + expect(reportStallSpy).to.not.have.been.called; + wallClock.tick(config.detectStallWithCurrentTimeMs + 1); + gapController.poll(lastCurrentTime, null, undefined, State.IDLE); + expect(reportStallSpy).to.have.been.calledOnce; + }); + + it('should trigger reportStall when stalling for after waiting event', function () { setStalling(); wallClock.tick(250); gapController.stalled = 1; - gapController.poll(lastCurrentTime); + gapController.poll(lastCurrentTime, null, undefined, State.IDLE); expect(reportStallSpy).to.not.have.been.called; - wallClock.tick(251); - gapController.poll(lastCurrentTime); + gapController.waiting = 250; + wallClock.tick(1); + gapController.poll(lastCurrentTime, null, undefined, State.IDLE); expect(reportStallSpy).to.have.been.calledOnce; }); @@ -351,15 +437,15 @@ describe('GapController', function () { tickMediaClock(); expect(gapController.moved).to.equal(true); - expect(gapController.stalled).to.equal(null); + expect(gapController.stalled).to.equal(1234); expect(mockMedia.currentTime).to.equal(0.1 + SKIP_BUFFER_RANGE_START); }); it('should skip any initial gap when not having played yet on second poll', function () { mockMedia.currentTime = 0; mockTimeRangesData = [[0.9, 10]]; - gapController.poll(0); - gapController.poll(0); + gapController.poll(0, null, undefined, State.IDLE); + gapController.poll(0, null, undefined, State.IDLE); expect(mockMedia.currentTime).to.equal(0.9 + SKIP_BUFFER_RANGE_START); }); }); diff --git a/tests/unit/utils/buffer-helper.js b/tests/unit/utils/buffer-helper.js index 3448ad46ac0..91f99e8c06d 100644 --- a/tests/unit/utils/buffer-helper.js +++ b/tests/unit/utils/buffer-helper.js @@ -79,6 +79,10 @@ describe('BufferHelper', function () { start: 0, end: 0.5, nextStart: 1, + buffered: [ + { start: 0, end: 0.5 }, + { start: 1, end: 2 }, + ], }); }); it('should return empty buffer info if media does not exist', function () { @@ -94,7 +98,6 @@ describe('BufferHelper', function () { len: 0, start: 0, end: 0, - nextStart: undefined, }); }); it('should return empty buffer info if media does not exist', function () { @@ -103,7 +106,6 @@ describe('BufferHelper', function () { len: 0, start: 0, end: 0, - nextStart: undefined, }); }); }); @@ -130,6 +132,7 @@ describe('BufferHelper', function () { start: 0, end: 0.5, nextStart: 1, + buffered, }); expect( BufferHelper.bufferedInfo(buffered, 0.5, maxHoleDuration), @@ -138,6 +141,7 @@ describe('BufferHelper', function () { start: 0.5, end: 0.5, nextStart: 1, + buffered, }); expect( BufferHelper.bufferedInfo(buffered, 1, maxHoleDuration), @@ -146,6 +150,7 @@ describe('BufferHelper', function () { start: 1, end: 2, nextStart: undefined, + buffered, }); expect( BufferHelper.bufferedInfo(buffered, 1.5, maxHoleDuration), @@ -154,6 +159,7 @@ describe('BufferHelper', function () { start: 1, end: 2, nextStart: undefined, + buffered, }); }); it('should return found buffer info when maxHoleDuration is 0.5', function () { @@ -177,6 +183,7 @@ describe('BufferHelper', function () { start: 0, end: 0.5, nextStart: 1, + buffered, }); // M: maxHoleDuration: 0.5 // |////////|________|////////////////| @@ -188,6 +195,7 @@ describe('BufferHelper', function () { start: 1, end: 2, nextStart: undefined, + buffered, }); expect( BufferHelper.bufferedInfo(buffered, 1, maxHoleDuration), @@ -196,6 +204,7 @@ describe('BufferHelper', function () { start: 1, end: 2, nextStart: undefined, + buffered, }); expect( BufferHelper.bufferedInfo(buffered, 2, maxHoleDuration), @@ -204,6 +213,7 @@ describe('BufferHelper', function () { start: 2, end: 2, nextStart: undefined, + buffered, }); }); it('should be able to handle unordered buffered', function () { @@ -227,6 +237,7 @@ describe('BufferHelper', function () { start: 0, end: 0.5, nextStart: 1, + buffered, }); }); it('should be able to merge adjacent time ranges with a small hole', function () { @@ -250,6 +261,7 @@ describe('BufferHelper', function () { start: 0, end: 2, nextStart: undefined, + buffered, }); }); it('should be able to merge overlapping time ranges', function () { @@ -274,6 +286,7 @@ describe('BufferHelper', function () { start: 0, end: 1, nextStart: undefined, + buffered, }); }); it('should return empty buffered if pos is out of range', function () { @@ -295,18 +308,21 @@ describe('BufferHelper', function () { start: 5, end: 5, nextStart: undefined, + buffered, }); }); it('should return empty buffered if buffered is empty', function () { const buffered = []; const maxHoleDuration = 0; + expect( BufferHelper.bufferedInfo(buffered, 5, maxHoleDuration), - ).to.deep.equal({ + ).to.include({ + // `buffered` empty array fails deep.equal len: 0, start: 5, end: 5, - nextStart: undefined, + buffered, }); }); });