Favivon - Correção

This commit is contained in:
2026-05-30 19:59:39 -03:00
parent 76ddaa815d
commit d7dfd221f0
32859 changed files with 5459654 additions and 404 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+438
View File
@@ -0,0 +1,438 @@
import BasePlaylistController from './base-playlist-controller';
import { ErrorDetails, ErrorTypes } from '../errors';
import { Events } from '../events';
import { PlaylistContextType } from '../types/loader';
import { mediaAttributesIdentical } from '../utils/media-option-attributes';
import {
audioMatchPredicate,
findClosestLevelWithAudioGroup,
findMatchingOption,
matchesOption,
useAlternateAudio,
} from '../utils/rendition-helper';
import type Hls from '../hls';
import type {
AudioTrackLoadedData,
AudioTracksUpdatedData,
ErrorData,
LevelLoadingData,
LevelSwitchingData,
ManifestParsedData,
} from '../types/events';
import type { HlsUrlParameters } from '../types/level';
import type {
AudioSelectionOption,
MediaPlaylist,
} from '../types/media-playlist';
class AudioTrackController extends BasePlaylistController {
private tracks: MediaPlaylist[] = [];
private groupIds: (string | undefined)[] | null = null;
private tracksInGroup: MediaPlaylist[] = [];
private trackId: number = -1;
private currentTrack: MediaPlaylist | null = null;
private selectDefaultTrack: boolean = true;
constructor(hls: Hls) {
super(hls, 'audio-track-controller');
this.registerListeners();
}
private registerListeners() {
const { hls } = this;
hls.on(Events.MANIFEST_LOADING, this.onManifestLoading, this);
hls.on(Events.MANIFEST_PARSED, this.onManifestParsed, this);
hls.on(Events.LEVEL_LOADING, this.onLevelLoading, this);
hls.on(Events.LEVEL_SWITCHING, this.onLevelSwitching, this);
hls.on(Events.AUDIO_TRACK_LOADED, this.onAudioTrackLoaded, this);
hls.on(Events.ERROR, this.onError, this);
}
private unregisterListeners() {
const { hls } = this;
hls.off(Events.MANIFEST_LOADING, this.onManifestLoading, this);
hls.off(Events.MANIFEST_PARSED, this.onManifestParsed, this);
hls.off(Events.LEVEL_LOADING, this.onLevelLoading, this);
hls.off(Events.LEVEL_SWITCHING, this.onLevelSwitching, this);
hls.off(Events.AUDIO_TRACK_LOADED, this.onAudioTrackLoaded, this);
hls.off(Events.ERROR, this.onError, this);
}
public destroy() {
this.unregisterListeners();
this.tracks.length = 0;
this.tracksInGroup.length = 0;
this.currentTrack = null;
super.destroy();
}
protected onManifestLoading(): void {
this.tracks = [];
this.tracksInGroup = [];
this.groupIds = null;
this.currentTrack = null;
this.trackId = -1;
this.selectDefaultTrack = true;
}
protected onManifestParsed(
event: Events.MANIFEST_PARSED,
data: ManifestParsedData,
): void {
this.tracks = data.audioTracks || [];
}
protected onAudioTrackLoaded(
event: Events.AUDIO_TRACK_LOADED,
data: AudioTrackLoadedData,
): void {
const { id, groupId, details } = data;
const trackInActiveGroup = this.tracksInGroup[id];
if (!trackInActiveGroup || trackInActiveGroup.groupId !== groupId) {
this.warn(
`Audio track with id:${id} and group:${groupId} not found in active group ${trackInActiveGroup?.groupId}`,
);
return;
}
const curDetails = trackInActiveGroup.details;
trackInActiveGroup.details = data.details;
this.log(
`Audio track ${id} "${trackInActiveGroup.name}" lang:${trackInActiveGroup.lang} group:${groupId} loaded [${details.startSN}-${details.endSN}]`,
);
if (id === this.trackId) {
this.playlistLoaded(id, data, curDetails);
}
}
protected onLevelLoading(
event: Events.LEVEL_LOADING,
data: LevelLoadingData,
): void {
this.switchLevel(data.level);
}
protected onLevelSwitching(
event: Events.LEVEL_SWITCHING,
data: LevelSwitchingData,
): void {
this.switchLevel(data.level);
}
private switchLevel(levelIndex: number) {
const levelInfo = this.hls.levels[levelIndex];
if (!levelInfo) {
return;
}
const audioGroups = levelInfo.audioGroups || null;
const currentGroups = this.groupIds;
let currentTrack = this.currentTrack;
if (
!audioGroups ||
currentGroups?.length !== audioGroups?.length ||
audioGroups?.some((groupId) => currentGroups?.indexOf(groupId) === -1)
) {
this.groupIds = audioGroups;
this.trackId = -1;
this.currentTrack = null;
const audioTracks = this.tracks.filter(
(track): boolean =>
!audioGroups || audioGroups.indexOf(track.groupId) !== -1,
);
if (audioTracks.length) {
// Disable selectDefaultTrack if there are no default tracks
if (
this.selectDefaultTrack &&
!audioTracks.some((track) => track.default)
) {
this.selectDefaultTrack = false;
}
// track.id should match hls.audioTracks index
audioTracks.forEach((track, i) => {
track.id = i;
});
} else if (!currentTrack && !this.tracksInGroup.length) {
// Do not dispatch AUDIO_TRACKS_UPDATED when there were and are no tracks
return;
}
this.tracksInGroup = audioTracks;
// Find preferred track
const audioPreference = this.hls.config.audioPreference;
if (!currentTrack && audioPreference) {
const groupIndex = findMatchingOption(
audioPreference,
audioTracks,
audioMatchPredicate,
);
if (groupIndex > -1) {
currentTrack = audioTracks[groupIndex];
} else {
const allIndex = findMatchingOption(audioPreference, this.tracks);
currentTrack = this.tracks[allIndex];
}
}
// Select initial track
let trackId = this.findTrackId(currentTrack);
if (trackId === -1 && currentTrack) {
trackId = this.findTrackId(null);
}
// Dispatch events and load track if needed
const audioTracksUpdated: AudioTracksUpdatedData = { audioTracks };
this.log(
`Updating audio tracks, ${
audioTracks.length
} track(s) found in group(s): ${audioGroups?.join(',')}`,
);
this.hls.trigger(Events.AUDIO_TRACKS_UPDATED, audioTracksUpdated);
const selectedTrackId = this.trackId;
if (trackId !== -1 && selectedTrackId === -1) {
this.setAudioTrack(trackId);
} else if (audioTracks.length && selectedTrackId === -1) {
const error = new Error(
`No audio track selected for current audio group-ID(s): ${this.groupIds?.join(
',',
)} track count: ${audioTracks.length}`,
);
this.warn(error.message);
this.hls.trigger(Events.ERROR, {
type: ErrorTypes.MEDIA_ERROR,
details: ErrorDetails.AUDIO_TRACK_LOAD_ERROR,
fatal: true,
error,
});
}
}
}
protected onError(event: Events.ERROR, data: ErrorData): void {
if (data.fatal || !data.context) {
return;
}
if (
data.context.type === PlaylistContextType.AUDIO_TRACK &&
data.context.id === this.trackId &&
(!this.groupIds || this.groupIds.indexOf(data.context.groupId) !== -1)
) {
this.checkRetry(data);
}
}
get allAudioTracks(): MediaPlaylist[] {
return this.tracks;
}
get audioTracks(): MediaPlaylist[] {
return this.tracksInGroup;
}
get audioTrack(): number {
return this.trackId;
}
set audioTrack(newId: number) {
// If audio track is selected from API then don't choose from the manifest default track
this.selectDefaultTrack = false;
this.setAudioTrack(newId);
}
public setAudioOption(
audioOption: MediaPlaylist | AudioSelectionOption | undefined,
): MediaPlaylist | null {
const hls = this.hls;
hls.config.audioPreference = audioOption;
if (audioOption) {
const allAudioTracks = this.allAudioTracks;
this.selectDefaultTrack = false;
if (allAudioTracks.length) {
// First see if current option matches (no switch op)
const currentTrack = this.currentTrack;
if (
currentTrack &&
matchesOption(audioOption, currentTrack, audioMatchPredicate)
) {
return currentTrack;
}
// Find option in available tracks (tracksInGroup)
const groupIndex = findMatchingOption(
audioOption,
this.tracksInGroup,
audioMatchPredicate,
);
if (groupIndex > -1) {
const track = this.tracksInGroup[groupIndex];
this.setAudioTrack(groupIndex);
return track;
} else if (currentTrack) {
// Find option in nearest level audio group
let searchIndex = hls.loadLevel;
if (searchIndex === -1) {
searchIndex = hls.firstAutoLevel;
}
const switchIndex = findClosestLevelWithAudioGroup(
audioOption,
hls.levels,
allAudioTracks,
searchIndex,
audioMatchPredicate,
);
if (switchIndex === -1) {
// could not find matching variant
return null;
}
// and switch level to acheive the audio group switch
hls.nextLoadLevel = switchIndex;
}
if (audioOption.channels || audioOption.audioCodec) {
// Could not find a match with codec / channels predicate
// Find a match without channels or codec
const withoutCodecAndChannelsMatch = findMatchingOption(
audioOption,
allAudioTracks,
);
if (withoutCodecAndChannelsMatch > -1) {
return allAudioTracks[withoutCodecAndChannelsMatch];
}
}
}
}
return null;
}
private setAudioTrack(newId: number): void {
const tracks = this.tracksInGroup;
// check if level idx is valid
if (newId < 0 || newId >= tracks.length) {
this.warn(`Invalid audio track id: ${newId}`);
return;
}
this.selectDefaultTrack = false;
const lastTrack = this.currentTrack;
const track = tracks[newId];
const trackLoaded = track.details && !track.details.live;
if (newId === this.trackId && track === lastTrack && trackLoaded) {
return;
}
this.log(
`Switching to audio-track ${newId} "${track.name}" lang:${track.lang} group:${track.groupId} channels:${track.channels}`,
);
this.trackId = newId;
this.currentTrack = track;
this.hls.trigger(Events.AUDIO_TRACK_SWITCHING, { ...track });
// Do not reload track unless live
if (trackLoaded) {
return;
}
const hlsUrlParameters = this.switchParams(
track.url,
lastTrack?.details,
track.details,
);
this.loadPlaylist(hlsUrlParameters);
}
private findTrackId(currentTrack: MediaPlaylist | null): number {
const audioTracks = this.tracksInGroup;
for (let i = 0; i < audioTracks.length; i++) {
const track = audioTracks[i];
if (this.selectDefaultTrack && !track.default) {
continue;
}
if (
!currentTrack ||
matchesOption(currentTrack, track, audioMatchPredicate)
) {
return i;
}
}
if (currentTrack) {
const { name, lang, assocLang, characteristics, audioCodec, channels } =
currentTrack;
for (let i = 0; i < audioTracks.length; i++) {
const track = audioTracks[i];
if (
matchesOption(
{ name, lang, assocLang, characteristics, audioCodec, channels },
track,
audioMatchPredicate,
)
) {
return i;
}
}
for (let i = 0; i < audioTracks.length; i++) {
const track = audioTracks[i];
if (
mediaAttributesIdentical(currentTrack.attrs, track.attrs, [
'LANGUAGE',
'ASSOC-LANGUAGE',
'CHARACTERISTICS',
])
) {
return i;
}
}
for (let i = 0; i < audioTracks.length; i++) {
const track = audioTracks[i];
if (
mediaAttributesIdentical(currentTrack.attrs, track.attrs, [
'LANGUAGE',
])
) {
return i;
}
}
}
return -1;
}
protected loadPlaylist(hlsUrlParameters?: HlsUrlParameters): void {
super.loadPlaylist();
const audioTrack = this.currentTrack;
if (!this.shouldLoadPlaylist(audioTrack)) {
return;
}
// Do not load audio rendition with URI matching main variant URI
if (useAlternateAudio(audioTrack.url, this.hls)) {
this.scheduleLoading(audioTrack, hlsUrlParameters);
}
}
protected loadingPlaylist(
audioTrack: MediaPlaylist,
hlsUrlParameters: HlsUrlParameters | undefined,
) {
super.loadingPlaylist(audioTrack, hlsUrlParameters);
const id = audioTrack.id;
const groupId = audioTrack.groupId as string;
const url = this.getUrlWithDirectives(audioTrack.url, hlsUrlParameters);
const details = audioTrack.details;
const age = details?.age;
this.log(
`Loading audio-track ${id} "${audioTrack.name}" lang:${audioTrack.lang} group:${groupId}${
hlsUrlParameters?.msn !== undefined
? ' at sn ' + hlsUrlParameters.msn + ' part ' + hlsUrlParameters.part
: ''
}${age && details.live ? ' age ' + age.toFixed(1) + (details.type ? ' ' + details.type || '' : '') : ''} ${url}`,
);
this.hls.trigger(Events.AUDIO_TRACK_LOADING, {
url,
id,
groupId,
deliveryDirectives: hlsUrlParameters || null,
track: audioTrack,
});
}
}
export default AudioTrackController;
+414
View File
@@ -0,0 +1,414 @@
import { NetworkErrorAction } from './error-controller';
import { ErrorDetails, ErrorTypes } from '../errors';
import { Events } from '../events';
import {
getSkipValue,
HlsSkip,
HlsUrlParameters,
type Level,
} from '../types/level';
import { getRetryDelay, isTimeoutError } from '../utils/error-helper';
import { computeReloadInterval, mergeDetails } from '../utils/level-helper';
import { Logger } from '../utils/logger';
import type Hls from '../hls';
import type { LevelDetails } from '../loader/level-details';
import type { NetworkComponentAPI } from '../types/component-api';
import type { ErrorData } from '../types/events';
import type {
AudioTrackLoadedData,
LevelLoadedData,
TrackLoadedData,
} from '../types/events';
import type { MediaPlaylist } from '../types/media-playlist';
export default class BasePlaylistController
extends Logger
implements NetworkComponentAPI
{
protected hls: Hls;
protected canLoad: boolean = false;
private timer: number = -1;
constructor(hls: Hls, logPrefix: string) {
super(logPrefix, hls.logger);
this.hls = hls;
}
public destroy() {
this.clearTimer();
// @ts-ignore
this.hls = this.log = this.warn = null;
}
private clearTimer() {
if (this.timer !== -1) {
self.clearTimeout(this.timer);
this.timer = -1;
}
}
public startLoad() {
this.canLoad = true;
this.loadPlaylist();
}
public stopLoad() {
this.canLoad = false;
this.clearTimer();
}
protected switchParams(
playlistUri: string,
previous: LevelDetails | undefined,
current: LevelDetails | undefined,
): HlsUrlParameters | undefined {
const renditionReports = previous?.renditionReports;
if (renditionReports) {
let foundIndex = -1;
for (let i = 0; i < renditionReports.length; i++) {
const attr = renditionReports[i];
let uri: string;
try {
uri = new self.URL(attr.URI, previous.url).href;
} catch (error) {
this.warn(
`Could not construct new URL for Rendition Report: ${error}`,
);
uri = attr.URI || '';
}
// Use exact match. Otherwise, the last partial match, if any, will be used
// (Playlist URI includes a query string that the Rendition Report does not)
if (uri === playlistUri) {
foundIndex = i;
break;
} else if (uri === playlistUri.substring(0, uri.length)) {
foundIndex = i;
}
}
if (foundIndex !== -1) {
const attr = renditionReports[foundIndex];
const msn = parseInt(attr['LAST-MSN']) || previous.lastPartSn;
let part = parseInt(attr['LAST-PART']) || previous.lastPartIndex;
if (this.hls.config.lowLatencyMode) {
const currentGoal = Math.min(
previous.age - previous.partTarget,
previous.targetduration,
);
if (part >= 0 && currentGoal > previous.partTarget) {
part += 1;
}
}
const skip = current && getSkipValue(current);
return new HlsUrlParameters(msn, part >= 0 ? part : undefined, skip);
}
}
}
protected loadPlaylist(hlsUrlParameters?: HlsUrlParameters) {
// Loading is handled by the subclasses
this.clearTimer();
}
protected loadingPlaylist(
playlist: Level | MediaPlaylist,
hlsUrlParameters?: HlsUrlParameters,
) {
// Loading is handled by the subclasses
this.clearTimer();
}
protected shouldLoadPlaylist(
playlist: Level | MediaPlaylist | null | undefined,
): playlist is Level | MediaPlaylist {
return (
this.canLoad &&
!!playlist &&
!!playlist.url &&
(!playlist.details || playlist.details.live)
);
}
protected getUrlWithDirectives(
uri: string,
hlsUrlParameters: HlsUrlParameters | undefined,
): string {
if (hlsUrlParameters) {
try {
return hlsUrlParameters.addDirectives(uri);
} catch (error) {
this.warn(
`Could not construct new URL with HLS Delivery Directives: ${error}`,
);
}
}
return uri;
}
protected playlistLoaded(
index: number,
data: LevelLoadedData | AudioTrackLoadedData | TrackLoadedData,
previousDetails?: LevelDetails,
) {
const { details, stats } = data;
// Set last updated date-time
const now = self.performance.now();
const elapsed = stats.loading.first
? Math.max(0, now - stats.loading.first)
: 0;
details.advancedDateTime = Date.now() - elapsed;
// shift fragment starts with timelineOffset
const timelineOffset = this.hls.config.timelineOffset;
if (timelineOffset !== details.appliedTimelineOffset) {
const offset = Math.max(timelineOffset || 0, 0);
details.appliedTimelineOffset = offset;
details.fragments.forEach((frag) => {
frag.setStart(frag.playlistOffset + offset);
});
}
// if current playlist is a live playlist, arm a timer to reload it
if (details.live || previousDetails?.live) {
const levelOrTrack = 'levelInfo' in data ? data.levelInfo : data.track;
details.reloaded(previousDetails);
// Merge live playlists to adjust fragment starts and fill in delta playlist skipped segments
if (previousDetails && details.fragments.length > 0) {
mergeDetails(previousDetails, details, this);
const error = details.playlistParsingError;
if (error) {
this.warn(error);
const hls = this.hls;
if (!hls.config.ignorePlaylistParsingErrors) {
const { networkDetails } = data;
hls.trigger(Events.ERROR, {
type: ErrorTypes.NETWORK_ERROR,
details: ErrorDetails.LEVEL_PARSING_ERROR,
fatal: false,
url: details.url,
error,
reason: error.message,
level: (data as any).level || undefined,
parent: details.fragments[0]?.type,
networkDetails,
stats,
});
return;
}
details.playlistParsingError = null;
}
}
if (details.requestScheduled === -1) {
details.requestScheduled = stats.loading.start;
}
const bufferInfo = this.hls.mainForwardBufferInfo;
const position = bufferInfo ? bufferInfo.end - bufferInfo.len : 0;
const distanceToLiveEdgeMs = (details.edge - position) * 1000;
const reloadInterval = computeReloadInterval(
details,
distanceToLiveEdgeMs,
);
if (details.requestScheduled + reloadInterval < now) {
details.requestScheduled = now;
} else {
details.requestScheduled += reloadInterval;
}
this.log(
`live playlist ${index} ${
details.advanced
? 'REFRESHED ' + details.lastPartSn + '-' + details.lastPartIndex
: details.updated
? 'UPDATED'
: 'MISSED'
}`,
);
if (!this.canLoad || !details.live) {
return;
}
let deliveryDirectives: HlsUrlParameters | undefined;
let msn: number | undefined = undefined;
let part: number | undefined = undefined;
if (details.canBlockReload && details.endSN && details.advanced) {
// Load level with LL-HLS delivery directives
const lowLatencyMode = this.hls.config.lowLatencyMode;
const lastPartSn = details.lastPartSn;
const endSn = details.endSN;
const lastPartIndex = details.lastPartIndex;
const hasParts = lastPartIndex !== -1;
const atLastPartOfSegment = lastPartSn === endSn;
if (hasParts) {
// When low latency mode is disabled, request the last part of the next segment
if (atLastPartOfSegment) {
msn = endSn + 1;
part = lowLatencyMode ? 0 : lastPartIndex;
} else {
msn = lastPartSn;
part = lowLatencyMode ? lastPartIndex + 1 : details.maxPartIndex;
}
} else {
msn = endSn + 1;
}
// Low-Latency CDN Tune-in: "age" header and time since load indicates we're behind by more than one part
// Update directives to obtain the Playlist that has the estimated additional duration of media
const lastAdvanced = details.age;
const cdnAge = lastAdvanced + details.ageHeader;
let currentGoal = Math.min(
cdnAge - details.partTarget,
details.targetduration * 1.5,
);
if (currentGoal > 0) {
if (cdnAge > details.targetduration * 3) {
// Omit segment and part directives when the last response was more than 3 target durations ago,
this.log(
`Playlist last advanced ${lastAdvanced.toFixed(
2,
)}s ago. Omitting segment and part directives.`,
);
msn = undefined;
part = undefined;
} else if (
previousDetails?.tuneInGoal &&
cdnAge - details.partTarget > previousDetails.tuneInGoal
) {
// If we attempted to get the next or latest playlist update, but currentGoal increased,
// then we either can't catchup, or the "age" header cannot be trusted.
this.warn(
`CDN Tune-in goal increased from: ${previousDetails.tuneInGoal} to: ${currentGoal} with playlist age: ${details.age}`,
);
currentGoal = 0;
} else {
const segments = Math.floor(currentGoal / details.targetduration);
msn += segments;
if (part !== undefined) {
const parts = Math.round(
(currentGoal % details.targetduration) / details.partTarget,
);
part += parts;
}
this.log(
`CDN Tune-in age: ${
details.ageHeader
}s last advanced ${lastAdvanced.toFixed(
2,
)}s goal: ${currentGoal} skip sn ${segments} to part ${part}`,
);
}
details.tuneInGoal = currentGoal;
}
deliveryDirectives = this.getDeliveryDirectives(
details,
data.deliveryDirectives,
msn,
part,
);
if (lowLatencyMode || !atLastPartOfSegment) {
details.requestScheduled = now;
this.loadingPlaylist(levelOrTrack, deliveryDirectives);
return;
}
} else if (details.canBlockReload || details.canSkipUntil) {
deliveryDirectives = this.getDeliveryDirectives(
details,
data.deliveryDirectives,
msn,
part,
);
}
if (deliveryDirectives && msn !== undefined && details.canBlockReload) {
details.requestScheduled =
stats.loading.first +
Math.max(reloadInterval - elapsed * 2, reloadInterval / 2);
}
this.scheduleLoading(levelOrTrack, deliveryDirectives, details);
} else {
this.clearTimer();
}
}
protected scheduleLoading(
levelOrTrack: Level | MediaPlaylist,
deliveryDirectives?: HlsUrlParameters,
updatedDetails?: LevelDetails,
) {
const details = updatedDetails || levelOrTrack.details;
if (!details) {
this.loadingPlaylist(levelOrTrack, deliveryDirectives);
return;
}
const now = self.performance.now();
const requestScheduled = details.requestScheduled;
if (now >= requestScheduled) {
this.loadingPlaylist(levelOrTrack, deliveryDirectives);
return;
}
const estimatedTimeUntilUpdate = requestScheduled - now;
this.log(
`reload live playlist ${levelOrTrack.name || levelOrTrack.bitrate + 'bps'} in ${Math.round(
estimatedTimeUntilUpdate,
)} ms`,
);
this.clearTimer();
this.timer = self.setTimeout(
() => this.loadingPlaylist(levelOrTrack, deliveryDirectives),
estimatedTimeUntilUpdate,
);
}
private getDeliveryDirectives(
details: LevelDetails,
previousDeliveryDirectives: HlsUrlParameters | null,
msn?: number,
part?: number,
): HlsUrlParameters {
let skip = getSkipValue(details);
if (previousDeliveryDirectives?.skip && details.deltaUpdateFailed) {
msn = previousDeliveryDirectives.msn;
part = previousDeliveryDirectives.part;
skip = HlsSkip.No;
}
return new HlsUrlParameters(msn, part, skip);
}
protected checkRetry(errorEvent: ErrorData): boolean {
const errorDetails = errorEvent.details;
const isTimeout = isTimeoutError(errorEvent);
const errorAction = errorEvent.errorAction;
const { action, retryCount = 0, retryConfig } = errorAction || {};
const retry =
!!errorAction &&
!!retryConfig &&
(action === NetworkErrorAction.RetryRequest ||
(!errorAction.resolved &&
action === NetworkErrorAction.SendAlternateToPenaltyBox));
if (retry) {
if (retryCount >= retryConfig.maxNumRetry) {
return false;
}
if (isTimeout && errorEvent.context?.deliveryDirectives) {
// The LL-HLS request already timed out so retry immediately
this.warn(
`Retrying playlist loading ${retryCount + 1}/${
retryConfig.maxNumRetry
} after "${errorDetails}" without delivery-directives`,
);
this.loadPlaylist();
} else {
const delay = getRetryDelay(retryConfig, retryCount);
// Schedule level/track reload
this.clearTimer();
this.timer = self.setTimeout(() => this.loadPlaylist(), delay);
this.warn(
`Retrying playlist loading ${retryCount + 1}/${
retryConfig.maxNumRetry
} after "${errorDetails}" in ${delay}ms`,
);
}
// `levelRetry = true` used to inform other controllers that a retry is happening
errorEvent.levelRetry = true;
errorAction.resolved = true;
}
return retry;
}
}
File diff suppressed because it is too large Load Diff
+1880
View File
File diff suppressed because it is too large Load Diff
+159
View File
@@ -0,0 +1,159 @@
import type {
BufferOperation,
BufferOperationQueues,
SourceBufferName,
SourceBufferTrackSet,
} from '../types/buffer';
export default class BufferOperationQueue {
private tracks: SourceBufferTrackSet | null;
private queues: BufferOperationQueues | null = {
video: [],
audio: [],
audiovideo: [],
};
constructor(sourceBufferReference: SourceBufferTrackSet) {
this.tracks = sourceBufferReference;
}
public destroy() {
this.tracks = this.queues = null;
}
public append(
operation: BufferOperation,
type: SourceBufferName,
pending?: boolean | undefined,
) {
if (this.queues === null || this.tracks === null) {
return;
}
const queue = this.queues[type];
queue.push(operation);
if (queue.length === 1 && !pending) {
this.executeNext(type);
}
}
public appendBlocker(type: SourceBufferName): Promise<void> {
return new Promise((resolve) => {
const operation: BufferOperation = {
label: 'async-blocker',
execute: resolve,
onStart: () => {},
onComplete: () => {},
onError: () => {},
};
this.append(operation, type);
});
}
public prependBlocker(type: SourceBufferName): Promise<void> {
return new Promise((resolve) => {
if (this.queues) {
const operation: BufferOperation = {
label: 'async-blocker-prepend',
execute: resolve,
onStart: () => {},
onComplete: () => {},
onError: () => {},
};
this.queues[type].unshift(operation);
}
});
}
public removeBlockers() {
if (this.queues === null) {
return;
}
[this.queues.video, this.queues.audio, this.queues.audiovideo].forEach(
(queue) => {
const label = queue[0]?.label;
if (label === 'async-blocker' || label === 'async-blocker-prepend') {
queue[0].execute();
queue.splice(0, 1);
}
},
);
}
public unblockAudio(op: BufferOperation) {
if (this.queues === null) {
return;
}
const queue = this.queues.audio;
if (queue[0] === op) {
this.shiftAndExecuteNext('audio');
}
}
public executeNext(type: SourceBufferName) {
if (this.queues === null || this.tracks === null) {
return;
}
const queue = this.queues[type];
if (queue.length) {
const operation: BufferOperation = queue[0];
try {
// Operations are expected to result in an 'updateend' event being fired. If not, the queue will lock. Operations
// which do not end with this event must call _onSBUpdateEnd manually
operation.execute();
} catch (error) {
operation.onError(error);
if (this.queues === null || this.tracks === null) {
return;
}
// Only shift the current operation off, otherwise the updateend handler will do this for us
const sb = this.tracks[type]?.buffer;
if (!sb?.updating) {
this.shiftAndExecuteNext(type);
}
}
}
}
public shiftAndExecuteNext(type: SourceBufferName) {
if (this.queues === null) {
return;
}
this.queues[type].shift();
this.executeNext(type);
}
public current(type: SourceBufferName): BufferOperation | null {
return this.queues?.[type][0] || null;
}
public toString(): string {
const { queues, tracks } = this;
if (queues === null || tracks === null) {
return `<destroyed>`;
}
return `
${this.list('video')}
${this.list('audio')}
${this.list('audiovideo')}}`;
}
public list(type: SourceBufferName): string {
return this.queues?.[type] || this.tracks?.[type]
? `${type}: (${this.listSbInfo(type)}) ${this.listOps(type)}`
: '';
}
private listSbInfo(type: SourceBufferName): string {
const track = this.tracks?.[type];
const sb = track?.buffer;
if (!sb) {
return 'none';
}
return `SourceBuffer${sb.updating ? ' updating' : ''}${track.ended ? ' ended' : ''}${track.ending ? ' ending' : ''}`;
}
private listOps(type: SourceBufferName): string {
return this.queues?.[type].map((op) => op.label).join(', ') || '';
}
}
+320
View File
@@ -0,0 +1,320 @@
/*
* cap stream level to media size dimension controller
*/
import { Events } from '../events';
import type StreamController from './stream-controller';
import type Hls from '../hls';
import type { ComponentAPI } from '../types/component-api';
import type {
BufferCodecsData,
FPSDropLevelCappingData,
LevelsUpdatedData,
ManifestParsedData,
MediaAttachingData,
} from '../types/events';
import type { Level } from '../types/level';
type RestrictedLevel = { width: number; height: number; bitrate: number };
class CapLevelController implements ComponentAPI {
private hls: Hls;
private autoLevelCapping: number;
private firstLevel: number;
private media: HTMLVideoElement | null;
private restrictedLevels: RestrictedLevel[];
private timer: number | undefined;
private clientRect: { width: number; height: number } | null;
private streamController?: StreamController;
constructor(hls: Hls) {
this.hls = hls;
this.autoLevelCapping = Number.POSITIVE_INFINITY;
this.firstLevel = -1;
this.media = null;
this.restrictedLevels = [];
this.timer = undefined;
this.clientRect = null;
this.registerListeners();
}
public setStreamController(streamController: StreamController) {
this.streamController = streamController;
}
public destroy() {
if (this.hls) {
this.unregisterListener();
}
if (this.timer) {
this.stopCapping();
}
this.media = null;
this.clientRect = null;
// @ts-ignore
this.hls = this.streamController = null;
}
protected registerListeners() {
const { hls } = this;
hls.on(Events.FPS_DROP_LEVEL_CAPPING, this.onFpsDropLevelCapping, this);
hls.on(Events.MEDIA_ATTACHING, this.onMediaAttaching, this);
hls.on(Events.MANIFEST_PARSED, this.onManifestParsed, this);
hls.on(Events.LEVELS_UPDATED, this.onLevelsUpdated, this);
hls.on(Events.BUFFER_CODECS, this.onBufferCodecs, this);
hls.on(Events.MEDIA_DETACHING, this.onMediaDetaching, this);
}
protected unregisterListener() {
const { hls } = this;
hls.off(Events.FPS_DROP_LEVEL_CAPPING, this.onFpsDropLevelCapping, this);
hls.off(Events.MEDIA_ATTACHING, this.onMediaAttaching, this);
hls.off(Events.MANIFEST_PARSED, this.onManifestParsed, this);
hls.off(Events.LEVELS_UPDATED, this.onLevelsUpdated, this);
hls.off(Events.BUFFER_CODECS, this.onBufferCodecs, this);
hls.off(Events.MEDIA_DETACHING, this.onMediaDetaching, this);
}
protected onFpsDropLevelCapping(
event: Events.FPS_DROP_LEVEL_CAPPING,
data: FPSDropLevelCappingData,
) {
// Don't add a restricted level more than once
const level = this.hls.levels[data.droppedLevel];
if (this.isLevelAllowed(level)) {
this.restrictedLevels.push({
bitrate: level.bitrate,
height: level.height,
width: level.width,
});
}
}
protected onMediaAttaching(
event: Events.MEDIA_ATTACHING,
data: MediaAttachingData,
) {
this.media = data.media instanceof HTMLVideoElement ? data.media : null;
this.clientRect = null;
if (this.timer && this.hls.levels.length) {
this.detectPlayerSize();
}
}
protected onManifestParsed(
event: Events.MANIFEST_PARSED,
data: ManifestParsedData,
) {
const hls = this.hls;
this.restrictedLevels = [];
this.firstLevel = data.firstLevel;
if (hls.config.capLevelToPlayerSize && data.video) {
// Start capping immediately if the manifest has signaled video codecs
this.startCapping();
}
}
private onLevelsUpdated(
event: Events.LEVELS_UPDATED,
data: LevelsUpdatedData,
) {
if (this.timer && Number.isFinite(this.autoLevelCapping)) {
this.detectPlayerSize();
}
}
// Only activate capping when playing a video stream; otherwise, multi-bitrate audio-only streams will be restricted
// to the first level
protected onBufferCodecs(
event: Events.BUFFER_CODECS,
data: BufferCodecsData,
) {
const hls = this.hls;
if (hls.config.capLevelToPlayerSize && data.video) {
// If the manifest did not signal a video codec capping has been deferred until we're certain video is present
this.startCapping();
}
}
protected onMediaDetaching() {
this.stopCapping();
this.media = null;
}
detectPlayerSize() {
if (this.media) {
if (this.mediaHeight <= 0 || this.mediaWidth <= 0) {
this.clientRect = null;
return;
}
const levels = this.hls.levels;
if (levels.length) {
const hls = this.hls;
const maxLevel = this.getMaxLevel(levels.length - 1);
if (maxLevel !== this.autoLevelCapping) {
hls.logger.log(
`Setting autoLevelCapping to ${maxLevel}: ${levels[maxLevel].height}p@${levels[maxLevel].bitrate} for media ${this.mediaWidth}x${this.mediaHeight}`,
);
}
hls.autoLevelCapping = maxLevel;
if (
hls.autoLevelEnabled &&
hls.autoLevelCapping > this.autoLevelCapping &&
this.streamController
) {
// if auto level capping has a higher value for the previous one, flush the buffer using nextLevelSwitch
// usually happen when the user go to the fullscreen mode.
this.streamController.nextLevelSwitch();
}
this.autoLevelCapping = hls.autoLevelCapping;
}
}
}
/*
* returns level should be the one with the dimensions equal or greater than the media (player) dimensions (so the video will be downscaled)
*/
getMaxLevel(capLevelIndex: number): number {
const levels = this.hls.levels;
if (!levels.length) {
return -1;
}
const validLevels = levels.filter(
(level, index) => this.isLevelAllowed(level) && index <= capLevelIndex,
);
this.clientRect = null;
return CapLevelController.getMaxLevelByMediaSize(
validLevels,
this.mediaWidth,
this.mediaHeight,
);
}
startCapping() {
if (this.timer) {
// Don't reset capping if started twice; this can happen if the manifest signals a video codec
return;
}
this.autoLevelCapping = Number.POSITIVE_INFINITY;
self.clearInterval(this.timer);
this.timer = self.setInterval(this.detectPlayerSize.bind(this), 1000);
this.detectPlayerSize();
}
stopCapping() {
this.restrictedLevels = [];
this.firstLevel = -1;
this.autoLevelCapping = Number.POSITIVE_INFINITY;
if (this.timer) {
self.clearInterval(this.timer);
this.timer = undefined;
}
}
getDimensions(): { width: number; height: number } {
if (this.clientRect) {
return this.clientRect;
}
const media = this.media;
const boundsRect = {
width: 0,
height: 0,
};
if (media) {
const clientRect = media.getBoundingClientRect();
boundsRect.width = clientRect.width;
boundsRect.height = clientRect.height;
if (!boundsRect.width && !boundsRect.height) {
// When the media element has no width or height (equivalent to not being in the DOM),
// then use its width and height attributes (media.width, media.height)
boundsRect.width =
clientRect.right - clientRect.left || media.width || 0;
boundsRect.height =
clientRect.bottom - clientRect.top || media.height || 0;
}
}
this.clientRect = boundsRect;
return boundsRect;
}
get mediaWidth(): number {
return this.getDimensions().width * this.contentScaleFactor;
}
get mediaHeight(): number {
return this.getDimensions().height * this.contentScaleFactor;
}
get contentScaleFactor(): number {
let pixelRatio = 1;
if (!this.hls.config.ignoreDevicePixelRatio) {
try {
pixelRatio = self.devicePixelRatio;
} catch (e) {
/* no-op */
}
}
return Math.min(pixelRatio, this.hls.config.maxDevicePixelRatio);
}
private isLevelAllowed(level: Level): boolean {
const restrictedLevels = this.restrictedLevels;
return !restrictedLevels.some((restrictedLevel) => {
return (
level.bitrate === restrictedLevel.bitrate &&
level.width === restrictedLevel.width &&
level.height === restrictedLevel.height
);
});
}
static getMaxLevelByMediaSize(
levels: Array<Level>,
width: number,
height: number,
): number {
if (!levels?.length) {
return -1;
}
// Levels can have the same dimensions but differing bandwidths - since levels are ordered, we can look to the next
// to determine whether we've chosen the greatest bandwidth for the media's dimensions
const atGreatestBandwidth = (
curLevel: Level,
nextLevel: Level | undefined,
) => {
if (!nextLevel) {
return true;
}
return (
curLevel.width !== nextLevel.width ||
curLevel.height !== nextLevel.height
);
};
// If we run through the loop without breaking, the media's dimensions are greater than every level, so default to
// the max level
let maxLevelIndex = levels.length - 1;
// Prevent changes in aspect-ratio from causing capping to toggle back and forth
const squareSize = Math.max(width, height);
for (let i = 0; i < levels.length; i += 1) {
const level = levels[i];
if (
(level.width >= squareSize || level.height >= squareSize) &&
atGreatestBandwidth(level, levels[i + 1])
) {
maxLevelIndex = i;
break;
}
}
return maxLevelIndex;
}
}
export default CapLevelController;
+422
View File
@@ -0,0 +1,422 @@
import { CmcdObjectType } from '@svta/common-media-library/cmcd/CmcdObjectType';
import { CmcdStreamingFormat } from '@svta/common-media-library/cmcd/CmcdStreamingFormat';
import { appendCmcdHeaders } from '@svta/common-media-library/cmcd/appendCmcdHeaders';
import { appendCmcdQuery } from '@svta/common-media-library/cmcd/appendCmcdQuery';
import { Events } from '../events';
import { BufferHelper } from '../utils/buffer-helper';
import type {
FragmentLoaderConstructor,
HlsConfig,
PlaylistLoaderConstructor,
} from '../config';
import type Hls from '../hls';
import type { Fragment, Part } from '../loader/fragment';
import type { ExtendedSourceBuffer } from '../types/buffer';
import type { ComponentAPI } from '../types/component-api';
import type { BufferCreatedData, MediaAttachedData } from '../types/events';
import type {
FragmentLoaderContext,
Loader,
LoaderCallbacks,
LoaderConfiguration,
LoaderContext,
PlaylistLoaderContext,
} from '../types/loader';
import type { Cmcd } from '@svta/common-media-library/cmcd/Cmcd';
import type { CmcdEncodeOptions } from '@svta/common-media-library/cmcd/CmcdEncodeOptions';
/**
* Controller to deal with Common Media Client Data (CMCD)
* @see https://cdn.cta.tech/cta/media/media/resources/standards/pdfs/cta-5004-final.pdf
*/
export default class CMCDController implements ComponentAPI {
private hls: Hls;
private config: HlsConfig;
private media?: HTMLMediaElement;
private sid?: string;
private cid?: string;
private useHeaders: boolean = false;
private includeKeys?: string[];
private initialized: boolean = false;
private starved: boolean = false;
private buffering: boolean = true;
private audioBuffer?: ExtendedSourceBuffer;
private videoBuffer?: ExtendedSourceBuffer;
constructor(hls: Hls) {
this.hls = hls;
const config = (this.config = hls.config);
const { cmcd } = config;
if (cmcd != null) {
config.pLoader = this.createPlaylistLoader();
config.fLoader = this.createFragmentLoader();
this.sid = cmcd.sessionId || hls.sessionId;
this.cid = cmcd.contentId;
this.useHeaders = cmcd.useHeaders === true;
this.includeKeys = cmcd.includeKeys;
this.registerListeners();
}
}
private registerListeners() {
const hls = this.hls;
hls.on(Events.MEDIA_ATTACHED, this.onMediaAttached, this);
hls.on(Events.MEDIA_DETACHED, this.onMediaDetached, this);
hls.on(Events.BUFFER_CREATED, this.onBufferCreated, this);
}
private unregisterListeners() {
const hls = this.hls;
hls.off(Events.MEDIA_ATTACHED, this.onMediaAttached, this);
hls.off(Events.MEDIA_DETACHED, this.onMediaDetached, this);
hls.off(Events.BUFFER_CREATED, this.onBufferCreated, this);
}
destroy() {
this.unregisterListeners();
this.onMediaDetached();
// @ts-ignore
this.hls = this.config = this.audioBuffer = this.videoBuffer = null;
// @ts-ignore
this.onWaiting = this.onPlaying = this.media = null;
}
private onMediaAttached(
event: Events.MEDIA_ATTACHED,
data: MediaAttachedData,
) {
this.media = data.media;
this.media.addEventListener('waiting', this.onWaiting);
this.media.addEventListener('playing', this.onPlaying);
}
private onMediaDetached() {
if (!this.media) {
return;
}
this.media.removeEventListener('waiting', this.onWaiting);
this.media.removeEventListener('playing', this.onPlaying);
// @ts-ignore
this.media = null;
}
private onBufferCreated(
event: Events.BUFFER_CREATED,
data: BufferCreatedData,
) {
this.audioBuffer = data.tracks.audio?.buffer;
this.videoBuffer = data.tracks.video?.buffer;
}
private onWaiting = () => {
if (this.initialized) {
this.starved = true;
}
this.buffering = true;
};
private onPlaying = () => {
if (!this.initialized) {
this.initialized = true;
}
this.buffering = false;
};
/**
* Create baseline CMCD data
*/
private createData(): Cmcd {
return {
v: 1,
sf: CmcdStreamingFormat.HLS,
sid: this.sid,
cid: this.cid,
pr: this.media?.playbackRate,
mtp: this.hls.bandwidthEstimate / 1000,
};
}
/**
* Apply CMCD data to a request.
*/
private apply(context: LoaderContext, data: Cmcd = {}) {
// apply baseline data
Object.assign(data, this.createData());
const isVideo =
data.ot === CmcdObjectType.INIT ||
data.ot === CmcdObjectType.VIDEO ||
data.ot === CmcdObjectType.MUXED;
if (this.starved && isVideo) {
data.bs = true;
data.su = true;
this.starved = false;
}
if (data.su == null) {
data.su = this.buffering;
}
// TODO: Implement rtp, nrr, dl
const { includeKeys } = this;
if (includeKeys) {
data = Object.keys(data).reduce((acc, key) => {
includeKeys.includes(key) && (acc[key] = data[key]);
return acc;
}, {});
}
const options: CmcdEncodeOptions = { baseUrl: context.url };
if (this.useHeaders) {
if (!context.headers) {
context.headers = {};
}
appendCmcdHeaders(context.headers, data, options);
} else {
context.url = appendCmcdQuery(context.url, data, options);
}
}
/**
* Apply CMCD data to a manifest request.
*/
private applyPlaylistData = (context: PlaylistLoaderContext) => {
try {
this.apply(context, {
ot: CmcdObjectType.MANIFEST,
su: !this.initialized,
});
} catch (error) {
this.hls.logger.warn('Could not generate manifest CMCD data.', error);
}
};
/**
* Apply CMCD data to a segment request
*/
private applyFragmentData = (context: FragmentLoaderContext) => {
try {
const { frag, part } = context;
const level = this.hls.levels[frag.level];
const ot = this.getObjectType(frag);
const data: Cmcd = { d: (part || frag).duration * 1000, ot };
if (
ot === CmcdObjectType.VIDEO ||
ot === CmcdObjectType.AUDIO ||
ot == CmcdObjectType.MUXED
) {
data.br = level.bitrate / 1000;
data.tb = this.getTopBandwidth(ot) / 1000;
data.bl = this.getBufferLength(ot);
}
const next = part ? this.getNextPart(part) : this.getNextFrag(frag);
if (next?.url && next.url !== frag.url) {
data.nor = next.url;
}
this.apply(context, data);
} catch (error) {
this.hls.logger.warn('Could not generate segment CMCD data.', error);
}
};
private getNextFrag(fragment: Fragment): Fragment | undefined {
const levelDetails = this.hls.levels[fragment.level]?.details;
if (levelDetails) {
const index = (fragment.sn as number) - levelDetails.startSN;
return levelDetails.fragments[index + 1];
}
return undefined;
}
private getNextPart(part: Part): Part | undefined {
const { index, fragment } = part;
const partList = this.hls.levels[fragment.level]?.details?.partList;
if (partList) {
const { sn } = fragment;
for (let i = partList.length - 1; i >= 0; i--) {
const p = partList[i];
if (p.index === index && p.fragment.sn === sn) {
return partList[i + 1];
}
}
}
return undefined;
}
/**
* The CMCD object type.
*/
private getObjectType(fragment: Fragment): CmcdObjectType | undefined {
const { type } = fragment;
if (type === 'subtitle') {
return CmcdObjectType.TIMED_TEXT;
}
if (fragment.sn === 'initSegment') {
return CmcdObjectType.INIT;
}
if (type === 'audio') {
return CmcdObjectType.AUDIO;
}
if (type === 'main') {
if (!this.hls.audioTracks.length) {
return CmcdObjectType.MUXED;
}
return CmcdObjectType.VIDEO;
}
return undefined;
}
/**
* Get the highest bitrate.
*/
private getTopBandwidth(type: CmcdObjectType) {
let bitrate: number = 0;
let levels;
const hls = this.hls;
if (type === CmcdObjectType.AUDIO) {
levels = hls.audioTracks;
} else {
const max = hls.maxAutoLevel;
const len = max > -1 ? max + 1 : hls.levels.length;
levels = hls.levels.slice(0, len);
}
levels.forEach((level) => {
if (level.bitrate > bitrate) {
bitrate = level.bitrate;
}
});
return bitrate > 0 ? bitrate : NaN;
}
/**
* Get the buffer length for a media type in milliseconds
*/
private getBufferLength(type: CmcdObjectType) {
const media = this.media;
const buffer =
type === CmcdObjectType.AUDIO ? this.audioBuffer : this.videoBuffer;
if (!buffer || !media) {
return NaN;
}
const info = BufferHelper.bufferInfo(
buffer,
media.currentTime,
this.config.maxBufferHole,
);
return info.len * 1000;
}
/**
* Create a playlist loader
*/
private createPlaylistLoader(): PlaylistLoaderConstructor | undefined {
const { pLoader } = this.config;
const apply = this.applyPlaylistData;
const Ctor = pLoader || (this.config.loader as PlaylistLoaderConstructor);
return class CmcdPlaylistLoader {
private loader: Loader<PlaylistLoaderContext>;
constructor(config: HlsConfig) {
this.loader = new Ctor(config);
}
get stats() {
return this.loader.stats;
}
get context() {
return this.loader.context;
}
destroy() {
this.loader.destroy();
}
abort() {
this.loader.abort();
}
load(
context: PlaylistLoaderContext,
config: LoaderConfiguration,
callbacks: LoaderCallbacks<PlaylistLoaderContext>,
) {
apply(context);
this.loader.load(context, config, callbacks);
}
};
}
/**
* Create a playlist loader
*/
private createFragmentLoader(): FragmentLoaderConstructor | undefined {
const { fLoader } = this.config;
const apply = this.applyFragmentData;
const Ctor = fLoader || (this.config.loader as FragmentLoaderConstructor);
return class CmcdFragmentLoader {
private loader: Loader<FragmentLoaderContext>;
constructor(config: HlsConfig) {
this.loader = new Ctor(config);
}
get stats() {
return this.loader.stats;
}
get context() {
return this.loader.context;
}
destroy() {
this.loader.destroy();
}
abort() {
this.loader.abort();
}
load(
context: FragmentLoaderContext,
config: LoaderConfiguration,
callbacks: LoaderCallbacks<FragmentLoaderContext>,
) {
apply(context);
this.loader.load(context, config, callbacks);
}
};
}
}
+621
View File
@@ -0,0 +1,621 @@
import { ErrorActionFlags, NetworkErrorAction } from './error-controller';
import { ErrorDetails } from '../errors';
import { Events } from '../events';
import { Level } from '../types/level';
import {
type Loader,
type LoaderCallbacks,
type LoaderConfiguration,
type LoaderContext,
type LoaderResponse,
type LoaderStats,
PlaylistContextType,
} from '../types/loader';
import { AttrList } from '../utils/attr-list';
import { reassignFragmentLevelIndexes } from '../utils/level-helper';
import { Logger } from '../utils/logger';
import { stringify } from '../utils/safe-json-stringify';
import type { RetryConfig } from '../config';
import type Hls from '../hls';
import type { NetworkComponentAPI } from '../types/component-api';
import type {
ErrorData,
ManifestLoadedData,
ManifestParsedData,
SteeringManifestLoadedData,
} from '../types/events';
import type { MediaAttributes, MediaPlaylist } from '../types/media-playlist';
export type SteeringManifest = {
VERSION: 1;
TTL: number;
'RELOAD-URI'?: string;
'PATHWAY-PRIORITY': string[];
'PATHWAY-CLONES'?: PathwayClone[];
};
export type PathwayClone = {
'BASE-ID': string;
ID: string;
'URI-REPLACEMENT': UriReplacement;
};
export type UriReplacement = {
HOST?: string;
PARAMS?: { [queryParameter: string]: string };
'PER-VARIANT-URIS'?: { [stableVariantId: string]: string };
'PER-RENDITION-URIS'?: { [stableRenditionId: string]: string };
};
const PATHWAY_PENALTY_DURATION_MS = 300000;
export default class ContentSteeringController
extends Logger
implements NetworkComponentAPI
{
private readonly hls: Hls;
private loader: Loader<LoaderContext> | null = null;
private uri: string | null = null;
private pathwayId: string = '.';
private _pathwayPriority: string[] | null = null;
private timeToLoad: number = 300;
private reloadTimer: number = -1;
private updated: number = 0;
private started: boolean = false;
private enabled: boolean = true;
private levels: Level[] | null = null;
private audioTracks: MediaPlaylist[] | null = null;
private subtitleTracks: MediaPlaylist[] | null = null;
private penalizedPathways: { [pathwayId: string]: number } = {};
constructor(hls: Hls) {
super('content-steering', hls.logger);
this.hls = hls;
this.registerListeners();
}
private registerListeners() {
const hls = this.hls;
hls.on(Events.MANIFEST_LOADING, this.onManifestLoading, this);
hls.on(Events.MANIFEST_LOADED, this.onManifestLoaded, this);
hls.on(Events.MANIFEST_PARSED, this.onManifestParsed, this);
hls.on(Events.ERROR, this.onError, this);
}
private unregisterListeners() {
const hls = this.hls;
if (!hls) {
return;
}
hls.off(Events.MANIFEST_LOADING, this.onManifestLoading, this);
hls.off(Events.MANIFEST_LOADED, this.onManifestLoaded, this);
hls.off(Events.MANIFEST_PARSED, this.onManifestParsed, this);
hls.off(Events.ERROR, this.onError, this);
}
pathways() {
return (this.levels || []).reduce((pathways, level) => {
if (pathways.indexOf(level.pathwayId) === -1) {
pathways.push(level.pathwayId);
}
return pathways;
}, [] as string[]);
}
get pathwayPriority(): string[] | null {
return this._pathwayPriority;
}
set pathwayPriority(pathwayPriority: string[]) {
this.updatePathwayPriority(pathwayPriority);
}
startLoad() {
this.started = true;
this.clearTimeout();
if (this.enabled && this.uri) {
if (this.updated) {
const ttl = this.timeToLoad * 1000 - (performance.now() - this.updated);
if (ttl > 0) {
this.scheduleRefresh(this.uri, ttl);
return;
}
}
this.loadSteeringManifest(this.uri);
}
}
stopLoad() {
this.started = false;
if (this.loader) {
this.loader.destroy();
this.loader = null;
}
this.clearTimeout();
}
clearTimeout() {
if (this.reloadTimer !== -1) {
self.clearTimeout(this.reloadTimer);
this.reloadTimer = -1;
}
}
destroy() {
this.unregisterListeners();
this.stopLoad();
// @ts-ignore
this.hls = null;
this.levels = this.audioTracks = this.subtitleTracks = null;
}
removeLevel(levelToRemove: Level) {
const levels = this.levels;
if (levels) {
this.levels = levels.filter((level) => level !== levelToRemove);
}
}
private onManifestLoading() {
this.stopLoad();
this.enabled = true;
this.timeToLoad = 300;
this.updated = 0;
this.uri = null;
this.pathwayId = '.';
this.levels = this.audioTracks = this.subtitleTracks = null;
}
private onManifestLoaded(
event: Events.MANIFEST_LOADED,
data: ManifestLoadedData,
) {
const { contentSteering } = data;
if (contentSteering === null) {
return;
}
this.pathwayId = contentSteering.pathwayId;
this.uri = contentSteering.uri;
if (this.started) {
this.startLoad();
}
}
private onManifestParsed(
event: Events.MANIFEST_PARSED,
data: ManifestParsedData,
) {
this.audioTracks = data.audioTracks;
this.subtitleTracks = data.subtitleTracks;
}
private onError(event: Events.ERROR, data: ErrorData) {
const { errorAction } = data;
if (
errorAction?.action === NetworkErrorAction.SendAlternateToPenaltyBox &&
errorAction.flags === ErrorActionFlags.MoveAllAlternatesMatchingHost
) {
const levels = this.levels;
let pathwayPriority = this._pathwayPriority;
let errorPathway = this.pathwayId;
if (data.context) {
const { groupId, pathwayId, type } = data.context;
if (groupId && levels) {
errorPathway = this.getPathwayForGroupId(groupId, type, errorPathway);
} else if (pathwayId) {
errorPathway = pathwayId;
}
}
if (!(errorPathway in this.penalizedPathways)) {
this.penalizedPathways[errorPathway] = performance.now();
}
if (!pathwayPriority && levels) {
// If PATHWAY-PRIORITY was not provided, list pathways for error handling
pathwayPriority = this.pathways();
}
if (pathwayPriority && pathwayPriority.length > 1) {
this.updatePathwayPriority(pathwayPriority);
errorAction.resolved = this.pathwayId !== errorPathway;
}
if (data.details === ErrorDetails.BUFFER_APPEND_ERROR && !data.fatal) {
// Error will become fatal in buffer-controller when reaching `appendErrorMaxRetry`
// Stream-controllers are expected to reduce buffer length even if this is not deemed a QuotaExceededError
errorAction.resolved = true;
} else if (!errorAction.resolved) {
this.warn(
`Could not resolve ${data.details} ("${
data.error.message
}") with content-steering for Pathway: ${errorPathway} levels: ${
levels ? levels.length : levels
} priorities: ${stringify(
pathwayPriority,
)} penalized: ${stringify(this.penalizedPathways)}`,
);
}
}
}
public filterParsedLevels(levels: Level[]): Level[] {
// Filter levels to only include those that are in the initial pathway
this.levels = levels;
let pathwayLevels = this.getLevelsForPathway(this.pathwayId);
if (pathwayLevels.length === 0) {
const pathwayId = levels[0].pathwayId;
this.log(
`No levels found in Pathway ${this.pathwayId}. Setting initial Pathway to "${pathwayId}"`,
);
pathwayLevels = this.getLevelsForPathway(pathwayId);
this.pathwayId = pathwayId;
}
if (pathwayLevels.length !== levels.length) {
this.log(
`Found ${pathwayLevels.length}/${levels.length} levels in Pathway "${this.pathwayId}"`,
);
}
return pathwayLevels;
}
private getLevelsForPathway(pathwayId: string): Level[] {
if (this.levels === null) {
return [];
}
return this.levels.filter((level) => pathwayId === level.pathwayId);
}
private updatePathwayPriority(pathwayPriority: string[]) {
this._pathwayPriority = pathwayPriority;
let levels: Level[] | undefined;
// Evaluate if we should remove the pathway from the penalized list
const penalizedPathways = this.penalizedPathways;
const now = performance.now();
Object.keys(penalizedPathways).forEach((pathwayId) => {
if (now - penalizedPathways[pathwayId] > PATHWAY_PENALTY_DURATION_MS) {
delete penalizedPathways[pathwayId];
}
});
for (let i = 0; i < pathwayPriority.length; i++) {
const pathwayId = pathwayPriority[i];
if (pathwayId in penalizedPathways) {
continue;
}
if (pathwayId === this.pathwayId) {
return;
}
const selectedIndex = this.hls.nextLoadLevel;
const selectedLevel: Level = this.hls.levels[selectedIndex];
levels = this.getLevelsForPathway(pathwayId);
if (levels.length > 0) {
this.log(`Setting Pathway to "${pathwayId}"`);
this.pathwayId = pathwayId;
reassignFragmentLevelIndexes(levels);
this.hls.trigger(Events.LEVELS_UPDATED, { levels });
// Set LevelController's level to trigger LEVEL_SWITCHING which loads playlist if needed
const levelAfterChange = this.hls.levels[selectedIndex];
if (selectedLevel && levelAfterChange && this.levels) {
if (
levelAfterChange.attrs['STABLE-VARIANT-ID'] !==
selectedLevel.attrs['STABLE-VARIANT-ID'] &&
levelAfterChange.bitrate !== selectedLevel.bitrate
) {
this.log(
`Unstable Pathways change from bitrate ${selectedLevel.bitrate} to ${levelAfterChange.bitrate}`,
);
}
this.hls.nextLoadLevel = selectedIndex;
}
break;
}
}
}
private getPathwayForGroupId(
groupId: string,
type: PlaylistContextType,
defaultPathway: string,
): string {
const levels = this.getLevelsForPathway(defaultPathway).concat(
this.levels || [],
);
for (let i = 0; i < levels.length; i++) {
if (
(type === PlaylistContextType.AUDIO_TRACK &&
levels[i].hasAudioGroup(groupId)) ||
(type === PlaylistContextType.SUBTITLE_TRACK &&
levels[i].hasSubtitleGroup(groupId))
) {
return levels[i].pathwayId;
}
}
return defaultPathway;
}
private clonePathways(pathwayClones: PathwayClone[]) {
const levels = this.levels;
if (!levels) {
return;
}
const audioGroupCloneMap: Record<string, string> = {};
const subtitleGroupCloneMap: Record<string, string> = {};
pathwayClones.forEach((pathwayClone) => {
const {
ID: cloneId,
'BASE-ID': baseId,
'URI-REPLACEMENT': uriReplacement,
} = pathwayClone;
if (levels.some((level) => level.pathwayId === cloneId)) {
return;
}
const clonedVariants = this.getLevelsForPathway(baseId).map(
(baseLevel) => {
const attributes = new AttrList(baseLevel.attrs);
attributes['PATHWAY-ID'] = cloneId;
const clonedAudioGroupId: string | undefined =
attributes.AUDIO && `${attributes.AUDIO}_clone_${cloneId}`;
const clonedSubtitleGroupId: string | undefined =
attributes.SUBTITLES && `${attributes.SUBTITLES}_clone_${cloneId}`;
if (clonedAudioGroupId) {
audioGroupCloneMap[attributes.AUDIO] = clonedAudioGroupId;
attributes.AUDIO = clonedAudioGroupId;
}
if (clonedSubtitleGroupId) {
subtitleGroupCloneMap[attributes.SUBTITLES] = clonedSubtitleGroupId;
attributes.SUBTITLES = clonedSubtitleGroupId;
}
const url = performUriReplacement(
baseLevel.uri,
attributes['STABLE-VARIANT-ID'],
'PER-VARIANT-URIS',
uriReplacement,
);
const clonedLevel = new Level({
attrs: attributes,
audioCodec: baseLevel.audioCodec,
bitrate: baseLevel.bitrate,
height: baseLevel.height,
name: baseLevel.name,
url,
videoCodec: baseLevel.videoCodec,
width: baseLevel.width,
});
if (baseLevel.audioGroups) {
for (let i = 1; i < baseLevel.audioGroups.length; i++) {
clonedLevel.addGroupId(
'audio',
`${baseLevel.audioGroups[i]}_clone_${cloneId}`,
);
}
}
if (baseLevel.subtitleGroups) {
for (let i = 1; i < baseLevel.subtitleGroups.length; i++) {
clonedLevel.addGroupId(
'text',
`${baseLevel.subtitleGroups[i]}_clone_${cloneId}`,
);
}
}
return clonedLevel;
},
);
levels.push(...clonedVariants);
cloneRenditionGroups(
this.audioTracks,
audioGroupCloneMap,
uriReplacement,
cloneId,
);
cloneRenditionGroups(
this.subtitleTracks,
subtitleGroupCloneMap,
uriReplacement,
cloneId,
);
});
}
private loadSteeringManifest(uri: string) {
const config = this.hls.config;
const Loader = config.loader;
if (this.loader) {
this.loader.destroy();
}
this.loader = new Loader(config) as Loader<LoaderContext>;
let url: URL;
try {
url = new self.URL(uri);
} catch (error) {
this.enabled = false;
this.log(`Failed to parse Steering Manifest URI: ${uri}`);
return;
}
if (url.protocol !== 'data:') {
const throughput =
(this.hls.bandwidthEstimate || config.abrEwmaDefaultEstimate) | 0;
url.searchParams.set('_HLS_pathway', this.pathwayId);
url.searchParams.set('_HLS_throughput', '' + throughput);
}
const context: LoaderContext = {
responseType: 'json',
url: url.href,
};
const loadPolicy = config.steeringManifestLoadPolicy.default;
const legacyRetryCompatibility: RetryConfig | Record<string, void> =
loadPolicy.errorRetry || loadPolicy.timeoutRetry || {};
const loaderConfig: LoaderConfiguration = {
loadPolicy,
timeout: loadPolicy.maxLoadTimeMs,
maxRetry: legacyRetryCompatibility.maxNumRetry || 0,
retryDelay: legacyRetryCompatibility.retryDelayMs || 0,
maxRetryDelay: legacyRetryCompatibility.maxRetryDelayMs || 0,
};
const callbacks: LoaderCallbacks<LoaderContext> = {
onSuccess: (
response: LoaderResponse,
stats: LoaderStats,
context: LoaderContext,
networkDetails: any,
) => {
this.log(`Loaded steering manifest: "${url}"`);
const steeringData = response.data as SteeringManifest;
if (steeringData?.VERSION !== 1) {
this.log(`Steering VERSION ${steeringData.VERSION} not supported!`);
return;
}
this.updated = performance.now();
this.timeToLoad = steeringData.TTL;
const {
'RELOAD-URI': reloadUri,
'PATHWAY-CLONES': pathwayClones,
'PATHWAY-PRIORITY': pathwayPriority,
} = steeringData;
if (reloadUri) {
try {
this.uri = new self.URL(reloadUri, url).href;
} catch (error) {
this.enabled = false;
this.log(
`Failed to parse Steering Manifest RELOAD-URI: ${reloadUri}`,
);
return;
}
}
this.scheduleRefresh(this.uri || context.url);
if (pathwayClones) {
this.clonePathways(pathwayClones);
}
const loadedSteeringData: SteeringManifestLoadedData = {
steeringManifest: steeringData,
url: url.toString(),
};
this.hls.trigger(Events.STEERING_MANIFEST_LOADED, loadedSteeringData);
if (pathwayPriority) {
this.updatePathwayPriority(pathwayPriority);
}
},
onError: (
error: { code: number; text: string },
context: LoaderContext,
networkDetails: any,
stats: LoaderStats,
) => {
this.log(
`Error loading steering manifest: ${error.code} ${error.text} (${context.url})`,
);
this.stopLoad();
if (error.code === 410) {
this.enabled = false;
this.log(`Steering manifest ${context.url} no longer available`);
return;
}
let ttl = this.timeToLoad * 1000;
if (error.code === 429) {
const loader = this.loader;
if (typeof loader?.getResponseHeader === 'function') {
const retryAfter = loader.getResponseHeader('Retry-After');
if (retryAfter) {
ttl = parseFloat(retryAfter) * 1000;
}
}
this.log(`Steering manifest ${context.url} rate limited`);
return;
}
this.scheduleRefresh(this.uri || context.url, ttl);
},
onTimeout: (
stats: LoaderStats,
context: LoaderContext,
networkDetails: any,
) => {
this.log(`Timeout loading steering manifest (${context.url})`);
this.scheduleRefresh(this.uri || context.url);
},
};
this.log(`Requesting steering manifest: ${url}`);
this.loader.load(context, loaderConfig, callbacks);
}
private scheduleRefresh(uri: string, ttlMs: number = this.timeToLoad * 1000) {
this.clearTimeout();
this.reloadTimer = self.setTimeout(() => {
const media = this.hls?.media;
if (media && !media.ended) {
this.loadSteeringManifest(uri);
return;
}
this.scheduleRefresh(uri, this.timeToLoad * 1000);
}, ttlMs);
}
}
function cloneRenditionGroups(
tracks: MediaPlaylist[] | null,
groupCloneMap: Record<string, string>,
uriReplacement: UriReplacement,
cloneId: string,
) {
if (!tracks) {
return;
}
Object.keys(groupCloneMap).forEach((audioGroupId) => {
const clonedTracks = tracks
.filter((track) => track.groupId === audioGroupId)
.map((track) => {
const clonedTrack = Object.assign({}, track);
clonedTrack.details = undefined;
clonedTrack.attrs = new AttrList(clonedTrack.attrs) as MediaAttributes;
clonedTrack.url = clonedTrack.attrs.URI = performUriReplacement(
track.url,
track.attrs['STABLE-RENDITION-ID'],
'PER-RENDITION-URIS',
uriReplacement,
);
clonedTrack.groupId = clonedTrack.attrs['GROUP-ID'] =
groupCloneMap[audioGroupId];
clonedTrack.attrs['PATHWAY-ID'] = cloneId;
return clonedTrack;
});
tracks.push(...clonedTracks);
});
}
function performUriReplacement(
uri: string,
stableId: string | undefined,
perOptionKey: 'PER-VARIANT-URIS' | 'PER-RENDITION-URIS',
uriReplacement: UriReplacement,
): string {
const {
HOST: host,
PARAMS: params,
[perOptionKey]: perOptionUris,
} = uriReplacement;
let perVariantUri;
if (stableId) {
perVariantUri = perOptionUris?.[stableId];
if (perVariantUri) {
uri = perVariantUri;
}
}
const url = new self.URL(uri);
if (host && !perVariantUri) {
url.host = host;
}
if (params) {
Object.keys(params)
.sort()
.forEach((key) => {
if (key) {
url.searchParams.set(key, params[key]);
}
});
}
return url.href;
}
File diff suppressed because it is too large Load Diff
+603
View File
@@ -0,0 +1,603 @@
import { findFragmentByPTS } from './fragment-finders';
import { ErrorDetails, ErrorTypes } from '../errors';
import { Events } from '../events';
import { HdcpLevels } from '../types/level';
import { PlaylistContextType, PlaylistLevelType } from '../types/loader';
import { getCodecsForMimeType } from '../utils/codecs';
import {
getRetryConfig,
isKeyError,
isTimeoutError,
isUnusableKeyError,
shouldRetry,
} from '../utils/error-helper';
import { arrayToHex } from '../utils/hex';
import { Logger } from '../utils/logger';
import type { RetryConfig } from '../config';
import type { LevelKey } from '../hls';
import type Hls from '../hls';
import type { Fragment, MediaFragment } from '../loader/fragment';
import type { NetworkComponentAPI } from '../types/component-api';
import type { ErrorData } from '../types/events';
import type { HdcpLevel, Level } from '../types/level';
export const enum NetworkErrorAction {
DoNothing = 0,
SendEndCallback = 1, // Reserved for future use
SendAlternateToPenaltyBox = 2,
RemoveAlternatePermanently = 3, // Reserved for future use
InsertDiscontinuity = 4, // Reserved for future use
RetryRequest = 5,
}
export const enum ErrorActionFlags {
None = 0,
MoveAllAlternatesMatchingHost = 1,
MoveAllAlternatesMatchingHDCP = 2,
MoveAllAlternatesMatchingKey = 4,
SwitchToSDR = 8,
}
export type IErrorAction = {
action: NetworkErrorAction;
flags: ErrorActionFlags;
retryCount?: number;
retryConfig?: RetryConfig;
hdcpLevel?: HdcpLevel;
nextAutoLevel?: number;
resolved?: boolean;
};
export default class ErrorController
extends Logger
implements NetworkComponentAPI
{
private readonly hls: Hls;
private playlistError: number = 0;
constructor(hls: Hls) {
super('error-controller', hls.logger);
this.hls = hls;
this.registerListeners();
}
private registerListeners() {
const hls = this.hls;
hls.on(Events.ERROR, this.onError, this);
hls.on(Events.MANIFEST_LOADING, this.onManifestLoading, this);
hls.on(Events.LEVEL_UPDATED, this.onLevelUpdated, this);
}
private unregisterListeners() {
const hls = this.hls;
if (!hls) {
return;
}
hls.off(Events.ERROR, this.onError, this);
hls.off(Events.ERROR, this.onErrorOut, this);
hls.off(Events.MANIFEST_LOADING, this.onManifestLoading, this);
hls.off(Events.LEVEL_UPDATED, this.onLevelUpdated, this);
}
destroy() {
this.unregisterListeners();
// @ts-ignore
this.hls = null;
}
startLoad(startPosition: number): void {}
stopLoad(): void {
this.playlistError = 0;
}
private getVariantLevelIndex(frag: Fragment | undefined): number {
if (frag?.type === PlaylistLevelType.MAIN) {
return frag.level;
}
return this.getVariantIndex();
}
private getVariantIndex(): number {
const hls = this.hls;
const currentLevel = hls.currentLevel;
if (hls.loadLevelObj?.details || currentLevel === -1) {
return hls.loadLevel;
}
return currentLevel;
}
private variantHasKey(
level: Level | undefined,
keyInError: LevelKey,
): boolean {
if (level) {
if (level.details?.hasKey(keyInError)) {
return true;
}
const audioGroupsIds = level.audioGroups;
if (audioGroupsIds) {
const audioTracks = this.hls.allAudioTracks.filter(
(track) => audioGroupsIds.indexOf(track.groupId) >= 0,
);
return audioTracks.some((track) => track.details?.hasKey(keyInError));
}
}
return false;
}
private onManifestLoading() {
this.playlistError = 0;
}
private onLevelUpdated() {
this.playlistError = 0;
}
private onError(event: Events.ERROR, data: ErrorData) {
if (data.fatal) {
return;
}
const hls = this.hls;
const context = data.context;
switch (data.details) {
case ErrorDetails.FRAG_LOAD_ERROR:
case ErrorDetails.FRAG_LOAD_TIMEOUT:
case ErrorDetails.KEY_LOAD_ERROR:
case ErrorDetails.KEY_LOAD_TIMEOUT:
data.errorAction = this.getFragRetryOrSwitchAction(data);
return;
case ErrorDetails.FRAG_PARSING_ERROR:
// ignore empty segment errors marked as gap
if (data.frag?.gap) {
data.errorAction = createDoNothingErrorAction();
return;
}
// falls through
case ErrorDetails.FRAG_GAP:
case ErrorDetails.FRAG_DECRYPT_ERROR: {
// Switch level if possible, otherwise allow retry count to reach max error retries
data.errorAction = this.getFragRetryOrSwitchAction(data);
data.errorAction.action = NetworkErrorAction.SendAlternateToPenaltyBox;
return;
}
case ErrorDetails.LEVEL_EMPTY_ERROR:
case ErrorDetails.LEVEL_PARSING_ERROR:
{
// Only retry when empty and live
const levelIndex =
data.parent === PlaylistLevelType.MAIN
? (data.level as number)
: hls.loadLevel;
if (
data.details === ErrorDetails.LEVEL_EMPTY_ERROR &&
!!data.context?.levelDetails?.live
) {
data.errorAction = this.getPlaylistRetryOrSwitchAction(
data,
levelIndex,
);
} else {
// Escalate to fatal if not retrying or switching
data.levelRetry = false;
data.errorAction = this.getLevelSwitchAction(data, levelIndex);
}
}
return;
case ErrorDetails.LEVEL_LOAD_ERROR:
case ErrorDetails.LEVEL_LOAD_TIMEOUT:
if (typeof context?.level === 'number') {
data.errorAction = this.getPlaylistRetryOrSwitchAction(
data,
context.level,
);
}
return;
case ErrorDetails.AUDIO_TRACK_LOAD_ERROR:
case ErrorDetails.AUDIO_TRACK_LOAD_TIMEOUT:
case ErrorDetails.SUBTITLE_LOAD_ERROR:
case ErrorDetails.SUBTITLE_TRACK_LOAD_TIMEOUT:
if (context) {
const level = hls.loadLevelObj;
if (
level &&
((context.type === PlaylistContextType.AUDIO_TRACK &&
level.hasAudioGroup(context.groupId)) ||
(context.type === PlaylistContextType.SUBTITLE_TRACK &&
level.hasSubtitleGroup(context.groupId)))
) {
// Perform Pathway switch or Redundant failover if possible for fastest recovery
// otherwise allow playlist retry count to reach max error retries
data.errorAction = this.getPlaylistRetryOrSwitchAction(
data,
hls.loadLevel,
);
data.errorAction.action =
NetworkErrorAction.SendAlternateToPenaltyBox;
data.errorAction.flags =
ErrorActionFlags.MoveAllAlternatesMatchingHost;
return;
}
}
return;
case ErrorDetails.KEY_SYSTEM_STATUS_OUTPUT_RESTRICTED:
{
data.errorAction = {
action: NetworkErrorAction.SendAlternateToPenaltyBox,
flags: ErrorActionFlags.MoveAllAlternatesMatchingHDCP,
};
}
return;
case ErrorDetails.KEY_SYSTEM_SESSION_UPDATE_FAILED:
case ErrorDetails.KEY_SYSTEM_STATUS_INTERNAL_ERROR:
case ErrorDetails.KEY_SYSTEM_NO_SESSION:
{
data.errorAction = {
action: NetworkErrorAction.SendAlternateToPenaltyBox,
flags: ErrorActionFlags.MoveAllAlternatesMatchingKey,
};
}
return;
case ErrorDetails.BUFFER_ADD_CODEC_ERROR:
case ErrorDetails.REMUX_ALLOC_ERROR:
case ErrorDetails.BUFFER_APPEND_ERROR:
// Buffer-controller can set errorAction when append errors can be ignored or resolved locally
if (!data.errorAction) {
data.errorAction = this.getLevelSwitchAction(
data,
data.level ?? hls.loadLevel,
);
}
return;
case ErrorDetails.INTERNAL_EXCEPTION:
case ErrorDetails.BUFFER_APPENDING_ERROR:
case ErrorDetails.BUFFER_FULL_ERROR:
case ErrorDetails.LEVEL_SWITCH_ERROR:
case ErrorDetails.BUFFER_STALLED_ERROR:
case ErrorDetails.BUFFER_SEEK_OVER_HOLE:
case ErrorDetails.BUFFER_NUDGE_ON_STALL:
data.errorAction = createDoNothingErrorAction();
return;
}
if (data.type === ErrorTypes.KEY_SYSTEM_ERROR) {
// Do not retry level. Should be fatal if ErrorDetails.KEY_SYSTEM_<ERROR> not handled with early return above.
data.levelRetry = false;
data.errorAction = createDoNothingErrorAction();
}
}
private getPlaylistRetryOrSwitchAction(
data: ErrorData,
levelIndex: number | null | undefined,
): IErrorAction {
const hls = this.hls;
const retryConfig = getRetryConfig(hls.config.playlistLoadPolicy, data);
const retryCount = this.playlistError++;
const retry = shouldRetry(
retryConfig,
retryCount,
isTimeoutError(data),
data.response,
);
if (retry) {
return {
action: NetworkErrorAction.RetryRequest,
flags: ErrorActionFlags.None,
retryConfig,
retryCount,
};
}
const errorAction = this.getLevelSwitchAction(data, levelIndex);
if (retryConfig) {
errorAction.retryConfig = retryConfig;
errorAction.retryCount = retryCount;
}
return errorAction;
}
private getFragRetryOrSwitchAction(data: ErrorData): IErrorAction {
const hls = this.hls;
// Share fragment error count accross media options (main, audio, subs)
// This allows for level based rendition switching when media option assets fail
const variantLevelIndex = this.getVariantLevelIndex(data.frag);
const level = hls.levels[variantLevelIndex];
const { fragLoadPolicy, keyLoadPolicy } = hls.config;
const retryConfig = getRetryConfig(
isKeyError(data) ? keyLoadPolicy : fragLoadPolicy,
data,
);
const fragmentErrors = hls.levels.reduce(
(acc, level) => acc + level.fragmentError,
0,
);
// Switch levels when out of retried or level index out of bounds
if (level) {
if (data.details !== ErrorDetails.FRAG_GAP) {
level.fragmentError++;
}
if (!isUnusableKeyError(data)) {
const retry = shouldRetry(
retryConfig,
fragmentErrors,
isTimeoutError(data),
data.response,
);
if (retry) {
return {
action: NetworkErrorAction.RetryRequest,
flags: ErrorActionFlags.None,
retryConfig,
retryCount: fragmentErrors,
};
}
}
}
// Reach max retry count, or Missing level reference
// Switch to valid index
const errorAction = this.getLevelSwitchAction(data, variantLevelIndex);
// Add retry details to allow skipping of FRAG_PARSING_ERROR
if (retryConfig) {
errorAction.retryConfig = retryConfig;
errorAction.retryCount = fragmentErrors;
}
return errorAction;
}
private getLevelSwitchAction(
data: ErrorData,
levelIndex: number | null | undefined,
): IErrorAction {
const hls = this.hls;
if (levelIndex === null || levelIndex === undefined) {
levelIndex = hls.loadLevel;
}
const level = this.hls.levels[levelIndex];
if (level) {
const errorDetails = data.details;
level.loadError++;
if (errorDetails === ErrorDetails.BUFFER_APPEND_ERROR) {
level.fragmentError++;
}
// Search for next level to retry
let nextLevel = -1;
const { levels, loadLevel, minAutoLevel, maxAutoLevel } = hls;
if (!hls.autoLevelEnabled && !hls.config.preserveManualLevelOnError) {
hls.loadLevel = -1;
}
const fragErrorType = data.frag?.type;
// Find alternate audio codec if available on audio codec error
const isAudioCodecError =
(fragErrorType === PlaylistLevelType.AUDIO &&
errorDetails === ErrorDetails.FRAG_PARSING_ERROR) ||
(data.sourceBufferName === 'audio' &&
(errorDetails === ErrorDetails.BUFFER_ADD_CODEC_ERROR ||
errorDetails === ErrorDetails.BUFFER_APPEND_ERROR));
const findAudioCodecAlternate =
isAudioCodecError &&
levels.some(({ audioCodec }) => level.audioCodec !== audioCodec);
// Find alternate video codec if available on video codec error
const isVideoCodecError =
data.sourceBufferName === 'video' &&
(errorDetails === ErrorDetails.BUFFER_ADD_CODEC_ERROR ||
errorDetails === ErrorDetails.BUFFER_APPEND_ERROR);
const findVideoCodecAlternate =
isVideoCodecError &&
levels.some(
({ codecSet, audioCodec }) =>
level.codecSet !== codecSet && level.audioCodec === audioCodec,
);
const { type: playlistErrorType, groupId: playlistErrorGroupId } =
data.context ?? {};
for (let i = levels.length; i--; ) {
const candidate = (i + loadLevel) % levels.length;
if (
candidate !== loadLevel &&
candidate >= minAutoLevel &&
candidate <= maxAutoLevel &&
levels[candidate].loadError === 0
) {
const levelCandidate = levels[candidate];
// Skip level switch if GAP tag is found in next level at same position
if (
errorDetails === ErrorDetails.FRAG_GAP &&
fragErrorType === PlaylistLevelType.MAIN &&
data.frag
) {
const levelDetails = levels[candidate].details;
if (levelDetails) {
const fragCandidate = findFragmentByPTS(
data.frag as MediaFragment,
levelDetails.fragments,
data.frag.start,
);
if (fragCandidate?.gap) {
continue;
}
}
} else if (
(playlistErrorType === PlaylistContextType.AUDIO_TRACK &&
levelCandidate.hasAudioGroup(playlistErrorGroupId)) ||
(playlistErrorType === PlaylistContextType.SUBTITLE_TRACK &&
levelCandidate.hasSubtitleGroup(playlistErrorGroupId))
) {
// For audio/subs playlist errors find another group ID or fallthrough to redundant fail-over
continue;
} else if (
(fragErrorType === PlaylistLevelType.AUDIO &&
level.audioGroups?.some((groupId) =>
levelCandidate.hasAudioGroup(groupId),
)) ||
(fragErrorType === PlaylistLevelType.SUBTITLE &&
level.subtitleGroups?.some((groupId) =>
levelCandidate.hasSubtitleGroup(groupId),
)) ||
(findAudioCodecAlternate &&
level.audioCodec === levelCandidate.audioCodec) ||
(findVideoCodecAlternate &&
level.codecSet === levelCandidate.codecSet) ||
(!findAudioCodecAlternate &&
level.codecSet !== levelCandidate.codecSet)
) {
// For video/audio/subs frag errors find another group ID or fallthrough to redundant fail-over
continue;
}
nextLevel = candidate;
break;
}
}
if (nextLevel > -1 && hls.loadLevel !== nextLevel) {
data.levelRetry = true;
this.playlistError = 0;
return {
action: NetworkErrorAction.SendAlternateToPenaltyBox,
flags: ErrorActionFlags.None,
nextAutoLevel: nextLevel,
};
}
}
// No levels to switch / Manual level selection / Level not found
// Resolve with Pathway switch, Redundant fail-over, or stay on lowest Level
return {
action: NetworkErrorAction.SendAlternateToPenaltyBox,
flags: ErrorActionFlags.MoveAllAlternatesMatchingHost,
};
}
public onErrorOut(event: Events.ERROR, data: ErrorData) {
switch (data.errorAction?.action) {
case NetworkErrorAction.DoNothing:
break;
case NetworkErrorAction.SendAlternateToPenaltyBox:
this.sendAlternateToPenaltyBox(data);
if (
!data.errorAction.resolved &&
data.details !== ErrorDetails.FRAG_GAP
) {
data.fatal = true;
} else if (/MediaSource readyState: ended/.test(data.error.message)) {
this.warn(
`MediaSource ended after "${data.sourceBufferName}" sourceBuffer append error. Attempting to recover from media error.`,
);
this.hls.recoverMediaError();
}
break;
case NetworkErrorAction.RetryRequest:
// handled by stream and playlist/level controllers
break;
}
if (data.fatal) {
this.hls.stopLoad();
return;
}
}
private sendAlternateToPenaltyBox(data: ErrorData) {
const hls = this.hls;
const errorAction = data.errorAction;
if (!errorAction) {
return;
}
const { flags } = errorAction;
const nextAutoLevel = errorAction.nextAutoLevel;
switch (flags) {
case ErrorActionFlags.None:
this.switchLevel(data, nextAutoLevel);
break;
case ErrorActionFlags.MoveAllAlternatesMatchingHDCP: {
const levelIndex = this.getVariantLevelIndex(data.frag);
const level = hls.levels[levelIndex];
const restrictedHdcpLevel = (level as Level | undefined)?.attrs[
'HDCP-LEVEL'
];
errorAction.hdcpLevel = restrictedHdcpLevel;
if (restrictedHdcpLevel === 'NONE') {
this.warn(`HDCP policy resticted output with HDCP-LEVEL=NONE`);
} else if (restrictedHdcpLevel) {
hls.maxHdcpLevel =
HdcpLevels[HdcpLevels.indexOf(restrictedHdcpLevel) - 1];
errorAction.resolved = true;
this.warn(
`Restricting playback to HDCP-LEVEL of "${hls.maxHdcpLevel}" or lower`,
);
break;
}
// Fallthrough when no HDCP-LEVEL attribute is found
}
// eslint-disable-next-line no-fallthrough
case ErrorActionFlags.MoveAllAlternatesMatchingKey: {
const levelKey = data.decryptdata;
if (levelKey) {
// Penalize all levels with key
const levels = this.hls.levels;
const levelCountWithError = levels.length;
for (let i = levelCountWithError; i--; ) {
if (this.variantHasKey(levels[i], levelKey)) {
this.log(
`Banned key found in level ${i} (${levels[i].bitrate}bps) or audio group "${levels[i].audioGroups?.join(',')}" (${data.frag?.type} fragment) ${arrayToHex(levelKey.keyId || [])}`,
);
levels[i].fragmentError++;
levels[i].loadError++;
this.log(`Removing level ${i} with key error (${data.error})`);
this.hls.removeLevel(i);
}
}
const frag = data.frag;
if (this.hls.levels.length < levelCountWithError) {
errorAction.resolved = true;
} else if (frag && frag.type !== PlaylistLevelType.MAIN) {
// Ignore key error for audio track with unmatched key (main session error)
const fragLevelKey = frag.decryptdata;
if (fragLevelKey && !levelKey.matches(fragLevelKey)) {
errorAction.resolved = true;
}
}
}
break;
}
}
// If not resolved by previous actions try to switch to next level
if (!errorAction.resolved) {
this.switchLevel(data, nextAutoLevel);
}
}
private switchLevel(data: ErrorData, levelIndex: number | undefined) {
if (levelIndex !== undefined && data.errorAction) {
this.warn(`switching to level ${levelIndex} after ${data.details}`);
this.hls.nextAutoLevel = levelIndex;
data.errorAction.resolved = true;
// Stream controller is responsible for this but won't switch on false start
this.hls.nextLoadLevel = this.hls.nextAutoLevel;
if (
data.details === ErrorDetails.BUFFER_ADD_CODEC_ERROR &&
data.mimeType &&
data.sourceBufferName !== 'audiovideo'
) {
const codec = getCodecsForMimeType(data.mimeType);
const levels = this.hls.levels;
for (let i = levels.length; i--; ) {
if (levels[i][`${data.sourceBufferName}Codec`] === codec) {
this.log(
`Removing level ${i} for ${data.details} ("${codec}" not supported)`,
);
this.hls.removeLevel(i);
}
}
}
}
}
}
export function createDoNothingErrorAction(resolved?: boolean): IErrorAction {
const errorAction: IErrorAction = {
action: NetworkErrorAction.DoNothing,
flags: ErrorActionFlags.None,
};
if (resolved) {
errorAction.resolved = true;
}
return errorAction;
}
+146
View File
@@ -0,0 +1,146 @@
import { Events } from '../events';
import type StreamController from './stream-controller';
import type Hls from '../hls';
import type { ComponentAPI } from '../types/component-api';
import type { MediaAttachingData } from '../types/events';
class FPSController implements ComponentAPI {
private hls: Hls;
private isVideoPlaybackQualityAvailable: boolean = false;
private timer?: number;
private media: HTMLVideoElement | null = null;
private lastTime: any;
private lastDroppedFrames: number = 0;
private lastDecodedFrames: number = 0;
// stream controller must be provided as a dependency!
private streamController!: StreamController;
constructor(hls: Hls) {
this.hls = hls;
this.registerListeners();
}
public setStreamController(streamController: StreamController) {
this.streamController = streamController;
}
protected registerListeners() {
this.hls.on(Events.MEDIA_ATTACHING, this.onMediaAttaching, this);
this.hls.on(Events.MEDIA_DETACHING, this.onMediaDetaching, this);
}
protected unregisterListeners() {
this.hls.off(Events.MEDIA_ATTACHING, this.onMediaAttaching, this);
this.hls.off(Events.MEDIA_DETACHING, this.onMediaDetaching, this);
}
destroy() {
if (this.timer) {
clearInterval(this.timer);
}
this.unregisterListeners();
this.isVideoPlaybackQualityAvailable = false;
this.media = null;
}
protected onMediaAttaching(
event: Events.MEDIA_ATTACHING,
data: MediaAttachingData,
) {
const config = this.hls.config;
if (config.capLevelOnFPSDrop) {
const media =
data.media instanceof self.HTMLVideoElement ? data.media : null;
this.media = media;
if (media && typeof media.getVideoPlaybackQuality === 'function') {
this.isVideoPlaybackQualityAvailable = true;
}
self.clearInterval(this.timer);
this.timer = self.setInterval(
this.checkFPSInterval.bind(this),
config.fpsDroppedMonitoringPeriod,
);
}
}
private onMediaDetaching() {
this.media = null;
}
checkFPS(
video: HTMLVideoElement,
decodedFrames: number,
droppedFrames: number,
) {
const currentTime = performance.now();
if (decodedFrames) {
if (this.lastTime) {
const currentPeriod = currentTime - this.lastTime;
const currentDropped = droppedFrames - this.lastDroppedFrames;
const currentDecoded = decodedFrames - this.lastDecodedFrames;
const droppedFPS = (1000 * currentDropped) / currentPeriod;
const hls = this.hls;
hls.trigger(Events.FPS_DROP, {
currentDropped: currentDropped,
currentDecoded: currentDecoded,
totalDroppedFrames: droppedFrames,
});
if (droppedFPS > 0) {
// hls.logger.log('checkFPS : droppedFPS/decodedFPS:' + droppedFPS/(1000 * currentDecoded / currentPeriod));
if (
currentDropped >
hls.config.fpsDroppedMonitoringThreshold * currentDecoded
) {
let currentLevel = hls.currentLevel;
hls.logger.warn(
'drop FPS ratio greater than max allowed value for currentLevel: ' +
currentLevel,
);
if (
currentLevel > 0 &&
(hls.autoLevelCapping === -1 ||
hls.autoLevelCapping >= currentLevel)
) {
currentLevel = currentLevel - 1;
hls.trigger(Events.FPS_DROP_LEVEL_CAPPING, {
level: currentLevel,
droppedLevel: hls.currentLevel,
});
hls.autoLevelCapping = currentLevel;
this.streamController.nextLevelSwitch();
}
}
}
}
this.lastTime = currentTime;
this.lastDroppedFrames = droppedFrames;
this.lastDecodedFrames = decodedFrames;
}
}
checkFPSInterval() {
const video = this.media;
if (video) {
if (this.isVideoPlaybackQualityAvailable) {
const videoPlaybackQuality = video.getVideoPlaybackQuality();
this.checkFPS(
video,
videoPlaybackQuality.totalVideoFrames,
videoPlaybackQuality.droppedVideoFrames,
);
} else {
// HTMLVideoElement doesn't include the webkit types
this.checkFPS(
video,
(video as any).webkitDecodedFrameCount as number,
(video as any).webkitDroppedFrameCount as number,
);
}
}
}
}
export default FPSController;
+257
View File
@@ -0,0 +1,257 @@
import BinarySearch from '../utils/binary-search';
import type { Fragment, MediaFragment } from '../loader/fragment';
import type { LevelDetails } from '../loader/level-details';
/**
* Returns first fragment whose endPdt value exceeds the given PDT, or null.
* @param fragments - The array of candidate fragments
* @param PDTValue - The PDT value which must be exceeded
* @param maxFragLookUpTolerance - The amount of time that a fragment's start/end can be within in order to be considered contiguous
*/
export function findFragmentByPDT(
fragments: MediaFragment[],
PDTValue: number | null,
maxFragLookUpTolerance: number,
): MediaFragment | null {
if (
PDTValue === null ||
!Array.isArray(fragments) ||
!fragments.length ||
!Number.isFinite(PDTValue)
) {
return null;
}
// if less than start
const startPDT = fragments[0].programDateTime;
if (PDTValue < (startPDT || 0)) {
return null;
}
const endPDT = fragments[fragments.length - 1].endProgramDateTime;
if (PDTValue >= (endPDT || 0)) {
return null;
}
for (let seg = 0; seg < fragments.length; ++seg) {
const frag = fragments[seg];
if (pdtWithinToleranceTest(PDTValue, maxFragLookUpTolerance, frag)) {
return frag;
}
}
return null;
}
/**
* Finds a fragment based on the SN of the previous fragment; or based on the needs of the current buffer.
* This method compensates for small buffer gaps by applying a tolerance to the start of any candidate fragment, thus
* breaking any traps which would cause the same fragment to be continuously selected within a small range.
* @param fragPrevious - The last frag successfully appended
* @param fragments - The array of candidate fragments
* @param bufferEnd - The end of the contiguous buffered range the playhead is currently within
* @param maxFragLookUpTolerance - The amount of time that a fragment's start/end can be within in order to be considered contiguous
* @returns a matching fragment or null
*/
export function findFragmentByPTS(
fragPrevious: MediaFragment | null,
fragments: MediaFragment[],
bufferEnd: number = 0,
maxFragLookUpTolerance: number = 0,
nextFragLookupTolerance: number = 0.005,
): MediaFragment | null {
let fragNext: MediaFragment | null = null;
if (fragPrevious) {
fragNext = fragments[1 + fragPrevious.sn - fragments[0].sn] || null;
// check for buffer-end rounding error
const bufferEdgeError = (fragPrevious.endDTS as number) - bufferEnd;
if (bufferEdgeError > 0 && bufferEdgeError < 0.0000015) {
bufferEnd += 0.0000015;
}
if (
fragNext &&
fragPrevious.level !== fragNext.level &&
fragNext.end <= fragPrevious.end
) {
fragNext = fragments[2 + fragPrevious.sn - fragments[0].sn] || null;
}
} else if (bufferEnd === 0 && fragments[0].start === 0) {
fragNext = fragments[0];
}
// Prefer the next fragment if it's within tolerance
if (
fragNext &&
(((!fragPrevious || fragPrevious.level === fragNext.level) &&
fragmentWithinToleranceTest(
bufferEnd,
maxFragLookUpTolerance,
fragNext,
) === 0) ||
fragmentWithinFastStartSwitch(
fragNext,
fragPrevious,
Math.min(nextFragLookupTolerance, maxFragLookUpTolerance),
))
) {
return fragNext;
}
// We might be seeking past the tolerance so find the best match
const foundFragment = BinarySearch.search(
fragments,
fragmentWithinToleranceTest.bind(null, bufferEnd, maxFragLookUpTolerance),
);
if (foundFragment && (foundFragment !== fragPrevious || !fragNext)) {
return foundFragment;
}
// If no match was found return the next fragment after fragPrevious, or null
return fragNext;
}
function fragmentWithinFastStartSwitch(
fragNext: Fragment,
fragPrevious: Fragment | null,
nextFragLookupTolerance: number,
): boolean {
if (
fragPrevious &&
fragPrevious.start === 0 &&
fragPrevious.level < fragNext.level &&
(fragPrevious.endPTS || 0) > 0
) {
const firstDuration = fragPrevious.tagList.reduce((duration, tag) => {
if (tag[0] === 'INF') {
duration += parseFloat(tag[1]);
}
return duration;
}, nextFragLookupTolerance);
return fragNext.start <= firstDuration;
}
return false;
}
/**
* The test function used by the findFragmentBySn's BinarySearch to look for the best match to the current buffer conditions.
* @param candidate - The fragment to test
* @param bufferEnd - The end of the current buffered range the playhead is currently within
* @param maxFragLookUpTolerance - The amount of time that a fragment's start can be within in order to be considered contiguous
* @returns 0 if it matches, 1 if too low, -1 if too high
*/
export function fragmentWithinToleranceTest(
bufferEnd = 0,
maxFragLookUpTolerance = 0,
candidate: MediaFragment,
) {
// eagerly accept an accurate match (no tolerance)
if (
candidate.start <= bufferEnd &&
candidate.start + candidate.duration > bufferEnd
) {
return 0;
}
// offset should be within fragment boundary - config.maxFragLookUpTolerance
// this is to cope with situations like
// bufferEnd = 9.991
// frag[Ø] : [0,10]
// frag[1] : [10,20]
// bufferEnd is within frag[0] range ... although what we are expecting is to return frag[1] here
// frag start frag start+duration
// |-----------------------------|
// <---> <--->
// ...--------><-----------------------------><---------....
// previous frag matching fragment next frag
// return -1 return 0 return 1
// logger.log(`level/sn/start/end/bufEnd:${level}/${candidate.sn}/${candidate.start}/${(candidate.start+candidate.duration)}/${bufferEnd}`);
// Set the lookup tolerance to be small enough to detect the current segment - ensures we don't skip over very small segments
const candidateLookupTolerance = Math.min(
maxFragLookUpTolerance,
candidate.duration + (candidate.deltaPTS ? candidate.deltaPTS : 0),
);
if (
candidate.start + candidate.duration - candidateLookupTolerance <=
bufferEnd
) {
return 1;
} else if (
candidate.start - candidateLookupTolerance > bufferEnd &&
candidate.start
) {
// if maxFragLookUpTolerance will have negative value then don't return -1 for first element
return -1;
}
return 0;
}
/**
* The test function used by the findFragmentByPdt's BinarySearch to look for the best match to the current buffer conditions.
* This function tests the candidate's program date time values, as represented in Unix time
* @param candidate - The fragment to test
* @param pdtBufferEnd - The Unix time representing the end of the current buffered range
* @param maxFragLookUpTolerance - The amount of time that a fragment's start can be within in order to be considered contiguous
* @returns true if contiguous, false otherwise
*/
export function pdtWithinToleranceTest(
pdtBufferEnd: number,
maxFragLookUpTolerance: number,
candidate: MediaFragment,
): boolean {
const candidateLookupTolerance =
Math.min(
maxFragLookUpTolerance,
candidate.duration + (candidate.deltaPTS ? candidate.deltaPTS : 0),
) * 1000;
// endProgramDateTime can be null, default to zero
const endProgramDateTime = candidate.endProgramDateTime || 0;
return endProgramDateTime - candidateLookupTolerance > pdtBufferEnd;
}
export function findFragWithCC(
fragments: MediaFragment[],
cc: number,
): MediaFragment | null {
return BinarySearch.search(fragments, (candidate) => {
if (candidate.cc < cc) {
return 1;
} else if (candidate.cc > cc) {
return -1;
} else {
return 0;
}
});
}
export function findNearestWithCC(
details: LevelDetails | undefined,
cc: number,
pos: number,
): MediaFragment | null {
if (details) {
if (details.startCC <= cc && details.endCC >= cc) {
let fragments = details.fragments;
const { fragmentHint } = details;
if (fragmentHint) {
fragments = fragments.concat(fragmentHint);
}
let closest: MediaFragment | undefined;
BinarySearch.search(fragments, (candidate) => {
if (candidate.cc < cc) {
return 1;
}
if (candidate.cc > cc) {
return -1;
}
closest = candidate;
if (candidate.end <= pos) {
return 1;
}
if (candidate.start > pos) {
return -1;
}
return 0;
});
return closest || null;
}
}
return null;
}
+567
View File
@@ -0,0 +1,567 @@
import { Events } from '../events';
import type Hls from '../hls';
import type { Fragment, MediaFragment, Part } from '../loader/fragment';
import type { SourceBufferName } from '../types/buffer';
import type { ComponentAPI } from '../types/component-api';
import type {
BufferAppendedData,
FragBufferedData,
FragLoadedData,
} from '../types/events';
import type {
FragmentBufferedRange,
FragmentEntity,
FragmentTimeRange,
} from '../types/fragment-tracker';
import type { PlaylistLevelType } from '../types/loader';
export const enum FragmentState {
NOT_LOADED = 'NOT_LOADED',
APPENDING = 'APPENDING',
PARTIAL = 'PARTIAL',
OK = 'OK',
}
export class FragmentTracker implements ComponentAPI {
private activePartLists: { [key in PlaylistLevelType]?: Part[] } =
Object.create(null);
private endListFragments: { [key in PlaylistLevelType]?: FragmentEntity } =
Object.create(null);
private fragments: Partial<Record<string, FragmentEntity>> =
Object.create(null);
private timeRanges:
| {
[key in SourceBufferName]?: TimeRanges;
}
| null = Object.create(null);
private bufferPadding: number = 0.2;
private hls: Hls | null;
private hasGaps: boolean = false;
constructor(hls: Hls) {
this.hls = hls;
this._registerListeners();
}
private _registerListeners() {
const { hls } = this;
if (hls) {
hls.on(Events.MANIFEST_LOADING, this.onManifestLoading, this);
hls.on(Events.BUFFER_APPENDED, this.onBufferAppended, this);
hls.on(Events.FRAG_BUFFERED, this.onFragBuffered, this);
hls.on(Events.FRAG_LOADED, this.onFragLoaded, this);
}
}
private _unregisterListeners() {
const { hls } = this;
if (hls) {
hls.off(Events.MANIFEST_LOADING, this.onManifestLoading, this);
hls.off(Events.BUFFER_APPENDED, this.onBufferAppended, this);
hls.off(Events.FRAG_BUFFERED, this.onFragBuffered, this);
hls.off(Events.FRAG_LOADED, this.onFragLoaded, this);
}
}
public destroy() {
this._unregisterListeners();
// @ts-ignore
this.hls =
// @ts-ignore
this.fragments =
// @ts-ignore
this.activePartLists =
// @ts-ignore
this.endListFragments =
this.timeRanges =
null;
}
/**
* Return a Fragment or Part with an appended range that matches the position and levelType
* Otherwise, return null
*/
public getAppendedFrag(
position: number,
levelType: PlaylistLevelType,
): MediaFragment | Part | null {
const activeParts = this.activePartLists[levelType];
if (activeParts) {
for (let i = activeParts.length; i--; ) {
const activePart = activeParts[i];
if (!activePart as any) {
break;
}
if (
activePart.start <= position &&
position <= activePart.end &&
activePart.loaded
) {
return activePart;
}
}
}
return this.getBufferedFrag(position, levelType);
}
/**
* Return a buffered Fragment that matches the position and levelType.
* A buffered Fragment is one whose loading, parsing and appending is done (completed or "partial" meaning aborted).
* If not found any Fragment, return null
*/
public getBufferedFrag(
position: number,
levelType: PlaylistLevelType,
): MediaFragment | null {
return this.getFragAtPos(position, levelType, true);
}
public getFragAtPos(
position: number,
levelType: PlaylistLevelType,
buffered?: boolean,
): MediaFragment | null {
const { fragments } = this;
const keys = Object.keys(fragments);
for (let i = keys.length; i--; ) {
const fragmentEntity = fragments[keys[i]];
if (
fragmentEntity?.body.type === levelType &&
(!buffered || fragmentEntity.buffered)
) {
const frag = fragmentEntity.body;
if (frag.start <= position && position <= frag.end) {
return frag;
}
}
}
return null;
}
/**
* Partial fragments effected by coded frame eviction will be removed
* The browser will unload parts of the buffer to free up memory for new buffer data
* Fragments will need to be reloaded when the buffer is freed up, removing partial fragments will allow them to reload(since there might be parts that are still playable)
*/
public detectEvictedFragments(
elementaryStream: SourceBufferName,
timeRange: TimeRanges,
playlistType: PlaylistLevelType,
appendedPart?: Part | null,
removeAppending?: boolean,
) {
if (this.timeRanges) {
this.timeRanges[elementaryStream] = timeRange;
}
// Check if any flagged fragments have been unloaded
// excluding anything newer than appendedPartSn
const appendedPartSn = appendedPart?.fragment.sn || -1;
Object.keys(this.fragments).forEach((key) => {
const fragmentEntity = this.fragments[key];
if (!fragmentEntity) {
return;
}
if (appendedPartSn >= fragmentEntity.body.sn) {
return;
}
if (
!fragmentEntity.buffered &&
(!fragmentEntity.loaded || removeAppending)
) {
if (fragmentEntity.body.type === playlistType) {
this.removeFragment(fragmentEntity.body);
}
return;
}
const esData = fragmentEntity.range[elementaryStream];
if (!esData) {
return;
}
if (esData.time.length === 0) {
this.removeFragment(fragmentEntity.body);
return;
}
esData.time.some((time: FragmentTimeRange) => {
const isNotBuffered = !this.isTimeBuffered(
time.startPTS,
time.endPTS,
timeRange,
);
if (isNotBuffered) {
// Unregister partial fragment as it needs to load again to be reused
this.removeFragment(fragmentEntity.body);
}
return isNotBuffered;
});
});
}
/**
* Checks if the fragment passed in is loaded in the buffer properly
* Partially loaded fragments will be registered as a partial fragment
*/
public detectPartialFragments(data: FragBufferedData) {
const timeRanges = this.timeRanges;
if (!timeRanges || data.frag.sn === 'initSegment') {
return;
}
const frag = data.frag as MediaFragment;
const fragKey = getFragmentKey(frag);
const fragmentEntity = this.fragments[fragKey];
if (!fragmentEntity || (fragmentEntity.buffered && frag.gap)) {
return;
}
const isFragHint = !frag.relurl;
Object.keys(timeRanges).forEach((elementaryStream: SourceBufferName) => {
const streamInfo = frag.elementaryStreams[elementaryStream];
if (!streamInfo) {
return;
}
const timeRange = timeRanges[elementaryStream] as TimeRanges;
const partial = isFragHint || streamInfo.partial === true;
fragmentEntity.range[elementaryStream] = this.getBufferedTimes(
frag,
data.part,
partial,
timeRange,
);
});
fragmentEntity.loaded = null;
if (Object.keys(fragmentEntity.range).length) {
this.bufferedEnd(fragmentEntity, frag);
if (!isPartial(fragmentEntity)) {
// Remove older fragment parts from lookup after frag is tracked as buffered
this.removeParts(frag.sn - 1, frag.type);
}
} else {
// remove fragment if nothing was appended
this.removeFragment(fragmentEntity.body);
}
}
private bufferedEnd(fragmentEntity: FragmentEntity, frag: MediaFragment) {
fragmentEntity.buffered = true;
const endList = (fragmentEntity.body.endList =
frag.endList || fragmentEntity.body.endList);
if (endList) {
this.endListFragments[fragmentEntity.body.type] = fragmentEntity;
}
}
private removeParts(snToKeep: number, levelType: PlaylistLevelType) {
const activeParts = this.activePartLists[levelType];
if (!activeParts) {
return;
}
this.activePartLists[levelType] = filterParts(
activeParts,
(part) => part.fragment.sn >= snToKeep,
);
}
public fragBuffered(frag: MediaFragment, force?: true) {
const fragKey = getFragmentKey(frag);
let fragmentEntity = this.fragments[fragKey];
if (!fragmentEntity && force) {
fragmentEntity = this.fragments[fragKey] = {
body: frag,
appendedPTS: null,
loaded: null,
buffered: false,
range: Object.create(null),
};
if (frag.gap) {
this.hasGaps = true;
}
}
if (fragmentEntity) {
fragmentEntity.loaded = null;
this.bufferedEnd(fragmentEntity, frag);
}
}
private getBufferedTimes(
fragment: Fragment,
part: Part | null,
partial: boolean,
timeRange: TimeRanges,
): FragmentBufferedRange {
const buffered: FragmentBufferedRange = {
time: [],
partial,
};
const startPTS = fragment.start;
const endPTS = fragment.end;
const minEndPTS = fragment.minEndPTS || endPTS;
const maxStartPTS = fragment.maxStartPTS || startPTS;
for (let i = 0; i < timeRange.length; i++) {
const startTime = timeRange.start(i) - this.bufferPadding;
const endTime = timeRange.end(i) + this.bufferPadding;
if (maxStartPTS >= startTime && minEndPTS <= endTime) {
// Fragment is entirely contained in buffer
// No need to check the other timeRange times since it's completely playable
buffered.time.push({
startPTS: Math.max(startPTS, timeRange.start(i)),
endPTS: Math.min(endPTS, timeRange.end(i)),
});
break;
} else if (startPTS < endTime && endPTS > startTime) {
const start = Math.max(startPTS, timeRange.start(i));
const end = Math.min(endPTS, timeRange.end(i));
if (end > start) {
buffered.partial = true;
// Check for intersection with buffer
// Get playable sections of the fragment
buffered.time.push({
startPTS: start,
endPTS: end,
});
}
} else if (endPTS <= startTime) {
// No need to check the rest of the timeRange as it is in order
break;
}
}
return buffered;
}
/**
* Gets the partial fragment for a certain time
*/
public getPartialFragment(time: number): MediaFragment | null {
let bestFragment: Fragment | null = null;
let timePadding: number;
let startTime: number;
let endTime: number;
let bestOverlap: number = 0;
const { bufferPadding, fragments } = this;
Object.keys(fragments).forEach((key) => {
const fragmentEntity = fragments[key];
if (!fragmentEntity) {
return;
}
if (isPartial(fragmentEntity)) {
startTime = fragmentEntity.body.start - bufferPadding;
endTime = fragmentEntity.body.end + bufferPadding;
if (time >= startTime && time <= endTime) {
// Use the fragment that has the most padding from start and end time
timePadding = Math.min(time - startTime, endTime - time);
if (bestOverlap <= timePadding) {
bestFragment = fragmentEntity.body;
bestOverlap = timePadding;
}
}
}
});
return bestFragment;
}
public isEndListAppended(type: PlaylistLevelType): boolean {
const lastFragmentEntity = this.endListFragments[type];
return (
lastFragmentEntity !== undefined &&
(lastFragmentEntity.buffered || isPartial(lastFragmentEntity))
);
}
public getState(fragment: Fragment): FragmentState {
const fragKey = getFragmentKey(fragment);
const fragmentEntity = this.fragments[fragKey];
if (fragmentEntity) {
if (!fragmentEntity.buffered) {
return FragmentState.APPENDING;
} else if (isPartial(fragmentEntity)) {
return FragmentState.PARTIAL;
} else {
return FragmentState.OK;
}
}
return FragmentState.NOT_LOADED;
}
private isTimeBuffered(
startPTS: number,
endPTS: number,
timeRange: TimeRanges,
): boolean {
let startTime;
let endTime;
for (let i = 0; i < timeRange.length; i++) {
startTime = timeRange.start(i) - this.bufferPadding;
endTime = timeRange.end(i) + this.bufferPadding;
if (startPTS >= startTime && endPTS <= endTime) {
return true;
}
if (endPTS <= startTime) {
// No need to check the rest of the timeRange as it is in order
return false;
}
}
return false;
}
private onManifestLoading() {
this.removeAllFragments();
}
private onFragLoaded(event: Events.FRAG_LOADED, data: FragLoadedData) {
// don't track initsegment (for which sn is not a number)
// don't track frags used for bitrateTest, they're irrelevant.
if (data.frag.sn === 'initSegment' || data.frag.bitrateTest) {
return;
}
const frag = data.frag as MediaFragment;
// Fragment entity `loaded` FragLoadedData is null when loading parts
const loaded = data.part ? null : data;
const fragKey = getFragmentKey(frag);
this.fragments[fragKey] = {
body: frag,
appendedPTS: null,
loaded,
buffered: false,
range: Object.create(null),
};
}
private onBufferAppended(
event: Events.BUFFER_APPENDED,
data: BufferAppendedData,
) {
const { frag, part, timeRanges, type } = data;
if (frag.sn === 'initSegment') {
return;
}
const playlistType = frag.type;
if (part) {
let activeParts = this.activePartLists[playlistType];
if (!activeParts) {
this.activePartLists[playlistType] = activeParts = [];
}
activeParts.push(part);
}
// Store the latest timeRanges loaded in the buffer
this.timeRanges = timeRanges;
const timeRange = timeRanges[type] as TimeRanges;
this.detectEvictedFragments(type, timeRange, playlistType, part);
}
private onFragBuffered(event: Events.FRAG_BUFFERED, data: FragBufferedData) {
this.detectPartialFragments(data);
}
private hasFragment(fragment: Fragment): boolean {
const fragKey = getFragmentKey(fragment);
return !!this.fragments[fragKey];
}
public hasFragments(type?: PlaylistLevelType): boolean {
const { fragments } = this;
const keys = Object.keys(fragments);
if (!type) {
return keys.length > 0;
}
for (let i = keys.length; i--; ) {
const fragmentEntity = fragments[keys[i]];
if (fragmentEntity?.body.type === type) {
return true;
}
}
return false;
}
public hasParts(type: PlaylistLevelType): boolean {
return !!this.activePartLists[type]?.length;
}
public removeFragmentsInRange(
start: number,
end: number,
playlistType: PlaylistLevelType,
withGapOnly?: boolean,
unbufferedOnly?: boolean,
) {
if (withGapOnly && !this.hasGaps) {
return;
}
Object.keys(this.fragments).forEach((key) => {
const fragmentEntity = this.fragments[key];
if (!fragmentEntity) {
return;
}
const frag = fragmentEntity.body;
if (frag.type !== playlistType || (withGapOnly && !frag.gap)) {
return;
}
if (
frag.start < end &&
frag.end > start &&
(fragmentEntity.buffered || unbufferedOnly)
) {
this.removeFragment(frag);
}
});
}
public removeFragment(fragment: Fragment) {
const fragKey = getFragmentKey(fragment);
fragment.clearElementaryStreamInfo();
const activeParts = this.activePartLists[fragment.type];
if (activeParts) {
const snToRemove = fragment.sn;
this.activePartLists[fragment.type] = filterParts(
activeParts,
(part) => part.fragment.sn !== snToRemove,
);
}
delete this.fragments[fragKey];
if (fragment.endList) {
delete this.endListFragments[fragment.type];
}
}
public removeAllFragments() {
this.fragments = Object.create(null);
this.endListFragments = Object.create(null);
this.activePartLists = Object.create(null);
this.hasGaps = false;
const partlist = this.hls?.latestLevelDetails?.partList;
if (partlist) {
partlist.forEach((part) => part.clearElementaryStreamInfo());
}
}
}
function isPartial(fragmentEntity: FragmentEntity): boolean {
return (
fragmentEntity.buffered &&
!!(
fragmentEntity.body.gap ||
fragmentEntity.range.video?.partial ||
fragmentEntity.range.audio?.partial ||
fragmentEntity.range.audiovideo?.partial
)
);
}
function getFragmentKey(fragment: Fragment): string {
return `${fragment.type}_${fragment.level}_${fragment.sn}`;
}
function filterParts(partList: Part[], predicate: (part: Part) => boolean) {
return partList.filter((part) => {
const keep = predicate(part);
if (!keep) {
part.clearElementaryStreamInfo();
}
return keep;
});
}
+715
View File
@@ -0,0 +1,715 @@
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)
);
}
+516
View File
@@ -0,0 +1,516 @@
import { getId3Frames } from '@svta/common-media-library/id3/getId3Frames';
import { isId3TimestampFrame } from '@svta/common-media-library/id3/isId3TimestampFrame';
import { Events } from '../events';
import {
isDateRangeCueAttribute,
isSCTE35Attribute,
} from '../loader/date-range';
import { MetadataSchema } from '../types/demuxer';
import { hexToArrayBuffer } from '../utils/hex';
import { stringify } from '../utils/safe-json-stringify';
import {
clearCurrentCues,
removeCuesInRange,
sendAddTrackEvent,
} from '../utils/texttrack-utils';
import type { MediaFragment } from '../hls';
import type Hls from '../hls';
import type { DateRange } from '../loader/date-range';
import type { LevelDetails } from '../loader/level-details';
import type { ComponentAPI } from '../types/component-api';
import type {
BufferFlushingData,
FragParsingMetadataData,
LevelPTSUpdatedData,
LevelUpdatedData,
MediaAttachingData,
MediaDetachingData,
} from '../types/events';
declare global {
interface Window {
WebKitDataCue: VTTCue | void;
}
}
const MIN_CUE_DURATION = 0.25;
function getCueClass(): typeof VTTCue | typeof TextTrackCue | undefined {
if (typeof self === 'undefined') return undefined;
return (self.VTTCue as typeof VTTCue | undefined) || self.TextTrackCue;
}
function createCueWithDataFields(
Cue: typeof VTTCue | typeof TextTrackCue,
startTime: number,
endTime: number,
data: Object,
type?: string,
): VTTCue | TextTrackCue | undefined {
let cue = new Cue(startTime, endTime, '');
try {
(cue as any).value = data;
if (type) {
(cue as any).type = type;
}
} catch (e) {
cue = new Cue(
startTime,
endTime,
stringify(type ? { type, ...data } : data),
);
}
return cue;
}
// VTTCue latest draft allows an infinite duration, fallback
// to MAX_VALUE if necessary
const MAX_CUE_ENDTIME = (() => {
const Cue = getCueClass();
try {
Cue && new Cue(0, Number.POSITIVE_INFINITY, '');
} catch (e) {
return Number.MAX_VALUE;
}
return Number.POSITIVE_INFINITY;
})();
class ID3TrackController implements ComponentAPI {
private hls: Hls | null;
private id3Track: TextTrack | null = null;
private media: HTMLMediaElement | null = null;
private dateRangeCuesAppended: Record<
string,
| {
cues: Record<string, VTTCue | TextTrackCue | undefined>;
dateRange: DateRange;
durationKnown: boolean;
}
| undefined
> = {};
private removeCues: boolean = true;
private assetCue?: VTTCue | TextTrackCue;
constructor(hls) {
this.hls = hls;
this._registerListeners();
}
public destroy() {
this._unregisterListeners();
this.id3Track = null;
this.media = null;
this.dateRangeCuesAppended = {};
// @ts-ignore
this.hls = this.onEventCueEnter = null;
}
private _registerListeners() {
const { hls } = this;
if (hls) {
hls.on(Events.MEDIA_ATTACHING, this.onMediaAttaching, this);
hls.on(Events.MEDIA_ATTACHED, this.onMediaAttached, this);
hls.on(Events.MEDIA_DETACHING, this.onMediaDetaching, this);
hls.on(Events.MANIFEST_LOADING, this.onManifestLoading, this);
hls.on(Events.FRAG_PARSING_METADATA, this.onFragParsingMetadata, this);
hls.on(Events.BUFFER_FLUSHING, this.onBufferFlushing, this);
hls.on(Events.LEVEL_UPDATED, this.onLevelUpdated, this);
hls.on(Events.LEVEL_PTS_UPDATED, this.onLevelPtsUpdated, this);
}
}
private _unregisterListeners() {
const { hls } = this;
if (hls) {
hls.off(Events.MEDIA_ATTACHING, this.onMediaAttaching, this);
hls.off(Events.MEDIA_ATTACHED, this.onMediaAttached, this);
hls.off(Events.MEDIA_DETACHING, this.onMediaDetaching, this);
hls.off(Events.MANIFEST_LOADING, this.onManifestLoading, this);
hls.off(Events.FRAG_PARSING_METADATA, this.onFragParsingMetadata, this);
hls.off(Events.BUFFER_FLUSHING, this.onBufferFlushing, this);
hls.off(Events.LEVEL_UPDATED, this.onLevelUpdated, this);
hls.off(Events.LEVEL_PTS_UPDATED, this.onLevelPtsUpdated, this);
}
}
private onEventCueEnter = () => {
if (!this.hls) {
return;
}
this.hls.trigger(Events.EVENT_CUE_ENTER, {});
};
// Add ID3 metatadata text track.
private onMediaAttaching(
event: Events.MEDIA_ATTACHING,
data: MediaAttachingData,
): void {
this.media = data.media;
if (data.overrides?.cueRemoval === false) {
this.removeCues = false;
}
}
private onMediaAttached() {
const details = this.hls?.latestLevelDetails;
if (details) {
this.updateDateRangeCues(details);
}
}
private onMediaDetaching(
event: Events.MEDIA_DETACHING,
data: MediaDetachingData,
) {
this.media = null;
const transferringMedia = !!data.transferMedia;
if (transferringMedia) {
return;
}
if (this.id3Track) {
if (this.removeCues) {
clearCurrentCues(this.id3Track, this.onEventCueEnter);
}
this.id3Track = null;
}
this.dateRangeCuesAppended = {};
}
private onManifestLoading() {
this.dateRangeCuesAppended = {};
}
private createTrack(media: HTMLMediaElement): TextTrack {
const track = this.getID3Track(media.textTracks) as TextTrack;
track.mode = 'hidden';
return track;
}
private getID3Track(textTracks: TextTrackList): TextTrack | void {
if (!this.media) {
return;
}
for (let i = 0; i < textTracks.length; i++) {
const textTrack: TextTrack = textTracks[i];
if (textTrack.kind === 'metadata' && textTrack.label === 'id3') {
// send 'addtrack' when reusing the textTrack for metadata,
// same as what we do for captions
sendAddTrackEvent(textTrack, this.media);
return textTrack;
}
}
return this.media.addTextTrack('metadata', 'id3');
}
private onFragParsingMetadata(
event: Events.FRAG_PARSING_METADATA,
data: FragParsingMetadataData,
) {
if (!this.media || !this.hls) {
return;
}
const { enableEmsgMetadataCues, enableID3MetadataCues } = this.hls.config;
if (!enableEmsgMetadataCues && !enableID3MetadataCues) {
return;
}
const { samples } = data;
// create track dynamically
if (!this.id3Track) {
this.id3Track = this.createTrack(this.media);
}
const Cue = getCueClass();
if (!Cue) {
return;
}
for (let i = 0; i < samples.length; i++) {
const type = samples[i].type;
if (
(type === MetadataSchema.emsg && !enableEmsgMetadataCues) ||
!enableID3MetadataCues
) {
continue;
}
const frames = getId3Frames(samples[i].data);
const startTime = samples[i].pts;
let endTime: number = startTime + samples[i].duration;
if (endTime > MAX_CUE_ENDTIME) {
endTime = MAX_CUE_ENDTIME;
}
const timeDiff = endTime - startTime;
if (timeDiff <= 0) {
endTime = startTime + MIN_CUE_DURATION;
}
for (let j = 0; j < frames.length; j++) {
const frame = frames[j];
// Safari doesn't put the timestamp frame in the TextTrack
if (!isId3TimestampFrame(frame)) {
// add a bounds to any unbounded cues
this.updateId3CueEnds(startTime, type);
const cue = createCueWithDataFields(
Cue,
startTime,
endTime,
frame,
type,
);
if (cue) {
this.id3Track.addCue(cue);
}
}
}
}
}
private updateId3CueEnds(startTime: number, type: MetadataSchema) {
const cues = this.id3Track?.cues;
if (cues) {
for (let i = cues.length; i--; ) {
const cue = cues[i] as any;
if (
cue.type === type &&
cue.startTime < startTime &&
cue.endTime === MAX_CUE_ENDTIME
) {
cue.endTime = startTime;
}
}
}
}
private onBufferFlushing(
event: Events.BUFFER_FLUSHING,
{ startOffset, endOffset, type }: BufferFlushingData,
) {
const { id3Track, hls } = this;
if (!hls) {
return;
}
const {
config: { enableEmsgMetadataCues, enableID3MetadataCues },
} = hls;
if (id3Track && (enableEmsgMetadataCues || enableID3MetadataCues)) {
let predicate;
if (type === 'audio') {
predicate = (cue) =>
(cue as any).type === MetadataSchema.audioId3 &&
enableID3MetadataCues;
} else if (type === 'video') {
predicate = (cue) =>
(cue as any).type === MetadataSchema.emsg && enableEmsgMetadataCues;
} else {
predicate = (cue) =>
((cue as any).type === MetadataSchema.audioId3 &&
enableID3MetadataCues) ||
((cue as any).type === MetadataSchema.emsg && enableEmsgMetadataCues);
}
removeCuesInRange(id3Track, startOffset, endOffset, predicate);
}
}
private onLevelUpdated(
event: Events.LEVEL_UPDATED,
{ details }: LevelUpdatedData,
) {
this.updateDateRangeCues(details, true);
}
private onLevelPtsUpdated(
event: Events.LEVEL_PTS_UPDATED,
data: LevelPTSUpdatedData,
) {
if (Math.abs(data.drift) > 0.01) {
this.updateDateRangeCues(data.details);
}
}
private updateDateRangeCues(details: LevelDetails, removeOldCues?: true) {
if (!this.hls || !this.media) {
return;
}
const {
assetPlayerId,
timelineOffset,
enableDateRangeMetadataCues,
interstitialsController,
} = this.hls.config;
if (!enableDateRangeMetadataCues) {
return;
}
const Cue = getCueClass();
if (
__USE_INTERSTITIALS__ &&
assetPlayerId &&
timelineOffset &&
!interstitialsController
) {
const { fragmentStart, fragmentEnd } = details;
let cue = this.assetCue;
if (cue) {
cue.startTime = fragmentStart;
cue.endTime = fragmentEnd;
} else if (Cue) {
cue = this.assetCue = createCueWithDataFields(
Cue,
fragmentStart,
fragmentEnd,
{ assetPlayerId: this.hls.config.assetPlayerId },
'hlsjs.interstitial.asset',
);
if (cue) {
cue.id = assetPlayerId;
this.id3Track ||= this.createTrack(this.media);
this.id3Track.addCue(cue);
cue.addEventListener('enter', this.onEventCueEnter);
}
}
}
if (!details.hasProgramDateTime) {
return;
}
const { id3Track } = this;
const { dateRanges } = details;
const ids = Object.keys(dateRanges);
let dateRangeCuesAppended = this.dateRangeCuesAppended;
// Remove cues from track not found in details.dateRanges
if (id3Track && removeOldCues) {
if (id3Track.cues?.length) {
const idsToRemove = Object.keys(dateRangeCuesAppended).filter(
(id) => !ids.includes(id),
);
for (let i = idsToRemove.length; i--; ) {
const id = idsToRemove[i];
const cues = dateRangeCuesAppended[id]?.cues;
delete dateRangeCuesAppended[id];
if (cues) {
Object.keys(cues).forEach((key) => {
const cue = cues[key];
if (cue) {
cue.removeEventListener('enter', this.onEventCueEnter);
try {
id3Track.removeCue(cue);
} catch (e) {
/* no-op */
}
}
});
}
}
} else {
dateRangeCuesAppended = this.dateRangeCuesAppended = {};
}
}
// Exit if the playlist does not have Date Ranges or does not have Program Date Time
const lastFragment = details.fragments[details.fragments.length - 1] as
| MediaFragment
| undefined;
if (ids.length === 0 || !Number.isFinite(lastFragment?.programDateTime)) {
return;
}
this.id3Track ||= this.createTrack(this.media);
for (let i = 0; i < ids.length; i++) {
const id = ids[i];
const dateRange = dateRanges[id]!;
const startTime = dateRange.startTime;
// Process DateRanges to determine end-time (known DURATION, END-DATE, or END-ON-NEXT)
const appendedDateRangeCues = dateRangeCuesAppended[id];
const cues = appendedDateRangeCues?.cues || {};
let durationKnown = appendedDateRangeCues?.durationKnown || false;
let endTime = MAX_CUE_ENDTIME;
const { duration, endDate } = dateRange;
if (endDate && duration !== null) {
endTime = startTime + duration;
durationKnown = true;
} else if (dateRange.endOnNext && !durationKnown) {
const nextDateRangeWithSameClass = ids.reduce(
(candidateDateRange: DateRange | null, id) => {
if (id !== dateRange.id) {
const otherDateRange = dateRanges[id]!;
if (
otherDateRange.class === dateRange.class &&
otherDateRange.startDate > dateRange.startDate &&
(!candidateDateRange ||
dateRange.startDate < candidateDateRange.startDate)
) {
return otherDateRange;
}
}
return candidateDateRange;
},
null,
);
if (nextDateRangeWithSameClass) {
endTime = nextDateRangeWithSameClass.startTime;
durationKnown = true;
}
}
// Create TextTrack Cues for each MetadataGroup Item (select DateRange attribute)
// This is to emulate Safari HLS playback handling of DateRange tags
const attributes = Object.keys(dateRange.attr);
for (let j = 0; j < attributes.length; j++) {
const key = attributes[j];
if (!isDateRangeCueAttribute(key)) {
continue;
}
const cue = cues[key];
if (cue) {
if (durationKnown && !appendedDateRangeCues?.durationKnown) {
cue.endTime = endTime;
} else if (Math.abs(cue.startTime - startTime) > 0.01) {
cue.startTime = startTime;
cue.endTime = endTime;
}
} else if (Cue) {
let data = dateRange.attr[key];
if (isSCTE35Attribute(key)) {
data = hexToArrayBuffer(data);
}
const payload: any = { key, data };
const cue = createCueWithDataFields(
Cue,
startTime,
endTime,
payload,
MetadataSchema.dateRange,
);
if (cue) {
cue.id = id;
this.id3Track.addCue(cue);
cues[key] = cue;
if (__USE_INTERSTITIALS__ && interstitialsController) {
if (key === 'X-ASSET-LIST' || key === 'X-ASSET-URL') {
cue.addEventListener('enter', this.onEventCueEnter);
}
}
}
}
}
// Keep track of processed DateRanges by ID for updating cues with new DateRange tag attributes
dateRangeCuesAppended[id] = {
cues,
dateRange,
durationKnown,
};
}
}
}
export default ID3TrackController;
+302
View File
@@ -0,0 +1,302 @@
import { Events, type HlsListeners } from '../events';
import {
eventAssetToString,
getInterstitialUrl,
type InterstitialAssetId,
type InterstitialAssetItem,
type InterstitialEvent,
type InterstitialId,
} from '../loader/interstitial-event';
import { BufferHelper } from '../utils/buffer-helper';
import type { HlsConfig } from '../config';
import type { InterstitialScheduleEventItem } from '../controller/interstitials-schedule';
import type Hls from '../hls';
import type { BufferCodecsData, MediaAttachingData } from '../types/events';
export interface InterstitialPlayer {
bufferedEnd: number;
currentTime: number;
duration: number;
assetPlayers: (HlsAssetPlayer | null)[];
playingIndex: number;
scheduleItem: InterstitialScheduleEventItem | null;
}
export type HlsAssetPlayerConfig = Partial<HlsConfig> &
Required<Pick<HlsConfig, 'assetPlayerId' | 'primarySessionId'>>;
export class HlsAssetPlayer {
public hls: Hls | null;
public interstitial: InterstitialEvent;
public readonly assetItem: InterstitialAssetItem;
public tracks: Partial<BufferCodecsData> | null = null;
private hasDetails: boolean = false;
private mediaAttached: HTMLMediaElement | null = null;
private _currentTime?: number;
private _bufferedEosTime?: number;
constructor(
HlsPlayerClass: typeof Hls,
userConfig: HlsAssetPlayerConfig,
interstitial: InterstitialEvent,
assetItem: InterstitialAssetItem,
) {
const hls = (this.hls = new HlsPlayerClass(userConfig));
this.interstitial = interstitial;
this.assetItem = assetItem;
const detailsLoaded = () => {
this.hasDetails = true;
};
hls.once(Events.LEVEL_LOADED, detailsLoaded);
hls.once(Events.AUDIO_TRACK_LOADED, detailsLoaded);
hls.once(Events.SUBTITLE_TRACK_LOADED, detailsLoaded);
hls.on(Events.MEDIA_ATTACHING, (name, { media }) => {
this.removeMediaListeners();
this.mediaAttached = media;
const event = this.interstitial;
if (event.playoutLimit) {
media.addEventListener('timeupdate', this.checkPlayout);
if (this.appendInPlace) {
hls.on(Events.BUFFER_APPENDED, () => {
const bufferedEnd = this.bufferedEnd;
if (this.reachedPlayout(bufferedEnd)) {
this._bufferedEosTime = bufferedEnd;
hls.trigger(Events.BUFFERED_TO_END, undefined);
}
});
}
}
});
}
get appendInPlace(): boolean {
return this.interstitial.appendInPlace;
}
loadSource() {
const hls = this.hls;
if (!hls) {
return;
}
if (!hls.url) {
let uri: string = this.assetItem.uri;
try {
uri = getInterstitialUrl(uri, hls.config.primarySessionId || '').href;
} catch (error) {
// Ignore error parsing ASSET_URI or adding _HLS_primary_id to it. The
// issue should surface as an INTERSTITIAL_ASSET_ERROR loading the asset.
}
hls.loadSource(uri);
} else if (hls.levels.length && !(hls as any).started) {
hls.startLoad(-1, true);
}
}
bufferedInPlaceToEnd(media?: HTMLMediaElement | null) {
if (!this.appendInPlace) {
return false;
}
if (this.hls?.bufferedToEnd) {
return true;
}
if (!media) {
return false;
}
const duration = Math.min(this._bufferedEosTime || Infinity, this.duration);
const start = this.timelineOffset;
const bufferInfo = BufferHelper.bufferInfo(media, start, 0);
const bufferedEnd = this.getAssetTime(bufferInfo.end);
return bufferedEnd >= duration - 0.02;
}
private checkPlayout = () => {
if (this.reachedPlayout(this.currentTime) && this.hls) {
this.hls.trigger(Events.PLAYOUT_LIMIT_REACHED, {});
}
};
private reachedPlayout(time: number): boolean {
const interstitial = this.interstitial;
const playoutLimit = interstitial.playoutLimit;
return this.startOffset + time >= playoutLimit;
}
get destroyed(): boolean {
return !this.hls?.userConfig;
}
get assetId(): InterstitialAssetId {
return this.assetItem.identifier;
}
get interstitialId(): InterstitialId {
return this.assetItem.parentIdentifier;
}
get media(): HTMLMediaElement | null {
return this.hls?.media || null;
}
get bufferedEnd(): number {
const media = this.media || this.mediaAttached;
if (!media) {
if (this._bufferedEosTime) {
return this._bufferedEosTime;
}
return this.currentTime;
}
const bufferInfo = BufferHelper.bufferInfo(media, media.currentTime, 0.001);
return this.getAssetTime(bufferInfo.end);
}
get currentTime(): number {
const media = this.media || this.mediaAttached;
if (!media) {
return this._currentTime || 0;
}
return this.getAssetTime(media.currentTime);
}
get duration(): number {
const duration = this.assetItem.duration;
if (!duration) {
return 0;
}
const playoutLimit = this.interstitial.playoutLimit;
if (playoutLimit) {
const assetPlayout = playoutLimit - this.startOffset;
if (assetPlayout > 0 && assetPlayout < duration) {
return assetPlayout;
}
}
return duration;
}
get remaining(): number {
const duration = this.duration;
if (!duration) {
return 0;
}
return Math.max(0, duration - this.currentTime);
}
get startOffset(): number {
return this.assetItem.startOffset;
}
get timelineOffset(): number {
return this.hls?.config.timelineOffset || 0;
}
set timelineOffset(value: number) {
const timelineOffset = this.timelineOffset;
if (value !== timelineOffset) {
const diff = value - timelineOffset;
if (Math.abs(diff) > 1 / 90000 && this.hls) {
if (this.hasDetails) {
throw new Error(
`Cannot set timelineOffset after playlists are loaded`,
);
}
this.hls.config.timelineOffset = value;
}
}
}
private getAssetTime(time: number): number {
const timelineOffset = this.timelineOffset;
const duration = this.duration;
return Math.min(Math.max(0, time - timelineOffset), duration);
}
private removeMediaListeners() {
const media = this.mediaAttached;
if (media) {
this._currentTime = media.currentTime;
this.bufferSnapShot();
media.removeEventListener('timeupdate', this.checkPlayout);
}
}
private bufferSnapShot() {
if (this.mediaAttached) {
if (this.hls?.bufferedToEnd) {
this._bufferedEosTime = this.bufferedEnd;
}
}
}
destroy() {
this.removeMediaListeners();
if (this.hls) {
this.hls.destroy();
}
this.hls = null;
// @ts-ignore
this.tracks = this.mediaAttached = this.checkPlayout = null;
}
attachMedia(data: HTMLMediaElement | MediaAttachingData) {
this.loadSource();
this.hls?.attachMedia(data);
}
detachMedia() {
this.removeMediaListeners();
this.mediaAttached = null;
this.hls?.detachMedia();
}
resumeBuffering() {
this.hls?.resumeBuffering();
}
pauseBuffering() {
this.hls?.pauseBuffering();
}
transferMedia() {
this.bufferSnapShot();
return this.hls?.transferMedia() || null;
}
resetDetails() {
const hls = this.hls;
if (hls && this.hasDetails) {
hls.stopLoad();
const deleteDetails = (obj) => delete obj.details;
hls.levels.forEach(deleteDetails);
hls.allAudioTracks.forEach(deleteDetails);
hls.allSubtitleTracks.forEach(deleteDetails);
this.hasDetails = false;
}
}
on<E extends keyof HlsListeners, Context = undefined>(
event: E,
listener: HlsListeners[E],
context?: Context,
) {
this.hls?.on(event, listener);
}
once<E extends keyof HlsListeners, Context = undefined>(
event: E,
listener: HlsListeners[E],
context?: Context,
) {
this.hls?.once(event, listener);
}
off<E extends keyof HlsListeners, Context = undefined>(
event: E,
listener: HlsListeners[E],
context?: Context,
) {
this.hls?.off(event, listener);
}
toString(): string {
return `HlsAssetPlayer: ${eventAssetToString(this.assetItem)} ${this.hls?.sessionId} ${this.appendInPlace ? 'append-in-place' : ''}`;
}
}
File diff suppressed because it is too large Load Diff
+698
View File
@@ -0,0 +1,698 @@
import { findFragmentByPTS } from './fragment-finders';
import {
ALIGNED_END_THRESHOLD_SECONDS,
type BaseData,
InterstitialEvent,
type InterstitialId,
TimelineOccupancy,
} from '../loader/interstitial-event';
import { Logger } from '../utils/logger';
import type { DateRange } from '../loader/date-range';
import type { MediaSelection } from '../types/media-playlist';
import type { ILogger } from '../utils/logger';
const ABUTTING_THRESHOLD_SECONDS = 0.033;
export type InterstitialScheduleEventItem = {
event: InterstitialEvent;
start: number;
end: number;
playout: {
start: number;
end: number;
};
integrated: {
start: number;
end: number;
};
};
export type InterstitialSchedulePrimaryItem = {
nextEvent: InterstitialEvent | null;
previousEvent: InterstitialEvent | null;
event?: undefined;
start: number;
end: number;
playout: {
start: number;
end: number;
};
integrated: {
start: number;
end: number;
};
};
export type InterstitialScheduleItem =
| InterstitialScheduleEventItem
| InterstitialSchedulePrimaryItem;
export type InterstitialScheduleDurations = {
primary: number;
playout: number;
integrated: number;
};
export type TimelineType = 'primary' | 'playout' | 'integrated';
type ScheduleUpdateCallback = (
removed: InterstitialEvent[],
previousItems: InterstitialScheduleItem[] | null,
) => void;
export class InterstitialsSchedule extends Logger {
private onScheduleUpdate: ScheduleUpdateCallback;
private eventMap: Record<string, InterstitialEvent | undefined> = {};
public events: InterstitialEvent[] | null = null;
public items: InterstitialScheduleItem[] | null = null;
public durations: InterstitialScheduleDurations = {
primary: 0,
playout: 0,
integrated: 0,
};
constructor(onScheduleUpdate: ScheduleUpdateCallback, logger: ILogger) {
super('interstitials-sched', logger);
this.onScheduleUpdate = onScheduleUpdate;
}
public destroy() {
this.reset();
// @ts-ignore
this.onScheduleUpdate = null;
}
public reset() {
this.eventMap = {};
this.setDurations(0, 0, 0);
if (this.events) {
this.events.forEach((interstitial) => interstitial.reset());
}
this.events = this.items = null;
}
public resetErrorsInRange(start: number, end: number): number {
if (this.events) {
return this.events.reduce((count, interstitial) => {
if (
start <= interstitial.startOffset &&
end > interstitial.startOffset
) {
delete interstitial.error;
return count + 1;
}
return count;
}, 0);
}
return 0;
}
get duration(): number {
const items = this.items;
return items ? items[items.length - 1].end : 0;
}
get length(): number {
return this.items ? this.items.length : 0;
}
public getEvent(
identifier: InterstitialId | undefined,
): InterstitialEvent | null {
return identifier ? this.eventMap[identifier] || null : null;
}
public hasEvent(identifier: InterstitialId): boolean {
return identifier in this.eventMap;
}
public findItemIndex(item: InterstitialScheduleItem, time?: number): number {
if (item.event) {
// Find Event Item
return this.findEventIndex(item.event.identifier);
}
// Find Primary Item
let index = -1;
if (item.nextEvent) {
index = this.findEventIndex(item.nextEvent.identifier) - 1;
} else if (item.previousEvent) {
index = this.findEventIndex(item.previousEvent.identifier) + 1;
}
const items = this.items;
if (items) {
if (!items[index]) {
if (time === undefined) {
time = item.start;
}
index = this.findItemIndexAtTime(time);
}
// Only return index of a Primary Item
while (index >= 0 && items[index]?.event) {
// If index found is an interstitial it is not a valid result as it should have been matched up top
// decrement until result is negative (not found) or a primary segment
index--;
}
}
return index;
}
public findItemIndexAtTime(
timelinePos: number,
timelineType?: TimelineType,
): number {
const items = this.items;
if (items) {
for (let i = 0; i < items.length; i++) {
let timeRange: { start: number; end: number } = items[i];
if (timelineType && timelineType !== 'primary') {
timeRange = timeRange[timelineType];
}
if (
timelinePos === timeRange.start ||
(timelinePos > timeRange.start && timelinePos < timeRange.end)
) {
return i;
}
}
}
return -1;
}
public findJumpRestrictedIndex(startIndex: number, endIndex: number): number {
const items = this.items;
if (items) {
for (let i = startIndex; i <= endIndex; i++) {
if (!items[i]) {
break;
}
const event = items[i].event;
if (event?.restrictions.jump && !event.appendInPlace) {
return i;
}
}
}
return -1;
}
public findEventIndex(identifier: InterstitialId): number {
const items = this.items;
if (items) {
for (let i = items.length; i--; ) {
if (items[i].event?.identifier === identifier) {
return i;
}
}
}
return -1;
}
public findAssetIndex(event: InterstitialEvent, timelinePos: number): number {
const assetList = event.assetList;
const length = assetList.length;
if (length > 1) {
for (let i = 0; i < length; i++) {
const asset = assetList[i];
if (!asset.error) {
const timelineStart = asset.timelineStart;
if (
timelinePos === timelineStart ||
(timelinePos > timelineStart &&
(timelinePos < timelineStart + (asset.duration || 0) ||
i === length - 1))
) {
return i;
}
}
}
}
return 0;
}
public get assetIdAtEnd(): string | null {
const interstitialAtEnd = this.items?.[this.length - 1]?.event;
if (interstitialAtEnd) {
const assetList = interstitialAtEnd.assetList;
const assetAtEnd = assetList[assetList.length - 1];
if (assetAtEnd) {
return assetAtEnd.identifier;
}
}
return null;
}
public parseInterstitialDateRanges(
mediaSelection: MediaSelection,
enableAppendInPlace: boolean,
) {
const details = mediaSelection.main.details!;
const { dateRanges } = details;
const previousInterstitialEvents = this.events;
const interstitialEvents = this.parseDateRanges(
dateRanges,
{
url: details.url,
},
enableAppendInPlace,
);
const ids = Object.keys(dateRanges);
const removedInterstitials = previousInterstitialEvents
? previousInterstitialEvents.filter(
(event) => !ids.includes(event.identifier),
)
: [];
if (interstitialEvents.length) {
// pre-rolls, post-rolls, and events with the same start time are played in playlist tag order
// all other events are ordered by start time
interstitialEvents.sort((a, b) => {
const aPre = a.cue.pre;
const aPost = a.cue.post;
const bPre = b.cue.pre;
const bPost = b.cue.post;
if (aPre && !bPre) {
return -1;
}
if (bPre && !aPre) {
return 1;
}
if (aPost && !bPost) {
return 1;
}
if (bPost && !aPost) {
return -1;
}
if (!aPre && !bPre && !aPost && !bPost) {
const startA = a.startTime;
const startB = b.startTime;
if (startA !== startB) {
return startA - startB;
}
}
return a.dateRange.tagOrder - b.dateRange.tagOrder;
});
}
this.events = interstitialEvents;
// Clear removed DateRanges from buffered list (kills playback of active Interstitials)
removedInterstitials.forEach((interstitial) => {
this.removeEvent(interstitial);
});
this.updateSchedule(mediaSelection, removedInterstitials);
}
public updateSchedule(
mediaSelection: MediaSelection,
removedInterstitials: InterstitialEvent[] = [],
forceUpdate: boolean = false,
) {
const events = this.events || [];
if (events.length || removedInterstitials.length || this.length < 2) {
const currentItems = this.items;
const updatedItems = this.parseSchedule(events, mediaSelection);
const updated =
forceUpdate ||
removedInterstitials.length ||
currentItems?.length !== updatedItems.length ||
updatedItems.some((item, i) => {
return (
Math.abs(item.playout.start - currentItems[i].playout.start) >
0.005 ||
Math.abs(item.playout.end - currentItems[i].playout.end) > 0.005
);
});
if (updated) {
this.items = updatedItems;
// call interstitials-controller onScheduleUpdated()
this.onScheduleUpdate(removedInterstitials, currentItems);
}
}
}
private parseDateRanges(
dateRanges: Record<string, DateRange | undefined>,
baseData: BaseData,
enableAppendInPlace: boolean,
): InterstitialEvent[] {
const interstitialEvents: InterstitialEvent[] = [];
const ids = Object.keys(dateRanges);
for (let i = 0; i < ids.length; i++) {
const id = ids[i];
const dateRange = dateRanges[id]!;
if (dateRange.isInterstitial) {
let interstitial = this.eventMap[id];
if (interstitial) {
// Update InterstitialEvent already parsed and mapped
// This retains already loaded duration and loaded asset list info
interstitial.setDateRange(dateRange);
} else {
interstitial = new InterstitialEvent(dateRange, baseData);
this.eventMap[id] = interstitial;
if (enableAppendInPlace === false) {
interstitial.appendInPlace = enableAppendInPlace;
}
}
interstitialEvents.push(interstitial);
}
}
return interstitialEvents;
}
private parseSchedule(
interstitialEvents: InterstitialEvent[],
mediaSelection: MediaSelection,
): InterstitialScheduleItem[] {
const schedule: InterstitialScheduleItem[] = [];
const details = mediaSelection.main.details!;
const primaryDuration = details.live ? Infinity : details.edge;
let playoutDuration = 0;
// Filter events that have errored from the schedule (Primary fallback)
interstitialEvents = interstitialEvents.filter(
(event) => !event.error && !(event.cue.once && event.hasPlayed),
);
if (interstitialEvents.length) {
// Update Schedule
this.resolveOffsets(interstitialEvents, mediaSelection);
// Populate Schedule with Interstitial Event and Primary Segment Items
let primaryPosition = 0;
let integratedTime = 0;
interstitialEvents.forEach((interstitial, i) => {
const preroll = interstitial.cue.pre;
const postroll = interstitial.cue.post;
const previousEvent =
(interstitialEvents[i - 1] as InterstitialEvent | undefined) || null;
const appendInPlace = interstitial.appendInPlace;
const eventStart = postroll
? primaryDuration
: interstitial.startOffset;
const interstitialDuration = interstitial.duration;
const timelineDuration =
interstitial.timelineOccupancy === TimelineOccupancy.Range
? interstitialDuration
: 0;
const resumptionOffset = interstitial.resumptionOffset;
const inSameStartTimeSequence = previousEvent?.startTime === eventStart;
const start = eventStart + interstitial.cumulativeDuration;
let end = appendInPlace
? start + interstitialDuration
: eventStart + resumptionOffset;
if (preroll || (!postroll && eventStart <= 0)) {
// preroll or in-progress midroll
const integratedStart = integratedTime;
integratedTime += timelineDuration;
interstitial.timelineStart = start;
const playoutStart = playoutDuration;
playoutDuration += interstitialDuration;
schedule.push({
event: interstitial,
start,
end,
playout: {
start: playoutStart,
end: playoutDuration,
},
integrated: {
start: integratedStart,
end: integratedTime,
},
});
} else if (eventStart <= primaryDuration) {
if (!inSameStartTimeSequence) {
const segmentDuration = eventStart - primaryPosition;
// Do not schedule a primary segment if interstitials are abutting by less than ABUTTING_THRESHOLD_SECONDS
if (segmentDuration > ABUTTING_THRESHOLD_SECONDS) {
// primary segment
const timelineStart = primaryPosition;
const integratedStart = integratedTime;
integratedTime += segmentDuration;
const playoutStart = playoutDuration;
playoutDuration += segmentDuration;
const primarySegment = {
previousEvent: interstitialEvents[i - 1] || null,
nextEvent: interstitial,
start: timelineStart,
end: timelineStart + segmentDuration,
playout: {
start: playoutStart,
end: playoutDuration,
},
integrated: {
start: integratedStart,
end: integratedTime,
},
};
schedule.push(primarySegment);
} else if (segmentDuration > 0 && previousEvent) {
// Add previous event `resumeTime` (based on duration or resumeOffset) so that it ends aligned with this one
previousEvent.cumulativeDuration += segmentDuration;
schedule[schedule.length - 1].end = eventStart;
}
}
// midroll / postroll
if (postroll) {
end = start;
}
interstitial.timelineStart = start;
const integratedStart = integratedTime;
integratedTime += timelineDuration;
const playoutStart = playoutDuration;
playoutDuration += interstitialDuration;
schedule.push({
event: interstitial,
start,
end,
playout: {
start: playoutStart,
end: playoutDuration,
},
integrated: {
start: integratedStart,
end: integratedTime,
},
});
} else {
// Interstitial starts after end of primary VOD - not included in schedule
return;
}
const resumeTime = interstitial.resumeTime;
if (postroll || resumeTime > primaryDuration) {
primaryPosition = primaryDuration;
} else {
primaryPosition = resumeTime;
}
});
if (primaryPosition < primaryDuration) {
// last primary segment
const timelineStart = primaryPosition;
const integratedStart = integratedTime;
const segmentDuration = primaryDuration - primaryPosition;
integratedTime += segmentDuration;
const playoutStart = playoutDuration;
playoutDuration += segmentDuration;
schedule.push({
previousEvent: schedule[schedule.length - 1]?.event || null,
nextEvent: null,
start: primaryPosition,
end: timelineStart + segmentDuration,
playout: {
start: playoutStart,
end: playoutDuration,
},
integrated: {
start: integratedStart,
end: integratedTime,
},
});
}
this.setDurations(primaryDuration, playoutDuration, integratedTime);
} else {
// no interstials - schedule is one primary segment
const start = 0;
schedule.push({
previousEvent: null,
nextEvent: null,
start,
end: primaryDuration,
playout: {
start,
end: primaryDuration,
},
integrated: {
start,
end: primaryDuration,
},
});
this.setDurations(primaryDuration, primaryDuration, primaryDuration);
}
return schedule;
}
private setDurations(primary: number, playout: number, integrated: number) {
this.durations = {
primary,
playout,
integrated,
};
}
private resolveOffsets(
interstitialEvents: InterstitialEvent[],
mediaSelection: MediaSelection,
) {
const details = mediaSelection.main.details!;
const primaryDuration = details.live ? Infinity : details.edge;
// First resolve cumulative resumption offsets for Interstitials that start at the same DateTime
let cumulativeDuration = 0;
let lastScheduledStart = -1;
interstitialEvents.forEach((interstitial, i) => {
const preroll = interstitial.cue.pre;
const postroll = interstitial.cue.post;
const eventStart = preroll
? 0
: postroll
? primaryDuration
: interstitial.startTime;
this.updateAssetDurations(interstitial);
// X-RESUME-OFFSET values of interstitials scheduled at the same time are cumulative
const inSameStartTimeSequence = lastScheduledStart === eventStart;
if (inSameStartTimeSequence) {
interstitial.cumulativeDuration = cumulativeDuration;
} else {
cumulativeDuration = 0;
lastScheduledStart = eventStart;
}
if (!postroll && interstitial.snapOptions.in) {
// FIXME: Include audio playlist in snapping
interstitial.resumeAnchor =
findFragmentByPTS(
null,
details.fragments,
interstitial.startOffset + interstitial.resumptionOffset,
0,
0,
) || undefined;
}
// Check if primary fragments align with resumption offset and disable appendInPlace if they do not
if (interstitial.appendInPlace && !interstitial.appendInPlaceStarted) {
const alignedSegmentStart = this.primaryCanResumeInPlaceAt(
interstitial,
mediaSelection,
);
if (!alignedSegmentStart) {
interstitial.appendInPlace = false;
}
}
if (!interstitial.appendInPlace && i + 1 < interstitialEvents.length) {
// abutting Interstitials must use the same MediaSource strategy, this applies to all whether or not they are back to back:
const timeBetween =
interstitialEvents[i + 1].startTime -
interstitialEvents[i].resumeTime;
if (timeBetween < ABUTTING_THRESHOLD_SECONDS) {
interstitialEvents[i + 1].appendInPlace = false;
if (interstitialEvents[i + 1].appendInPlace) {
this.warn(
`Could not change append strategy for abutting event ${interstitial}`,
);
}
}
}
// Update cumulativeDuration for next abutting interstitial with the same start date
const resumeOffset = Number.isFinite(interstitial.resumeOffset)
? interstitial.resumeOffset
: interstitial.duration;
cumulativeDuration += resumeOffset;
});
}
private primaryCanResumeInPlaceAt(
interstitial: InterstitialEvent,
mediaSelection: MediaSelection,
): boolean {
const resumeTime = interstitial.resumeTime;
const resumesInPlaceAt =
interstitial.startTime + interstitial.resumptionOffset;
if (
Math.abs(resumeTime - resumesInPlaceAt) > ALIGNED_END_THRESHOLD_SECONDS
) {
this.log(
`"${interstitial.identifier}" resumption ${resumeTime} not aligned with estimated timeline end ${resumesInPlaceAt}`,
);
return false;
}
const playlists = Object.keys(mediaSelection);
return !playlists.some((playlistType) => {
const details = mediaSelection[playlistType].details;
const playlistEnd = details.edge;
if (resumeTime >= playlistEnd) {
// Live playback - resumption segments are not yet available
this.log(
`"${interstitial.identifier}" resumption ${resumeTime} past ${playlistType} playlist end ${playlistEnd}`,
);
// Assume alignment is possible (or reset can take place)
return false;
}
const startFragment = findFragmentByPTS(
null,
details.fragments,
resumeTime,
);
if (!startFragment) {
this.log(
`"${interstitial.identifier}" resumption ${resumeTime} does not align with any fragments in ${playlistType} playlist (${details.fragStart}-${details.fragmentEnd})`,
);
return true;
}
const allowance = playlistType === 'audio' ? 0.175 : 0;
const alignedWithSegment =
Math.abs(startFragment.start - resumeTime) <
ALIGNED_END_THRESHOLD_SECONDS + allowance ||
Math.abs(startFragment.end - resumeTime) <
ALIGNED_END_THRESHOLD_SECONDS + allowance;
if (!alignedWithSegment) {
this.log(
`"${interstitial.identifier}" resumption ${resumeTime} not aligned with ${playlistType} fragment bounds (${startFragment.start}-${startFragment.end} sn: ${startFragment.sn} cc: ${startFragment.cc})`,
);
return true;
}
return false;
});
}
private updateAssetDurations(interstitial: InterstitialEvent) {
if (!interstitial.assetListLoaded) {
return;
}
const eventStart = interstitial.timelineStart;
let sumDuration = 0;
let hasUnknownDuration = false;
let hasErrors = false;
for (let i = 0; i < interstitial.assetList.length; i++) {
const asset = interstitial.assetList[i];
const timelineStart = eventStart + sumDuration;
asset.startOffset = sumDuration;
asset.timelineStart = timelineStart;
hasUnknownDuration ||= asset.duration === null;
hasErrors ||= !!asset.error;
const duration = asset.error ? 0 : (asset.duration as number) || 0;
sumDuration += duration;
}
// Use the sum of known durations when it is greater than the stated duration
if (hasUnknownDuration && !hasErrors) {
interstitial.duration = Math.max(sumDuration, interstitial.duration);
} else {
interstitial.duration = sumDuration;
}
}
private removeEvent(interstitial: InterstitialEvent) {
interstitial.reset();
delete this.eventMap[interstitial.identifier];
}
}
export function segmentToString(segment: InterstitialScheduleItem): string {
return `[${segment.event ? '"' + segment.event.identifier + '"' : 'primary'}: ${segment.start.toFixed(2)}-${segment.end.toFixed(2)}]`;
}
+294
View File
@@ -0,0 +1,294 @@
import { ErrorDetails } from '../errors';
import { Events } from '../events';
import type { HlsConfig } from '../config';
import type Hls from '../hls';
import type { LevelDetails } from '../loader/level-details';
import type { ComponentAPI } from '../types/component-api';
import type {
ErrorData,
LevelUpdatedData,
MediaAttachingData,
} from '../types/events';
export default class LatencyController implements ComponentAPI {
private hls: Hls | null;
private readonly config: HlsConfig;
private media: HTMLMediaElement | null = null;
private currentTime: number = 0;
private stallCount: number = 0;
private _latency: number | null = null;
private _targetLatencyUpdated = false;
constructor(hls: Hls) {
this.hls = hls;
this.config = hls.config;
this.registerListeners();
}
private get levelDetails(): LevelDetails | null {
return this.hls?.latestLevelDetails || null;
}
get latency(): number {
return this._latency || 0;
}
get maxLatency(): number {
const { config } = this;
if (config.liveMaxLatencyDuration !== undefined) {
return config.liveMaxLatencyDuration;
}
const levelDetails = this.levelDetails;
return levelDetails
? config.liveMaxLatencyDurationCount * levelDetails.targetduration
: 0;
}
get targetLatency(): number | null {
const levelDetails = this.levelDetails;
if (levelDetails === null || this.hls === null) {
return null;
}
const { holdBack, partHoldBack, targetduration } = levelDetails;
const { liveSyncDuration, liveSyncDurationCount, lowLatencyMode } =
this.config;
const userConfig = this.hls.userConfig;
let targetLatency = lowLatencyMode ? partHoldBack || holdBack : holdBack;
if (
this._targetLatencyUpdated ||
userConfig.liveSyncDuration ||
userConfig.liveSyncDurationCount ||
targetLatency === 0
) {
targetLatency =
liveSyncDuration !== undefined
? liveSyncDuration
: liveSyncDurationCount * targetduration;
}
const maxLiveSyncOnStallIncrease = targetduration;
return (
targetLatency +
Math.min(
this.stallCount * this.config.liveSyncOnStallIncrease,
maxLiveSyncOnStallIncrease,
)
);
}
set targetLatency(latency: number) {
this.stallCount = 0;
this.config.liveSyncDuration = latency;
this._targetLatencyUpdated = true;
}
get liveSyncPosition(): number | null {
const liveEdge = this.estimateLiveEdge();
const targetLatency = this.targetLatency;
if (liveEdge === null || targetLatency === null) {
return null;
}
const levelDetails = this.levelDetails;
if (levelDetails === null) {
return null;
}
const edge = levelDetails.edge;
const syncPosition = liveEdge - targetLatency - this.edgeStalled;
const min = edge - levelDetails.totalduration;
const max =
edge -
((this.config.lowLatencyMode && levelDetails.partTarget) ||
levelDetails.targetduration);
return Math.min(Math.max(min, syncPosition), max);
}
get drift(): number {
const levelDetails = this.levelDetails;
if (levelDetails === null) {
return 1;
}
return levelDetails.drift;
}
get edgeStalled(): number {
const levelDetails = this.levelDetails;
if (levelDetails === null) {
return 0;
}
const maxLevelUpdateAge =
((this.config.lowLatencyMode && levelDetails.partTarget) ||
levelDetails.targetduration) * 3;
return Math.max(levelDetails.age - maxLevelUpdateAge, 0);
}
private get forwardBufferLength(): number {
const { media } = this;
const levelDetails = this.levelDetails;
if (!media || !levelDetails) {
return 0;
}
const bufferedRanges = media.buffered.length;
return (
(bufferedRanges
? media.buffered.end(bufferedRanges - 1)
: levelDetails.edge) - this.currentTime
);
}
public destroy(): void {
this.unregisterListeners();
this.onMediaDetaching();
this.hls = null;
}
private registerListeners() {
const { hls } = this;
if (!hls) {
return;
}
hls.on(Events.MEDIA_ATTACHED, this.onMediaAttached, this);
hls.on(Events.MEDIA_DETACHING, this.onMediaDetaching, this);
hls.on(Events.MANIFEST_LOADING, this.onManifestLoading, this);
hls.on(Events.LEVEL_UPDATED, this.onLevelUpdated, this);
hls.on(Events.ERROR, this.onError, this);
}
private unregisterListeners() {
const { hls } = this;
if (!hls) {
return;
}
hls.off(Events.MEDIA_ATTACHED, this.onMediaAttached, this);
hls.off(Events.MEDIA_DETACHING, this.onMediaDetaching, this);
hls.off(Events.MANIFEST_LOADING, this.onManifestLoading, this);
hls.off(Events.LEVEL_UPDATED, this.onLevelUpdated, this);
hls.off(Events.ERROR, this.onError, this);
}
private onMediaAttached(
event: Events.MEDIA_ATTACHED,
data: MediaAttachingData,
) {
this.media = data.media;
this.media.addEventListener('timeupdate', this.onTimeupdate);
}
private onMediaDetaching() {
if (this.media) {
this.media.removeEventListener('timeupdate', this.onTimeupdate);
this.media = null;
}
}
private onManifestLoading() {
this._latency = null;
this.stallCount = 0;
}
private onLevelUpdated(
event: Events.LEVEL_UPDATED,
{ details }: LevelUpdatedData,
) {
if (details.advanced) {
this.onTimeupdate();
}
if (!details.live && this.media) {
this.media.removeEventListener('timeupdate', this.onTimeupdate);
}
}
private onError(event: Events.ERROR, data: ErrorData) {
if (data.details !== ErrorDetails.BUFFER_STALLED_ERROR) {
return;
}
this.stallCount++;
if (this.hls && this.levelDetails?.live) {
this.hls.logger.warn(
'[latency-controller]: Stall detected, adjusting target latency',
);
}
}
private onTimeupdate = () => {
const { media } = this;
const levelDetails = this.levelDetails;
if (!media || !levelDetails) {
return;
}
this.currentTime = media.currentTime;
const latency = this.computeLatency();
if (latency === null) {
return;
}
this._latency = latency;
// Adapt playbackRate to meet target latency in low-latency mode
const { lowLatencyMode, maxLiveSyncPlaybackRate } = this.config;
if (
!lowLatencyMode ||
maxLiveSyncPlaybackRate === 1 ||
!levelDetails.live
) {
return;
}
const targetLatency = this.targetLatency;
if (targetLatency === null) {
return;
}
const distanceFromTarget = latency - targetLatency;
// Only adjust playbackRate when within one target duration of targetLatency
// and more than one second from under-buffering.
// Playback further than one target duration from target can be considered DVR playback.
const liveMinLatencyDuration = Math.min(
this.maxLatency,
targetLatency + levelDetails.targetduration,
);
const inLiveRange = distanceFromTarget < liveMinLatencyDuration;
if (
inLiveRange &&
distanceFromTarget > 0.05 &&
this.forwardBufferLength > 1
) {
const max = Math.min(2, Math.max(1.0, maxLiveSyncPlaybackRate));
const rate =
Math.round(
(2 / (1 + Math.exp(-0.75 * distanceFromTarget - this.edgeStalled))) *
20,
) / 20;
const playbackRate = Math.min(max, Math.max(1, rate));
this.changeMediaPlaybackRate(media, playbackRate);
} else if (media.playbackRate !== 1 && media.playbackRate !== 0) {
this.changeMediaPlaybackRate(media, 1);
}
};
private changeMediaPlaybackRate(
media: HTMLMediaElement,
playbackRate: number,
) {
if (media.playbackRate === playbackRate) {
return;
}
this.hls?.logger.debug(
`[latency-controller]: latency=${this.latency.toFixed(3)}, targetLatency=${this.targetLatency?.toFixed(3)}, forwardBufferLength=${this.forwardBufferLength.toFixed(3)}: adjusting playback rate from ${media.playbackRate} to ${playbackRate}`,
);
media.playbackRate = playbackRate;
}
private estimateLiveEdge(): number | null {
const levelDetails = this.levelDetails;
if (levelDetails === null) {
return null;
}
return levelDetails.edge + levelDetails.age;
}
private computeLatency(): number | null {
const liveEdge = this.estimateLiveEdge();
if (liveEdge === null) {
return null;
}
return liveEdge - this.currentTime;
}
}
+753
View File
@@ -0,0 +1,753 @@
import BasePlaylistController from './base-playlist-controller';
import { ErrorDetails, ErrorTypes } from '../errors';
import { Events } from '../events';
import { isVideoRange, Level, VideoRangeValues } from '../types/level';
import { PlaylistContextType, PlaylistLevelType } from '../types/loader';
import {
areCodecsMediaSourceSupported,
codecsSetSelectionPreferenceValue,
convertAVC1ToAVCOTI,
getCodecCompatibleName,
videoCodecPreferenceValue,
} from '../utils/codecs';
import { reassignFragmentLevelIndexes } from '../utils/level-helper';
import { getUnsupportedResult } from '../utils/mediacapabilities-helper';
import { stringify } from '../utils/safe-json-stringify';
import type ContentSteeringController from './content-steering-controller';
import type Hls from '../hls';
import type {
ErrorData,
FragBufferedData,
LevelLoadedData,
LevelsUpdatedData,
LevelSwitchingData,
ManifestLoadedData,
ManifestLoadingData,
ManifestParsedData,
} from '../types/events';
import type { HlsUrlParameters, LevelParsed } from '../types/level';
import type { MediaPlaylist } from '../types/media-playlist';
export default class LevelController extends BasePlaylistController {
private _levels: Level[] = [];
private _firstLevel: number = -1;
private _maxAutoLevel: number = -1;
private _startLevel?: number;
private currentLevel: Level | null = null;
private currentLevelIndex: number = -1;
private manualLevelIndex: number = -1;
private steering: ContentSteeringController | null;
public onParsedComplete!: Function;
constructor(
hls: Hls,
contentSteeringController: ContentSteeringController | null,
) {
super(hls, 'level-controller');
this.steering = contentSteeringController;
this._registerListeners();
}
private _registerListeners() {
const { hls } = this;
hls.on(Events.MANIFEST_LOADING, this.onManifestLoading, this);
hls.on(Events.MANIFEST_LOADED, this.onManifestLoaded, this);
hls.on(Events.LEVEL_LOADED, this.onLevelLoaded, this);
hls.on(Events.LEVELS_UPDATED, this.onLevelsUpdated, this);
hls.on(Events.FRAG_BUFFERED, this.onFragBuffered, this);
hls.on(Events.ERROR, this.onError, this);
}
private _unregisterListeners() {
const { hls } = this;
hls.off(Events.MANIFEST_LOADING, this.onManifestLoading, this);
hls.off(Events.MANIFEST_LOADED, this.onManifestLoaded, this);
hls.off(Events.LEVEL_LOADED, this.onLevelLoaded, this);
hls.off(Events.LEVELS_UPDATED, this.onLevelsUpdated, this);
hls.off(Events.FRAG_BUFFERED, this.onFragBuffered, this);
hls.off(Events.ERROR, this.onError, this);
}
public destroy() {
this._unregisterListeners();
this.steering = null;
this.resetLevels();
super.destroy();
}
public stopLoad(): void {
const levels = this._levels;
// clean up live level details to force reload them, and reset load errors
levels.forEach((level) => {
level.loadError = 0;
level.fragmentError = 0;
});
super.stopLoad();
}
private resetLevels() {
this._startLevel = undefined;
this.manualLevelIndex = -1;
this.currentLevelIndex = -1;
this.currentLevel = null;
this._levels = [];
this._maxAutoLevel = -1;
}
private onManifestLoading(
event: Events.MANIFEST_LOADING,
data: ManifestLoadingData,
) {
this.resetLevels();
}
protected onManifestLoaded(
event: Events.MANIFEST_LOADED,
data: ManifestLoadedData,
) {
const preferManagedMediaSource = this.hls.config.preferManagedMediaSource;
const levels: Level[] = [];
const redundantSet: { [key: string]: Level } = {};
const generatePathwaySet: { [key: string]: number } = {};
let resolutionFound = false;
let videoCodecFound = false;
let audioCodecFound = false;
data.levels.forEach((levelParsed: LevelParsed) => {
const attributes = levelParsed.attrs;
let { audioCodec, videoCodec } = levelParsed;
if (audioCodec) {
// Returns empty and set to undefined for 'mp4a.40.34' with fallback to 'audio/mpeg' SourceBuffer
levelParsed.audioCodec = audioCodec =
getCodecCompatibleName(audioCodec, preferManagedMediaSource) ||
undefined;
}
if (videoCodec) {
videoCodec = levelParsed.videoCodec = convertAVC1ToAVCOTI(videoCodec);
}
// only keep levels with supported audio/video codecs
const { width, height, unknownCodecs } = levelParsed;
const unknownUnsupportedCodecCount = unknownCodecs?.length || 0;
resolutionFound ||= !!(width && height);
videoCodecFound ||= !!videoCodec;
audioCodecFound ||= !!audioCodec;
if (
unknownUnsupportedCodecCount ||
(audioCodec && !this.isAudioSupported(audioCodec)) ||
(videoCodec && !this.isVideoSupported(videoCodec))
) {
this.log(`Some or all CODECS not supported "${attributes.CODECS}"`);
return;
}
const {
CODECS,
'FRAME-RATE': FRAMERATE,
'HDCP-LEVEL': HDCP,
'PATHWAY-ID': PATHWAY,
RESOLUTION,
'VIDEO-RANGE': VIDEO_RANGE,
} = attributes;
const contentSteeringPrefix = `${PATHWAY || '.'}-`;
const levelKey = `${contentSteeringPrefix}${levelParsed.bitrate}-${RESOLUTION}-${FRAMERATE}-${CODECS}-${VIDEO_RANGE}-${HDCP}`;
if (!redundantSet[levelKey]) {
const level = this.createLevel(levelParsed);
redundantSet[levelKey] = level;
generatePathwaySet[levelKey] = 1;
levels.push(level);
} else if (
redundantSet[levelKey].uri !== levelParsed.url &&
!levelParsed.attrs['PATHWAY-ID']
) {
// Assign Pathway IDs to Redundant Streams (default Pathways is ".". Redundant Streams "..", "...", and so on.)
// Content Steering controller to handles Pathway fallback on error
const pathwayCount = (generatePathwaySet[levelKey] += 1);
levelParsed.attrs['PATHWAY-ID'] = new Array(pathwayCount + 1).join('.');
const level = this.createLevel(levelParsed);
redundantSet[levelKey] = level;
levels.push(level);
} else {
redundantSet[levelKey].addGroupId('audio', attributes.AUDIO);
redundantSet[levelKey].addGroupId('text', attributes.SUBTITLES);
}
});
this.filterAndSortMediaOptions(
levels,
data,
resolutionFound,
videoCodecFound,
audioCodecFound,
);
}
private createLevel(levelParsed: LevelParsed): Level {
const level = new Level(levelParsed);
const supplemental = levelParsed.supplemental;
if (
supplemental?.videoCodec &&
!this.isVideoSupported(supplemental.videoCodec)
) {
const error = new Error(
`SUPPLEMENTAL-CODECS not supported "${supplemental.videoCodec}"`,
);
this.log(error.message);
level.supportedResult = getUnsupportedResult(error, []);
}
return level;
}
private isAudioSupported(codec: string): boolean {
return areCodecsMediaSourceSupported(
codec,
'audio',
this.hls.config.preferManagedMediaSource,
);
}
private isVideoSupported(codec: string): boolean {
return areCodecsMediaSourceSupported(
codec,
'video',
this.hls.config.preferManagedMediaSource,
);
}
private filterAndSortMediaOptions(
filteredLevels: Level[],
data: ManifestLoadedData,
resolutionFound: boolean,
videoCodecFound: boolean,
audioCodecFound: boolean,
) {
let audioTracks: MediaPlaylist[] = [];
let subtitleTracks: MediaPlaylist[] = [];
let levels = filteredLevels;
const statsParsing = data.stats?.parsing || {};
// remove audio-only and invalid video-range levels if we also have levels with video codecs or RESOLUTION signalled
if ((resolutionFound || videoCodecFound) && audioCodecFound) {
levels = levels.filter(
({ videoCodec, videoRange, width, height }) =>
(!!videoCodec || !!(width && height)) && isVideoRange(videoRange),
);
}
if (levels.length === 0) {
// Dispatch error after MANIFEST_LOADED is done propagating
// eslint-disable-next-line @typescript-eslint/no-floating-promises
Promise.resolve().then(() => {
if (this.hls) {
let message = 'no level with compatible codecs found in manifest';
let reason = message;
if (data.levels.length) {
reason = `one or more CODECS in variant not supported: ${stringify(
data.levels
.map((level) => level.attrs.CODECS)
.filter(
(value, index, array) => array.indexOf(value) === index,
),
)}`;
this.warn(reason);
message += ` (${reason})`;
}
const error = new Error(message);
this.hls.trigger(Events.ERROR, {
type: ErrorTypes.MEDIA_ERROR,
details: ErrorDetails.MANIFEST_INCOMPATIBLE_CODECS_ERROR,
fatal: true,
url: data.url,
error,
reason,
});
}
});
statsParsing.end = performance.now();
return;
}
if (data.audioTracks) {
audioTracks = data.audioTracks.filter(
(track) => !track.audioCodec || this.isAudioSupported(track.audioCodec),
);
// Assign ids after filtering as array indices by group-id
assignTrackIdsByGroup(audioTracks);
}
if (data.subtitles) {
subtitleTracks = data.subtitles;
assignTrackIdsByGroup(subtitleTracks);
}
// start bitrate is the first bitrate of the manifest
const unsortedLevels = levels.slice(0);
// sort levels from lowest to highest
levels.sort((a, b) => {
if (a.attrs['HDCP-LEVEL'] !== b.attrs['HDCP-LEVEL']) {
return (a.attrs['HDCP-LEVEL'] || '') > (b.attrs['HDCP-LEVEL'] || '')
? 1
: -1;
}
// sort on height before bitrate for cap-level-controller
if (resolutionFound && a.height !== b.height) {
return a.height - b.height;
}
if (a.frameRate !== b.frameRate) {
return a.frameRate - b.frameRate;
}
if (a.videoRange !== b.videoRange) {
return (
VideoRangeValues.indexOf(a.videoRange) -
VideoRangeValues.indexOf(b.videoRange)
);
}
if (a.videoCodec !== b.videoCodec) {
const valueA = videoCodecPreferenceValue(a.videoCodec);
const valueB = videoCodecPreferenceValue(b.videoCodec);
if (valueA !== valueB) {
return valueB - valueA;
}
}
if (a.uri === b.uri && a.codecSet !== b.codecSet) {
const valueA = codecsSetSelectionPreferenceValue(a.codecSet);
const valueB = codecsSetSelectionPreferenceValue(b.codecSet);
if (valueA !== valueB) {
return valueB - valueA;
}
}
if (a.averageBitrate !== b.averageBitrate) {
return a.averageBitrate - b.averageBitrate;
}
return 0;
});
let firstLevelInPlaylist = unsortedLevels[0];
if (this.steering) {
levels = this.steering.filterParsedLevels(levels);
if (levels.length !== unsortedLevels.length) {
for (let i = 0; i < unsortedLevels.length; i++) {
if (unsortedLevels[i].pathwayId === levels[0].pathwayId) {
firstLevelInPlaylist = unsortedLevels[i];
break;
}
}
}
}
this._levels = levels;
// find index of first level in sorted levels
for (let i = 0; i < levels.length; i++) {
if (levels[i] === firstLevelInPlaylist) {
this._firstLevel = i;
const firstLevelBitrate = firstLevelInPlaylist.bitrate;
const bandwidthEstimate = this.hls.bandwidthEstimate;
this.log(
`manifest loaded, ${levels.length} level(s) found, first bitrate: ${firstLevelBitrate}`,
);
// Update default bwe to first variant bitrate as long it has not been configured or set
if (this.hls.userConfig?.abrEwmaDefaultEstimate === undefined) {
const startingBwEstimate = Math.min(
firstLevelBitrate,
this.hls.config.abrEwmaDefaultEstimateMax,
);
if (
startingBwEstimate > bandwidthEstimate &&
bandwidthEstimate === this.hls.abrEwmaDefaultEstimate
) {
this.hls.bandwidthEstimate = startingBwEstimate;
}
}
break;
}
}
// Audio is only alternate if manifest include a URI along with the audio group tag,
// and this is not an audio-only stream where levels contain audio-only
const audioOnly = audioCodecFound && !videoCodecFound;
const config = this.hls.config;
const altAudioEnabled = !!(
config.audioStreamController && config.audioTrackController
);
const edata: ManifestParsedData = {
levels,
audioTracks,
subtitleTracks,
sessionData: data.sessionData,
sessionKeys: data.sessionKeys,
firstLevel: this._firstLevel,
stats: data.stats,
audio: audioCodecFound,
video: videoCodecFound,
altAudio:
altAudioEnabled && !audioOnly && audioTracks.some((t) => !!t.url),
};
statsParsing.end = performance.now();
this.hls.trigger(Events.MANIFEST_PARSED, edata);
}
get levels(): Level[] | null {
if (this._levels.length === 0) {
return null;
}
return this._levels;
}
get loadLevelObj(): Level | null {
return this.currentLevel;
}
get level(): number {
return this.currentLevelIndex;
}
set level(newLevel: number) {
const levels = this._levels;
if (levels.length === 0) {
return;
}
// check if level idx is valid
if (newLevel < 0 || newLevel >= levels.length) {
// invalid level id given, trigger error
const error = new Error('invalid level idx');
const fatal = newLevel < 0;
this.hls.trigger(Events.ERROR, {
type: ErrorTypes.OTHER_ERROR,
details: ErrorDetails.LEVEL_SWITCH_ERROR,
level: newLevel,
fatal,
error,
reason: error.message,
});
if (fatal) {
return;
}
newLevel = Math.min(newLevel, levels.length - 1);
}
const lastLevelIndex = this.currentLevelIndex;
const lastLevel = this.currentLevel;
const lastPathwayId = lastLevel ? lastLevel.attrs['PATHWAY-ID'] : undefined;
const level = levels[newLevel];
const pathwayId = level.attrs['PATHWAY-ID'];
this.currentLevelIndex = newLevel;
this.currentLevel = level;
if (
lastLevelIndex === newLevel &&
lastLevel &&
lastPathwayId === pathwayId
) {
return;
}
this.log(
`Switching to level ${newLevel} (${
level.height ? level.height + 'p ' : ''
}${level.videoRange ? level.videoRange + ' ' : ''}${
level.codecSet ? level.codecSet + ' ' : ''
}@${level.bitrate})${
pathwayId ? ' with Pathway ' + pathwayId : ''
} from level ${lastLevelIndex}${
lastPathwayId ? ' with Pathway ' + lastPathwayId : ''
}`,
);
const levelSwitchingData: LevelSwitchingData = {
level: newLevel,
attrs: level.attrs,
details: level.details,
bitrate: level.bitrate,
averageBitrate: level.averageBitrate,
maxBitrate: level.maxBitrate,
realBitrate: level.realBitrate,
width: level.width,
height: level.height,
codecSet: level.codecSet,
audioCodec: level.audioCodec,
videoCodec: level.videoCodec,
audioGroups: level.audioGroups,
subtitleGroups: level.subtitleGroups,
loaded: level.loaded,
loadError: level.loadError,
fragmentError: level.fragmentError,
name: level.name,
id: level.id,
uri: level.uri,
url: level.url,
urlId: 0,
audioGroupIds: level.audioGroupIds,
textGroupIds: level.textGroupIds,
};
this.hls.trigger(Events.LEVEL_SWITCHING, levelSwitchingData);
// check if we need to load playlist for this level
const levelDetails = level.details;
if (!levelDetails || levelDetails.live) {
// level not retrieved yet, or live playlist we need to (re)load it
const hlsUrlParameters = this.switchParams(
level.uri,
lastLevel?.details,
levelDetails,
);
this.loadPlaylist(hlsUrlParameters);
}
}
get manualLevel(): number {
return this.manualLevelIndex;
}
set manualLevel(newLevel) {
this.manualLevelIndex = newLevel;
if (this._startLevel === undefined) {
this._startLevel = newLevel;
}
if (newLevel !== -1) {
this.level = newLevel;
}
}
get firstLevel(): number {
return this._firstLevel;
}
set firstLevel(newLevel) {
this._firstLevel = newLevel;
}
get startLevel(): number {
// Setting hls.startLevel (this._startLevel) overrides config.startLevel
if (this._startLevel === undefined) {
const configStartLevel = this.hls.config.startLevel;
if (configStartLevel !== undefined) {
return configStartLevel;
}
return this.hls.firstAutoLevel;
}
return this._startLevel;
}
set startLevel(newLevel: number) {
this._startLevel = newLevel;
}
get pathways(): string[] {
if (this.steering) {
return this.steering.pathways();
}
return [];
}
get pathwayPriority(): string[] | null {
if (this.steering) {
return this.steering.pathwayPriority;
}
return null;
}
set pathwayPriority(pathwayPriority: string[]) {
if (this.steering) {
const pathwaysList = this.steering.pathways();
const filteredPathwayPriority = pathwayPriority.filter((pathwayId) => {
return pathwaysList.indexOf(pathwayId) !== -1;
});
if (pathwayPriority.length < 1) {
this.warn(
`pathwayPriority ${pathwayPriority} should contain at least one pathway from list: ${pathwaysList}`,
);
return;
}
this.steering.pathwayPriority = filteredPathwayPriority;
}
}
protected onError(event: Events.ERROR, data: ErrorData) {
if (data.fatal || !data.context) {
return;
}
if (
data.context.type === PlaylistContextType.LEVEL &&
data.context.level === this.level
) {
this.checkRetry(data);
}
}
// reset errors on the successful load of a fragment
protected onFragBuffered(
event: Events.FRAG_BUFFERED,
{ frag }: FragBufferedData,
) {
if (frag !== undefined && frag.type === PlaylistLevelType.MAIN) {
const el = frag.elementaryStreams;
if (!Object.keys(el).some((type) => !!el[type])) {
return;
}
const level = this._levels[frag.level];
if (level?.loadError) {
this.log(
`Resetting level error count of ${level.loadError} on frag buffered`,
);
level.loadError = 0;
}
}
}
protected onLevelLoaded(event: Events.LEVEL_LOADED, data: LevelLoadedData) {
const { level, details } = data;
const curLevel = data.levelInfo;
if (!curLevel) {
this.warn(`Invalid level index ${level}`);
if (data.deliveryDirectives?.skip) {
details.deltaUpdateFailed = true;
}
return;
}
// only process level loaded events matching with expected level or prior to switch when media playlist is loaded directly
if (curLevel === this.currentLevel || data.withoutMultiVariant) {
// reset level load error counter on successful level loaded only if there is no issues with fragments
if (curLevel.fragmentError === 0) {
curLevel.loadError = 0;
}
// Ignore matching details populated by loading a Media Playlist directly
let previousDetails = curLevel.details;
if (previousDetails === data.details && previousDetails.advanced) {
previousDetails = undefined;
}
this.playlistLoaded(level, data, previousDetails);
} else if (data.deliveryDirectives?.skip) {
// received a delta playlist update that cannot be merged
details.deltaUpdateFailed = true;
}
}
protected loadPlaylist(hlsUrlParameters?: HlsUrlParameters) {
super.loadPlaylist();
if (this.shouldLoadPlaylist(this.currentLevel)) {
this.scheduleLoading(this.currentLevel, hlsUrlParameters);
}
}
protected loadingPlaylist(
currentLevel: Level,
hlsUrlParameters: HlsUrlParameters | undefined,
) {
super.loadingPlaylist(currentLevel, hlsUrlParameters);
const url = this.getUrlWithDirectives(currentLevel.uri, hlsUrlParameters);
const currentLevelIndex = this.currentLevelIndex;
const pathwayId = currentLevel.attrs['PATHWAY-ID'];
const details = currentLevel.details;
const age = details?.age;
this.log(
`Loading level index ${currentLevelIndex}${
hlsUrlParameters?.msn !== undefined
? ' at sn ' + hlsUrlParameters.msn + ' part ' + hlsUrlParameters.part
: ''
}${pathwayId ? ' Pathway ' + pathwayId : ''}${age && details.live ? ' age ' + age.toFixed(1) + (details.type ? ' ' + details.type || '' : '') : ''} ${url}`,
);
this.hls.trigger(Events.LEVEL_LOADING, {
url,
level: currentLevelIndex,
levelInfo: currentLevel,
pathwayId: currentLevel.attrs['PATHWAY-ID'],
id: 0, // Deprecated Level urlId
deliveryDirectives: hlsUrlParameters || null,
});
}
get nextLoadLevel() {
if (this.manualLevelIndex !== -1) {
return this.manualLevelIndex;
} else {
return this.hls.nextAutoLevel;
}
}
set nextLoadLevel(nextLevel) {
this.level = nextLevel;
if (this.manualLevelIndex === -1) {
this.hls.nextAutoLevel = nextLevel;
}
}
removeLevel(levelIndex: number) {
if (this._levels.length === 1) {
return;
}
const levels = this._levels.filter((level, index) => {
if (index !== levelIndex) {
return true;
}
if (this.steering) {
this.steering.removeLevel(level);
}
if (level === this.currentLevel) {
this.currentLevel = null;
this.currentLevelIndex = -1;
if (level.details) {
level.details.fragments.forEach((f) => (f.level = -1));
}
}
return false;
});
reassignFragmentLevelIndexes(levels);
this._levels = levels;
if (this.currentLevelIndex > -1 && this.currentLevel?.details) {
this.currentLevelIndex = this.currentLevel.details.fragments[0].level;
}
if (this.manualLevelIndex > -1) {
this.manualLevelIndex = this.currentLevelIndex;
}
const maxLevel = levels.length - 1;
this._firstLevel = Math.min(this._firstLevel, maxLevel);
if (this._startLevel) {
this._startLevel = Math.min(this._startLevel, maxLevel);
}
this.hls.trigger(Events.LEVELS_UPDATED, { levels });
}
private onLevelsUpdated(
event: Events.LEVELS_UPDATED,
{ levels }: LevelsUpdatedData,
) {
this._levels = levels;
}
public checkMaxAutoUpdated() {
const { autoLevelCapping, maxAutoLevel, maxHdcpLevel } = this.hls;
if (this._maxAutoLevel !== maxAutoLevel) {
this._maxAutoLevel = maxAutoLevel;
this.hls.trigger(Events.MAX_AUTO_LEVEL_UPDATED, {
autoLevelCapping,
levels: this.levels,
maxAutoLevel,
minAutoLevel: this.hls.minAutoLevel,
maxHdcpLevel,
});
}
}
}
function assignTrackIdsByGroup(tracks: MediaPlaylist[]): void {
const groups = {};
tracks.forEach((track) => {
const groupId = track.groupId || '';
track.id = groups[groupId] = groups[groupId] || 0;
groups[groupId]++;
});
}
File diff suppressed because it is too large Load Diff
+555
View File
@@ -0,0 +1,555 @@
import BaseStreamController, { State } from './base-stream-controller';
import { findFragmentByPTS } from './fragment-finders';
import { FragmentState } from './fragment-tracker';
import { ErrorDetails, ErrorTypes } from '../errors';
import { Events } from '../events';
import {
type Fragment,
isMediaFragment,
type MediaFragment,
} from '../loader/fragment';
import { Level } from '../types/level';
import { PlaylistLevelType } from '../types/loader';
import { BufferHelper } from '../utils/buffer-helper';
import { alignMediaPlaylistByPDT } from '../utils/discontinuities';
import {
getAesModeFromFullSegmentMethod,
isFullSegmentEncryption,
} from '../utils/encryption-methods-util';
import { addSliding } from '../utils/level-helper';
import { subtitleOptionsIdentical } from '../utils/media-option-attributes';
import type { FragmentTracker } from './fragment-tracker';
import type Hls from '../hls';
import type KeyLoader from '../loader/key-loader';
import type { LevelDetails } from '../loader/level-details';
import type { NetworkComponentAPI } from '../types/component-api';
import type {
BufferFlushingData,
ErrorData,
FragLoadedData,
LevelLoadedData,
MediaDetachingData,
SubtitleFragProcessed,
SubtitleTracksUpdatedData,
TrackLoadedData,
TrackSwitchedData,
} from '../types/events';
import type { Bufferable } from '../utils/buffer-helper';
const TICK_INTERVAL = 500; // how often to tick in ms
interface TimeRange {
start: number;
end: number;
}
export class SubtitleStreamController
extends BaseStreamController
implements NetworkComponentAPI
{
private currentTrackId: number = -1;
private tracksBuffered: Array<TimeRange[]> = [];
private mainDetails: LevelDetails | null = null;
constructor(
hls: Hls,
fragmentTracker: FragmentTracker,
keyLoader: KeyLoader,
) {
super(
hls,
fragmentTracker,
keyLoader,
'subtitle-stream-controller',
PlaylistLevelType.SUBTITLE,
);
this.registerListeners();
}
protected onHandlerDestroying() {
this.unregisterListeners();
super.onHandlerDestroying();
this.mainDetails = null;
}
protected registerListeners() {
super.registerListeners();
const { hls } = this;
hls.on(Events.LEVEL_LOADED, this.onLevelLoaded, this);
hls.on(Events.SUBTITLE_TRACKS_UPDATED, this.onSubtitleTracksUpdated, this);
hls.on(Events.SUBTITLE_TRACK_SWITCH, this.onSubtitleTrackSwitch, this);
hls.on(Events.SUBTITLE_TRACK_LOADED, this.onSubtitleTrackLoaded, this);
hls.on(Events.SUBTITLE_FRAG_PROCESSED, this.onSubtitleFragProcessed, this);
hls.on(Events.BUFFER_FLUSHING, this.onBufferFlushing, this);
}
protected unregisterListeners() {
super.unregisterListeners();
const { hls } = this;
hls.off(Events.LEVEL_LOADED, this.onLevelLoaded, this);
hls.off(Events.SUBTITLE_TRACKS_UPDATED, this.onSubtitleTracksUpdated, this);
hls.off(Events.SUBTITLE_TRACK_SWITCH, this.onSubtitleTrackSwitch, this);
hls.off(Events.SUBTITLE_TRACK_LOADED, this.onSubtitleTrackLoaded, this);
hls.off(Events.SUBTITLE_FRAG_PROCESSED, this.onSubtitleFragProcessed, this);
hls.off(Events.BUFFER_FLUSHING, this.onBufferFlushing, this);
}
startLoad(startPosition: number, skipSeekToStartPosition?: boolean) {
this.stopLoad();
this.state = State.IDLE;
this.setInterval(TICK_INTERVAL);
this.nextLoadPosition = this.lastCurrentTime =
startPosition + this.timelineOffset;
this.startPosition = skipSeekToStartPosition ? -1 : startPosition;
this.tick();
}
protected onManifestLoading() {
super.onManifestLoading();
this.mainDetails = null;
}
protected onMediaDetaching(
event: Events.MEDIA_DETACHING,
data: MediaDetachingData,
) {
this.tracksBuffered = [];
super.onMediaDetaching(event, data);
}
private onLevelLoaded(event: Events.LEVEL_LOADED, data: LevelLoadedData) {
this.mainDetails = data.details;
}
private onSubtitleFragProcessed(
event: Events.SUBTITLE_FRAG_PROCESSED,
data: SubtitleFragProcessed,
) {
const { frag, success } = data;
if (!this.fragContextChanged(frag)) {
if (isMediaFragment(frag)) {
this.fragPrevious = frag;
}
this.state = State.IDLE;
}
if (!success) {
return;
}
const buffered = this.tracksBuffered[this.currentTrackId];
if (!buffered) {
return;
}
// Create/update a buffered array matching the interface used by BufferHelper.bufferedInfo
// so we can re-use the logic used to detect how much has been buffered
let timeRange: TimeRange | undefined;
const fragStart = frag.start;
for (let i = 0; i < buffered.length; i++) {
if (fragStart >= buffered[i].start && fragStart <= buffered[i].end) {
timeRange = buffered[i];
break;
}
}
const fragEnd = frag.start + frag.duration;
if (timeRange) {
timeRange.end = fragEnd;
} else {
timeRange = {
start: fragStart,
end: fragEnd,
};
buffered.push(timeRange);
}
this.fragmentTracker.fragBuffered(frag as MediaFragment);
this.fragBufferedComplete(frag, null);
if (this.media) {
this.tick();
}
}
private onBufferFlushing(
event: Events.BUFFER_FLUSHING,
data: BufferFlushingData,
) {
const { startOffset, endOffset } = data;
if (startOffset === 0 && endOffset !== Number.POSITIVE_INFINITY) {
const endOffsetSubtitles = endOffset - 1;
if (endOffsetSubtitles <= 0) {
return;
}
data.endOffsetSubtitles = Math.max(0, endOffsetSubtitles);
this.tracksBuffered.forEach((buffered) => {
for (let i = 0; i < buffered.length; ) {
if (buffered[i].end <= endOffsetSubtitles) {
buffered.shift();
continue;
} else if (buffered[i].start < endOffsetSubtitles) {
buffered[i].start = endOffsetSubtitles;
} else {
break;
}
i++;
}
});
this.fragmentTracker.removeFragmentsInRange(
startOffset,
endOffsetSubtitles,
PlaylistLevelType.SUBTITLE,
);
}
}
// If something goes wrong, proceed to next frag, if we were processing one.
protected onError(event: Events.ERROR, data: ErrorData) {
const frag = data.frag;
if (frag?.type === PlaylistLevelType.SUBTITLE) {
if (data.details === ErrorDetails.FRAG_GAP) {
this.fragmentTracker.fragBuffered(frag as MediaFragment, true);
}
if (this.fragCurrent) {
this.fragCurrent.abortRequests();
}
if (this.state !== State.STOPPED) {
this.state = State.IDLE;
}
}
}
// Got all new subtitle levels.
private onSubtitleTracksUpdated(
event: Events.SUBTITLE_TRACKS_UPDATED,
{ subtitleTracks }: SubtitleTracksUpdatedData,
) {
if (this.levels && subtitleOptionsIdentical(this.levels, subtitleTracks)) {
this.levels = subtitleTracks.map(
(mediaPlaylist) => new Level(mediaPlaylist),
);
return;
}
this.tracksBuffered = [];
this.levels = subtitleTracks.map((mediaPlaylist) => {
const level = new Level(mediaPlaylist);
this.tracksBuffered[level.id] = [];
return level;
});
this.fragmentTracker.removeFragmentsInRange(
0,
Number.POSITIVE_INFINITY,
PlaylistLevelType.SUBTITLE,
);
this.fragPrevious = null;
this.mediaBuffer = null;
}
private onSubtitleTrackSwitch(
event: Events.SUBTITLE_TRACK_SWITCH,
data: TrackSwitchedData,
) {
this.currentTrackId = data.id;
if (!this.levels?.length || this.currentTrackId === -1) {
this.clearInterval();
return;
}
// Check if track has the necessary details to load fragments
const currentTrack = this.levels[this.currentTrackId];
if (currentTrack?.details) {
this.mediaBuffer = this.mediaBufferTimeRanges;
} else {
this.mediaBuffer = null;
}
if (currentTrack && this.state !== State.STOPPED) {
this.setInterval(TICK_INTERVAL);
}
}
// Got a new set of subtitle fragments.
private onSubtitleTrackLoaded(
event: Events.SUBTITLE_TRACK_LOADED,
data: TrackLoadedData,
) {
const { currentTrackId, levels } = this;
const { details: newDetails, id: trackId } = data;
if (!levels) {
this.warn(`Subtitle tracks were reset while loading level ${trackId}`);
return;
}
const track: Level = levels[trackId];
if (trackId >= levels.length || !track) {
return;
}
this.log(
`Subtitle track ${trackId} loaded [${newDetails.startSN},${
newDetails.endSN
}]${
newDetails.lastPartSn
? `[part-${newDetails.lastPartSn}-${newDetails.lastPartIndex}]`
: ''
},duration:${newDetails.totalduration}`,
);
this.mediaBuffer = this.mediaBufferTimeRanges;
let sliding = 0;
if (newDetails.live || track.details?.live) {
if (newDetails.deltaUpdateFailed) {
return;
}
const mainDetails = this.mainDetails;
if (!mainDetails) {
this.startFragRequested = false;
return;
}
const mainSlidingStartFragment = mainDetails.fragments[0];
if (!track.details) {
if (newDetails.hasProgramDateTime && mainDetails.hasProgramDateTime) {
alignMediaPlaylistByPDT(newDetails, mainDetails);
sliding = newDetails.fragmentStart;
} else if (mainSlidingStartFragment) {
// line up live playlist with main so that fragments in range are loaded
sliding = mainSlidingStartFragment.start;
addSliding(newDetails, sliding);
}
} else {
sliding = this.alignPlaylists(
newDetails,
track.details,
this.levelLastLoaded?.details,
);
if (sliding === 0 && mainSlidingStartFragment) {
// realign with main when there is no overlap with last refresh
sliding = mainSlidingStartFragment.start;
addSliding(newDetails, sliding);
}
}
// compute start position if we are aligned with the main playlist
if (mainDetails && !this.startFragRequested) {
this.setStartPosition(mainDetails, sliding);
}
}
track.details = newDetails;
this.levelLastLoaded = track;
if (trackId !== currentTrackId) {
return;
}
this.hls.trigger(Events.SUBTITLE_TRACK_UPDATED, {
details: newDetails,
id: trackId,
groupId: data.groupId,
});
// trigger handler right now
this.tick();
// If playlist is misaligned because of bad PDT or drift, delete details to resync with main on reload
if (
newDetails.live &&
!this.fragCurrent &&
this.media &&
this.state === State.IDLE
) {
const foundFrag = findFragmentByPTS(
null,
newDetails.fragments,
this.media.currentTime,
0,
);
if (!foundFrag) {
this.warn('Subtitle playlist not aligned with playback');
track.details = undefined;
}
}
}
_handleFragmentLoadComplete(fragLoadedData: FragLoadedData) {
const { frag, payload } = fragLoadedData;
const decryptData = frag.decryptdata;
const hls = this.hls;
if (this.fragContextChanged(frag)) {
return;
}
// check to see if the payload needs to be decrypted
if (
payload &&
payload.byteLength > 0 &&
decryptData?.key &&
decryptData.iv &&
isFullSegmentEncryption(decryptData.method)
) {
const startTime = performance.now();
// decrypt the subtitles
this.decrypter
.decrypt(
new Uint8Array(payload),
decryptData.key.buffer,
decryptData.iv.buffer,
getAesModeFromFullSegmentMethod(decryptData.method),
)
.catch((err) => {
hls.trigger(Events.ERROR, {
type: ErrorTypes.MEDIA_ERROR,
details: ErrorDetails.FRAG_DECRYPT_ERROR,
fatal: false,
error: err,
reason: err.message,
frag,
});
throw err;
})
.then((decryptedData) => {
const endTime = performance.now();
hls.trigger(Events.FRAG_DECRYPTED, {
frag,
payload: decryptedData,
stats: {
tstart: startTime,
tdecrypt: endTime,
},
});
})
.catch((err) => {
this.warn(`${err.name}: ${err.message}`);
this.state = State.IDLE;
});
}
}
doTick() {
if (!this.media) {
this.state = State.IDLE;
return;
}
if (this.state === State.IDLE) {
const { currentTrackId, levels } = this;
const track = levels?.[currentTrackId];
if (!track || !levels.length || !track.details) {
return;
}
if (this.waitForLive(track)) {
return;
}
const { config } = this;
const currentTime = this.getLoadPosition();
const bufferedInfo = BufferHelper.bufferedInfo(
this.tracksBuffered[this.currentTrackId] || [],
currentTime,
config.maxBufferHole,
);
const { end: targetBufferTime, len: bufferLen } = bufferedInfo;
const trackDetails = track.details as LevelDetails;
const maxBufLen =
this.hls.maxBufferLength + trackDetails.levelTargetDuration;
if (bufferLen > maxBufLen) {
return;
}
const fragments = trackDetails.fragments;
const fragLen = fragments.length;
const end = trackDetails.edge;
let foundFrag: MediaFragment | null = null;
const fragPrevious = this.fragPrevious;
if (targetBufferTime < end) {
const tolerance = config.maxFragLookUpTolerance;
const lookupTolerance =
targetBufferTime > end - tolerance ? 0 : tolerance;
foundFrag = findFragmentByPTS(
fragPrevious,
fragments,
Math.max(fragments[0].start, targetBufferTime),
lookupTolerance,
);
if (
!foundFrag &&
fragPrevious &&
fragPrevious.start < fragments[0].start
) {
foundFrag = fragments[0];
}
} else {
foundFrag = fragments[fragLen - 1];
}
foundFrag = this.filterReplacedPrimary(foundFrag, track.details);
if (!foundFrag) {
return;
}
// Load earlier fragment in same discontinuity to make up for misaligned playlists and cues that extend beyond end of segment
const curSNIdx = foundFrag.sn - trackDetails.startSN;
const prevFrag = fragments[curSNIdx - 1];
if (
prevFrag &&
prevFrag.cc === foundFrag.cc &&
this.fragmentTracker.getState(prevFrag) === FragmentState.NOT_LOADED
) {
foundFrag = prevFrag;
}
if (
this.fragmentTracker.getState(foundFrag) === FragmentState.NOT_LOADED
) {
// only load if fragment is not loaded
const fragToLoad = this.mapToInitFragWhenRequired(foundFrag);
if (fragToLoad) {
this.loadFragment(fragToLoad, track, targetBufferTime);
}
}
}
}
protected loadFragment(
frag: Fragment,
level: Level,
targetBufferTime: number,
) {
if (!isMediaFragment(frag)) {
this._loadInitSegment(frag, level);
} else {
super.loadFragment(frag, level, targetBufferTime);
}
}
get mediaBufferTimeRanges(): Bufferable {
return new BufferableInstance(
this.tracksBuffered[this.currentTrackId] || [],
);
}
}
class BufferableInstance implements Bufferable {
public readonly buffered: TimeRanges;
constructor(timeranges: TimeRange[]) {
const getRange = (
name: 'start' | 'end',
index: number,
length: number,
): number => {
index = index >>> 0;
if (index > length - 1) {
throw new DOMException(
`Failed to execute '${name}' on 'TimeRanges': The index provided (${index}) is greater than the maximum bound (${length})`,
);
}
return timeranges[index][name];
};
this.buffered = {
get length() {
return timeranges.length;
},
end(index: number): number {
return getRange('end', index, timeranges.length);
},
start(index: number): number {
return getRange('start', index, timeranges.length);
},
};
}
}
+589
View File
@@ -0,0 +1,589 @@
import BasePlaylistController from './base-playlist-controller';
import { Events } from '../events';
import { PlaylistContextType } from '../types/loader';
import {
mediaAttributesIdentical,
subtitleTrackMatchesTextTrack,
} from '../utils/media-option-attributes';
import { findMatchingOption, matchesOption } from '../utils/rendition-helper';
import {
clearCurrentCues,
filterSubtitleTracks,
} from '../utils/texttrack-utils';
import type Hls from '../hls';
import type {
ErrorData,
LevelLoadingData,
LevelSwitchingData,
ManifestParsedData,
MediaAttachedData,
MediaDetachingData,
SubtitleTracksUpdatedData,
TrackLoadedData,
} from '../types/events';
import type { HlsUrlParameters } from '../types/level';
import type {
MediaPlaylist,
SubtitleSelectionOption,
} from '../types/media-playlist';
class SubtitleTrackController extends BasePlaylistController {
private media: HTMLMediaElement | null = null;
private tracks: MediaPlaylist[] = [];
private groupIds: (string | undefined)[] | null = null;
private tracksInGroup: MediaPlaylist[] = [];
private trackId: number = -1;
private currentTrack: MediaPlaylist | null = null;
private selectDefaultTrack: boolean = true;
private queuedDefaultTrack: number = -1;
private useTextTrackPolling: boolean = false;
private subtitlePollingInterval: number = -1;
private _subtitleDisplay: boolean = true;
private asyncPollTrackChange = () => this.pollTrackChange(0);
constructor(hls: Hls) {
super(hls, 'subtitle-track-controller');
this.registerListeners();
}
public destroy() {
this.unregisterListeners();
this.tracks.length = 0;
this.tracksInGroup.length = 0;
this.currentTrack = null;
// @ts-ignore
this.onTextTracksChanged = this.asyncPollTrackChange = null;
super.destroy();
}
public get subtitleDisplay(): boolean {
return this._subtitleDisplay;
}
public set subtitleDisplay(value: boolean) {
this._subtitleDisplay = value;
if (this.trackId > -1) {
this.toggleTrackModes();
}
}
private registerListeners() {
const { hls } = this;
hls.on(Events.MEDIA_ATTACHED, this.onMediaAttached, this);
hls.on(Events.MEDIA_DETACHING, this.onMediaDetaching, this);
hls.on(Events.MANIFEST_LOADING, this.onManifestLoading, this);
hls.on(Events.MANIFEST_PARSED, this.onManifestParsed, this);
hls.on(Events.LEVEL_LOADING, this.onLevelLoading, this);
hls.on(Events.LEVEL_SWITCHING, this.onLevelSwitching, this);
hls.on(Events.SUBTITLE_TRACK_LOADED, this.onSubtitleTrackLoaded, this);
hls.on(Events.ERROR, this.onError, this);
}
private unregisterListeners() {
const { hls } = this;
hls.off(Events.MEDIA_ATTACHED, this.onMediaAttached, this);
hls.off(Events.MEDIA_DETACHING, this.onMediaDetaching, this);
hls.off(Events.MANIFEST_LOADING, this.onManifestLoading, this);
hls.off(Events.MANIFEST_PARSED, this.onManifestParsed, this);
hls.off(Events.LEVEL_LOADING, this.onLevelLoading, this);
hls.off(Events.LEVEL_SWITCHING, this.onLevelSwitching, this);
hls.off(Events.SUBTITLE_TRACK_LOADED, this.onSubtitleTrackLoaded, this);
hls.off(Events.ERROR, this.onError, this);
}
// Listen for subtitle track change, then extract the current track ID.
protected onMediaAttached(
event: Events.MEDIA_ATTACHED,
data: MediaAttachedData,
): void {
this.media = data.media;
if (!this.media) {
return;
}
if (this.queuedDefaultTrack > -1) {
this.subtitleTrack = this.queuedDefaultTrack;
this.queuedDefaultTrack = -1;
}
this.useTextTrackPolling = !(
this.media.textTracks && 'onchange' in this.media.textTracks
);
if (this.useTextTrackPolling) {
this.pollTrackChange(500);
} else {
this.media.textTracks.addEventListener(
'change',
this.asyncPollTrackChange,
);
}
}
private pollTrackChange(timeout: number) {
self.clearInterval(this.subtitlePollingInterval);
this.subtitlePollingInterval = self.setInterval(
this.onTextTracksChanged,
timeout,
);
}
protected onMediaDetaching(
event: Events.MEDIA_DETACHING,
data: MediaDetachingData,
) {
const media = this.media;
if (!media) {
return;
}
const transferringMedia = !!data.transferMedia;
self.clearInterval(this.subtitlePollingInterval);
if (!this.useTextTrackPolling) {
media.textTracks.removeEventListener('change', this.asyncPollTrackChange);
}
if (this.trackId > -1) {
this.queuedDefaultTrack = this.trackId;
}
// Disable all subtitle tracks before detachment so when reattached only tracks in that content are enabled.
this.subtitleTrack = -1;
this.media = null;
if (transferringMedia) {
return;
}
const textTracks = filterSubtitleTracks(media.textTracks);
// Clear loaded cues on media detachment from tracks
textTracks.forEach((track) => {
clearCurrentCues(track);
});
}
protected onManifestLoading(): void {
this.tracks = [];
this.groupIds = null;
this.tracksInGroup = [];
this.trackId = -1;
this.currentTrack = null;
this.selectDefaultTrack = true;
}
// Fired whenever a new manifest is loaded.
protected onManifestParsed(
event: Events.MANIFEST_PARSED,
data: ManifestParsedData,
): void {
this.tracks = data.subtitleTracks;
}
protected onSubtitleTrackLoaded(
event: Events.SUBTITLE_TRACK_LOADED,
data: TrackLoadedData,
): void {
const { id, groupId, details } = data;
const trackInActiveGroup = this.tracksInGroup[id];
if (!trackInActiveGroup || trackInActiveGroup.groupId !== groupId) {
this.warn(
`Subtitle track with id:${id} and group:${groupId} not found in active group ${trackInActiveGroup?.groupId}`,
);
return;
}
const curDetails = trackInActiveGroup.details;
trackInActiveGroup.details = data.details;
this.log(
`Subtitle track ${id} "${trackInActiveGroup.name}" lang:${trackInActiveGroup.lang} group:${groupId} loaded [${details.startSN}-${details.endSN}]`,
);
if (id === this.trackId) {
this.playlistLoaded(id, data, curDetails);
}
}
protected onLevelLoading(
event: Events.LEVEL_LOADING,
data: LevelLoadingData,
): void {
this.switchLevel(data.level);
}
protected onLevelSwitching(
event: Events.LEVEL_SWITCHING,
data: LevelSwitchingData,
): void {
this.switchLevel(data.level);
}
private switchLevel(levelIndex: number) {
const levelInfo = this.hls.levels[levelIndex];
if (!levelInfo) {
return;
}
const subtitleGroups = levelInfo.subtitleGroups || null;
const currentGroups = this.groupIds;
let currentTrack = this.currentTrack;
if (
!subtitleGroups ||
currentGroups?.length !== subtitleGroups?.length ||
subtitleGroups?.some((groupId) => currentGroups?.indexOf(groupId) === -1)
) {
this.groupIds = subtitleGroups;
this.trackId = -1;
this.currentTrack = null;
const subtitleTracks = this.tracks.filter(
(track): boolean =>
!subtitleGroups || subtitleGroups.indexOf(track.groupId) !== -1,
);
if (subtitleTracks.length) {
// Disable selectDefaultTrack if there are no default tracks
if (
this.selectDefaultTrack &&
!subtitleTracks.some((track) => track.default)
) {
this.selectDefaultTrack = false;
}
// track.id should match hls.audioTracks index
subtitleTracks.forEach((track, i) => {
track.id = i;
});
} else if (!currentTrack && !this.tracksInGroup.length) {
// Do not dispatch SUBTITLE_TRACKS_UPDATED when there were and are no tracks
return;
}
this.tracksInGroup = subtitleTracks;
// Find preferred track
const subtitlePreference = this.hls.config.subtitlePreference;
if (!currentTrack && subtitlePreference) {
this.selectDefaultTrack = false;
const groupIndex = findMatchingOption(
subtitlePreference,
subtitleTracks,
);
if (groupIndex > -1) {
currentTrack = subtitleTracks[groupIndex];
} else {
const allIndex = findMatchingOption(subtitlePreference, this.tracks);
currentTrack = this.tracks[allIndex];
}
}
// Select initial track
let trackId = this.findTrackId(currentTrack);
if (trackId === -1 && currentTrack) {
trackId = this.findTrackId(null);
}
// Dispatch events and load track if needed
const subtitleTracksUpdated: SubtitleTracksUpdatedData = {
subtitleTracks,
};
this.log(
`Updating subtitle tracks, ${
subtitleTracks.length
} track(s) found in "${subtitleGroups?.join(',')}" group-id`,
);
this.hls.trigger(Events.SUBTITLE_TRACKS_UPDATED, subtitleTracksUpdated);
if (trackId !== -1 && this.trackId === -1) {
this.setSubtitleTrack(trackId);
}
}
}
private findTrackId(currentTrack: MediaPlaylist | null): number {
const tracks = this.tracksInGroup;
const selectDefault = this.selectDefaultTrack;
for (let i = 0; i < tracks.length; i++) {
const track = tracks[i];
if (
(selectDefault && !track.default) ||
(!selectDefault && !currentTrack)
) {
continue;
}
if (!currentTrack || matchesOption(track, currentTrack)) {
return i;
}
}
if (currentTrack) {
for (let i = 0; i < tracks.length; i++) {
const track = tracks[i];
if (
mediaAttributesIdentical(currentTrack.attrs, track.attrs, [
'LANGUAGE',
'ASSOC-LANGUAGE',
'CHARACTERISTICS',
])
) {
return i;
}
}
for (let i = 0; i < tracks.length; i++) {
const track = tracks[i];
if (
mediaAttributesIdentical(currentTrack.attrs, track.attrs, [
'LANGUAGE',
])
) {
return i;
}
}
}
return -1;
}
private findTrackForTextTrack(textTrack: TextTrack | null): number {
if (textTrack) {
const tracks = this.tracksInGroup;
for (let i = 0; i < tracks.length; i++) {
const track = tracks[i];
if (subtitleTrackMatchesTextTrack(track, textTrack)) {
return i;
}
}
}
return -1;
}
protected onError(event: Events.ERROR, data: ErrorData): void {
if (data.fatal || !data.context) {
return;
}
if (
data.context.type === PlaylistContextType.SUBTITLE_TRACK &&
data.context.id === this.trackId &&
(!this.groupIds || this.groupIds.indexOf(data.context.groupId) !== -1)
) {
this.checkRetry(data);
}
}
get allSubtitleTracks(): MediaPlaylist[] {
return this.tracks;
}
/** get alternate subtitle tracks list from playlist **/
get subtitleTracks(): MediaPlaylist[] {
return this.tracksInGroup;
}
/** get/set index of the selected subtitle track (based on index in subtitle track lists) **/
get subtitleTrack(): number {
return this.trackId;
}
set subtitleTrack(newId: number) {
this.selectDefaultTrack = false;
this.setSubtitleTrack(newId);
}
public setSubtitleOption(
subtitleOption: MediaPlaylist | SubtitleSelectionOption | undefined,
): MediaPlaylist | null {
this.hls.config.subtitlePreference = subtitleOption;
if (subtitleOption) {
if (subtitleOption.id === -1) {
this.setSubtitleTrack(-1);
return null;
}
const allSubtitleTracks = this.allSubtitleTracks;
this.selectDefaultTrack = false;
if (allSubtitleTracks.length) {
// First see if current option matches (no switch op)
const currentTrack = this.currentTrack;
if (currentTrack && matchesOption(subtitleOption, currentTrack)) {
return currentTrack;
}
// Find option in current group
const groupIndex = findMatchingOption(
subtitleOption,
this.tracksInGroup,
);
if (groupIndex > -1) {
const track = this.tracksInGroup[groupIndex];
this.setSubtitleTrack(groupIndex);
return track;
} else if (currentTrack) {
// If this is not the initial selection return null
// option should have matched one in active group
return null;
} else {
// Find the option in all tracks for initial selection
const allIndex = findMatchingOption(
subtitleOption,
allSubtitleTracks,
);
if (allIndex > -1) {
return allSubtitleTracks[allIndex];
}
}
}
}
return null;
}
protected loadPlaylist(hlsUrlParameters?: HlsUrlParameters): void {
super.loadPlaylist();
if (this.shouldLoadPlaylist(this.currentTrack)) {
this.scheduleLoading(this.currentTrack, hlsUrlParameters);
}
}
protected loadingPlaylist(
currentTrack: MediaPlaylist,
hlsUrlParameters: HlsUrlParameters | undefined,
) {
super.loadingPlaylist(currentTrack, hlsUrlParameters);
const id = currentTrack.id;
const groupId = currentTrack.groupId as string;
const url = this.getUrlWithDirectives(currentTrack.url, hlsUrlParameters);
const details = currentTrack.details;
const age = details?.age;
this.log(
`Loading subtitle ${id} "${currentTrack.name}" lang:${currentTrack.lang} group:${groupId}${
hlsUrlParameters?.msn !== undefined
? ' at sn ' + hlsUrlParameters.msn + ' part ' + hlsUrlParameters.part
: ''
}${age && details.live ? ' age ' + age.toFixed(1) + (details.type ? ' ' + details.type || '' : '') : ''} ${url}`,
);
this.hls.trigger(Events.SUBTITLE_TRACK_LOADING, {
url,
id,
groupId,
deliveryDirectives: hlsUrlParameters || null,
track: currentTrack,
});
}
/**
* Disables the old subtitleTrack and sets current mode on the next subtitleTrack.
* This operates on the DOM textTracks.
* A value of -1 will disable all subtitle tracks.
*/
private toggleTrackModes(): void {
const { media } = this;
if (!media) {
return;
}
const textTracks = filterSubtitleTracks(media.textTracks);
const currentTrack = this.currentTrack;
let nextTrack;
if (currentTrack) {
nextTrack = textTracks.filter((textTrack) =>
subtitleTrackMatchesTextTrack(currentTrack, textTrack),
)[0];
if (!nextTrack) {
this.warn(
`Unable to find subtitle TextTrack with name "${currentTrack.name}" and language "${currentTrack.lang}"`,
);
}
}
[].slice.call(textTracks).forEach((track) => {
if (track.mode !== 'disabled' && track !== nextTrack) {
track.mode = 'disabled';
}
});
if (nextTrack) {
const mode = this.subtitleDisplay ? 'showing' : 'hidden';
if (nextTrack.mode !== mode) {
nextTrack.mode = mode;
}
}
}
/**
* This method is responsible for validating the subtitle index and periodically reloading if live.
* Dispatches the SUBTITLE_TRACK_SWITCH event, which instructs the subtitle-stream-controller to load the selected track.
*/
private setSubtitleTrack(newId: number): void {
const tracks = this.tracksInGroup;
// setting this.subtitleTrack will trigger internal logic
// if media has not been attached yet, it will fail
// we keep a reference to the default track id
// and we'll set subtitleTrack when onMediaAttached is triggered
if (!this.media) {
this.queuedDefaultTrack = newId;
return;
}
// exit if track id as already set or invalid
if (newId < -1 || newId >= tracks.length || !Number.isFinite(newId)) {
this.warn(`Invalid subtitle track id: ${newId}`);
return;
}
this.selectDefaultTrack = false;
const lastTrack = this.currentTrack;
const track: MediaPlaylist | null = tracks[newId] || null;
this.trackId = newId;
this.currentTrack = track;
this.toggleTrackModes();
if (!track) {
// switch to -1
this.hls.trigger(Events.SUBTITLE_TRACK_SWITCH, { id: newId });
return;
}
const trackLoaded = !!track.details && !track.details.live;
if (newId === this.trackId && track === lastTrack && trackLoaded) {
return;
}
this.log(
`Switching to subtitle-track ${newId}` +
(track
? ` "${track.name}" lang:${track.lang} group:${track.groupId}`
: ''),
);
const { id, groupId = '', name, type, url } = track;
this.hls.trigger(Events.SUBTITLE_TRACK_SWITCH, {
id,
groupId,
name,
type,
url,
});
const hlsUrlParameters = this.switchParams(
track.url,
lastTrack?.details,
track.details,
);
this.loadPlaylist(hlsUrlParameters);
}
private onTextTracksChanged = () => {
if (!this.useTextTrackPolling) {
self.clearInterval(this.subtitlePollingInterval);
}
// Media is undefined when switching streams via loadSource()
if (!this.media || !this.hls.config.renderTextTracksNatively) {
return;
}
let textTrack: TextTrack | null = null;
const tracks = filterSubtitleTracks(this.media.textTracks);
for (let i = 0; i < tracks.length; i++) {
if (tracks[i].mode === 'hidden') {
// Do not break in case there is a following track with showing.
textTrack = tracks[i];
} else if (tracks[i].mode === 'showing') {
textTrack = tracks[i];
break;
}
}
// Find internal track index for TextTrack
const trackId = this.findTrackForTextTrack(textTrack);
if (this.subtitleTrack !== trackId) {
this.setSubtitleTrack(trackId);
}
};
}
export default SubtitleTrackController;
+814
View File
@@ -0,0 +1,814 @@
import { Events } from '../events';
import { PlaylistLevelType } from '../types/loader';
import Cea608Parser from '../utils/cea-608-parser';
import { IMSC1_CODEC, parseIMSC1 } from '../utils/imsc1-ttml-parser';
import {
subtitleOptionsIdentical,
subtitleTrackMatchesTextTrack,
} from '../utils/media-option-attributes';
import { appendUint8Array } from '../utils/mp4-tools';
import OutputFilter from '../utils/output-filter';
import {
addCueToTrack,
clearCurrentCues,
filterSubtitleTracks,
removeCuesInRange,
sendAddTrackEvent,
} from '../utils/texttrack-utils';
import { parseWebVTT } from '../utils/webvtt-parser';
import type { HlsConfig } from '../config';
import type Hls from '../hls';
import type { Fragment } from '../loader/fragment';
import type { ComponentAPI } from '../types/component-api';
import type {
BufferFlushingData,
FragDecryptedData,
FragLoadedData,
FragLoadingData,
FragParsingUserdataData,
InitPTSFoundData,
ManifestLoadedData,
MediaAttachingData,
MediaDetachingData,
SubtitleTracksUpdatedData,
} from '../types/events';
import type { MediaPlaylist } from '../types/media-playlist';
import type { VTTCCs } from '../types/vtt';
import type { CaptionScreen } from '../utils/cea-608-parser';
import type { CuesInterface } from '../utils/cues';
import type { TimestampOffset } from '../utils/timescale-conversion';
type TrackProperties = {
label: string;
languageCode: string;
media?: MediaPlaylist;
};
type NonNativeCaptionsTrack = {
_id?: string;
label: string;
kind: string;
default: boolean;
closedCaptions?: MediaPlaylist;
subtitleTrack?: MediaPlaylist;
};
export class TimelineController implements ComponentAPI {
private hls: Hls;
private media: HTMLMediaElement | null = null;
private config: HlsConfig;
private enabled: boolean = true;
private Cues: CuesInterface;
private textTracks: Array<TextTrack> = [];
private tracks: Array<MediaPlaylist> = [];
private initPTS: TimestampOffset[] = [];
private unparsedVttFrags: Array<FragLoadedData | FragDecryptedData> = [];
private captionsTracks: Record<string, TextTrack> = {};
private nonNativeCaptionsTracks: Record<string, NonNativeCaptionsTrack> = {};
private cea608Parser1?: Cea608Parser;
private cea608Parser2?: Cea608Parser;
private lastCc: number = -1; // Last video (CEA-608) fragment CC
private lastSn: number = -1; // Last video (CEA-608) fragment MSN
private lastPartIndex: number = -1; // Last video (CEA-608) fragment Part Index
private prevCC: number = -1; // Last subtitle fragment CC
private vttCCs: VTTCCs = newVTTCCs();
private captionsProperties: {
textTrack1: TrackProperties;
textTrack2: TrackProperties;
textTrack3: TrackProperties;
textTrack4: TrackProperties;
};
constructor(hls: Hls) {
this.hls = hls;
this.config = hls.config;
this.Cues = hls.config.cueHandler;
this.captionsProperties = {
textTrack1: {
label: this.config.captionsTextTrack1Label,
languageCode: this.config.captionsTextTrack1LanguageCode,
},
textTrack2: {
label: this.config.captionsTextTrack2Label,
languageCode: this.config.captionsTextTrack2LanguageCode,
},
textTrack3: {
label: this.config.captionsTextTrack3Label,
languageCode: this.config.captionsTextTrack3LanguageCode,
},
textTrack4: {
label: this.config.captionsTextTrack4Label,
languageCode: this.config.captionsTextTrack4LanguageCode,
},
};
hls.on(Events.MEDIA_ATTACHING, this.onMediaAttaching, this);
hls.on(Events.MEDIA_DETACHING, this.onMediaDetaching, this);
hls.on(Events.MANIFEST_LOADING, this.onManifestLoading, this);
hls.on(Events.MANIFEST_LOADED, this.onManifestLoaded, this);
hls.on(Events.SUBTITLE_TRACKS_UPDATED, this.onSubtitleTracksUpdated, this);
hls.on(Events.FRAG_LOADING, this.onFragLoading, this);
hls.on(Events.FRAG_LOADED, this.onFragLoaded, this);
hls.on(Events.FRAG_PARSING_USERDATA, this.onFragParsingUserdata, this);
hls.on(Events.FRAG_DECRYPTED, this.onFragDecrypted, this);
hls.on(Events.INIT_PTS_FOUND, this.onInitPtsFound, this);
hls.on(Events.SUBTITLE_TRACKS_CLEARED, this.onSubtitleTracksCleared, this);
hls.on(Events.BUFFER_FLUSHING, this.onBufferFlushing, this);
}
public destroy(): void {
const { hls } = this;
hls.off(Events.MEDIA_ATTACHING, this.onMediaAttaching, this);
hls.off(Events.MEDIA_DETACHING, this.onMediaDetaching, this);
hls.off(Events.MANIFEST_LOADING, this.onManifestLoading, this);
hls.off(Events.MANIFEST_LOADED, this.onManifestLoaded, this);
hls.off(Events.SUBTITLE_TRACKS_UPDATED, this.onSubtitleTracksUpdated, this);
hls.off(Events.FRAG_LOADING, this.onFragLoading, this);
hls.off(Events.FRAG_LOADED, this.onFragLoaded, this);
hls.off(Events.FRAG_PARSING_USERDATA, this.onFragParsingUserdata, this);
hls.off(Events.FRAG_DECRYPTED, this.onFragDecrypted, this);
hls.off(Events.INIT_PTS_FOUND, this.onInitPtsFound, this);
hls.off(Events.SUBTITLE_TRACKS_CLEARED, this.onSubtitleTracksCleared, this);
hls.off(Events.BUFFER_FLUSHING, this.onBufferFlushing, this);
// @ts-ignore
this.hls = this.config = this.media = null;
this.cea608Parser1 = this.cea608Parser2 = undefined;
}
private initCea608Parsers() {
const channel1 = new OutputFilter(this, 'textTrack1');
const channel2 = new OutputFilter(this, 'textTrack2');
const channel3 = new OutputFilter(this, 'textTrack3');
const channel4 = new OutputFilter(this, 'textTrack4');
this.cea608Parser1 = new Cea608Parser(1, channel1, channel2);
this.cea608Parser2 = new Cea608Parser(3, channel3, channel4);
}
public addCues(
trackName: string,
startTime: number,
endTime: number,
screen: CaptionScreen,
cueRanges: Array<[number, number]>,
) {
// skip cues which overlap more than 50% with previously parsed time ranges
let merged = false;
for (let i = cueRanges.length; i--; ) {
const cueRange = cueRanges[i];
const overlap = intersection(
cueRange[0],
cueRange[1],
startTime,
endTime,
);
if (overlap >= 0) {
cueRange[0] = Math.min(cueRange[0], startTime);
cueRange[1] = Math.max(cueRange[1], endTime);
merged = true;
if (overlap / (endTime - startTime) > 0.5) {
return;
}
}
}
if (!merged) {
cueRanges.push([startTime, endTime]);
}
if (this.config.renderTextTracksNatively) {
const track = this.captionsTracks[trackName];
this.Cues.newCue(track, startTime, endTime, screen);
} else {
const cues = this.Cues.newCue(null, startTime, endTime, screen);
this.hls.trigger(Events.CUES_PARSED, {
type: 'captions',
cues,
track: trackName,
});
}
}
// Triggered when an initial PTS is found; used for synchronisation of WebVTT.
private onInitPtsFound(
event: Events.INIT_PTS_FOUND,
{ frag, id, initPTS, timescale, trackId }: InitPTSFoundData,
) {
const { unparsedVttFrags } = this;
if (id === PlaylistLevelType.MAIN) {
this.initPTS[frag.cc] = { baseTime: initPTS, timescale, trackId };
}
// Due to asynchronous processing, initial PTS may arrive later than the first VTT fragments are loaded.
// Parse any unparsed fragments upon receiving the initial PTS.
if (unparsedVttFrags.length) {
this.unparsedVttFrags = [];
unparsedVttFrags.forEach((data) => {
if (this.initPTS[data.frag.cc]) {
this.onFragLoaded(Events.FRAG_LOADED, data as FragLoadedData);
} else {
this.hls.trigger(Events.SUBTITLE_FRAG_PROCESSED, {
success: false,
frag: data.frag,
error: new Error(
'Subtitle discontinuity domain does not match main',
),
});
}
});
}
}
private getExistingTrack(label: string, language: string): TextTrack | null {
const { media } = this;
if (media) {
for (let i = 0; i < media.textTracks.length; i++) {
const textTrack = media.textTracks[i];
if (
canReuseVttTextTrack(textTrack, {
name: label,
lang: language,
characteristics:
'transcribes-spoken-dialog,describes-music-and-sound',
attrs: {} as any,
})
) {
return textTrack;
}
}
}
return null;
}
public createCaptionsTrack(trackName: string) {
if (this.config.renderTextTracksNatively) {
this.createNativeTrack(trackName);
} else {
this.createNonNativeTrack(trackName);
}
}
private createNativeTrack(trackName: string) {
if (this.captionsTracks[trackName]) {
return;
}
const { captionsProperties, captionsTracks, media } = this;
const { label, languageCode } = captionsProperties[trackName];
// Enable reuse of existing text track.
const existingTrack = this.getExistingTrack(label, languageCode);
if (!existingTrack) {
const textTrack = this.createTextTrack('captions', label, languageCode);
if (textTrack) {
// Set a special property on the track so we know it's managed by Hls.js
textTrack[trackName] = true;
captionsTracks[trackName] = textTrack;
}
} else {
captionsTracks[trackName] = existingTrack;
clearCurrentCues(captionsTracks[trackName]);
sendAddTrackEvent(captionsTracks[trackName], media as HTMLMediaElement);
}
}
private createNonNativeTrack(trackName: string) {
if (this.nonNativeCaptionsTracks[trackName]) {
return;
}
// Create a list of a single track for the provider to consume
const trackProperties: TrackProperties = this.captionsProperties[trackName];
if (!trackProperties) {
return;
}
const label = trackProperties.label as string;
const track = {
_id: trackName,
label,
kind: 'captions',
default: trackProperties.media ? !!trackProperties.media.default : false,
closedCaptions: trackProperties.media,
};
this.nonNativeCaptionsTracks[trackName] = track;
this.hls.trigger(Events.NON_NATIVE_TEXT_TRACKS_FOUND, { tracks: [track] });
}
private createTextTrack(
kind: TextTrackKind,
label: string,
lang?: string,
): TextTrack | undefined {
const media = this.media;
if (!media) {
return;
}
return media.addTextTrack(kind, label, lang);
}
private onMediaAttaching(
event: Events.MEDIA_ATTACHING,
data: MediaAttachingData,
) {
this.media = data.media;
if (!data.mediaSource) {
this._cleanTracks();
}
}
private onMediaDetaching(
event: Events.MEDIA_DETACHING,
data: MediaDetachingData,
) {
const transferringMedia = !!data.transferMedia;
this.media = null;
if (transferringMedia) {
return;
}
const { captionsTracks } = this;
Object.keys(captionsTracks).forEach((trackName) => {
clearCurrentCues(captionsTracks[trackName]);
delete captionsTracks[trackName];
});
this.nonNativeCaptionsTracks = {};
}
private onManifestLoading() {
// Detect discontinuity in video fragment (CEA-608) parsing
this.lastCc = -1;
this.lastSn = -1;
this.lastPartIndex = -1;
// Detect discontinuity in subtitle manifests
this.prevCC = -1;
this.vttCCs = newVTTCCs();
// Reset tracks
this._cleanTracks();
this.tracks = [];
this.captionsTracks = {};
this.nonNativeCaptionsTracks = {};
this.textTracks = [];
this.unparsedVttFrags = [];
this.initPTS = [];
if (this.cea608Parser1 && this.cea608Parser2) {
this.cea608Parser1.reset();
this.cea608Parser2.reset();
}
}
private _cleanTracks() {
// clear outdated subtitles
const { media } = this;
if (!media) {
return;
}
const textTracks = media.textTracks;
if (textTracks) {
for (let i = 0; i < textTracks.length; i++) {
clearCurrentCues(textTracks[i]);
}
}
}
private onSubtitleTracksUpdated(
event: Events.SUBTITLE_TRACKS_UPDATED,
data: SubtitleTracksUpdatedData,
) {
const tracks: Array<MediaPlaylist> = data.subtitleTracks || [];
const hasIMSC1 = tracks.some((track) => track.textCodec === IMSC1_CODEC);
if (this.config.enableWebVTT || (hasIMSC1 && this.config.enableIMSC1)) {
const listIsIdentical = subtitleOptionsIdentical(this.tracks, tracks);
if (listIsIdentical) {
this.tracks = tracks;
return;
}
this.textTracks = [];
this.tracks = tracks;
if (this.config.renderTextTracksNatively) {
const media = this.media;
const inUseTracks: (TextTrack | null)[] | null = media
? filterSubtitleTracks(media.textTracks)
: null;
this.tracks.forEach((track, index) => {
// Reuse tracks with the same label and lang, but do not reuse 608/708 tracks
let textTrack: TextTrack | undefined;
if (inUseTracks) {
let inUseTrack: TextTrack | null = null;
for (let i = 0; i < inUseTracks.length; i++) {
if (
inUseTracks[i] &&
canReuseVttTextTrack(inUseTracks[i], track)
) {
inUseTrack = inUseTracks[i];
inUseTracks[i] = null;
break;
}
}
if (inUseTrack) {
textTrack = inUseTrack;
}
}
if (textTrack) {
clearCurrentCues(textTrack);
} else {
const textTrackKind = captionsOrSubtitlesFromCharacteristics(track);
textTrack = this.createTextTrack(
textTrackKind,
track.name,
track.lang,
);
if (textTrack) {
textTrack.mode = 'disabled';
}
}
if (textTrack) {
this.textTracks.push(textTrack);
}
});
// Warn when video element has captions or subtitle TextTracks carried over from another source
if (inUseTracks?.length) {
const unusedTextTracks = inUseTracks
.filter((t) => t !== null)
.map((t) => (t as TextTrack).label);
if (unusedTextTracks.length) {
this.hls.logger.warn(
`Media element contains unused subtitle tracks: ${unusedTextTracks.join(
', ',
)}. Replace media element for each source to clear TextTracks and captions menu.`,
);
}
}
} else if (this.tracks.length) {
// Create a list of tracks for the provider to consume
const tracksList = this.tracks.map((track) => {
return {
label: track.name,
kind: track.type.toLowerCase(),
default: track.default,
subtitleTrack: track,
};
});
this.hls.trigger(Events.NON_NATIVE_TEXT_TRACKS_FOUND, {
tracks: tracksList,
});
}
}
}
private onManifestLoaded(
event: Events.MANIFEST_LOADED,
data: ManifestLoadedData,
) {
if (this.config.enableCEA708Captions && data.captions) {
data.captions.forEach((captionsTrack) => {
const instreamIdMatch = /(?:CC|SERVICE)([1-4])/.exec(
captionsTrack.instreamId as string,
);
if (!instreamIdMatch) {
return;
}
const trackName = `textTrack${instreamIdMatch[1]}`;
const trackProperties: TrackProperties =
this.captionsProperties[trackName];
if (!trackProperties) {
return;
}
trackProperties.label = captionsTrack.name;
if (captionsTrack.lang) {
// optional attribute
trackProperties.languageCode = captionsTrack.lang;
}
trackProperties.media = captionsTrack;
});
}
}
private closedCaptionsForLevel(frag: Fragment): string | undefined {
const level = this.hls.levels[frag.level];
return level?.attrs['CLOSED-CAPTIONS'];
}
private onFragLoading(event: Events.FRAG_LOADING, data: FragLoadingData) {
// if this frag isn't contiguous, clear the parser so cues with bad start/end times aren't added to the textTrack
if (this.enabled && data.frag.type === PlaylistLevelType.MAIN) {
const { cea608Parser1, cea608Parser2, lastSn } = this;
const { cc, sn } = data.frag;
const partIndex = data.part?.index ?? -1;
if (cea608Parser1 && cea608Parser2) {
if (
sn !== lastSn + 1 ||
(sn === lastSn && partIndex !== this.lastPartIndex + 1) ||
cc !== this.lastCc
) {
cea608Parser1.reset();
cea608Parser2.reset();
}
}
this.lastCc = cc as number;
this.lastSn = sn as number;
this.lastPartIndex = partIndex;
}
}
private onFragLoaded(
event: Events.FRAG_LOADED,
data: FragDecryptedData | FragLoadedData,
) {
const { frag, payload } = data;
if (frag.type === PlaylistLevelType.SUBTITLE) {
// If fragment is subtitle type, parse as WebVTT.
if (payload.byteLength) {
const decryptData = frag.decryptdata;
// fragment after decryption has a stats object
const decrypted = 'stats' in data;
// If the subtitles are not encrypted, parse VTTs now. Otherwise, we need to wait.
if (decryptData == null || !decryptData.encrypted || decrypted) {
const trackPlaylistMedia = this.tracks[frag.level];
const vttCCs = this.vttCCs;
if (!vttCCs[frag.cc]) {
vttCCs[frag.cc] = {
start: frag.start,
prevCC: this.prevCC,
new: true,
};
this.prevCC = frag.cc;
}
if (
trackPlaylistMedia &&
trackPlaylistMedia.textCodec === IMSC1_CODEC
) {
this._parseIMSC1(frag, payload);
} else {
this._parseVTTs(data);
}
}
} else {
// In case there is no payload, finish unsuccessfully.
this.hls.trigger(Events.SUBTITLE_FRAG_PROCESSED, {
success: false,
frag,
error: new Error('Empty subtitle payload'),
});
}
}
}
private _parseIMSC1(frag: Fragment, payload: ArrayBuffer) {
const hls = this.hls;
parseIMSC1(
payload,
this.initPTS[frag.cc],
(cues) => {
this._appendCues(cues, frag.level);
hls.trigger(Events.SUBTITLE_FRAG_PROCESSED, {
success: true,
frag: frag,
});
},
(error) => {
hls.logger.log(`Failed to parse IMSC1: ${error}`);
hls.trigger(Events.SUBTITLE_FRAG_PROCESSED, {
success: false,
frag: frag,
error,
});
},
);
}
private _parseVTTs(data: FragDecryptedData | FragLoadedData) {
const { frag, payload } = data;
// We need an initial synchronisation PTS. Store fragments as long as none has arrived
const { initPTS, unparsedVttFrags } = this;
const maxAvCC = initPTS.length - 1;
if (!initPTS[frag.cc] && maxAvCC === -1) {
unparsedVttFrags.push(data);
return;
}
const hls = this.hls;
// Parse the WebVTT file contents.
const payloadWebVTT = frag.initSegment?.data
? appendUint8Array(frag.initSegment.data, new Uint8Array(payload)).buffer
: payload;
parseWebVTT(
payloadWebVTT,
this.initPTS[frag.cc],
this.vttCCs,
frag.cc,
frag.start,
(cues) => {
this._appendCues(cues, frag.level);
hls.trigger(Events.SUBTITLE_FRAG_PROCESSED, {
success: true,
frag: frag,
});
},
(error) => {
const missingInitPTS =
error.message === 'Missing initPTS for VTT MPEGTS';
if (missingInitPTS) {
unparsedVttFrags.push(data);
} else {
this._fallbackToIMSC1(frag, payload);
}
// Something went wrong while parsing. Trigger event with success false.
hls.logger.log(`Failed to parse VTT cue: ${error}`);
if (missingInitPTS && maxAvCC > frag.cc) {
return;
}
hls.trigger(Events.SUBTITLE_FRAG_PROCESSED, {
success: false,
frag: frag,
error,
});
},
);
}
private _fallbackToIMSC1(frag: Fragment, payload: ArrayBuffer) {
// If textCodec is unknown, try parsing as IMSC1. Set textCodec based on the result
const trackPlaylistMedia = this.tracks[frag.level];
if (!trackPlaylistMedia.textCodec) {
parseIMSC1(
payload,
this.initPTS[frag.cc],
() => {
trackPlaylistMedia.textCodec = IMSC1_CODEC;
this._parseIMSC1(frag, payload);
},
() => {
trackPlaylistMedia.textCodec = 'wvtt';
},
);
}
}
private _appendCues(cues: VTTCue[], fragLevel: number) {
const hls = this.hls;
if (this.config.renderTextTracksNatively) {
const textTrack = this.textTracks[fragLevel];
// WebVTTParser.parse is an async method and if the currently selected text track mode is set to "disabled"
// before parsing is done then don't try to access currentTrack.cues.getCueById as cues will be null
// and trying to access getCueById method of cues will throw an exception
// Because we check if the mode is disabled, we can force check `cues` below. They can't be null.
if (!textTrack || textTrack.mode === 'disabled') {
return;
}
cues.forEach((cue) => addCueToTrack(textTrack, cue));
} else {
const currentTrack = this.tracks[fragLevel];
if (!currentTrack) {
return;
}
const track = currentTrack.default ? 'default' : 'subtitles' + fragLevel;
hls.trigger(Events.CUES_PARSED, { type: 'subtitles', cues, track });
}
}
private onFragDecrypted(
event: Events.FRAG_DECRYPTED,
data: FragDecryptedData,
) {
const { frag } = data;
if (frag.type === PlaylistLevelType.SUBTITLE) {
this.onFragLoaded(Events.FRAG_LOADED, data as unknown as FragLoadedData);
}
}
private onSubtitleTracksCleared() {
this.tracks = [];
this.captionsTracks = {};
}
private onFragParsingUserdata(
event: Events.FRAG_PARSING_USERDATA,
data: FragParsingUserdataData,
) {
if (!this.enabled || !this.config.enableCEA708Captions) {
return;
}
const { frag, samples } = data;
if (
frag.type === PlaylistLevelType.MAIN &&
this.closedCaptionsForLevel(frag) === 'NONE'
) {
return;
}
// If the event contains captions (found in the bytes property), push all bytes into the parser immediately
// It will create the proper timestamps based on the PTS value
for (let i = 0; i < samples.length; i++) {
const ccBytes = samples[i].bytes;
if (ccBytes) {
if (!this.cea608Parser1) {
this.initCea608Parsers();
}
const ccdatas = this.extractCea608Data(ccBytes);
this.cea608Parser1!.addData(samples[i].pts, ccdatas[0]);
this.cea608Parser2!.addData(samples[i].pts, ccdatas[1]);
}
}
}
onBufferFlushing(
event: Events.BUFFER_FLUSHING,
{ startOffset, endOffset, endOffsetSubtitles, type }: BufferFlushingData,
) {
const { media } = this;
if (!media || media.currentTime < endOffset) {
return;
}
// Clear 608 caption cues from the captions TextTracks when the video back buffer is flushed
// Forward cues are never removed because we can loose streamed 608 content from recent fragments
if (!type || type === 'video') {
const { captionsTracks } = this;
Object.keys(captionsTracks).forEach((trackName) =>
removeCuesInRange(captionsTracks[trackName], startOffset, endOffset),
);
}
if (this.config.renderTextTracksNatively) {
// Clear VTT/IMSC1 subtitle cues from the subtitle TextTracks when the back buffer is flushed
if (startOffset === 0 && endOffsetSubtitles !== undefined) {
const { textTracks } = this;
Object.keys(textTracks).forEach((trackName) =>
removeCuesInRange(
textTracks[trackName],
startOffset,
endOffsetSubtitles,
),
);
}
}
}
private extractCea608Data(byteArray: Uint8Array): number[][] {
const actualCCBytes: number[][] = [[], []];
const count = byteArray[0] & 0x1f;
let position = 2;
for (let j = 0; j < count; j++) {
const tmpByte = byteArray[position++];
const ccbyte1 = 0x7f & byteArray[position++];
const ccbyte2 = 0x7f & byteArray[position++];
if (ccbyte1 === 0 && ccbyte2 === 0) {
continue;
}
const ccValid = (0x04 & tmpByte) !== 0; // Support all four channels
if (ccValid) {
const ccType = 0x03 & tmpByte;
if (
0x00 /* CEA608 field1*/ === ccType ||
0x01 /* CEA608 field2*/ === ccType
) {
// Exclude CEA708 CC data.
actualCCBytes[ccType].push(ccbyte1);
actualCCBytes[ccType].push(ccbyte2);
}
}
}
return actualCCBytes;
}
}
function captionsOrSubtitlesFromCharacteristics(
track: Pick<MediaPlaylist, 'name' | 'lang' | 'attrs' | 'characteristics'>,
): TextTrackKind {
if (track.characteristics) {
if (
/transcribes-spoken-dialog/gi.test(track.characteristics) &&
/describes-music-and-sound/gi.test(track.characteristics)
) {
return 'captions';
}
}
return 'subtitles';
}
function canReuseVttTextTrack(
inUseTrack: TextTrack | null,
manifestTrack: Pick<
MediaPlaylist,
'name' | 'lang' | 'attrs' | 'characteristics'
>,
): boolean {
return (
!!inUseTrack &&
inUseTrack.kind === captionsOrSubtitlesFromCharacteristics(manifestTrack) &&
subtitleTrackMatchesTextTrack(manifestTrack, inUseTrack)
);
}
function intersection(x1: number, x2: number, y1: number, y2: number): number {
return Math.min(x2, y2) - Math.max(x1, y1);
}
function newVTTCCs(): VTTCCs {
return {
ccOffset: 0,
presentationOffset: 0,
0: {
start: 0,
prevCC: -1,
new: true,
},
};
}