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
91 changes: 75 additions & 16 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,19 @@ export default class BaseStreamController
}

if (media) {
// Remove gap fragments
this.fragmentTracker.removeFragmentsInRange(
currentTime,
Infinity,
this.playlistType,
true,
);
if (
this.hls?.hasEnoughToStart &&
!fowardBuffer &&
currentTime !== this.startPosition
) {
// 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 @@ -1625,7 +1636,7 @@ export default class BaseStreamController
return true;
}
if (trackerState === FragmentState.PARTIAL) {
if (frag.gap) {
if (frag.gap || this.fragmentTracker.isGap(frag)) {
return true;
}
if (frag.stats.retry > 1) {
Expand Down Expand Up @@ -1758,7 +1769,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);
Comment on lines +1772 to +1773

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you explain this change? Do source buffer errors on parts result in the whole fragment getting marked as gap?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes. The refused append is often not the damaged one: a truncated part is accepted and the next append fails. Marking only the reported part reset repeatedly in 3 of 5 runs.

The check skips the parts loadPart rejects (frag.gap || part.gap). It reads the tracker because after a refresh part.fragment is a new object.

if (loaded) {
nextPart = -1;
} else if (
Expand Down Expand Up @@ -1878,7 +1890,7 @@ export default class BaseStreamController
): MediaFragment | null {
const { config } = this;
let { fragPrevious } = this;
let { fragments, endSN } = levelDetails;
let { fragments } = levelDetails;
const { fragmentHint } = levelDetails;
const { maxFragLookUpTolerance } = config;
const partList = levelDetails.partList;
Expand All @@ -1895,7 +1907,6 @@ export default class BaseStreamController
) {
// Include incomplete fragment with parts at end
fragments = fragments.concat(fragmentHint);
endSN = fragmentHint.sn;
}

// Clear fragPrevious if removed from buffer / tracker so that is picked again
Expand Down Expand Up @@ -1937,19 +1948,24 @@ export default class BaseStreamController
const fragState = this.fragmentTracker.getState(frag);
if (
fragState === FragmentState.OK ||
(fragState === FragmentState.PARTIAL && frag.gap)
(fragState === FragmentState.PARTIAL &&
(frag.gap || this.fragmentTracker.isGap(frag)))
) {
fragPrevious = frag;
}
if (
mediaFragmentsAreEqual(frag, fragPrevious) &&
(!loadingParts ||
partList[0].fragment.sn > frag.sn ||
!levelDetails.live)
!levelDetails.live ||
this.fragmentTracker.isGap(frag))
) {
const nextFrag = fragments[curSNIdx + 1];
let nextFrag = fragments[curSNIdx + 1];
while (nextFrag && this.fragmentTracker.isGap(nextFrag)) {
nextFrag = fragments[nextFrag.sn - levelDetails.startSN + 1];
}
if (
frag.sn < endSN &&
nextFrag &&
this.fragmentTracker.getState(nextFrag) !== FragmentState.OK
) {
frag = nextFrag;
Expand Down Expand Up @@ -2211,6 +2227,49 @@ 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;
}
const previous = getFragmentWithSN(details, frag.sn - 1);
if (
previous &&
this.fragmentTracker.getState(previous) !== FragmentState.NOT_LOADED &&
!BufferHelper.isBuffered(
Comment on lines +2256 to +2257

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The previous fragment is not buffered but also not-not_loaded?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, a truncated tail. The previous fragment was appended without error, but the buffer ends before the refused fragment starts, so it would be reloaded and refused again. !== NOT_LOADED excludes expected holes (start, seek, level switch).

In 7 of 24 truncated runs the tracker state was OK, because the hole was under its 0.2 s padding, so the buffered check is needed. Removed the comment.

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