Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions api-extractor/report/hls.js.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -592,6 +592,8 @@ export class BaseStreamController extends TaskLoop implements NetworkComponentAP
// (undocumented)
protected onMediaSeeking: () => void;
// (undocumented)
protected onSourceBufferError(filterType: PlaylistLevelType, data: ErrorData): void;
// (undocumented)
protected onTickEnd(): void;
// (undocumented)
pauseBuffering(): void;
Expand Down Expand Up @@ -2130,6 +2132,7 @@ export class FragmentTracker implements ComponentAPI {
detectPartialFragments(data: FragBufferedData): void;
// (undocumented)
fragBuffered(frag: MediaFragment, force?: true): FragmentEntity | undefined;
gapFragments(): MediaFragment[];
getAppendedFrag(position: number, levelType: PlaylistLevelType): MediaFragment | Part | null;
getBackBufferEvictionEnd(beforePosition: number, levelType: PlaylistLevelType, bytesNeeded: number): number;
getBufferedFrag(position: number, levelType: PlaylistLevelType): MediaFragment | null;
Expand All @@ -2144,6 +2147,7 @@ export class FragmentTracker implements ComponentAPI {
hasParts(type: PlaylistLevelType): boolean;
// (undocumented)
isEndListAppended(type: PlaylistLevelType): boolean;
isGap(frag: Fragment): boolean;
// (undocumented)
removeAllFragments(): void;
// (undocumented)
Expand Down
3 changes: 3 additions & 0 deletions src/controller/audio-stream-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -853,6 +853,9 @@ class AudioStreamController
this.fragmentTracker.addAsGap(data.frag as MediaFragment);
}
break;
case ErrorDetails.MEDIA_SOURCE_REQUIRES_RESET:
this.onSourceBufferError(PlaylistLevelType.AUDIO, data);
break;
case ErrorDetails.BUFFER_ADD_CODEC_ERROR:
case ErrorDetails.BUFFER_APPEND_ERROR:
if (data.parent !== 'audio') {
Expand Down
81 changes: 72 additions & 9 deletions src/controller/base-stream-controller.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { SOURCE_BUFFER_ERROR_NAME } from './buffer-controller';
import { ErrorActionFlags, NetworkErrorAction } from './error-controller';
import {
findFragmentByPDT,
Expand Down Expand Up @@ -368,7 +369,11 @@ export default class BaseStreamController
return;
}
this.loadingParts = false;
this.fragmentTracker.removeAllFragments();
// Keep tracked gaps to skip segments given up on after re-attach (`recoverMediaError()`)
const { fragmentTracker } = this;
const gaps = fragmentTracker.gapFragments();
fragmentTracker.removeAllFragments();
gaps.forEach((frag) => fragmentTracker.addAsGap(frag));
this.stopLoad();
}

Expand Down Expand Up @@ -438,13 +443,15 @@ export default class BaseStreamController
}

if (media) {
// Remove gap fragments
this.fragmentTracker.removeFragmentsInRange(
currentTime,
Infinity,
this.playlistType,
true,
);
if (this.hls?.hasEnoughToStart && !fowardBuffer) {
// Remove gap fragments on a seek out of the buffer, after `seekToStartPos`
this.fragmentTracker.removeFragmentsInRange(
currentTime,
Infinity,
this.playlistType,
true,
);
}

// Don't set lastCurrentTime with backward seeks (allows for frag selection with strict tolerances)
const lastCurrentTime = this.lastCurrentTime;
Expand Down Expand Up @@ -1613,6 +1620,17 @@ export default class BaseStreamController
this.nextLoadPosition = programFrag.start;
}
}
while (programFrag && this.fragmentTracker.isGap(programFrag)) {
const afterGap = this.filterReplacedPrimary(
getNextFrag(levelDetails, programFrag.sn),
levelDetails,
);
if (!afterGap) {
return null;
}
this.nextLoadPosition = afterGap.start;
programFrag = afterGap;
}
return programFrag;
}

Expand Down Expand Up @@ -1758,7 +1776,8 @@ export default class BaseStreamController
if (nextPart > -1 && targetBufferTime < part.start) {
break;
}
const loaded = part.loaded || part.gap;
const loaded =
part.loaded || part.gap || this.fragmentTracker.isGap(part.fragment);
if (loaded) {
nextPart = -1;
} else if (
Expand Down Expand Up @@ -2211,6 +2230,50 @@ export default class BaseStreamController
this.tickImmediate();
}

protected onSourceBufferError(
filterType: PlaylistLevelType,
data: ErrorData,
) {
const { frag, part, sourceBufferName } = data;
const details = frag && this.levels?.[frag.level]?.details;
if (
data.error.name !== SOURCE_BUFFER_ERROR_NAME ||
frag?.type !== filterType ||
!isMediaFragment(frag) ||
!details?.live
) {
return;
}
const refused = part ? (part.index === 0 ? part : null) : frag;
const start =
refused && sourceBufferName
? refused.elementaryStreams[sourceBufferName]?.startPTS
: undefined;
this.markSourceBufferErrorGap(frag);
if (start === undefined) {
return;
}
// Also mark the previous fragment when the buffer ends more than maxBufferHole before the refused fragment or first part
const previous = getFragmentWithSN(details, frag.sn - 1);
if (
previous &&
this.fragmentTracker.getState(previous) !== FragmentState.NOT_LOADED &&
!BufferHelper.isBuffered(
this.mediaBuffer || this.media,
start - this.config.maxBufferHole,
)
) {
this.markSourceBufferErrorGap(previous);
}
}

private markSourceBufferErrorGap(frag: MediaFragment) {
this.warn(
`Marking fragment ${frag.sn} of ${this.playlistLabel()} ${frag.level} as a gap after a SourceBuffer error`,
);
this.fragmentTracker.addAsGap(frag);
}

protected checkRetryDate() {
const now = self.performance.now();
const retryDate = this.retryDate;
Expand Down
2 changes: 2 additions & 0 deletions src/controller/buffer-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ const VIDEO_CODEC_PROFILE_REPLACE =
/(avc[1234]|hvc1|hev1|dvh[1e]|vp09|av01)(?:\.[^.,]+)+/;

const TRACK_REMOVED_ERROR_NAME = 'HlsJsTrackRemovedError';
export const SOURCE_BUFFER_ERROR_NAME = 'HlsJsSourceBufferError';

const LOOP_FLUSH_SAFETY_MARGIN = 0.25;

Expand Down Expand Up @@ -2120,6 +2121,7 @@ transfer tracks: ${stringify(transferredTracks, (key, value) => (key === 'initSe
const error = new Error(
`${type} SourceBuffer error. MediaSource readyState: ${readyState}`,
);
error.name = SOURCE_BUFFER_ERROR_NAME;
this.error(`${error.message}`, event);
// according to http://www.w3.org/TR/media-source/#sourcebuffer-append-error
// SourceBuffer errors are not necessarily fatal; if so, the HTMLMediaElement will fire an error event
Expand Down
17 changes: 17 additions & 0 deletions src/controller/fragment-tracker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -631,6 +631,23 @@ export class FragmentTracker implements ComponentAPI {
}
}

/** Whether the fragment tracked under this key is a gap */
public isGap(frag: Fragment): boolean {
return (
this.hasGaps && this.fragments[getFragmentKey(frag)]?.body.gap === true
);
}

/** Tracked fragments marked as gaps */
public gapFragments(): MediaFragment[] {
if (!this.hasGaps) {
return [];
}
return Object.keys(this.fragments)
.map((key) => this.fragments[key]?.body)
.filter((frag): frag is MediaFragment => !!frag?.gap);
}

public removeAllFragments() {
this.fragments = Object.create(null);
this.endListFragments = Object.create(null);
Expand Down
5 changes: 4 additions & 1 deletion src/controller/stream-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -999,8 +999,8 @@ export default class StreamController
return;
}
if (!this._hasEnoughToStart && BufferHelper.getBuffered(media).length) {
this._hasEnoughToStart = true;
this.seekToStartPos();
this._hasEnoughToStart = true;
}
if (bufferedMainFragment) {
this.tick();
Expand Down Expand Up @@ -1052,6 +1052,9 @@ export default class StreamController
this.fragmentTracker.addAsGap(data.frag as MediaFragment);
}
break;
case ErrorDetails.MEDIA_SOURCE_REQUIRES_RESET:
this.onSourceBufferError(PlaylistLevelType.MAIN, data);
break;
case ErrorDetails.BUFFER_ADD_CODEC_ERROR:
case ErrorDetails.BUFFER_APPEND_ERROR:
if (data.parent !== 'main') {
Expand Down
40 changes: 40 additions & 0 deletions tests/unit/controller/audio-stream-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import AudioStreamController from '../../../src/controller/audio-stream-controll
import BaseStreamController, {
State,
} from '../../../src/controller/base-stream-controller';
import { SOURCE_BUFFER_ERROR_NAME } from '../../../src/controller/buffer-controller';
import { FragmentTracker } from '../../../src/controller/fragment-tracker';
import { ErrorDetails, ErrorTypes } from '../../../src/errors';
import { Events } from '../../../src/events';
Expand Down Expand Up @@ -543,6 +544,45 @@ describe('AudioStreamController', function () {
});
});

describe('onError MEDIA_SOURCE_REQUIRES_RESET', function () {
const resetError = (frag: Fragment) => {
const error = new Error(
'audio SourceBuffer error. MediaSource readyState: ended',
);
error.name = SOURCE_BUFFER_ERROR_NAME;
return {
type: ErrorTypes.MEDIA_ERROR,
details: ErrorDetails.MEDIA_SOURCE_REQUIRES_RESET,
fatal: false,
error,
parent: frag.type,
frag,
part: null,
};
};

beforeEach(function () {
// only fragments of a live playlist are marked
(audioStreamController as any).levels = [{ details: { live: true } }];
});

it('marks the audio fragment as a gap after a SourceBuffer error', function () {
const frag = new Fragment(PlaylistLevelType.AUDIO, '');
frag.sn = 4;
frag.level = 0;
(audioStreamController as any).onError(Events.ERROR, resetError(frag));
expect(frag.gap).to.equal(true);
});

it('ignores a main fragment', function () {
const frag = new Fragment(PlaylistLevelType.MAIN, '');
frag.sn = 4;
frag.level = 0;
(audioStreamController as any).onError(Events.ERROR, resetError(frag));
expect(frag.gap).to.not.equal(true);
});
});

describe('audio track switching with flushImmediate flag', function () {
beforeEach(function () {
audioStreamController.levels = tracks;
Expand Down
Loading
Loading