download
raw
24.2 kB
import { State } from './base-stream-controller';
import { ErrorDetails, ErrorTypes } from '../errors';
import { Events } from '../events';
import TaskLoop from '../task-loop';
import { PlaylistLevelType } from '../types/loader';
import { BufferHelper } from '../utils/buffer-helper';
import {
addEventListener,
removeEventListener,
} from '../utils/event-listener-helper';
import { stringify } from '../utils/safe-json-stringify';
import type { InFlightData } from './base-stream-controller';
import type { InFlightFragments } from '../hls';
import type Hls from '../hls';
import type { FragmentTracker } from './fragment-tracker';
import type { Fragment, MediaFragment, Part } from '../loader/fragment';
import type { SourceBufferName } from '../types/buffer';
import type {
BufferAppendedData,
MediaAttachedData,
MediaDetachingData,
} from '../types/events';
import type { ErrorData } from '../types/events';
import type { BufferInfo } from '../utils/buffer-helper';
export const MAX_START_GAP_JUMP = 2.0;
export const SKIP_BUFFER_HOLE_STEP_SECONDS = 0.1;
export const SKIP_BUFFER_RANGE_START = 0.05;
const TICK_INTERVAL = 100;
export default class GapController extends TaskLoop {
private hls: Hls | null;
private fragmentTracker: FragmentTracker | null;
private media: HTMLMediaElement | null = null;
private mediaSource?: MediaSource;
private nudgeRetry: number = 0;
private stallReported: boolean = false;
private stalled: number | null = null;
private moved: boolean = false;
private seeking: boolean = false;
private buffered: Partial<Record<SourceBufferName, TimeRanges>> = {};
private lastCurrentTime: number = 0;
public ended: number = 0;
public waiting: number = 0;
constructor(hls: Hls, fragmentTracker: FragmentTracker) {
super('gap-controller', hls.logger);
this.hls = hls;
this.fragmentTracker = fragmentTracker;
this.registerListeners();
}
private registerListeners() {
const { hls } = this;
if (hls) {
hls.on(Events.MEDIA_ATTACHED, this.onMediaAttached, this);
hls.on(Events.MEDIA_DETACHING, this.onMediaDetaching, this);
hls.on(Events.BUFFER_APPENDED, this.onBufferAppended, this);
}
}
private unregisterListeners() {
const { hls } = this;
if (hls) {
hls.off(Events.MEDIA_ATTACHED, this.onMediaAttached, this);
hls.off(Events.MEDIA_DETACHING, this.onMediaDetaching, this);
hls.off(Events.BUFFER_APPENDED, this.onBufferAppended, this);
}
}
public destroy() {
super.destroy();
this.unregisterListeners();
this.media = this.hls = this.fragmentTracker = null;
this.mediaSource = undefined;
}
private onMediaAttached(
event: Events.MEDIA_ATTACHED,
data: MediaAttachedData,
) {
this.setInterval(TICK_INTERVAL);
this.mediaSource = data.mediaSource;
const media = (this.media = data.media);
addEventListener(media, 'playing', this.onMediaPlaying);
addEventListener(media, 'waiting', this.onMediaWaiting);
addEventListener(media, 'ended', this.onMediaEnded);
}
private onMediaDetaching(
event: Events.MEDIA_DETACHING,
data: MediaDetachingData,
) {
this.clearInterval();
const { media } = this;
if (media) {
removeEventListener(media, 'playing', this.onMediaPlaying);
removeEventListener(media, 'waiting', this.onMediaWaiting);
removeEventListener(media, 'ended', this.onMediaEnded);
this.media = null;
}
this.mediaSource = undefined;
}
private onBufferAppended(
event: Events.BUFFER_APPENDED,
data: BufferAppendedData,
) {
this.buffered = data.timeRanges;
}
private onMediaPlaying = () => {
this.ended = 0;
this.waiting = 0;
};
private onMediaWaiting = () => {
if (this.media?.seeking) {
return;
}
this.waiting = self.performance.now();
this.tick();
};
private onMediaEnded = () => {
if (this.hls) {
// ended is set when triggering MEDIA_ENDED so that we do not trigger it again on stall or on tick with media.ended
this.ended = this.media?.currentTime || 1;
this.hls.trigger(Events.MEDIA_ENDED, {
stalled: false,
});
}
};
public get hasBuffered(): boolean {
return Object.keys(this.buffered).length > 0;
}
public tick() {
if (!this.media?.readyState || !this.hasBuffered) {
return;
}
const currentTime = this.media.currentTime;
this.poll(currentTime, this.lastCurrentTime);
this.lastCurrentTime = currentTime;
}
/**
* Checks if the playhead is stuck within a gap, and if so, attempts to free it.
* A gap is an unbuffered range between two buffered ranges (or the start and the first buffered range).
*
* @param lastCurrentTime - Previously read playhead position
*/
public poll(currentTime: number, lastCurrentTime: number) {
const config = this.hls?.config;
if (!config) {
return;
}
const media = this.media;
if (!media) {
return;
}
const { seeking } = media;
const seeked = this.seeking && !seeking;
const beginSeek = !this.seeking && seeking;
const pausedEndedOrHalted =
(media.paused && !seeking) || media.ended || media.playbackRate === 0;
this.seeking = seeking;
// The playhead is moving, no-op
if (currentTime !== lastCurrentTime) {
if (lastCurrentTime) {
this.ended = 0;
}
this.moved = true;
if (!seeking) {
this.nudgeRetry = 0;
// When crossing between buffered video time ranges, but not audio, flush pipeline with seek (Chrome)
if (
config.nudgeOnVideoHole &&
!pausedEndedOrHalted &&
currentTime > lastCurrentTime
) {
this.nudgeOnVideoHole(currentTime, lastCurrentTime);
}
}
if (this.waiting === 0) {
this.stallResolved(currentTime);
}
return;
}
// Clear stalled state when beginning or finishing seeking so that we don't report stalls coming out of a seek
if (beginSeek || seeked) {
if (seeked) {
this.stallResolved(currentTime);
}
return;
}
// The playhead should not be moving
if (pausedEndedOrHalted) {
this.nudgeRetry = 0;
this.stallResolved(currentTime);
// Fire MEDIA_ENDED to workaround event not being dispatched by browser
if (!this.ended && media.ended && this.hls) {
this.ended = currentTime || 1;
this.hls.trigger(Events.MEDIA_ENDED, {
stalled: false,
});
}
return;
}
if (!BufferHelper.getBuffered(media).length) {
this.nudgeRetry = 0;
return;
}
// Resolve stalls at buffer holes using the main buffer, whose ranges are the intersections of the A/V sourcebuffers
const bufferInfo = BufferHelper.bufferInfo(media, currentTime, 0);
const nextStart = bufferInfo.nextStart || 0;
const fragmentTracker = this.fragmentTracker;
if (seeking && fragmentTracker && this.hls) {
// Is there a fragment loading/parsing/appending before currentTime?
const inFlightDependency = getInFlightDependency(
this.hls.inFlightFragments,
currentTime,
);
// Waiting for seeking in a buffered range to complete
const hasEnoughBuffer = bufferInfo.len > MAX_START_GAP_JUMP;
// Next buffered range is too far ahead to jump to while still seeking
const noBufferHole =
!nextStart ||
inFlightDependency ||
(nextStart - currentTime > MAX_START_GAP_JUMP &&
!fragmentTracker.getPartialFragment(currentTime));
if (hasEnoughBuffer || noBufferHole) {
return;
}
// Reset moved state when seeking to a point in or before a gap/hole
this.moved = false;
}
// Skip start gaps if we haven't played, but the last poll detected the start of a stall
// The addition poll gives the browser a chance to jump the gap for us
const levelDetails = this.hls?.latestLevelDetails;
if (!this.moved && this.stalled !== null && fragmentTracker) {
// There is no playable buffer (seeked, waiting for buffer)
const isBuffered = bufferInfo.len > 0;
if (!isBuffered && !nextStart) {
return;
}
// Jump start gaps within jump threshold
const startJump =
Math.max(nextStart, bufferInfo.start || 0) - currentTime;
// When joining a live stream with audio tracks, account for live playlist window sliding by allowing
// a larger jump over start gaps caused by the audio-stream-controller buffering a start fragment
// that begins over 1 target duration after the video start position.
const isLive = !!levelDetails?.live;
const maxStartGapJump = isLive
? levelDetails!.targetduration * 2
: MAX_START_GAP_JUMP;
const appended = appendedFragAtPosition(currentTime, fragmentTracker);
if (startJump > 0 && (startJump <= maxStartGapJump || appended)) {
if (!media.paused) {
this._trySkipBufferHole(appended);
}
return;
}
}
// Start tracking stall time
const detectStallWithCurrentTimeMs = config.detectStallWithCurrentTimeMs;
const tnow = self.performance.now();
const tWaiting = this.waiting;
let stalled = this.stalled;
if (stalled === null) {
// Use time of recent "waiting" event
if (tWaiting > 0 && tnow - tWaiting < detectStallWithCurrentTimeMs) {
stalled = this.stalled = tWaiting;
} else {
this.stalled = tnow;
return;
}
}
const stalledDuration = tnow - stalled;
if (
!seeking &&
(stalledDuration >= detectStallWithCurrentTimeMs || tWaiting) &&
this.hls
) {
// Dispatch MEDIA_ENDED when media.ended/ended event is not signalled at end of stream
if (
this.mediaSource?.readyState === 'ended' &&
!levelDetails?.live &&
Math.abs(currentTime - (levelDetails?.edge || 0)) < 1
) {
if (this.ended) {
return;
}
this.ended = currentTime || 1;
this.hls.trigger(Events.MEDIA_ENDED, {
stalled: true,
});
return;
}
// Report stalling after trying to fix
this._reportStall(bufferInfo);
if (!this.media || (!this.hls as any)) {
return;
}
}
const bufferedWithHoles = BufferHelper.bufferInfo(
media,
currentTime,
config.maxBufferHole,
);
this._tryFixBufferStall(bufferedWithHoles, stalledDuration, currentTime);
}
private stallResolved(currentTime: number) {
const stalled = this.stalled;
if (stalled && this.hls) {
this.stalled = null;
// The playhead is now moving, but was previously stalled
if (this.stallReported) {
const stalledDuration = self.performance.now() - stalled;
this.log(
`playback not stuck anymore @${currentTime}, after ${Math.round(
stalledDuration,
)}ms`,
);
this.stallReported = false;
this.waiting = 0;
this.hls.trigger(Events.STALL_RESOLVED, {});
}
}
}
private nudgeOnVideoHole(currentTime: number, lastCurrentTime: number) {
// Chrome will play one second past a hole in video buffered time ranges without rendering any video from the subsequent range and then stall as long as audio is buffered:
// https://github.com/video-dev/hls.js/issues/5631
// https://issues.chromium.org/issues/40280613#comment10
// Detect the potential for this situation and proactively seek to flush the video pipeline once the playhead passes the start of the video hole.
// When there are audio and video buffers and currentTime is past the end of the first video buffered range...
const videoSourceBuffered = this.buffered.video;
if (
this.hls &&
this.media &&
this.fragmentTracker &&
this.buffered.audio?.length &&
videoSourceBuffered &&
videoSourceBuffered.length > 1 &&
currentTime > videoSourceBuffered.end(0)
) {
// and audio is buffered at the playhead
const audioBufferInfo = BufferHelper.bufferedInfo(
BufferHelper.timeRangesToArray(this.buffered.audio),
currentTime,
0,
);
if (audioBufferInfo.len > 1 && lastCurrentTime >= audioBufferInfo.start) {
const videoTimes = BufferHelper.timeRangesToArray(videoSourceBuffered);
const lastBufferedIndex = BufferHelper.bufferedInfo(
videoTimes,
lastCurrentTime,
0,
).bufferedIndex;
// nudge when crossing into another video buffered range (hole).
if (
lastBufferedIndex > -1 &&
lastBufferedIndex < videoTimes.length - 1
) {
const bufferedIndex = BufferHelper.bufferedInfo(
videoTimes,
currentTime,
0,
).bufferedIndex;
const holeStart = videoTimes[lastBufferedIndex].end;
const holeEnd = videoTimes[lastBufferedIndex + 1].start;
if (
(bufferedIndex === -1 || bufferedIndex > lastBufferedIndex) &&
holeEnd - holeStart < 1 && // `maxBufferHole` may be too small and setting it to 0 should not disable this feature
currentTime - holeStart < 2
) {
const error = new Error(
`nudging playhead to flush pipeline after video hole. currentTime: ${currentTime} hole: ${holeStart} -> ${holeEnd} buffered index: ${bufferedIndex}`,
);
this.warn(error.message);
// Magic number to flush the pipeline without interuption to audio playback:
this.media.currentTime += 0.000001;
let frag: MediaFragment | Part | null | undefined =
appendedFragAtPosition(currentTime, this.fragmentTracker);
if (frag && 'fragment' in frag) {
frag = frag.fragment;
} else if (!frag) {
frag = undefined;
}
const bufferInfo = BufferHelper.bufferInfo(
this.media,
currentTime,
0,
);
this.hls.trigger(Events.ERROR, {
type: ErrorTypes.MEDIA_ERROR,
details: ErrorDetails.BUFFER_SEEK_OVER_HOLE,
fatal: false,
error,
reason: error.message,
frag,
buffer: bufferInfo.len,
bufferInfo,
});
}
}
}
}
}
/**
* Detects and attempts to fix known buffer stalling issues.
* @param bufferInfo - The properties of the current buffer.
* @param stalledDurationMs - The amount of time Hls.js has been stalling for.
* @private
*/
private _tryFixBufferStall(
bufferInfo: BufferInfo,
stalledDurationMs: number,
currentTime: number,
) {
const { fragmentTracker, media } = this;
const config = this.hls?.config;
if (!media || !fragmentTracker || !config) {
return;
}
const levelDetails = this.hls?.latestLevelDetails;
const appended = appendedFragAtPosition(currentTime, fragmentTracker);
if (
appended ||
(levelDetails?.live && currentTime < levelDetails.fragmentStart)
) {
// Try to skip over the buffer hole caused by a partial fragment
// This method isn't limited by the size of the gap between buffered ranges
const targetTime = this._trySkipBufferHole(appended);
// we return here in this case, meaning
// the branch below only executes when we haven't seeked to a new position
if (targetTime || !this.media) {
return;
}
}
// if we haven't had to skip over a buffer hole of a partial fragment
// we may just have to "nudge" the playlist as the browser decoding/rendering engine
// needs to cross some sort of threshold covering all source-buffers content
// to start playing properly.
const bufferedRanges = bufferInfo.buffered;
const adjacentTraversal = this.adjacentTraversal(bufferInfo, currentTime);
if (
((bufferedRanges &&
bufferedRanges.length > 1 &&
bufferInfo.len > config.maxBufferHole) ||
(bufferInfo.nextStart &&
(bufferInfo.nextStart - currentTime < config.maxBufferHole ||
adjacentTraversal))) &&
(stalledDurationMs > config.highBufferWatchdogPeriod * 1000 ||
this.waiting)
) {
this.warn('Trying to nudge playhead over buffer-hole');
// Try to nudge currentTime over a buffer hole if we've been stalling for the configured amount of seconds
// We only try to jump the hole if it's under the configured size
this._tryNudgeBuffer(bufferInfo);
}
}
private adjacentTraversal(bufferInfo: BufferInfo, currentTime: number) {
const fragmentTracker = this.fragmentTracker;
const nextStart = bufferInfo.nextStart;
if (fragmentTracker && nextStart) {
const current = fragmentTracker.getFragAtPos(
currentTime,
PlaylistLevelType.MAIN,
);
const next = fragmentTracker.getFragAtPos(
nextStart,
PlaylistLevelType.MAIN,
);
if (current && next) {
return next.sn - current.sn < 2;
}
}
return false;
}
/**
* Triggers a BUFFER_STALLED_ERROR event, but only once per stall period.
* @param bufferLen - The playhead distance from the end of the current buffer segment.
* @private
*/
private _reportStall(bufferInfo: BufferInfo) {
const { hls, media, stallReported, stalled } = this;
if (!stallReported && stalled !== null && media && hls) {
// Report stalled error once
this.stallReported = true;
const error = new Error(
`Playback stalling at @${
media.currentTime
} due to low buffer (${stringify(bufferInfo)})`,
);
this.warn(error.message);
hls.trigger(Events.ERROR, {
type: ErrorTypes.MEDIA_ERROR,
details: ErrorDetails.BUFFER_STALLED_ERROR,
fatal: false,
error,
buffer: bufferInfo.len,
bufferInfo,
stalled: { start: stalled },
});
}
}
/**
* Attempts to fix buffer stalls by jumping over known gaps caused by partial fragments
* @param appended - The fragment or part found at the current time (where playback is stalling).
* @private
*/
private _trySkipBufferHole(appended: MediaFragment | Part | null): number {
const { fragmentTracker, media } = this;
const config = this.hls?.config;
if (!media || !fragmentTracker || !config) {
return 0;
}
// Check if currentTime is between unbuffered regions of partial fragments
const currentTime = media.currentTime;
const bufferInfo = BufferHelper.bufferInfo(media, currentTime, 0);
const startTime =
currentTime < bufferInfo.start ? bufferInfo.start : bufferInfo.nextStart;
if (startTime && this.hls) {
const bufferStarved = bufferInfo.len <= config.maxBufferHole;
const waiting =
bufferInfo.len > 0 && bufferInfo.len < 1 && media.readyState < 3;
const gapLength = startTime - currentTime;
if (gapLength > 0 && (bufferStarved || waiting)) {
// Only allow large gaps to be skipped if it is a start gap, or all fragments in skip range are partial
if (gapLength > config.maxBufferHole) {
let startGap = false;
if (currentTime === 0) {
const startFrag = fragmentTracker.getAppendedFrag(
0,
PlaylistLevelType.MAIN,
);
if (startFrag && startTime < startFrag.end) {
startGap = true;
}
}
if (!startGap && appended) {
// Do not seek when selected variant playlist is unloaded
if (!this.hls.loadLevelObj?.details) {
return 0;
}
// Do not seek when required fragments are inflight or appending
const inFlightDependency = getInFlightDependency(
this.hls.inFlightFragments,
startTime,
);
if (inFlightDependency) {
return 0;
}
// Do not seek if we can't walk tracked fragments to end of gap
let moreToLoad = false;
let pos = appended.end;
while (pos < startTime) {
const provisioned = appendedFragAtPosition(pos, fragmentTracker);
if (provisioned) {
pos += provisioned.duration;
} else {
moreToLoad = true;
break;
}
}
if (moreToLoad) {
return 0;
}
}
}
const targetTime = Math.max(
startTime + SKIP_BUFFER_RANGE_START,
currentTime + SKIP_BUFFER_HOLE_STEP_SECONDS,
);
this.warn(
`skipping hole, adjusting currentTime from ${currentTime} to ${targetTime}`,
);
this.moved = true;
media.currentTime = targetTime;
if (!appended?.gap) {
const error = new Error(
`fragment loaded with buffer holes, seeking from ${currentTime} to ${targetTime}`,
);
const errorData: ErrorData = {
type: ErrorTypes.MEDIA_ERROR,
details: ErrorDetails.BUFFER_SEEK_OVER_HOLE,
fatal: false,
error,
reason: error.message,
buffer: bufferInfo.len,
bufferInfo,
};
if (appended) {
if ('fragment' in appended) {
errorData.part = appended;
} else {
errorData.frag = appended;
}
}
this.hls.trigger(Events.ERROR, errorData);
}
return targetTime;
}
}
return 0;
}
/**
* Attempts to fix buffer stalls by advancing the mediaElement's current time by a small amount.
* @private
*/
private _tryNudgeBuffer(bufferInfo: BufferInfo) {
const { hls, media, nudgeRetry } = this;
const config = hls?.config;
if (!media || !config) {
return 0;
}
const currentTime = media.currentTime;
this.nudgeRetry++;
if (nudgeRetry < config.nudgeMaxRetry) {
const targetTime = currentTime + (nudgeRetry + 1) * config.nudgeOffset;
// playback stalled in buffered area ... let's nudge currentTime to try to overcome this
const error = new Error(
`Nudging 'currentTime' from ${currentTime} to ${targetTime}`,
);
this.warn(error.message);
media.currentTime = targetTime;
hls.trigger(Events.ERROR, {
type: ErrorTypes.MEDIA_ERROR,
details: ErrorDetails.BUFFER_NUDGE_ON_STALL,
error,
fatal: false,
buffer: bufferInfo.len,
bufferInfo,
});
} else {
const error = new Error(
`Playhead still not moving while enough data buffered @${currentTime} after ${config.nudgeMaxRetry} nudges`,
);
this.error(error.message);
hls.trigger(Events.ERROR, {
type: ErrorTypes.MEDIA_ERROR,
details: ErrorDetails.BUFFER_STALLED_ERROR,
error,
fatal: true,
buffer: bufferInfo.len,
bufferInfo,
});
}
}
}
function getInFlightDependency(
inFlightFragments: InFlightFragments,
currentTime: number,
): Fragment | null {
const main = inFlight(inFlightFragments.main);
if (main && main.start <= currentTime) {
return main;
}
const audio = inFlight(inFlightFragments.audio);
if (audio && audio.start <= currentTime) {
return audio;
}
return null;
}
function inFlight(inFlightData: InFlightData | undefined): Fragment | null {
if (!inFlightData) {
return null;
}
switch (inFlightData.state) {
case State.IDLE:
case State.STOPPED:
case State.ENDED:
case State.ERROR:
return null;
}
return inFlightData.frag;
}
function appendedFragAtPosition(pos: number, fragmentTracker: FragmentTracker) {
return (
fragmentTracker.getAppendedFrag(pos, PlaylistLevelType.MAIN) ||
fragmentTracker.getPartialFragment(pos)
);
}

Xet Storage Details

Size:
24.2 kB
·
Xet hash:
782a9d4eccc677b17cbf4c7cabd3dbe9edbe7874b85e28635324f7245eb54cb0

Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.