Skip to content

Commit 86e7131

Browse files
dzianis-dashkevichrobwalch
authored andcommitted
implemented flush-back-buffer-for-looped-streams
1 parent 47527a3 commit 86e7131

8 files changed

Lines changed: 430 additions & 33 deletions

File tree

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -778,6 +778,7 @@ export type BufferControllerConfig = {
778778
appendTimeout: number;
779779
backBufferLength: number;
780780
frontBufferFlushThreshold: number;
781+
loopBackBufferFlush?: boolean;
781782
liveDurationInfinity: boolean;
782783
liveBackBufferLength: number | null;
783784
};
@@ -1812,7 +1813,9 @@ export interface FragBufferedData {
18121813
// @public (undocumented)
18131814
export interface FragChangedData {
18141815
// (undocumented)
1815-
frag: Fragment;
1816+
frag: MediaFragment;
1817+
// (undocumented)
1818+
previousFrag: MediaFragment | null;
18161819
}
18171820

18181821
// Warning: (ae-missing-release-tag) "FragDecryptedData" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal)

docs/API.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ See [API Reference](https://hlsjs-dev.video-dev.org/api-docs/) for a complete li
3030
- [`maxBufferLength`](#maxbufferlength)
3131
- [`backBufferLength`](#backbufferlength)
3232
- [`frontBufferFlushThreshold`](#frontbufferflushthreshold)
33+
- [`loopBackBufferFlush`](#loopbackbufferflush)
3334
- [`startOnSegmentBoundary`](#startonsegmentboundary)
3435
- [`maxBufferSize`](#maxbuffersize)
3536
- [`maxBufferHole`](#maxbufferhole)
@@ -648,6 +649,12 @@ The maximum duration of buffered media to keep once it has been played, in secon
648649

649650
The maximum duration of buffered media, in seconds, from the play position to keep before evicting non-contiguous forward ranges. A value of `Infinity` means no active eviction will take place; This value will always be at least the `maxBufferLength`.
650651

652+
### `loopBackBufferFlush`
653+
654+
(default: `undefined`)
655+
656+
Controls back-buffer flushing on quality upgrades when the underlying `HTMLMediaElement` has `loop` set to `true`. When the player switches up to a higher-quality level during looped playback, the back buffer holds lower-quality segments that will be played again on the next loop. By default (`undefined`), HLS.js flushes those segments so the loop replays at the new, higher quality. Set this to `false` to opt out and preserve the existing back buffer across loops. Has no effect when `media.loop` is `false`.
657+
651658
### `startOnSegmentBoundary`
652659

653660
(default: `false`)

src/config.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ export type BufferControllerConfig = {
6969
appendTimeout: number;
7070
backBufferLength: number;
7171
frontBufferFlushThreshold: number;
72+
loopBackBufferFlush?: boolean;
7273
liveDurationInfinity: boolean;
7374
/**
7475
* @deprecated use backBufferLength
@@ -426,6 +427,7 @@ export const hlsDefaultConfig: HlsConfig = {
426427
maxBufferLength: 30, // used by stream-controller
427428
backBufferLength: Infinity, // used by buffer-controller
428429
frontBufferFlushThreshold: Infinity,
430+
loopBackBufferFlush: undefined, // used by buffer-controller
429431
startOnSegmentBoundary: false, // used by stream-controller
430432
nextAudioTrackBufferFlushForwardOffset: 0.25, // used by stream-controller
431433
maxBufferSize: 60 * 1000 * 1000, // used by stream-controller

src/controller/buffer-controller.ts

Lines changed: 108 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import BufferOperationQueue from './buffer-operation-queue';
22
import { createDoNothingErrorAction } from './error-controller';
33
import { ErrorDetails, ErrorTypes } from '../errors';
44
import { Events } from '../events';
5-
import { ElementaryStreamTypes, isMediaFragment } from '../loader/fragment';
5+
import { ElementaryStreamTypes } from '../loader/fragment';
66
import { DEFAULT_TARGET_DURATION } from '../loader/level-details';
77
import { PlaylistLevelType } from '../types/loader';
88
import { BufferHelper } from '../utils/buffer-helper';
@@ -70,6 +70,8 @@ const VIDEO_CODEC_PROFILE_REPLACE =
7070

7171
const TRACK_REMOVED_ERROR_NAME = 'HlsJsTrackRemovedError';
7272

73+
const LOOP_FLUSH_SAFETY_MARGIN = 0.25;
74+
7375
class HlsJsTrackRemovedError extends Error {
7476
constructor(message) {
7577
super(message);
@@ -1131,36 +1133,39 @@ transfer tracks: ${stringify(transferredTracks, (key, value) => (key === 'initSe
11311133
return;
11321134
}
11331135
const { backBufferLength, frontBufferFlushThreshold } = config;
1134-
this.trimBuffers(frontBufferFlushThreshold, backBufferLength);
1136+
this.trimBuffers(
1137+
frontBufferFlushThreshold,
1138+
backBufferLength,
1139+
data.frag,
1140+
data.previousFrag,
1141+
);
11351142

11361143
// Only clear append errors on successful encounter of buffered media. Init segments may complete without error for unsupported media.
1137-
if (isMediaFragment(data.frag)) {
1138-
const elementaryStreams = data.frag.elementaryStreams;
1139-
const { appendErrors } = this;
1140-
const appendErrorType = this.appendError?.sourceBufferName;
1144+
const elementaryStreams = data.frag.elementaryStreams;
1145+
const { appendErrors } = this;
1146+
const appendErrorType = this.appendError?.sourceBufferName;
11411147

1142-
Object.keys(elementaryStreams).forEach((type) => {
1143-
if (!elementaryStreams[type]) {
1144-
return;
1145-
}
1146-
appendErrors[type] = 0;
1147-
if (type === appendErrorType) {
1148+
Object.keys(elementaryStreams).forEach((type) => {
1149+
if (!elementaryStreams[type]) {
1150+
return;
1151+
}
1152+
appendErrors[type] = 0;
1153+
if (type === appendErrorType) {
1154+
this.appendError = undefined;
1155+
}
1156+
if (type === 'audio' || type === 'video') {
1157+
appendErrors.audiovideo = 0;
1158+
if (appendErrorType === 'audiovideo') {
11481159
this.appendError = undefined;
11491160
}
1150-
if (type === 'audio' || type === 'video') {
1151-
appendErrors.audiovideo = 0;
1152-
if (appendErrorType === 'audiovideo') {
1153-
this.appendError = undefined;
1154-
}
1155-
} else {
1156-
appendErrors.audio = 0;
1157-
appendErrors.video = 0;
1158-
if (appendErrorType !== 'audiovideo') {
1159-
this.appendError = undefined;
1160-
}
1161+
} else {
1162+
appendErrors.audio = 0;
1163+
appendErrors.video = 0;
1164+
if (appendErrorType !== 'audiovideo') {
1165+
this.appendError = undefined;
11611166
}
1162-
});
1163-
}
1167+
}
1168+
});
11641169
}
11651170

11661171
public get bufferedToEnd(): boolean {
@@ -1303,6 +1308,8 @@ transfer tracks: ${stringify(transferredTracks, (key, value) => (key === 'initSe
13031308
private trimBuffers(
13041309
frontBufferFlushThreshold: number,
13051310
backBufferLength: number,
1311+
frag?: MediaFragment,
1312+
previousFrag?: MediaFragment | null,
13061313
) {
13071314
const { hls, details, media } = this;
13081315
if (!media || details === null) {
@@ -1323,12 +1330,30 @@ transfer tracks: ${stringify(transferredTracks, (key, value) => (key === 'initSe
13231330
? config.liveBackBufferLength
13241331
: backBufferLength;
13251332

1333+
let targetBackBufferPosition = -Infinity;
13261334
if (Number.isFinite(backBufferLength) && backBufferLength >= 0) {
13271335
const maxBackBufferLength = Math.max(backBufferLength, targetDuration);
1328-
const targetBackBufferPosition =
1336+
targetBackBufferPosition =
13291337
Math.floor(currentTime / targetDuration) * targetDuration -
13301338
maxBackBufferLength;
1339+
}
1340+
1341+
// For looped media with a quality upgrade, extend the flush position
1342+
// to remove lower-quality segments from the back buffer.
1343+
if (frag) {
1344+
const loopFlushEnd = this.getLoopBackBufferFlushEnd(
1345+
frag,
1346+
previousFrag ?? null,
1347+
);
1348+
if (loopFlushEnd > 0) {
1349+
targetBackBufferPosition = Math.max(
1350+
targetBackBufferPosition,
1351+
loopFlushEnd,
1352+
);
1353+
}
1354+
}
13311355

1356+
if (targetBackBufferPosition > 0) {
13321357
this.flushBackBuffer(
13331358
currentTime,
13341359
targetDuration,
@@ -1358,6 +1383,57 @@ transfer tracks: ${stringify(transferredTracks, (key, value) => (key === 'initSe
13581383
}
13591384
}
13601385

1386+
/**
1387+
* For looped media, determine the back buffer flush position to remove
1388+
* lower-quality segments on a quality upgrade. Returns 0 if no flush is needed.
1389+
*/
1390+
private getLoopBackBufferFlushEnd(
1391+
frag: MediaFragment,
1392+
previousFrag: MediaFragment | null,
1393+
): number {
1394+
const { media } = this;
1395+
if (
1396+
this.hls?.config.loopBackBufferFlush === false ||
1397+
!media?.loop ||
1398+
!previousFrag ||
1399+
frag.level <= previousFrag.level
1400+
) {
1401+
return 0;
1402+
}
1403+
1404+
const { video, audiovideo } = frag.elementaryStreams;
1405+
if (video?.partial || audiovideo?.partial) {
1406+
return 0;
1407+
}
1408+
1409+
const flushEnd =
1410+
this.getEarliestElementaryStreamStart(frag) - LOOP_FLUSH_SAFETY_MARGIN;
1411+
if (flushEnd <= 0) {
1412+
return 0;
1413+
}
1414+
1415+
this.log(
1416+
`Flushing lower quality back buffer for loop: level ${frag.level}, range [0-${flushEnd.toFixed(3)}]`,
1417+
);
1418+
return flushEnd;
1419+
}
1420+
1421+
private getEarliestElementaryStreamStart(frag: MediaFragment): number {
1422+
const { audio, video, audiovideo } = frag.elementaryStreams;
1423+
let earliest = frag.start;
1424+
if (audiovideo) {
1425+
earliest = Math.min(earliest, audiovideo.startDTS);
1426+
} else {
1427+
if (audio) {
1428+
earliest = Math.min(earliest, audio.startDTS);
1429+
}
1430+
if (video) {
1431+
earliest = Math.min(earliest, video.startDTS);
1432+
}
1433+
}
1434+
return earliest;
1435+
}
1436+
13611437
private flushBackBuffer(
13621438
currentTime: number,
13631439
targetDuration: number,
@@ -1381,7 +1457,12 @@ transfer tracks: ${stringify(transferredTracks, (key, value) => (key === 'initSe
13811457
this.hls.trigger(Events.LIVE_BACK_BUFFER_REACHED, {
13821458
bufferEnd: targetBackBufferPosition,
13831459
});
1384-
} else if (track?.ended) {
1460+
} else if (
1461+
track?.ended &&
1462+
!(
1463+
this.media?.loop && this.hls?.config.loopBackBufferFlush !== false
1464+
)
1465+
) {
13851466
this.log(
13861467
`Cannot flush ${type} back buffer while SourceBuffer is in ended state`,
13871468
);

src/controller/stream-controller.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -444,7 +444,10 @@ export default class StreamController
444444
const fragPlaying = this.fragPlaying;
445445
if (fragPlaying) {
446446
const fragCurrentLevel = fragPlaying.level;
447-
this.hls.trigger(Events.FRAG_CHANGED, { frag: fragPlaying });
447+
this.hls.trigger(Events.FRAG_CHANGED, {
448+
frag: fragPlaying,
449+
previousFrag,
450+
});
448451
if (previousFrag?.level !== fragCurrentLevel) {
449452
this.hls.trigger(Events.LEVEL_SWITCHED, {
450453
level: fragCurrentLevel,

src/types/events.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -298,7 +298,8 @@ export interface SubtitleFragProcessed {
298298
}
299299

300300
export interface FragChangedData {
301-
frag: Fragment;
301+
frag: MediaFragment;
302+
previousFrag: MediaFragment | null;
302303
}
303304

304305
export interface FPSDropData {

tests/unit/controller/buffer-controller-operations.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,11 @@ import { FragmentTracker } from '../../../src/controller/fragment-tracker';
66
import { ErrorDetails, ErrorTypes } from '../../../src/errors';
77
import { Events } from '../../../src/events';
88
import Hls from '../../../src/hls';
9-
import { ElementaryStreamTypes, Fragment } from '../../../src/loader/fragment';
9+
import {
10+
ElementaryStreamTypes,
11+
Fragment,
12+
type MediaFragment,
13+
} from '../../../src/loader/fragment';
1014
import M3U8Parser from '../../../src/loader/m3u8-parser';
1115
import { PlaylistLevelType } from '../../../src/types/loader';
1216
import { ChunkMetadata } from '../../../src/types/transmuxer';
@@ -62,7 +66,11 @@ function setSourceBufferBufferedRange(
6266

6367
function evokeTrimBuffers(hls: HlsTestable) {
6468
const frag = new Fragment(PlaylistLevelType.MAIN, '');
65-
hls.trigger(Events.FRAG_CHANGED, { frag });
69+
frag.sn = 0;
70+
hls.trigger(Events.FRAG_CHANGED, {
71+
frag: frag as MediaFragment,
72+
previousFrag: null,
73+
});
6674
}
6775

6876
describe('BufferController with attached media', function () {

0 commit comments

Comments
 (0)