implemented flush-back-buffer-for-looped-streams

This commit is contained in:
Dennis Dashkevich
2026-05-26 09:14:26 -07:00
committed by Rob Walch
parent 47527a3a18
commit 86e7131b30
8 changed files with 430 additions and 33 deletions
+4 -1
View File
@@ -778,6 +778,7 @@ export type BufferControllerConfig = {
appendTimeout: number;
backBufferLength: number;
frontBufferFlushThreshold: number;
loopBackBufferFlush?: boolean;
liveDurationInfinity: boolean;
liveBackBufferLength: number | null;
};
@@ -1812,7 +1813,9 @@ export interface FragBufferedData {
// @public (undocumented)
export interface FragChangedData {
// (undocumented)
frag: Fragment;
frag: MediaFragment;
// (undocumented)
previousFrag: MediaFragment | null;
}
// 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)
+7
View File
@@ -30,6 +30,7 @@ See [API Reference](https://hlsjs-dev.video-dev.org/api-docs/) for a complete li
- [`maxBufferLength`](#maxbufferlength)
- [`backBufferLength`](#backbufferlength)
- [`frontBufferFlushThreshold`](#frontbufferflushthreshold)
- [`loopBackBufferFlush`](#loopbackbufferflush)
- [`startOnSegmentBoundary`](#startonsegmentboundary)
- [`maxBufferSize`](#maxbuffersize)
- [`maxBufferHole`](#maxbufferhole)
@@ -648,6 +649,12 @@ The maximum duration of buffered media to keep once it has been played, in secon
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`.
### `loopBackBufferFlush`
(default: `undefined`)
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`.
### `startOnSegmentBoundary`
(default: `false`)
+2
View File
@@ -69,6 +69,7 @@ export type BufferControllerConfig = {
appendTimeout: number;
backBufferLength: number;
frontBufferFlushThreshold: number;
loopBackBufferFlush?: boolean;
liveDurationInfinity: boolean;
/**
* @deprecated use backBufferLength
@@ -426,6 +427,7 @@ export const hlsDefaultConfig: HlsConfig = {
maxBufferLength: 30, // used by stream-controller
backBufferLength: Infinity, // used by buffer-controller
frontBufferFlushThreshold: Infinity,
loopBackBufferFlush: undefined, // used by buffer-controller
startOnSegmentBoundary: false, // used by stream-controller
nextAudioTrackBufferFlushForwardOffset: 0.25, // used by stream-controller
maxBufferSize: 60 * 1000 * 1000, // used by stream-controller
+108 -27
View File
@@ -2,7 +2,7 @@ import BufferOperationQueue from './buffer-operation-queue';
import { createDoNothingErrorAction } from './error-controller';
import { ErrorDetails, ErrorTypes } from '../errors';
import { Events } from '../events';
import { ElementaryStreamTypes, isMediaFragment } from '../loader/fragment';
import { ElementaryStreamTypes } from '../loader/fragment';
import { DEFAULT_TARGET_DURATION } from '../loader/level-details';
import { PlaylistLevelType } from '../types/loader';
import { BufferHelper } from '../utils/buffer-helper';
@@ -70,6 +70,8 @@ const VIDEO_CODEC_PROFILE_REPLACE =
const TRACK_REMOVED_ERROR_NAME = 'HlsJsTrackRemovedError';
const LOOP_FLUSH_SAFETY_MARGIN = 0.25;
class HlsJsTrackRemovedError extends Error {
constructor(message) {
super(message);
@@ -1131,36 +1133,39 @@ transfer tracks: ${stringify(transferredTracks, (key, value) => (key === 'initSe
return;
}
const { backBufferLength, frontBufferFlushThreshold } = config;
this.trimBuffers(frontBufferFlushThreshold, backBufferLength);
this.trimBuffers(
frontBufferFlushThreshold,
backBufferLength,
data.frag,
data.previousFrag,
);
// Only clear append errors on successful encounter of buffered media. Init segments may complete without error for unsupported media.
if (isMediaFragment(data.frag)) {
const elementaryStreams = data.frag.elementaryStreams;
const { appendErrors } = this;
const appendErrorType = this.appendError?.sourceBufferName;
const elementaryStreams = data.frag.elementaryStreams;
const { appendErrors } = this;
const appendErrorType = this.appendError?.sourceBufferName;
Object.keys(elementaryStreams).forEach((type) => {
if (!elementaryStreams[type]) {
return;
}
appendErrors[type] = 0;
if (type === appendErrorType) {
Object.keys(elementaryStreams).forEach((type) => {
if (!elementaryStreams[type]) {
return;
}
appendErrors[type] = 0;
if (type === appendErrorType) {
this.appendError = undefined;
}
if (type === 'audio' || type === 'video') {
appendErrors.audiovideo = 0;
if (appendErrorType === 'audiovideo') {
this.appendError = undefined;
}
if (type === 'audio' || type === 'video') {
appendErrors.audiovideo = 0;
if (appendErrorType === 'audiovideo') {
this.appendError = undefined;
}
} else {
appendErrors.audio = 0;
appendErrors.video = 0;
if (appendErrorType !== 'audiovideo') {
this.appendError = undefined;
}
} else {
appendErrors.audio = 0;
appendErrors.video = 0;
if (appendErrorType !== 'audiovideo') {
this.appendError = undefined;
}
});
}
}
});
}
public get bufferedToEnd(): boolean {
@@ -1303,6 +1308,8 @@ transfer tracks: ${stringify(transferredTracks, (key, value) => (key === 'initSe
private trimBuffers(
frontBufferFlushThreshold: number,
backBufferLength: number,
frag?: MediaFragment,
previousFrag?: MediaFragment | null,
) {
const { hls, details, media } = this;
if (!media || details === null) {
@@ -1323,12 +1330,30 @@ transfer tracks: ${stringify(transferredTracks, (key, value) => (key === 'initSe
? config.liveBackBufferLength
: backBufferLength;
let targetBackBufferPosition = -Infinity;
if (Number.isFinite(backBufferLength) && backBufferLength >= 0) {
const maxBackBufferLength = Math.max(backBufferLength, targetDuration);
const targetBackBufferPosition =
targetBackBufferPosition =
Math.floor(currentTime / targetDuration) * targetDuration -
maxBackBufferLength;
}
// For looped media with a quality upgrade, extend the flush position
// to remove lower-quality segments from the back buffer.
if (frag) {
const loopFlushEnd = this.getLoopBackBufferFlushEnd(
frag,
previousFrag ?? null,
);
if (loopFlushEnd > 0) {
targetBackBufferPosition = Math.max(
targetBackBufferPosition,
loopFlushEnd,
);
}
}
if (targetBackBufferPosition > 0) {
this.flushBackBuffer(
currentTime,
targetDuration,
@@ -1358,6 +1383,57 @@ transfer tracks: ${stringify(transferredTracks, (key, value) => (key === 'initSe
}
}
/**
* For looped media, determine the back buffer flush position to remove
* lower-quality segments on a quality upgrade. Returns 0 if no flush is needed.
*/
private getLoopBackBufferFlushEnd(
frag: MediaFragment,
previousFrag: MediaFragment | null,
): number {
const { media } = this;
if (
this.hls?.config.loopBackBufferFlush === false ||
!media?.loop ||
!previousFrag ||
frag.level <= previousFrag.level
) {
return 0;
}
const { video, audiovideo } = frag.elementaryStreams;
if (video?.partial || audiovideo?.partial) {
return 0;
}
const flushEnd =
this.getEarliestElementaryStreamStart(frag) - LOOP_FLUSH_SAFETY_MARGIN;
if (flushEnd <= 0) {
return 0;
}
this.log(
`Flushing lower quality back buffer for loop: level ${frag.level}, range [0-${flushEnd.toFixed(3)}]`,
);
return flushEnd;
}
private getEarliestElementaryStreamStart(frag: MediaFragment): number {
const { audio, video, audiovideo } = frag.elementaryStreams;
let earliest = frag.start;
if (audiovideo) {
earliest = Math.min(earliest, audiovideo.startDTS);
} else {
if (audio) {
earliest = Math.min(earliest, audio.startDTS);
}
if (video) {
earliest = Math.min(earliest, video.startDTS);
}
}
return earliest;
}
private flushBackBuffer(
currentTime: number,
targetDuration: number,
@@ -1381,7 +1457,12 @@ transfer tracks: ${stringify(transferredTracks, (key, value) => (key === 'initSe
this.hls.trigger(Events.LIVE_BACK_BUFFER_REACHED, {
bufferEnd: targetBackBufferPosition,
});
} else if (track?.ended) {
} else if (
track?.ended &&
!(
this.media?.loop && this.hls?.config.loopBackBufferFlush !== false
)
) {
this.log(
`Cannot flush ${type} back buffer while SourceBuffer is in ended state`,
);
+4 -1
View File
@@ -444,7 +444,10 @@ export default class StreamController
const fragPlaying = this.fragPlaying;
if (fragPlaying) {
const fragCurrentLevel = fragPlaying.level;
this.hls.trigger(Events.FRAG_CHANGED, { frag: fragPlaying });
this.hls.trigger(Events.FRAG_CHANGED, {
frag: fragPlaying,
previousFrag,
});
if (previousFrag?.level !== fragCurrentLevel) {
this.hls.trigger(Events.LEVEL_SWITCHED, {
level: fragCurrentLevel,
+2 -1
View File
@@ -298,7 +298,8 @@ export interface SubtitleFragProcessed {
}
export interface FragChangedData {
frag: Fragment;
frag: MediaFragment;
previousFrag: MediaFragment | null;
}
export interface FPSDropData {
@@ -6,7 +6,11 @@ import { FragmentTracker } from '../../../src/controller/fragment-tracker';
import { ErrorDetails, ErrorTypes } from '../../../src/errors';
import { Events } from '../../../src/events';
import Hls from '../../../src/hls';
import { ElementaryStreamTypes, Fragment } from '../../../src/loader/fragment';
import {
ElementaryStreamTypes,
Fragment,
type MediaFragment,
} from '../../../src/loader/fragment';
import M3U8Parser from '../../../src/loader/m3u8-parser';
import { PlaylistLevelType } from '../../../src/types/loader';
import { ChunkMetadata } from '../../../src/types/transmuxer';
@@ -62,7 +66,11 @@ function setSourceBufferBufferedRange(
function evokeTrimBuffers(hls: HlsTestable) {
const frag = new Fragment(PlaylistLevelType.MAIN, '');
hls.trigger(Events.FRAG_CHANGED, { frag });
frag.sn = 0;
hls.trigger(Events.FRAG_CHANGED, {
frag: frag as MediaFragment,
previousFrag: null,
});
}
describe('BufferController with attached media', function () {
+293 -1
View File
@@ -7,7 +7,7 @@ import { State } from '../../../src/controller/base-stream-controller';
import { FragmentState } from '../../../src/controller/fragment-tracker';
import { Events } from '../../../src/events';
import Hls from '../../../src/hls';
import { Fragment } from '../../../src/loader/fragment';
import { ElementaryStreamTypes, Fragment } from '../../../src/loader/fragment';
import { LevelDetails } from '../../../src/loader/level-details';
import { LoadStats } from '../../../src/loader/load-stats';
import M3U8Parser from '../../../src/loader/m3u8-parser';
@@ -16,6 +16,7 @@ import { PlaylistLevelType } from '../../../src/types/loader';
import { AttrList } from '../../../src/utils/attr-list';
import { mockFragments as mockFragmentArray } from '../../mocks/data';
import { TimeRangesMock } from '../../mocks/time-ranges.mock';
import type BufferController from '../../../src/controller/buffer-controller';
import type { FragmentTracker } from '../../../src/controller/fragment-tracker';
import type StreamController from '../../../src/controller/stream-controller';
import type { MediaFragment } from '../../../src/loader/fragment';
@@ -774,6 +775,297 @@ describe('StreamController', function () {
});
});
describe('flush lower quality back buffer when loopBackBufferFlush and media is looped', function () {
let triggerSpy: sinon.SinonSpy;
let bufferController: BufferController;
function createMediaMock(options: {
loop: boolean;
currentTime: number;
}): HTMLMediaElement {
return {
readyState: 4,
seeking: false,
currentTime: options.currentTime,
loop: options.loop,
buffered: new TimeRangesMock([0, options.currentTime + 10]),
addEventListener: sinon.stub(),
removeEventListener: sinon.stub(),
} as unknown as HTMLMediaElement;
}
function createFragment(
sn: number,
level: number,
start: number,
options?: { partial?: boolean },
): MediaFragment {
const frag = new Fragment(PlaylistLevelType.MAIN, `frag-${sn}.ts`);
frag.sn = sn;
frag.level = level;
frag.setStart(start);
frag.duration = 10;
frag.setElementaryStreamInfo(
ElementaryStreamTypes.VIDEO,
start,
start + 10,
start,
start + 10,
options?.partial ?? false,
);
return frag as MediaFragment;
}
function getBufferFlushingCalls() {
return triggerSpy
.getCalls()
.filter((call) => call.args[0] === Events.BUFFER_FLUSHING);
}
beforeEach(function () {
triggerSpy = sinon.spy(hls, 'trigger');
bufferController = hls['bufferController'] as BufferController;
});
afterEach(function () {
triggerSpy.restore();
});
function setupLevelDetails(fragments: MediaFragment[]) {
const details = new LevelDetails('');
details.fragments = fragments;
details.startSN = fragments[0]?.sn as number;
details.endSN = fragments[fragments.length - 1]?.sn as number;
bufferController['details'] = details;
}
function attachMediaAndStubTracker(
media: HTMLMediaElement,
frag: MediaFragment,
) {
hls.trigger(Events.MEDIA_ATTACHED, { media });
// Set media directly on buffer-controller to avoid MEDIA_ATTACHING
// side effects (MediaSource creation) that leak into other tests.
bufferController['media'] = media;
// Set up a mock source buffer so flushBackBuffer can iterate and flush
const mockSourceBuffer = {
buffered: media.buffered,
updating: false,
addEventListener: sinon.stub(),
removeEventListener: sinon.stub(),
remove: sinon.stub(),
};
bufferController['sourceBuffers'] = [
['video', mockSourceBuffer as any],
[null, null],
];
bufferController['tracks'] = {
video: { buffer: mockSourceBuffer },
} as any;
fragmentTracker.getAppendedFrag = sinon.stub().returns(frag);
}
it('should flush up to the start of fragPlaying on level upgrade', function () {
hls.config.loopBackBufferFlush = true;
const media = createMediaMock({ loop: true, currentTime: 25 });
const frag0 = createFragment(0, 0, 0);
const frag1 = createFragment(1, 0, 10);
const newFrag = createFragment(2, 2, 20);
setupLevelDetails([frag0, frag1, newFrag]);
attachMediaAndStubTracker(media, frag0);
streamController.tick();
fragmentTracker.getAppendedFrag = sinon.stub().returns(newFrag);
streamController.tick();
const flushCalls = getBufferFlushingCalls();
expect(flushCalls).to.have.length(1);
expect(flushCalls[0].args[1]).to.deep.include({
startOffset: 0,
endOffset: 19.75,
});
});
it('should trigger BUFFER_FLUSHING on level upgrade when loopBackBufferFlush is unset and media.loop is true', function () {
hls.config.loopBackBufferFlush = undefined;
const media = createMediaMock({ loop: true, currentTime: 25 });
const frag0 = createFragment(0, 0, 0);
const frag1 = createFragment(1, 0, 10);
const newFrag = createFragment(2, 2, 20);
setupLevelDetails([frag0, frag1, newFrag]);
attachMediaAndStubTracker(media, frag0);
streamController.tick();
fragmentTracker.getAppendedFrag = sinon.stub().returns(newFrag);
streamController.tick();
const flushCalls = getBufferFlushingCalls();
expect(flushCalls).to.have.length(1);
expect(flushCalls[0].args[1]).to.deep.include({
startOffset: 0,
endOffset: 19.75,
});
});
it('should not trigger BUFFER_FLUSHING when loopBackBufferFlush is unset and media.loop is false', function () {
hls.config.loopBackBufferFlush = undefined;
const media = createMediaMock({ loop: false, currentTime: 25 });
const frag0 = createFragment(0, 0, 0);
const newFrag = createFragment(2, 2, 20);
setupLevelDetails([frag0, newFrag]);
attachMediaAndStubTracker(media, frag0);
streamController.tick();
fragmentTracker.getAppendedFrag = sinon.stub().returns(newFrag);
streamController.tick();
expect(getBufferFlushingCalls()).to.have.length(0);
});
it('should not trigger BUFFER_FLUSHING when loopBackBufferFlush is disabled', function () {
hls.config.loopBackBufferFlush = false;
const media = createMediaMock({ loop: true, currentTime: 25 });
const frag0 = createFragment(0, 0, 0);
const newFrag = createFragment(2, 2, 20);
setupLevelDetails([frag0, newFrag]);
attachMediaAndStubTracker(media, frag0);
streamController.tick();
fragmentTracker.getAppendedFrag = sinon.stub().returns(newFrag);
streamController.tick();
expect(getBufferFlushingCalls()).to.have.length(0);
});
it('should not trigger BUFFER_FLUSHING when media.loop is false', function () {
hls.config.loopBackBufferFlush = true;
const media = createMediaMock({ loop: false, currentTime: 25 });
const frag0 = createFragment(0, 0, 0);
const newFrag = createFragment(2, 2, 20);
setupLevelDetails([frag0, newFrag]);
attachMediaAndStubTracker(media, frag0);
streamController.tick();
fragmentTracker.getAppendedFrag = sinon.stub().returns(newFrag);
streamController.tick();
expect(getBufferFlushingCalls()).to.have.length(0);
});
it('should not trigger BUFFER_FLUSHING when level has not changed', function () {
hls.config.loopBackBufferFlush = true;
const media = createMediaMock({ loop: true, currentTime: 15 });
const frag0 = createFragment(0, 0, 0);
const frag1 = createFragment(1, 0, 10);
setupLevelDetails([frag0, frag1]);
attachMediaAndStubTracker(media, frag0);
streamController.tick();
fragmentTracker.getAppendedFrag = sinon.stub().returns(frag1);
streamController.tick();
expect(getBufferFlushingCalls()).to.have.length(0);
});
it('should not trigger BUFFER_FLUSHING when fragPlaying is the first fragment', function () {
hls.config.loopBackBufferFlush = true;
const media = createMediaMock({ loop: true, currentTime: 5 });
const frag0 = createFragment(0, 0, 0);
const newFrag0 = createFragment(0, 2, 0);
setupLevelDetails([frag0]);
attachMediaAndStubTracker(media, frag0);
streamController.tick();
// Replace level details with the new level's fragment at position 0
setupLevelDetails([newFrag0]);
fragmentTracker.getAppendedFrag = sinon.stub().returns(newFrag0);
streamController.tick();
expect(getBufferFlushingCalls()).to.have.length(0);
});
it('should not trigger BUFFER_FLUSHING twice for the same level upgrade', function () {
hls.config.loopBackBufferFlush = true;
const media = createMediaMock({ loop: true, currentTime: 25 });
const frag0 = createFragment(0, 0, 0);
const frag1 = createFragment(1, 0, 10);
const frag2 = createFragment(2, 2, 20);
const frag3 = createFragment(3, 2, 30);
setupLevelDetails([frag0, frag1, frag2, frag3]);
// Level 0→2: should flush
attachMediaAndStubTracker(media, frag0);
streamController.tick();
fragmentTracker.getAppendedFrag = sinon.stub().returns(frag2);
streamController.tick();
expect(getBufferFlushingCalls()).to.have.length(1);
// Still level 2, no level change: should not flush again
fragmentTracker.getAppendedFrag = sinon.stub().returns(frag3);
streamController.tick();
expect(getBufferFlushingCalls()).to.have.length(1);
});
it('should trigger BUFFER_FLUSHING again after level downgrade then upgrade', function () {
hls.config.loopBackBufferFlush = true;
const media = createMediaMock({ loop: true, currentTime: 25 });
const frag0 = createFragment(0, 0, 0);
const frag1 = createFragment(1, 0, 10);
const frag2 = createFragment(2, 2, 20);
const frag3 = createFragment(3, 1, 30);
const frag4 = createFragment(4, 2, 40);
setupLevelDetails([frag0, frag1, frag2, frag3, frag4]);
// Level 0→2: first flush
attachMediaAndStubTracker(media, frag0);
streamController.tick();
fragmentTracker.getAppendedFrag = sinon.stub().returns(frag2);
streamController.tick();
expect(getBufferFlushingCalls()).to.have.length(1);
// Level 2→1: downgrade, no flush
fragmentTracker.getAppendedFrag = sinon.stub().returns(frag3);
streamController.tick();
expect(getBufferFlushingCalls()).to.have.length(1);
// Level 1→2: upgrade again, should flush
fragmentTracker.getAppendedFrag = sinon.stub().returns(frag4);
streamController.tick();
const flushCalls = getBufferFlushingCalls();
expect(flushCalls).to.have.length(2);
expect(flushCalls[0].args[1]).to.deep.include({
startOffset: 0,
endOffset: 19.75,
});
// Second flush: frag4 is independent (start=40)
expect(flushCalls[1].args[1]).to.deep.include({
startOffset: 0,
endOffset: 39.75,
});
});
it('should not trigger BUFFER_FLUSHING when playing fragment is partial', function () {
hls.config.loopBackBufferFlush = true;
const media = createMediaMock({ loop: true, currentTime: 25 });
const frag0 = createFragment(0, 0, 0);
const frag1 = createFragment(1, 0, 10);
const newFrag = createFragment(2, 2, 20, { partial: true });
setupLevelDetails([frag0, frag1, newFrag]);
attachMediaAndStubTracker(media, frag0);
streamController.tick();
fragmentTracker.getAppendedFrag = sinon.stub().returns(newFrag);
streamController.tick();
expect(getBufferFlushingCalls()).to.have.length(0);
});
});
describe('abortCurrentFrag override', function () {
it('should clear backtrackFragment and call super', function () {
const mockFrag = new Fragment(PlaylistLevelType.MAIN, 'test.ts');