src/controller/audio-track-controller.js
import Event from '../events';
import TaskLoop from '../task-loop';
import { logger } from '../utils/logger';
import { ErrorTypes, ErrorDetails } from '../errors';
/**
* @class AudioTrackController
* @implements {EventHandler}
*
* Handles main manifest and audio-track metadata loaded,
* owns and exposes the selectable audio-tracks data-models.
*
* Exposes internal interface to select available audio-tracks.
*
* Handles errors on loading audio-track playlists. Manages fallback mechanism
* with redundants tracks (group-IDs).
*
* Handles level-loading and group-ID switches for video (fallback on video levels),
* and eventually adapts the audio-track group-ID to match.
*
* @fires AUDIO_TRACK_LOADING
* @fires AUDIO_TRACK_SWITCHING
* @fires AUDIO_TRACKS_UPDATED
* @fires ERROR
*
*/
class AudioTrackController extends TaskLoop {
constructor (hls) {
super(hls,
Event.MANIFEST_LOADING,
Event.MANIFEST_PARSED,
Event.AUDIO_TRACK_LOADED,
Event.AUDIO_TRACK_SWITCHED,
Event.LEVEL_LOADED,
Event.ERROR
);
/**
* @private
* Currently selected index in `tracks`
* @member {number} trackId
*/
this._trackId = -1;
/**
* @private
* If should select tracks according to default track attribute
* @member {boolean} _selectDefaultTrack
*/
this._selectDefaultTrack = true;
/**
* @public
* All tracks available
* @member {AudioTrack[]}
*/
this.tracks = [];
/**
* @public
* List of blacklisted audio track IDs (that have caused failure)
* @member {number[]}
*/
this.trackIdBlacklist = Object.create(null);
/**
* @public
* The currently running group ID for audio
* (we grab this on manifest-parsed and new level-loaded)
* @member {string}
*/
this.audioGroupId = null;
}
/**
* Reset audio tracks on new manifest loading.
*/
onManifestLoading () {
this.tracks = [];
this._trackId = -1;
this._selectDefaultTrack = true;
}
/**
* Store tracks data from manifest parsed data.
*
* Trigger AUDIO_TRACKS_UPDATED event.
*
* @param {*} data
*/
onManifestParsed (data) {
const tracks = this.tracks = data.audioTracks || [];
this.hls.trigger(Event.AUDIO_TRACKS_UPDATED, { audioTracks: tracks });
this._selectAudioGroup(this.hls.nextLoadLevel);
}
/**
* Store track details of loaded track in our data-model.
*
* Set-up metadata update interval task for live-mode streams.
*
* @param {*} data
*/
onAudioTrackLoaded (data) {
if (data.id >= this.tracks.length) {
logger.warn('Invalid audio track id:', data.id);
return;
}
logger.log(`audioTrack ${data.id} loaded`);
this.tracks[data.id].details = data.details;
// check if current playlist is a live playlist
// and if we have already our reload interval setup
if (data.details.live && !this.hasInterval()) {
// if live playlist we will have to reload it periodically
// set reload period to playlist target duration
const updatePeriodMs = data.details.targetduration * 1000;
this.setInterval(updatePeriodMs);
}
if (!data.details.live && this.hasInterval()) {
// playlist is not live and timer is scheduled: cancel it
this.clearInterval();
}
}
/**
* Update the internal group ID to any audio-track we may have set manually
* or because of a failure-handling fallback.
*
* Quality-levels should update to that group ID in this case.
*
* @param {*} data
*/
onAudioTrackSwitched (data) {
const audioGroupId = this.tracks[data.id].groupId;
if (audioGroupId && (this.audioGroupId !== audioGroupId)) {
this.audioGroupId = audioGroupId;
}
}
/**
* When a level gets loaded, if it has redundant audioGroupIds (in the same ordinality as it's redundant URLs)
* we are setting our audio-group ID internally to the one set, if it is different from the group ID currently set.
*
* If group-ID got update, we re-select the appropriate audio-track with this group-ID matching the currently
* selected one (based on NAME property).
*
* @param {*} data
*/
onLevelLoaded (data) {
this._selectAudioGroup(data.level);
}
/**
* Handle network errors loading audio track manifests
* and also pausing on any netwok errors.
*
* @param {ErrorEventData} data
*/
onError (data) {
// Only handle network errors
if (data.type !== ErrorTypes.NETWORK_ERROR) {
return;
}
// If fatal network error, cancel update task
if (data.fatal) {
this.clearInterval();
}
// If not an audio-track loading error don't handle further
if (data.details !== ErrorDetails.AUDIO_TRACK_LOAD_ERROR) {
return;
}
logger.warn('Network failure on audio-track id:', data.context.id);
this._handleLoadError();
}
/**
* @type {AudioTrack[]} Audio-track list we own
*/
get audioTracks () {
return this.tracks;
}
/**
* @type {number} Index into audio-tracks list of currently selected track.
*/
get audioTrack () {
return this._trackId;
}
/**
* Select current track by index
*/
set audioTrack (newId) {
this._setAudioTrack(newId);
// If audio track is selected from API then don't choose from the manifest default track
this._selectDefaultTrack = false;
}
/**
* @private
* @param {number} newId
*/
_setAudioTrack (newId) {
// noop on same audio track id as already set
if (this._trackId === newId && this.tracks[this._trackId].details) {
logger.debug('Same id as current audio-track passed, and track details available -> no-op');
return;
}
// check if level idx is valid
if (newId < 0 || newId >= this.tracks.length) {
logger.warn('Invalid id passed to audio-track controller');
return;
}
const audioTrack = this.tracks[newId];
logger.log(`Now switching to audio-track index ${newId}`);
// stopping live reloading timer if any
this.clearInterval();
this._trackId = newId;
const { url, type, id } = audioTrack;
this.hls.trigger(Event.AUDIO_TRACK_SWITCHING, { id, type, url });
this._loadTrackDetailsIfNeeded(audioTrack);
}
/**
* @override
*/
doTick () {
this._updateTrack(this._trackId);
}
/**
* @param levelId
* @private
*/
_selectAudioGroup (levelId) {
const levelInfo = this.hls.levels[levelId];
if (!levelInfo || !levelInfo.audioGroupIds) {
return;
}
const audioGroupId = levelInfo.audioGroupIds[levelInfo.urlId];
if (this.audioGroupId !== audioGroupId) {
this.audioGroupId = audioGroupId;
this._selectInitialAudioTrack();
}
}
/**
* Select initial track
* @private
*/
_selectInitialAudioTrack () {
let tracks = this.tracks;
if (!tracks.length) {
return;
}
const currentAudioTrack = this.tracks[this._trackId];
let name = null;
if (currentAudioTrack) {
name = currentAudioTrack.name;
}
// Pre-select default tracks if there are any
if (this._selectDefaultTrack) {
const defaultTracks = tracks.filter((track) => track.default);
if (defaultTracks.length) {
tracks = defaultTracks;
} else {
logger.warn('No default audio tracks defined');
}
}
let trackFound = false;
const traverseTracks = () => {
// Select track with right group ID
tracks.forEach((track) => {
if (trackFound) {
return;
}
// We need to match the (pre-)selected group ID
// and the NAME of the current track.
if ((!this.audioGroupId || track.groupId === this.audioGroupId) &&
(!name || name === track.name)) {
// If there was a previous track try to stay with the same `NAME`.
// It should be unique across tracks of same group, and consistent through redundant track groups.
this._setAudioTrack(track.id);
trackFound = true;
}
});
};
traverseTracks();
if (!trackFound) {
name = null;
traverseTracks();
}
if (!trackFound) {
logger.error(`No track found for running audio group-ID: ${this.audioGroupId}`);
this.hls.trigger(Event.ERROR, {
type: ErrorTypes.MEDIA_ERROR,
details: ErrorDetails.AUDIO_TRACK_LOAD_ERROR,
fatal: true
});
}
}
/**
* @private
* @param {AudioTrack} audioTrack
* @returns {boolean}
*/
_needsTrackLoading (audioTrack) {
const { details, url } = audioTrack;
if (!details || details.live) {
// check if we face an audio track embedded in main playlist (audio track without URI attribute)
return !!url;
}
return false;
}
/**
* @private
* @param {AudioTrack} audioTrack
*/
_loadTrackDetailsIfNeeded (audioTrack) {
if (this._needsTrackLoading(audioTrack)) {
const { url, id } = audioTrack;
// track not retrieved yet, or live playlist we need to (re)load it
logger.log(`loading audio-track playlist for id: ${id}`);
this.hls.trigger(Event.AUDIO_TRACK_LOADING, { url, id });
}
}
/**
* @private
* @param {number} newId
*/
_updateTrack (newId) {
// check if level idx is valid
if (newId < 0 || newId >= this.tracks.length) {
return;
}
// stopping live reloading timer if any
this.clearInterval();
this._trackId = newId;
logger.log(`trying to update audio-track ${newId}`);
const audioTrack = this.tracks[newId];
this._loadTrackDetailsIfNeeded(audioTrack);
}
/**
* @private
*/
_handleLoadError () {
// First, let's black list current track id
this.trackIdBlacklist[this._trackId] = true;
// Let's try to fall back on a functional audio-track with the same group ID
const previousId = this._trackId;
const { name, language, groupId } = this.tracks[previousId];
logger.warn(`Loading failed on audio track id: ${previousId}, group-id: ${groupId}, name/language: "${name}" / "${language}"`);
// Find a non-blacklisted track ID with the same NAME
// At least a track that is not blacklisted, thus on another group-ID.
let newId = previousId;
for (let i = 0; i < this.tracks.length; i++) {
if (this.trackIdBlacklist[i]) {
continue;
}
const newTrack = this.tracks[i];
if (newTrack.name === name) {
newId = i;
break;
}
}
if (newId === previousId) {
logger.warn(`No fallback audio-track found for name/language: "${name}" / "${language}"`);
return;
}
logger.log('Attempting audio-track fallback id:', newId, 'group-id:', this.tracks[newId].groupId);
this._setAudioTrack(newId);
}
}
export default AudioTrackController;