Skip to content

Commit b9f85e9

Browse files
authored
Fix seeking into and over multiple EXT-X-GAP segments (video-dev#6988)
* Fix seeking into and over multiple EXT-X-GAP segments Fixes video-dev#6814 * Add `hls.loadLevelObj` to API - replaces internal use of `hls.levels[hls.loadLevel]`
1 parent 305a3a7 commit b9f85e9

13 files changed

+54
-20
lines changed

api-extractor/report/hls.js.api.md

+3-1
Original file line numberDiff line numberDiff line change
@@ -1866,7 +1866,7 @@ export class FragmentTracker implements ComponentAPI {
18661866
getBufferedFrag(position: number, levelType: PlaylistLevelType): MediaFragment | null;
18671867
// (undocumented)
18681868
getFragAtPos(position: number, levelType: PlaylistLevelType, buffered?: boolean): MediaFragment | null;
1869-
getPartialFragment(time: number): Fragment | null;
1869+
getPartialFragment(time: number): MediaFragment | null;
18701870
// (undocumented)
18711871
getState(fragment: Fragment): FragmentState;
18721872
// (undocumented)
@@ -2019,6 +2019,8 @@ class Hls implements HlsEventEmitter {
20192019
get loadLevel(): number;
20202020
// Warning: (ae-setter-with-docs) The doc comment for the property "loadLevel" must appear on the getter, not the setter.
20212021
set loadLevel(newLevel: number);
2022+
// (undocumented)
2023+
get loadLevelObj(): Level | null;
20222024
loadSource(url: string): void;
20232025
readonly logger: ILogger;
20242026
get lowLatencyMode(): boolean;

docs/API.md

+6-1
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,7 @@ See [API Reference](https://hlsjs-dev.video-dev.org/api-docs/) for a complete li
190190
- [Interstitial Objects and Classes](#interstitial-objects-and-classes)
191191
- [Additional data](#additional-data)
192192
- [`hls.latestLevelDetails`](#hlslatestleveldetails)
193+
- [`hjs.loadLevelObj`](#hjsloadlevelobj)
193194
- [`hls.sessionId`](#hlssessionid)
194195
- [Runtime Events](#runtime-events)
195196
- [Creating a Custom Loader](#creating-a-custom-loader)
@@ -2145,7 +2146,11 @@ type InterstitialAssetErrorData = {
21452146
21462147
### `hls.latestLevelDetails`
21472148
2148-
- get: Returns the LevelDetails of the most up-to-date HLS variant playlist data.
2149+
- get: Returns the `LevelDetails` of last loaded level (variant) or `null` prior to loading a media playlist.
2150+
2151+
### `hjs.loadLevelObj`
2152+
2153+
- get: Returns the `Level` object of the selected level (variant) or `null` prior to selecting a level or once the level is removed.
21492154
21502155
### `hls.sessionId`
21512156

src/controller/abr-controller.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -672,8 +672,8 @@ class AbrController extends Logger implements AbrComponentAPI {
672672
}
673673
// If no matching level found, see if min auto level would be a better option
674674
const minLevel = hls.levels[minAutoLevel];
675-
const autoLevel = hls.levels[hls.loadLevel];
676-
if (minLevel?.bitrate < autoLevel?.bitrate) {
675+
const autoLevel = hls.loadLevelObj;
676+
if (autoLevel && minLevel?.bitrate < autoLevel.bitrate) {
677677
return minAutoLevel;
678678
}
679679
// or if bitrate is not lower, continue to use loadLevel

src/controller/base-stream-controller.ts

+4-4
Original file line numberDiff line numberDiff line change
@@ -1215,11 +1215,11 @@ export default class BaseStreamController
12151215
bufferedFragAtPos &&
12161216
(bufferInfo.nextStart <= bufferedFragAtPos.end || bufferedFragAtPos.gap)
12171217
) {
1218-
return BufferHelper.bufferInfo(
1219-
bufferable,
1220-
pos,
1221-
Math.max(bufferInfo.nextStart, maxBufferHole),
1218+
const gapDuration = Math.max(
1219+
Math.min(bufferInfo.nextStart, bufferedFragAtPos.end) - pos,
1220+
maxBufferHole,
12221221
);
1222+
return BufferHelper.bufferInfo(bufferable, pos, gapDuration);
12231223
}
12241224
}
12251225
return bufferInfo;

src/controller/error-controller.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,7 @@ export default class ErrorController
176176
case ErrorDetails.SUBTITLE_LOAD_ERROR:
177177
case ErrorDetails.SUBTITLE_TRACK_LOAD_TIMEOUT:
178178
if (context) {
179-
const level = hls.levels[hls.loadLevel];
179+
const level = hls.loadLevelObj;
180180
if (
181181
level &&
182182
((context.type === PlaylistContextType.AUDIO_TRACK &&
@@ -200,7 +200,7 @@ export default class ErrorController
200200
return;
201201
case ErrorDetails.KEY_SYSTEM_STATUS_OUTPUT_RESTRICTED:
202202
{
203-
const level = hls.levels[hls.loadLevel];
203+
const level = hls.loadLevelObj;
204204
const restrictedHdcpLevel = level?.attrs['HDCP-LEVEL'];
205205
if (restrictedHdcpLevel) {
206206
data.errorAction = {

src/controller/fragment-tracker.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -321,7 +321,7 @@ export class FragmentTracker implements ComponentAPI {
321321
/**
322322
* Gets the partial fragment for a certain time
323323
*/
324-
public getPartialFragment(time: number): Fragment | null {
324+
public getPartialFragment(time: number): MediaFragment | null {
325325
let bestFragment: Fragment | null = null;
326326
let timePadding: number;
327327
let startTime: number;

src/controller/gap-controller.ts

+17-4
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import type { InFlightData } from './base-stream-controller';
1212
import type { InFlightFragments } from '../hls';
1313
import type Hls from '../hls';
1414
import type { FragmentTracker } from './fragment-tracker';
15-
import type { Fragment } from '../loader/fragment';
15+
import type { Fragment, MediaFragment } from '../loader/fragment';
1616
import type { SourceBufferName } from '../types/buffer';
1717
import type {
1818
BufferAppendedData,
@@ -503,7 +503,7 @@ export default class GapController extends TaskLoop {
503503
* @param partial - The partial fragment found at the current time (where playback is stalling).
504504
* @private
505505
*/
506-
private _trySkipBufferHole(partial: Fragment | null): number {
506+
private _trySkipBufferHole(partial: MediaFragment | null): number {
507507
const { fragmentTracker, media } = this;
508508
const config = this.hls?.config;
509509
if (!media || !fragmentTracker || !config) {
@@ -515,7 +515,7 @@ export default class GapController extends TaskLoop {
515515
const bufferInfo = BufferHelper.bufferInfo(media, currentTime, 0);
516516
const startTime =
517517
currentTime < bufferInfo.start ? bufferInfo.start : bufferInfo.nextStart;
518-
if (startTime) {
518+
if (startTime && this.hls) {
519519
const bufferStarved = bufferInfo.len <= config.maxBufferHole;
520520
const waiting =
521521
bufferInfo.len > 0 && bufferInfo.len < 1 && media.readyState < 3;
@@ -541,6 +541,19 @@ export default class GapController extends TaskLoop {
541541
PlaylistLevelType.MAIN,
542542
);
543543
if (startProvisioned) {
544+
// Do not seek when selected variant playlist is unloaded
545+
if (!this.hls.loadLevelObj?.details) {
546+
return 0;
547+
}
548+
// Do not seek when required fragments are inflight or appending
549+
const inFlightDependency = getInFlightDependency(
550+
this.hls.inFlightFragments,
551+
startTime,
552+
);
553+
if (inFlightDependency) {
554+
return 0;
555+
}
556+
// Do not seek if we can't walk tracked fragments to end of gap
544557
let moreToLoad = false;
545558
let pos = startProvisioned.end;
546559
while (pos < startTime) {
@@ -567,7 +580,7 @@ export default class GapController extends TaskLoop {
567580
);
568581
this.moved = true;
569582
media.currentTime = targetTime;
570-
if (!partial?.gap && this.hls) {
583+
if (!partial?.gap) {
571584
const error = new Error(
572585
`fragment loaded with buffer holes, seeking from ${currentTime} to ${targetTime}`,
573586
);

src/controller/interstitials-controller.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -1921,7 +1921,7 @@ Schedule: ${scheduleItems.map((seg) => segmentToString(seg))}`,
19211921
const userConfig = primary.userConfig;
19221922
let videoPreference = userConfig.videoPreference;
19231923
const currentLevel =
1924-
primary.levels[primary.loadLevel] || primary.levels[primary.currentLevel];
1924+
primary.loadLevelObj || primary.levels[primary.currentLevel];
19251925
if (videoPreference || currentLevel) {
19261926
videoPreference = Object.assign({}, videoPreference);
19271927
if (currentLevel.videoCodec) {

src/controller/level-controller.ts

+4
Original file line numberDiff line numberDiff line change
@@ -389,6 +389,10 @@ export default class LevelController extends BasePlaylistController {
389389
return this._levels;
390390
}
391391

392+
get loadLevelObj(): Level | null {
393+
return this.currentLevel;
394+
}
395+
392396
get level(): number {
393397
return this.currentLevelIndex;
394398
}

src/controller/stream-controller.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -237,7 +237,7 @@ export default class StreamController
237237

238238
protected onTickEnd() {
239239
super.onTickEnd();
240-
if (this.media?.readyState) {
240+
if (this.media?.readyState && this.media.seeking === false) {
241241
this.lastCurrentTime = this.media.currentTime;
242242
}
243243
this.checkFragmentChanged();

src/hls.ts

+10
Original file line numberDiff line numberDiff line change
@@ -690,10 +690,20 @@ export default class Hls implements HlsEventEmitter {
690690
return levels ? levels : [];
691691
}
692692

693+
/**
694+
* @returns LevelDetails of last loaded level (variant) or `null` prior to loading a media playlist.
695+
*/
693696
get latestLevelDetails(): LevelDetails | null {
694697
return this.streamController.getLevelDetails() || null;
695698
}
696699

700+
/**
701+
* @returns Level object of selected level (variant) or `null` prior to selecting a level or once the level is removed.
702+
*/
703+
get loadLevelObj(): Level | null {
704+
return this.levelController.loadLevelObj;
705+
}
706+
697707
/**
698708
* Index of quality level (variant) currently played
699709
*/

src/utils/rendition-helper.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -502,5 +502,5 @@ export function useAlternateAudio(
502502
audioTrackUrl: string | undefined,
503503
hls: Hls,
504504
): boolean {
505-
return !!audioTrackUrl && audioTrackUrl !== hls.levels[hls.loadLevel]?.uri;
505+
return !!audioTrackUrl && audioTrackUrl !== hls.loadLevelObj?.uri;
506506
}

tests/unit/controller/gap-controller.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import {
1616
import { MockMediaElement, MockMediaSource } from '../utils/mock-media';
1717
import type { HlsConfig } from '../../../src/config';
1818
import type StreamController from '../../../src/controller/stream-controller';
19-
import type { Fragment } from '../../../src/loader/fragment';
19+
import type { Fragment, MediaFragment } from '../../../src/loader/fragment';
2020

2121
chai.use(sinonChai);
2222
const expect = chai.expect;
@@ -202,7 +202,7 @@ describe('GapController', function () {
202202
const bufferInfo = BufferHelper.bufferedInfo([], 0, 0);
203203
sandbox
204204
.stub(gapController.fragmentTracker, 'getPartialFragment')
205-
.returns({} as unknown as Fragment);
205+
.returns({} as unknown as MediaFragment);
206206
const skipHoleStub = sandbox.stub(gapController, '_trySkipBufferHole');
207207
gapController._tryFixBufferStall(bufferInfo, 100);
208208
expect(skipHoleStub).to.have.been.calledOnce;

0 commit comments

Comments
 (0)