Fixed issue where certain Tizen devices would get stuck seeking over buffer gaps (#7630)

Authored-by: Kyle Seager <kyle.seager@mydirectv.com>
This commit is contained in:
Kyle Seager
2025-11-14 16:19:33 -07:00
committed by GitHub
parent 6238bc8aa5
commit 36078f82ac
5 changed files with 47 additions and 23 deletions
+1
View File
@@ -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)
+17 -1
View File
@@ -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)
+2
View File
@@ -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
+20 -17
View File
@@ -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,
+7 -5
View File
@@ -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,
);
});
});
});