From 890f425c21b448637d03e57d9da8bfd8759f8062 Mon Sep 17 00:00:00 2001 From: Kyle Seager Date: Sun, 12 Apr 2026 12:25:56 -0600 Subject: [PATCH 01/12] Replace reactive data.push with single dataset assignment in timeline chart & address issue with length assignment --- demo/chart/timeline-chart.ts | 80 +++++++++++++++--------------------- 1 file changed, 33 insertions(+), 47 deletions(-) diff --git a/demo/chart/timeline-chart.ts b/demo/chart/timeline-chart.ts index 0f06269614b..cb6ec8ba3b4 100644 --- a/demo/chart/timeline-chart.ts +++ b/demo/chart/timeline-chart.ts @@ -1,15 +1,15 @@ import Chart from 'chart.js'; import { applyChartInstanceOverrides, hhmmss } from './chartjs-horizontal-bar'; -import { Fragment } from '../../src/loader/fragment'; -import type { Level } from '../../src/types/level'; -import type { TrackSet } from '../../src/types/track'; -import type { MediaPlaylist } from '../../src/types/media-playlist'; +import type { Fragment } from '../../src/loader/fragment'; import type { LevelDetails } from '../../src/loader/level-details'; -import { +import type { FragChangedData, FragLoadedData, FragParsedData, } from '../../src/types/events'; +import type { Level } from '../../src/types/level'; +import type { MediaPlaylist } from '../../src/types/media-playlist'; +import type { TrackSet } from '../../src/types/track'; declare global { interface Window { @@ -413,49 +413,35 @@ export class TimelineChart { if (!levelDataSet) { return; } - const data = levelDataSet.data; - data.length = 0; - if (details.fragments) { - details.fragments.forEach((fragment) => { - // TODO: keep track of initial playlist start and duration so that we can show drift and pts offset - // (Make that a feature of hls.js v1.0.0 fragments) - const chartFragment = Object.assign( - { - dataType: 'fragment', - }, - fragment, - // Remove loader references for GC - { loader: null } - ); - data.push(chartFragment); - }); - } - if (details.partList) { - details.partList.forEach((part) => { - const chartPart = Object.assign( - { + // TODO: keep track of initial playlist start and duration so that we can show drift and pts offset + // (Make that a feature of hls.js v1.0.0 fragments) + const fragments = (details.fragments || []).map((fragment) => ({ + dataType: 'fragment', + ...fragment, + loader: null, + })); + + const parts = details.partList + ? [ + ...details.partList.map((part) => ({ dataType: 'part', + ...part, start: part.fragment.start + part.fragOffset, - }, - part, - { - fragment: Object.assign({}, part.fragment, { loader: null }), - } - ); - data.push(chartPart); - }); - if (details.fragmentHint) { - const chartFragment = Object.assign( - { - dataType: 'fragmentHint', - }, - details.fragmentHint, - // Remove loader references for GC - { loader: null } - ); - data.push(chartFragment); - } - } + fragment: { ...part.fragment, loader: null }, + })), + ...(details.fragmentHint + ? [ + { + dataType: 'fragmentHint', + ...details.fragmentHint, + loader: null, + }, + ] + : []), + ] + : []; + + levelDataSet.data = [...fragments, ...parts]; const start = getPlaylistStart(details); this.maxZoom = this.zoom100 = Math.max( start + totalduration + targetduration * 3, @@ -718,7 +704,7 @@ export class TimelineChart { const currentTime = self.hls.media.currentTime; const scale = this.chartScales[X_AXIS_SECONDS]; const ctx = chart.ctx; - if (this.hidden || !ctx || !ctx.canvas.width) { + if (this.hidden || !ctx?.canvas.width) { return; } const chartArea: { left; top; right; bottom } = chart.chartArea; From f4fcd2c2e47a531630131d79cc9dbc49834e2cc9 Mon Sep 17 00:00:00 2001 From: Kyle Seager Date: Thu, 21 May 2026 22:58:25 -0600 Subject: [PATCH 02/12] implemented parallel init segment downloading --- src/controller/audio-stream-controller.ts | 6 +- src/controller/base-stream-controller.ts | 77 +++++++++++--------- src/controller/stream-controller.ts | 10 +-- src/controller/subtitle-stream-controller.ts | 14 +--- src/loader/fragment-loader.ts | 31 +++++++- 5 files changed, 79 insertions(+), 59 deletions(-) diff --git a/src/controller/audio-stream-controller.ts b/src/controller/audio-stream-controller.ts index daddde331cf..224ec96ada7 100644 --- a/src/controller/audio-stream-controller.ts +++ b/src/controller/audio-stream-controller.ts @@ -1036,7 +1036,7 @@ class AudioStreamController } protected loadFragment( - frag: Fragment, + frag: MediaFragment, track: Level, targetBufferTime: number, ) { @@ -1049,9 +1049,7 @@ class AudioStreamController fragState === FragmentState.NOT_LOADED || fragState === FragmentState.PARTIAL ) { - if (!isMediaFragment(frag)) { - this._loadInitSegment(frag, track); - } else if (track.details?.live && !this.initPTS[frag.cc]) { + if (track.details?.live && !this.initPTS[frag.cc]) { this.log( `Waiting for video PTS in continuity counter ${frag.cc} of live stream before loading audio fragment ${frag.sn} of level ${this.trackId}`, ); diff --git a/src/controller/base-stream-controller.ts b/src/controller/base-stream-controller.ts index dd6fd67aa9d..42865103745 100644 --- a/src/controller/base-stream-controller.ts +++ b/src/controller/base-stream-controller.ts @@ -119,6 +119,7 @@ export default class BaseStreamController protected retryDate: number = 0; protected levels: Array | null = null; protected fragmentLoader: FragmentLoader; + protected initFragmentLoader: FragmentLoader; protected keyLoader: KeyLoader; protected levelLastLoaded: Level | null = null; protected startFragRequested: boolean = false; @@ -139,6 +140,7 @@ export default class BaseStreamController this.playlistType = playlistType; this.hls = hls; this.fragmentLoader = new FragmentLoader(hls.config); + this.initFragmentLoader = new FragmentLoader(hls.config); this.keyLoader = keyLoader; this.fragmentTracker = fragmentTracker; this.config = hls.config; @@ -176,6 +178,7 @@ export default class BaseStreamController return; } this.fragmentLoader.abort(); + this.initFragmentLoader.abort(); this.keyLoader.abort(this.playlistType); const frag = this.fragCurrent; if (frag?.loader) { @@ -504,6 +507,9 @@ export default class BaseStreamController if (this.fragmentLoader) { this.fragmentLoader.destroy(); } + if (this.initFragmentLoader) { + this.initFragmentLoader.destroy(); + } if (this.keyLoader) { this.keyLoader.destroy(); } @@ -517,6 +523,7 @@ export default class BaseStreamController this.decrypter = this.keyLoader = this.fragmentLoader = + this.initFragmentLoader = this.fragmentTracker = null as any; super.onHandlerDestroyed(); @@ -695,18 +702,20 @@ export default class BaseStreamController this.hls.trigger(Events.BUFFER_FLUSHING, flushScope); } - protected _loadInitSegment(fragment: Fragment, level: Level) { - this._doFragLoad(fragment, level) + protected _loadInitSegment(initFrag: Fragment, _level: Level): Promise { + const { hls } = this; + this.initFragmentLoader.abort(); + hls.trigger(Events.FRAG_LOADING, { frag: initFrag, targetBufferTime: 0 }); + return this.initFragmentLoader + .load(initFrag) .then((data) => { const frag = data?.frag; - if (!frag || this.fragContextChanged(frag) || !this.levels) { + if (!frag || this.fragCurrent?.initSegment !== frag || !this.levels) { throw new Error('init load aborted'); } - return data; }) .then((data: FragLoadedData) => { - const { hls } = this; const { frag, payload } = data; const decryptData = frag.decryptdata; @@ -758,29 +767,28 @@ export default class BaseStreamController }); } return this.completeInitSegmentLoad(data); - }) - .catch((reason) => { - if (this.state === State.STOPPED || this.state === State.ERROR) { - return; - } - this.warn(reason); - this.resetFragmentLoading(fragment); }); } private completeInitSegmentLoad(data: FragLoadedData) { - const { levels } = this; - if (!levels) { - throw new Error('init load aborted, missing levels'); - } const stats = data.frag.stats; - if (this.state !== State.STOPPED) { - this.state = State.IDLE; - } data.frag.data = new Uint8Array(data.payload); stats.parsing.start = stats.buffering.start = self.performance.now(); stats.parsing.end = stats.buffering.end = self.performance.now(); - this.tick(); + } + + private loadInitSegmentIfNeeded( + frag: Fragment, + level: Level, + ): Promise | undefined { + if ( + !this.bitrateTest && + isMediaFragment(frag) && + frag.initSegment && + !frag.initSegment.data + ) { + return this._loadInitSegment(frag.initSegment, level); + } } protected unhandledEncryptionError( @@ -1045,6 +1053,9 @@ export default class BaseStreamController // Load key before streaming fragment data const dataOnProgress = this.config.progressive && frag.type !== PlaylistLevelType.SUBTITLE; + + const initDataPromise = this.loadInitSegmentIfNeeded(frag, level); + let result: Promise; if (dataOnProgress && keyLoadingPromise) { result = keyLoadingPromise @@ -1056,6 +1067,7 @@ export default class BaseStreamController frag, this.iframesOnly, progressCallback, + initDataPromise, ); }) .catch((error) => this.handleFragLoadError(error)); @@ -1067,6 +1079,7 @@ export default class BaseStreamController frag, this.iframesOnly, dataOnProgress ? progressCallback : undefined, + initDataPromise, ), keyLoadingPromise, ]) @@ -1084,7 +1097,10 @@ export default class BaseStreamController new Error(`frag load aborted, context changed in FRAG_LOADING`), ); } - return result; + + return Promise.all([result, initDataPromise]).then( + ([fragData]) => fragData, + ); } private doFragPartsLoad( @@ -1415,7 +1431,7 @@ export default class BaseStreamController protected getNextFragment( pos: number, levelDetails: LevelDetails, - ): Fragment | null { + ): MediaFragment | null { const fragments = levelDetails.fragments; const fragLen = fragments.length; @@ -1507,7 +1523,7 @@ export default class BaseStreamController levelDetails, ); } - return this.mapToInitFragWhenRequired(programFrag); + return programFrag; } protected isLoopLoading(frag: Fragment, targetBufferTime: number): boolean { @@ -1531,13 +1547,13 @@ export default class BaseStreamController } protected getNextFragmentLoopLoading( - frag: Fragment, + frag: MediaFragment, levelDetails: LevelDetails, bufferInfo: BufferInfo, playlistType: PlaylistLevelType, maxBufLen: number, - ): Fragment | null { - let nextFragment: Fragment | null = null; + ): MediaFragment | null { + let nextFragment: MediaFragment | null = null; if (frag.gap) { nextFragment = this.getNextFragment(this.nextLoadPosition, levelDetails); if (nextFragment && !nextFragment.gap && bufferInfo.nextStart) { @@ -1641,15 +1657,6 @@ export default class BaseStreamController return frag; } - mapToInitFragWhenRequired(frag: Fragment | null): typeof frag { - // If an initSegment is present, it must be buffered first - if (frag?.initSegment && !frag.initSegment.data && !this.bitrateTest) { - return frag.initSegment; - } - - return frag; - } - getNextPart( partList: Part[], frag: Fragment, diff --git a/src/controller/stream-controller.ts b/src/controller/stream-controller.ts index 1014a5a2824..4c90954ebe1 100644 --- a/src/controller/stream-controller.ts +++ b/src/controller/stream-controller.ts @@ -371,15 +371,11 @@ export default class StreamController return; } - if (frag.initSegment && !frag.initSegment.data && !this.bitrateTest) { - frag = frag.initSegment; - } - this.loadFragment(frag, levelInfo, targetBufferTime); } protected loadFragment( - frag: Fragment, + frag: MediaFragment, level: Level, targetBufferTime: number, ) { @@ -389,9 +385,7 @@ export default class StreamController fragState === FragmentState.NOT_LOADED || fragState === FragmentState.PARTIAL ) { - if (!isMediaFragment(frag)) { - this._loadInitSegment(frag, level); - } else if (this.bitrateTest) { + if (this.bitrateTest) { this.log( `Fragment ${frag.sn} of level ${frag.level} is being downloaded to test bitrate and will not be buffered`, ); diff --git a/src/controller/subtitle-stream-controller.ts b/src/controller/subtitle-stream-controller.ts index 80f918a78b3..a028c69437f 100644 --- a/src/controller/subtitle-stream-controller.ts +++ b/src/controller/subtitle-stream-controller.ts @@ -2,11 +2,7 @@ import BaseStreamController, { State } from './base-stream-controller'; import { FragmentState } from './fragment-tracker'; import { ErrorDetails, ErrorTypes } from '../errors'; import { Events } from '../events'; -import { - type Fragment, - isMediaFragment, - type MediaFragment, -} from '../loader/fragment'; +import { isMediaFragment, type MediaFragment } from '../loader/fragment'; import { Level } from '../types/level'; import { PlaylistLevelType } from '../types/loader'; import { BufferHelper } from '../utils/buffer-helper'; @@ -450,7 +446,7 @@ export class SubtitleStreamController } protected loadFragment( - frag: Fragment, + frag: MediaFragment, level: Level, targetBufferTime: number, ) { @@ -460,11 +456,7 @@ export class SubtitleStreamController fragState === FragmentState.NOT_LOADED || fragState === FragmentState.PARTIAL ) { - if (!isMediaFragment(frag)) { - this._loadInitSegment(frag, level); - } else { - super.loadFragment(frag, level, targetBufferTime); - } + super.loadFragment(frag, level, targetBufferTime); } } diff --git a/src/loader/fragment-loader.ts b/src/loader/fragment-loader.ts index 2f5bca474b9..1594e8b582e 100644 --- a/src/loader/fragment-loader.ts +++ b/src/loader/fragment-loader.ts @@ -45,6 +45,7 @@ export default class FragmentLoader { frag: Fragment, isIFrame?: boolean, onProgress?: FragmentLoadProgressCallback, + progressGate?: Promise, ): Promise { const url = frag.url; if (!url) { @@ -159,8 +160,10 @@ export default class FragmentLoader { }, }; if (onProgress) { + const onProgressGated = this.gateProgress(onProgress, progressGate); + callbacks.onProgress = (stats, context, data, networkDetails) => - onProgress({ + onProgressGated({ frag, part: null, payload: data as ArrayBuffer, @@ -171,6 +174,32 @@ export default class FragmentLoader { }); } + private gateProgress( + onProgress: FragmentLoadProgressCallback, + progressGate?: Promise, + ): FragmentLoadProgressCallback { + let handleChunk = onProgress; + if (progressGate) { + const pendingChunks: FragLoadedData[] = []; + let gateDone = false; + progressGate + .then(() => { + gateDone = true; + pendingChunks.forEach((chunk) => onProgress(chunk)); + pendingChunks.length = 0; + }) + .catch(() => {}); + handleChunk = (data) => { + if (gateDone) { + onProgress(data); + } else { + pendingChunks.push(data as FragLoadedData); + } + }; + } + return handleChunk; + } + public loadPart( frag: Fragment, part: Part, From 6388bc3788346d87d6cf4ff603a5ce846da9487b Mon Sep 17 00:00:00 2001 From: Kyle Seager Date: Thu, 21 May 2026 23:07:13 -0600 Subject: [PATCH 03/12] Cleaned up changes --- src/controller/base-stream-controller.ts | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/src/controller/base-stream-controller.ts b/src/controller/base-stream-controller.ts index c6db5bb8cee..853f9e2f5d2 100644 --- a/src/controller/base-stream-controller.ts +++ b/src/controller/base-stream-controller.ts @@ -524,15 +524,10 @@ export default class BaseStreamController this.decrypter.destroy(); } - this.hls = - this.log = - this.warn = - this.decrypter = - this.keyLoader = - this.fragmentLoader = - this.initFragmentLoader = - this.fragmentTracker = - null as any; + //@ts-ignore + this.hls = this.decrypter = this.keyLoader = null; + //@ts-ignore + this.fragmentLoader = this.initFragmentLoader = this.fragmentTracker = null; super.onHandlerDestroyed(); } From 9b89c68960ec30a3bf76c858007cf42d04957ef1 Mon Sep 17 00:00:00 2001 From: Kyle Seager Date: Thu, 21 May 2026 23:33:35 -0600 Subject: [PATCH 04/12] Fixed bug --- src/controller/base-stream-controller.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/controller/base-stream-controller.ts b/src/controller/base-stream-controller.ts index 853f9e2f5d2..88db5a5ae64 100644 --- a/src/controller/base-stream-controller.ts +++ b/src/controller/base-stream-controller.ts @@ -1094,6 +1094,7 @@ export default class BaseStreamController initDataPromise, ), keyLoadingPromise, + initDataPromise, ]) .then(([fragLoadedData]) => { if (!dataOnProgress && progressCallback) { From 4892b66d6fbb3f10d25b719253a155e7d80617dc Mon Sep 17 00:00:00 2001 From: Kyle Seager Date: Fri, 22 May 2026 06:56:00 -0600 Subject: [PATCH 05/12] Clarified code --- src/controller/base-stream-controller.ts | 6 +++++- src/loader/fragment-loader.ts | 6 ++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/controller/base-stream-controller.ts b/src/controller/base-stream-controller.ts index 88db5a5ae64..110d702148e 100644 --- a/src/controller/base-stream-controller.ts +++ b/src/controller/base-stream-controller.ts @@ -713,7 +713,11 @@ export default class BaseStreamController .load(initFrag) .then((data) => { const frag = data?.frag; - if (!frag || this.fragCurrent?.initSegment !== frag || !this.levels) { + if ( + !frag || + !fragmentsAreEqual(frag, this.fragCurrent?.initSegment) || + !this.levels + ) { throw new Error('init load aborted'); } return data; diff --git a/src/loader/fragment-loader.ts b/src/loader/fragment-loader.ts index 1594e8b582e..fc4eb65c551 100644 --- a/src/loader/fragment-loader.ts +++ b/src/loader/fragment-loader.ts @@ -174,6 +174,12 @@ export default class FragmentLoader { }); } + /* + When a progressGate is provided, buffer incoming chunks until the + gate promise resolves, then flush them in arrival order. This + ensuring the transmuxer receives init data before the first chunk. + Which is important when progressively loading media fragments. + */ private gateProgress( onProgress: FragmentLoadProgressCallback, progressGate?: Promise, From 2643bea547a6b4710335ca4a253f46621779f6df Mon Sep 17 00:00:00 2001 From: Kyle Seager Date: Fri, 22 May 2026 07:42:18 -0600 Subject: [PATCH 06/12] Fixed bug & unit tests --- src/controller/base-stream-controller.ts | 13 ++- tests/unit/loader/fragment-loader.ts | 100 +++++++++++++++++++++++ 2 files changed, 110 insertions(+), 3 deletions(-) diff --git a/src/controller/base-stream-controller.ts b/src/controller/base-stream-controller.ts index 110d702148e..86f288cbb45 100644 --- a/src/controller/base-stream-controller.ts +++ b/src/controller/base-stream-controller.ts @@ -757,6 +757,7 @@ export default class BaseStreamController reason: err.message, frag, }); + this.fragmentLoader.abort(); throw err; }) .then((decryptedData) => { @@ -794,7 +795,14 @@ export default class BaseStreamController frag.initSegment && !frag.initSegment.data ) { - return this._loadInitSegment(frag.initSegment, level); + const loadInitSegment = () => + this._loadInitSegment(frag.initSegment!, level); + if (frag.initSegment.encrypted && !frag.initSegment.decryptdata?.key) { + return this.keyLoader + .load(frag.initSegment) + .then(() => loadInitSegment()); + } + return loadInitSegment(); } } @@ -1095,10 +1103,9 @@ export default class BaseStreamController frag, this.iframesOnly, dataOnProgress ? progressCallback : undefined, - initDataPromise, + dataOnProgress ? initDataPromise : undefined, ), keyLoadingPromise, - initDataPromise, ]) .then(([fragLoadedData]) => { if (!dataOnProgress && progressCallback) { diff --git a/tests/unit/loader/fragment-loader.ts b/tests/unit/loader/fragment-loader.ts index dbb796595ea..1dea228254a 100644 --- a/tests/unit/loader/fragment-loader.ts +++ b/tests/unit/loader/fragment-loader.ts @@ -140,6 +140,106 @@ describe('FragmentLoader tests', function () { }); }); + it('buffers progress callbacks until progressGate resolves', function () { + response = { data: new Uint8Array(4) }; + const onProgress = sinon.spy(); + let resolveGate: () => void; + const progressGate = new Promise((res) => { + resolveGate = res; + }); + const fragmentLoaderPrivates = fragmentLoader as any; + const chunk1 = new ArrayBuffer(4); + const chunk2 = new ArrayBuffer(8); + + const loadPromise = fragmentLoader.load( + frag, + false, + onProgress, + progressGate, + ); + + fragmentLoaderPrivates.loader.callbacks.onProgress( + stats, + context, + chunk1, + networkDetails, + ); + fragmentLoaderPrivates.loader.callbacks.onProgress( + stats, + context, + chunk2, + networkDetails, + ); + expect(onProgress).to.not.have.been.called; + + resolveGate!(); + + return Promise.resolve() + .then(() => { + expect(onProgress).to.have.been.calledTwice; + expect(onProgress.firstCall).to.have.been.calledWith({ + frag, + part: null, + payload: chunk1, + networkDetails, + }); + expect(onProgress.secondCall).to.have.been.calledWith({ + frag, + part: null, + payload: chunk2, + networkDetails, + }); + fragmentLoaderPrivates.loader.callbacks.onSuccess( + response, + stats, + context, + networkDetails, + ); + }) + .then(() => loadPromise) + .then((data) => { + expect(data.payload).to.equal(response.data); + }); + }); + + it('forwards progress callbacks immediately after progressGate resolves', function () { + response = { data: new Uint8Array(4) }; + const onProgress = sinon.spy(); + const progressGate = Promise.resolve(); + const fragmentLoaderPrivates = fragmentLoader as any; + const chunk = new ArrayBuffer(4); + + const loadPromise = fragmentLoader.load( + frag, + false, + onProgress, + progressGate, + ); + + return progressGate + .then(() => { + fragmentLoaderPrivates.loader.callbacks.onProgress( + stats, + context, + chunk, + networkDetails, + ); + expect(onProgress).to.have.been.calledOnceWith({ + frag, + part: null, + payload: chunk, + networkDetails, + }); + fragmentLoaderPrivates.loader.callbacks.onSuccess( + response, + stats, + context, + networkDetails, + ); + }) + .then(() => loadPromise); + }); + it('handles fragment load timeouts', function () { const fragmentLoaderPrivates = fragmentLoader as any; return new Promise((resolve, reject) => { From c749ad0c43f6b862ad69ee0888cb17b09028ebf9 Mon Sep 17 00:00:00 2001 From: Kyle Seager Date: Fri, 22 May 2026 07:44:57 -0600 Subject: [PATCH 07/12] Added unit tests --- .../unit/controller/base-stream-controller.ts | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/tests/unit/controller/base-stream-controller.ts b/tests/unit/controller/base-stream-controller.ts index 065f92791e0..0bd9abf0a4c 100644 --- a/tests/unit/controller/base-stream-controller.ts +++ b/tests/unit/controller/base-stream-controller.ts @@ -8,11 +8,13 @@ import Hls from '../../../src/hls'; import { Fragment } from '../../../src/loader/fragment'; import KeyLoader from '../../../src/loader/key-loader'; import { LevelDetails } from '../../../src/loader/level-details'; +import { LevelKey } from '../../../src/loader/level-key'; import { PlaylistLevelType } from '../../../src/types/loader'; import { BufferHelper } from '../../../src/utils/buffer-helper'; import { TimeRangesMock } from '../../mocks/time-ranges.mock'; import type BaseStreamController from '../../../src/controller/base-stream-controller'; import type { MediaFragment, Part } from '../../../src/loader/fragment'; +import type { Level } from '../../../src/types/level'; import type { BufferInfo } from '../../../src/utils/buffer-helper'; use(sinonChai); @@ -876,6 +878,76 @@ describe('BaseStreamController', function () { }); }); + describe('init segment loading', function () { + it('getNextFragment returns the media fragment when init segment is not loaded', function () { + const details = levelDetailsWithEndSequenceVodOrLive(3); + const mediaFrag = details.fragments[0] as MediaFragment; + const initSegment = new Fragment(PlaylistLevelType.MAIN, 'init.mp4'); + initSegment.sn = 'initSegment'; + initSegment.relurl = 'init.mp4'; + mediaFrag.initSegment = initSegment; + + const result = (baseStreamController as any).getNextFragment(0, details); + + expect(result).to.equal(mediaFrag); + expect(result).to.not.equal(initSegment); + }); + + it('loadInitSegmentIfNeeded loads the init fragment key before fetching init data', function () { + const initSegment = new Fragment(PlaylistLevelType.MAIN, 'init.mp4'); + initSegment.sn = 'initSegment'; + initSegment.relurl = 'init.mp4'; + initSegment.levelkeys = { + identity: new LevelKey( + 'AES-128', + 'https://example.com/key.bin', + 'identity', + ), + }; + + const mediaFrag = new Fragment( + PlaylistLevelType.MAIN, + 'segment.ts', + ) as MediaFragment; + mediaFrag.sn = 0; + mediaFrag.initSegment = initSegment; + + const level = { details: new LevelDetails('') } as unknown as Level; + const callOrder: string[] = []; + + const keyLoadStub = sinon + .stub((baseStreamController as any).keyLoader, 'load') + .callsFake((frag) => { + callOrder.push('keyLoad'); + expect(frag).to.equal(initSegment); + return Promise.resolve({ frag, keyInfo: {} }); + }); + const loadInitStub = sinon + .stub(baseStreamController as any, '_loadInitSegment') + .callsFake(() => { + callOrder.push('loadInit'); + return Promise.resolve(); + }); + + const result = (baseStreamController as any).loadInitSegmentIfNeeded( + mediaFrag, + level, + ); + + expect(result).to.be.a('promise'); + return result! + .then(() => { + expect(keyLoadStub).to.have.been.calledOnceWith(initSegment); + expect(loadInitStub).to.have.been.calledOnceWith(initSegment, level); + expect(callOrder).to.deep.equal(['keyLoad', 'loadInit']); + }) + .finally(() => { + keyLoadStub.restore(); + loadInitStub.restore(); + }); + }); + }); + describe('backtrackFragment and couldBacktrack properties', function () { it('should return undefined for backtrackFragment by default', function () { expect((baseStreamController as any).backtrackFragment).to.be.undefined; From af93b01774d1ad270069b791e72a95cb3d086453 Mon Sep 17 00:00:00 2001 From: Kyle Seager Date: Fri, 22 May 2026 07:46:26 -0600 Subject: [PATCH 08/12] Updated api doc --- api-extractor/report/hls.js.api.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/api-extractor/report/hls.js.api.md b/api-extractor/report/hls.js.api.md index 09540b1d3d7..551914acdbb 100644 --- a/api-extractor/report/hls.js.api.md +++ b/api-extractor/report/hls.js.api.md @@ -201,7 +201,7 @@ export class AudioStreamController extends BaseStreamController implements Netwo // (undocumented) _handleFragmentLoadProgress(data: FragLoadedData): void; // (undocumented) - protected loadFragment(frag: Fragment, track: Level, targetBufferTime: number): void; + protected loadFragment(frag: MediaFragment, track: Level, targetBufferTime: number): void; get nextAudioTrack(): number; // (undocumented) protected onError(event: Events.ERROR, data: ErrorData): void; @@ -529,9 +529,9 @@ export class BaseStreamController extends TaskLoop implements NetworkComponentAP // (undocumented) protected getMaxBufferLength(levelBitrate?: number): number; // (undocumented) - protected getNextFragment(pos: number, levelDetails: LevelDetails): Fragment | null; + protected getNextFragment(pos: number, levelDetails: LevelDetails): MediaFragment | null; // (undocumented) - protected getNextFragmentLoopLoading(frag: Fragment, levelDetails: LevelDetails, bufferInfo: BufferInfo, playlistType: PlaylistLevelType, maxBufLen: number): Fragment | null; + protected getNextFragmentLoopLoading(frag: MediaFragment, levelDetails: LevelDetails, bufferInfo: BufferInfo, playlistType: PlaylistLevelType, maxBufLen: number): MediaFragment | null; // (undocumented) getNextPart(partList: Part[], frag: Fragment, targetBufferTime: number): number; // (undocumented) @@ -547,6 +547,8 @@ export class BaseStreamController extends TaskLoop implements NetworkComponentAP // (undocumented) get inFlightFrag(): InFlightData; // (undocumented) + protected initFragmentLoader: FragmentLoader; + // (undocumented) protected initPTS: TimestampOffset[]; // (undocumented) protected isLoopLoading(frag: Fragment, targetBufferTime: number): boolean; @@ -563,9 +565,7 @@ export class BaseStreamController extends TaskLoop implements NetworkComponentAP // (undocumented) protected loadingParts: boolean; // (undocumented) - protected _loadInitSegment(fragment: Fragment, level: Level): void; - // (undocumented) - protected mapToInitFragWhenRequired(frag: T): T | Fragment; + protected _loadInitSegment(initFrag: Fragment, _level: Level): Promise; // (undocumented) protected media: HTMLMediaElement | null; // (undocumented) @@ -1988,7 +1988,7 @@ export class FragmentLoader { // (undocumented) destroy(): void; // (undocumented) - load(frag: Fragment, isIFrame?: boolean, onProgress?: FragmentLoadProgressCallback): Promise; + load(frag: Fragment, isIFrame?: boolean, onProgress?: FragmentLoadProgressCallback, progressGate?: Promise): Promise; // (undocumented) loadPart(frag: Fragment, part: Part, onProgress: FragmentLoadProgressCallback): Promise; } @@ -4902,7 +4902,7 @@ export class StreamController extends BaseStreamController implements NetworkCom // (undocumented) immediateLevelSwitch(): void; // (undocumented) - protected loadFragment(frag: Fragment, level: Level, targetBufferTime: number): void; + protected loadFragment(frag: MediaFragment, level: Level, targetBufferTime: number): void; // (undocumented) get maxBufferLength(): number; // (undocumented) @@ -4997,7 +4997,7 @@ export class SubtitleStreamController extends BaseStreamController implements Ne // (undocumented) _handleFragmentLoadComplete(fragLoadedData: FragLoadedData): void; // (undocumented) - protected loadFragment(frag: Fragment, level: Level, targetBufferTime: number): void; + protected loadFragment(frag: MediaFragment, level: Level, targetBufferTime: number): void; // (undocumented) get mediaBufferTimeRanges(): Bufferable; // (undocumented) From 1b0c461c860edf3708f2e9ab9be50b1af9558145 Mon Sep 17 00:00:00 2001 From: Kyle Seager Date: Fri, 22 May 2026 16:46:15 -0600 Subject: [PATCH 09/12] Cleaned up changes --- src/controller/base-stream-controller.ts | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/src/controller/base-stream-controller.ts b/src/controller/base-stream-controller.ts index 86f288cbb45..dab0ecb9cc8 100644 --- a/src/controller/base-stream-controller.ts +++ b/src/controller/base-stream-controller.ts @@ -705,7 +705,7 @@ export default class BaseStreamController this.hls.trigger(Events.BUFFER_FLUSHING, flushScope); } - protected _loadInitSegment(initFrag: Fragment, _level: Level): Promise { + protected _loadInitSegment(initFrag: Fragment): Promise { const { hls } = this; this.initFragmentLoader.abort(); hls.trigger(Events.FRAG_LOADING, { frag: initFrag, targetBufferTime: 0 }); @@ -785,24 +785,19 @@ export default class BaseStreamController stats.parsing.end = stats.buffering.end = self.performance.now(); } - private loadInitSegmentIfNeeded( - frag: Fragment, - level: Level, - ): Promise | undefined { + private loadInitSegmentIfNeeded(frag: Fragment): Promise | undefined { if ( !this.bitrateTest && isMediaFragment(frag) && frag.initSegment && !frag.initSegment.data ) { - const loadInitSegment = () => - this._loadInitSegment(frag.initSegment!, level); if (frag.initSegment.encrypted && !frag.initSegment.decryptdata?.key) { return this.keyLoader .load(frag.initSegment) - .then(() => loadInitSegment()); + .then(({ frag: initSegment }) => this._loadInitSegment(initSegment)); } - return loadInitSegment(); + return this._loadInitSegment(frag.initSegment); } } @@ -1078,7 +1073,7 @@ export default class BaseStreamController const dataOnProgress = this.config.progressive && frag.type !== PlaylistLevelType.SUBTITLE; - const initDataPromise = this.loadInitSegmentIfNeeded(frag, level); + const initDataPromise = this.loadInitSegmentIfNeeded(frag); let result: Promise; if (dataOnProgress && keyLoadingPromise) { From 8a7650ac0637b3863b5913d266ea6f5b48ec9b82 Mon Sep 17 00:00:00 2001 From: Kyle Seager Date: Fri, 22 May 2026 16:48:09 -0600 Subject: [PATCH 10/12] Fix LL-HLS issue --- src/controller/base-stream-controller.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/controller/base-stream-controller.ts b/src/controller/base-stream-controller.ts index dab0ecb9cc8..bf2151c1990 100644 --- a/src/controller/base-stream-controller.ts +++ b/src/controller/base-stream-controller.ts @@ -998,12 +998,15 @@ export default class BaseStreamController this.nextLoadPosition = part.start + part.duration; this.state = State.FRAG_LOADING; let result: Promise; - if (keyLoadingPromise) { - result = keyLoadingPromise - .then((keyLoadedData) => { + const initDataPromise = this.loadInitSegmentIfNeeded(frag); + if (keyLoadingPromise || initDataPromise) { + const useKeyLoader = !!keyLoadingPromise; + result = Promise.all([keyLoadingPromise, initDataPromise]) + .then(([keyLoadedData]) => { if ( - !keyLoadedData || - this.fragContextChanged(keyLoadedData.frag) + useKeyLoader && + (!keyLoadedData || + this.fragContextChanged(keyLoadedData.frag)) ) { return null; } From c3d4c8563670fa7975465cac4bebdce99fa6fec8 Mon Sep 17 00:00:00 2001 From: Kyle Seager Date: Fri, 22 May 2026 17:40:10 -0600 Subject: [PATCH 11/12] Fixed bug with init segment load handling --- src/controller/base-stream-controller.ts | 38 +++++-- .../unit/controller/base-stream-controller.ts | 104 +++++++++++++----- 2 files changed, 106 insertions(+), 36 deletions(-) diff --git a/src/controller/base-stream-controller.ts b/src/controller/base-stream-controller.ts index bf2151c1990..33511340fff 100644 --- a/src/controller/base-stream-controller.ts +++ b/src/controller/base-stream-controller.ts @@ -785,19 +785,36 @@ export default class BaseStreamController stats.parsing.end = stats.buffering.end = self.performance.now(); } - private loadInitSegmentIfNeeded(frag: Fragment): Promise | undefined { + private loadInitSegmentIfNeeded( + mediaFrag: Fragment, + ): Promise | undefined { + const { initSegment } = mediaFrag; if ( !this.bitrateTest && - isMediaFragment(frag) && - frag.initSegment && - !frag.initSegment.data + isMediaFragment(mediaFrag) && + initSegment && + !initSegment.data ) { - if (frag.initSegment.encrypted && !frag.initSegment.decryptdata?.key) { - return this.keyLoader - .load(frag.initSegment) - .then(({ frag: initSegment }) => this._loadInitSegment(initSegment)); - } - return this._loadInitSegment(frag.initSegment); + const initPromise = + initSegment.encrypted && !initSegment.decryptdata?.key + ? this.keyLoader + .load(initSegment) + .then(() => this._loadInitSegment(initSegment)) + : this._loadInitSegment(initSegment); + + return initPromise.catch((reason: LoadError | Error) => { + if (this.state === State.STOPPED || this.state === State.ERROR) { + throw reason; + } + if ('data' in reason) { + reason.data.frag = mediaFrag; + this.fragmentLoader.abort(); + this.handleFragLoadError(reason); + } + this.warn(reason); + this.resetFragmentLoading(mediaFrag); + throw reason; + }); } } @@ -1104,6 +1121,7 @@ export default class BaseStreamController dataOnProgress ? initDataPromise : undefined, ), keyLoadingPromise, + initDataPromise, ]) .then(([fragLoadedData]) => { if (!dataOnProgress && progressCallback) { diff --git a/tests/unit/controller/base-stream-controller.ts b/tests/unit/controller/base-stream-controller.ts index 0bd9abf0a4c..e0290b321cb 100644 --- a/tests/unit/controller/base-stream-controller.ts +++ b/tests/unit/controller/base-stream-controller.ts @@ -4,8 +4,10 @@ import sinonChai from 'sinon-chai'; import { hlsDefaultConfig } from '../../../src/config'; import { State } from '../../../src/controller/base-stream-controller'; import BaseStreamControllerImpl from '../../../src/controller/stream-controller'; +import { ErrorDetails, ErrorTypes } from '../../../src/errors'; import Hls from '../../../src/hls'; import { Fragment } from '../../../src/loader/fragment'; +import { LoadError } from '../../../src/loader/fragment-loader'; import KeyLoader from '../../../src/loader/key-loader'; import { LevelDetails } from '../../../src/loader/level-details'; import { LevelKey } from '../../../src/loader/level-key'; @@ -14,7 +16,6 @@ import { BufferHelper } from '../../../src/utils/buffer-helper'; import { TimeRangesMock } from '../../mocks/time-ranges.mock'; import type BaseStreamController from '../../../src/controller/base-stream-controller'; import type { MediaFragment, Part } from '../../../src/loader/fragment'; -import type { Level } from '../../../src/types/level'; import type { BufferInfo } from '../../../src/utils/buffer-helper'; use(sinonChai); @@ -879,31 +880,19 @@ describe('BaseStreamController', function () { }); describe('init segment loading', function () { - it('getNextFragment returns the media fragment when init segment is not loaded', function () { - const details = levelDetailsWithEndSequenceVodOrLive(3); - const mediaFrag = details.fragments[0] as MediaFragment; + function createMediaFragWithInitSegment(encrypted: boolean = false) { const initSegment = new Fragment(PlaylistLevelType.MAIN, 'init.mp4'); initSegment.sn = 'initSegment'; initSegment.relurl = 'init.mp4'; - mediaFrag.initSegment = initSegment; - - const result = (baseStreamController as any).getNextFragment(0, details); - - expect(result).to.equal(mediaFrag); - expect(result).to.not.equal(initSegment); - }); - - it('loadInitSegmentIfNeeded loads the init fragment key before fetching init data', function () { - const initSegment = new Fragment(PlaylistLevelType.MAIN, 'init.mp4'); - initSegment.sn = 'initSegment'; - initSegment.relurl = 'init.mp4'; - initSegment.levelkeys = { - identity: new LevelKey( - 'AES-128', - 'https://example.com/key.bin', - 'identity', - ), - }; + if (encrypted) { + initSegment.levelkeys = { + identity: new LevelKey( + 'AES-128', + 'https://example.com/key.bin', + 'identity', + ), + }; + } const mediaFrag = new Fragment( PlaylistLevelType.MAIN, @@ -911,8 +900,11 @@ describe('BaseStreamController', function () { ) as MediaFragment; mediaFrag.sn = 0; mediaFrag.initSegment = initSegment; + return { mediaFrag, initSegment }; + } - const level = { details: new LevelDetails('') } as unknown as Level; + it('loadInitSegmentIfNeeded loads the init fragment key before fetching init data', function () { + const { mediaFrag, initSegment } = createMediaFragWithInitSegment(true); const callOrder: string[] = []; const keyLoadStub = sinon @@ -931,14 +923,13 @@ describe('BaseStreamController', function () { const result = (baseStreamController as any).loadInitSegmentIfNeeded( mediaFrag, - level, ); expect(result).to.be.a('promise'); return result! .then(() => { expect(keyLoadStub).to.have.been.calledOnceWith(initSegment); - expect(loadInitStub).to.have.been.calledOnceWith(initSegment, level); + expect(loadInitStub).to.have.been.calledOnceWith(initSegment); expect(callOrder).to.deep.equal(['keyLoad', 'loadInit']); }) .finally(() => { @@ -946,6 +937,67 @@ describe('BaseStreamController', function () { loadInitStub.restore(); }); }); + + it('loadInitSegmentIfNeeded handles init segment load errors', function () { + const { mediaFrag, initSegment } = createMediaFragWithInitSegment(false); + baseStreamController.state = State.FRAG_LOADING; + const loadError = new LoadError({ + type: ErrorTypes.NETWORK_ERROR, + details: ErrorDetails.FRAG_LOAD_ERROR, + fatal: false, + error: new Error('init load failed'), + frag: initSegment, + networkDetails: null, + }); + + sinon + .stub(baseStreamController as any, '_loadInitSegment') + .rejects(loadError); + const abortStub = sinon.stub( + (baseStreamController as any).fragmentLoader, + 'abort', + ); + const handleFragLoadErrorStub = sinon.stub( + baseStreamController as any, + 'handleFragLoadError', + ); + const resetFragmentLoadingStub = sinon.stub( + baseStreamController as any, + 'resetFragmentLoading', + ); + const warnStub = sinon.stub(baseStreamController as any, 'warn'); + + const result = (baseStreamController as any).loadInitSegmentIfNeeded( + mediaFrag, + ); + + expect(result).to.be.a('promise'); + return result! + .then(() => { + throw new Error('Expected init segment load to reject'); + }) + .catch((error) => { + expect(error).to.equal(loadError); + expect(loadError.data.frag).to.equal(mediaFrag); + expect(abortStub).to.have.been.calledOnce; + expect(handleFragLoadErrorStub).to.have.been.calledOnceWith( + loadError, + ); + expect(handleFragLoadErrorStub.firstCall.args[0].data.frag).to.equal( + mediaFrag, + ); + expect(resetFragmentLoadingStub).to.have.been.calledOnceWith( + mediaFrag, + ); + expect(warnStub).to.have.been.calledOnceWith(loadError); + }) + .finally(() => { + abortStub.restore(); + handleFragLoadErrorStub.restore(); + resetFragmentLoadingStub.restore(); + warnStub.restore(); + }); + }); }); describe('backtrackFragment and couldBacktrack properties', function () { From 9717c3946dba572961f698c69349eae7e0bf8e3a Mon Sep 17 00:00:00 2001 From: Kyle Seager Date: Fri, 22 May 2026 17:44:06 -0600 Subject: [PATCH 12/12] Updated api doc --- api-extractor/report/hls.js.api.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api-extractor/report/hls.js.api.md b/api-extractor/report/hls.js.api.md index 551914acdbb..bd4af704c7a 100644 --- a/api-extractor/report/hls.js.api.md +++ b/api-extractor/report/hls.js.api.md @@ -565,7 +565,7 @@ export class BaseStreamController extends TaskLoop implements NetworkComponentAP // (undocumented) protected loadingParts: boolean; // (undocumented) - protected _loadInitSegment(initFrag: Fragment, _level: Level): Promise; + protected _loadInitSegment(initFrag: Fragment): Promise; // (undocumented) protected media: HTMLMediaElement | null; // (undocumented)