diff --git a/api-extractor/report/hls.js.api.md b/api-extractor/report/hls.js.api.md index 0dd5b746a..cda617f5c 100644 --- a/api-extractor/report/hls.js.api.md +++ b/api-extractor/report/hls.js.api.md @@ -2011,6 +2011,7 @@ export type GapControllerConfig = { nudgeOffset: number; nudgeMaxRetry: number; nudgeOnVideoHole: boolean; + skipBufferHolePadding: number; }; // Warning: (ae-missing-release-tag) "HdcpLevel" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) diff --git a/docs/API.md b/docs/API.md index 2d6f2761a..0dc1e7779 100644 --- a/docs/API.md +++ b/docs/API.md @@ -41,6 +41,7 @@ See [API Reference](https://hlsjs-dev.video-dev.org/api-docs/) for a complete li - [`nudgeOffset`](#nudgeoffset) - [`nudgeMaxRetry`](#nudgemaxretry) - [`nudgeOnVideoHole`](#nudgeonvideohole) + - [`skipBufferHolePadding`](#skipbufferholepadding) - [`maxFragLookUpTolerance`](#maxfraglookuptolerance) - [`maxMaxBufferLength`](#maxmaxbufferlength) - [`liveSyncMode`](#livesyncmode) @@ -695,7 +696,10 @@ In case playback continues to stall after first playhead nudging, currentTime wi (default: `3`) -Max number of playhead (`currentTime`) nudges before HLS.js raise a fatal BUFFER_STALLED_ERROR +Maximum retry threshold used for both buffer hole skipping and playhead nudging: + +- **Skip retries**: When jumping over buffer holes, if `skipRetry > nudgeMaxRetry`, a fatal `BUFFER_SEEK_OVER_HOLE` error is raised +- **Nudge retries**: When nudging the playhead in buffered areas, if `nudgeRetry >= nudgeMaxRetry`, a fatal `BUFFER_STALLED_ERROR` is raised ### `nudgeOnVideoHole` @@ -703,6 +707,18 @@ Max number of playhead (`currentTime`) nudges before HLS.js raise a fatal BUFFER Whether or not HLS.js should perform a seek nudge to flush the rendering pipeline upon traversing a gap or hole in video SourceBuffer buffered time ranges. This is only performed when audio is buffered at the point where the hole is detected. For more information see `nudgeOnVideoHole` in gap-controller and issues https://issues.chromium.org/issues/40280613#comment10 and https://github.com/video-dev/hls.js/issues/5631. +### `skipBufferHolePadding` + +(default: `0.1` seconds) + +Workaround for some platforms where the video element often rounds the value we want to set as `currentTime`, preventing the player from jumping over buffer gaps. + +Setting this to a higher value adds additional time to the skip buffer hole target time, which skips more media but mitigates infinite attempts to skip the same buffer hole. + +Known to be helpful for platforms such as Xbox, Legacy Edge, and Tizen. Based on research on Tizen, the `skipBufferHolePadding` value should be greater than your GOP (Group of Pictures) length. + +`media.currentTime = Math.max(nextBufferedRangeStartTime, media.currentTime) + skipBufferHolePadding` + ### `maxFragLookUpTolerance` (default 0.25s) diff --git a/src/config.ts b/src/config.ts index 7ba0901fb..15627fd4a 100644 --- a/src/config.ts +++ b/src/config.ts @@ -230,6 +230,7 @@ export type GapControllerConfig = { nudgeOffset: number; nudgeMaxRetry: number; nudgeOnVideoHole: boolean; + skipBufferHolePadding: number; }; export type SelectionPreferences = { @@ -384,6 +385,7 @@ export const hlsDefaultConfig: HlsConfig = { nudgeOffset: 0.1, // used by gap-controller nudgeMaxRetry: 3, // used by gap-controller nudgeOnVideoHole: true, // used by gap-controller + skipBufferHolePadding: 0.1, // used by gap-controller liveSyncMode: 'edge', // used by stream-controller liveSyncDurationCount: 3, // used by latency-controller liveSyncOnStallIncrease: 1, // used by latency-controller diff --git a/src/controller/gap-controller.ts b/src/controller/gap-controller.ts index 5ef6869ce..1f63fc634 100644 --- a/src/controller/gap-controller.ts +++ b/src/controller/gap-controller.ts @@ -24,8 +24,6 @@ import type { ErrorData } from '../types/events'; import type { BufferInfo } from '../utils/buffer-helper'; 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; const TICK_INTERVAL = 100; export default class GapController extends TaskLoop { @@ -35,6 +33,7 @@ export default class GapController extends TaskLoop { private mediaSource?: MediaSource; private nudgeRetry: number = 0; + private skipRetry: number = 0; private stallReported: boolean = false; private stalled: number | null = null; private moved: boolean = false; @@ -178,7 +177,7 @@ export default class GapController extends TaskLoop { } this.moved = true; if (!seeking) { - this.nudgeRetry = 0; + this.skipRetry = this.nudgeRetry = 0; // When crossing between buffered video time ranges, but not audio, flush pipeline with seek (Chrome) if ( config.nudgeOnVideoHole && @@ -204,7 +203,7 @@ export default class GapController extends TaskLoop { // The playhead should not be moving if (pausedEndedOrHalted) { - this.nudgeRetry = 0; + this.skipRetry = this.nudgeRetry = 0; this.stallResolved(currentTime); // Fire MEDIA_ENDED to workaround event not being dispatched by browser if (!this.ended && media.ended && this.hls) { @@ -217,7 +216,7 @@ export default class GapController extends TaskLoop { } if (!BufferHelper.getBuffered(media).length) { - this.nudgeRetry = 0; + this.skipRetry = this.nudgeRetry = 0; return; } @@ -595,23 +594,27 @@ export default class GapController extends TaskLoop { } } } - const targetTime = Math.max( - startTime + SKIP_BUFFER_RANGE_START, - currentTime + SKIP_BUFFER_HOLE_STEP_SECONDS, - ); - this.warn( - `skipping hole, adjusting currentTime from ${currentTime} to ${targetTime}`, - ); - this.moved = true; - media.currentTime = targetTime; - if (!appended?.gap) { + const { nudgeMaxRetry, skipBufferHolePadding } = config; + const fatal = ++this.skipRetry > nudgeMaxRetry; + const targetTime = + Math.max(startTime, currentTime) + skipBufferHolePadding; + if (!fatal) { + this.warn( + `skipping hole, adjusting currentTime from ${currentTime} to ${targetTime}`, + ); + this.moved = true; + media.currentTime = targetTime; + } + if (!appended?.gap || fatal) { const error = new Error( - `fragment loaded with buffer holes, seeking from ${currentTime} to ${targetTime}`, + fatal + ? `Playhead still not moving after seeking over buffer hole from ${currentTime} to ${targetTime} after ${config.nudgeMaxRetry} attempts.` + : `fragment loaded with buffer holes, seeking from ${currentTime} to ${targetTime}`, ); const errorData: ErrorData = { type: ErrorTypes.MEDIA_ERROR, details: ErrorDetails.BUFFER_SEEK_OVER_HOLE, - fatal: false, + fatal, error, reason: error.message, buffer: bufferInfo.len, diff --git a/tests/unit/controller/gap-controller.ts b/tests/unit/controller/gap-controller.ts index 4d0811265..d4479f7cd 100644 --- a/tests/unit/controller/gap-controller.ts +++ b/tests/unit/controller/gap-controller.ts @@ -3,9 +3,7 @@ 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 GapController from '../../../src/controller/gap-controller'; import { ErrorDetails, ErrorTypes } from '../../../src/errors'; import { Events } from '../../../src/events'; import Hls from '../../../src/hls'; @@ -445,7 +443,9 @@ describe('GapController', function () { expect(gapController.moved).to.equal(true); expect(gapController.stalled).to.equal(1234); - expect(mockMedia.currentTime).to.equal(0.1 + SKIP_BUFFER_RANGE_START); + expect(mockMedia.currentTime).to.equal( + 0.1 + config.skipBufferHolePadding, + ); }); it('should skip any initial gap when not having played yet on second poll', function () { @@ -453,7 +453,9 @@ describe('GapController', function () { mockTimeRangesData = [[0.9, 10]]; gapController.poll(0, mockMedia.currentTime); gapController.poll(0, mockMedia.currentTime); - expect(mockMedia.currentTime).to.equal(0.9 + SKIP_BUFFER_RANGE_START); + expect(mockMedia.currentTime).to.equal( + 0.9 + config.skipBufferHolePadding, + ); }); }); });