439 lines
13 KiB
TypeScript
439 lines
13 KiB
TypeScript
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;
|