src/controller/subtitle-track-controller.js
import Event from '../events';
import EventHandler from '../event-handler';
import { logger } from '../utils/logger';
import { computeReloadInterval } from './level-helper';
import { clearCurrentCues } from '../utils/texttrack-utils';
class SubtitleTrackController extends EventHandler {
constructor (hls) {
super(hls,
Event.MEDIA_ATTACHED,
Event.MEDIA_DETACHING,
Event.MANIFEST_LOADED,
Event.SUBTITLE_TRACK_LOADED);
this.tracks = [];
this.trackId = -1;
this.media = null;
this.stopped = true;
/**
* @member {boolean} subtitleDisplay Enable/disable subtitle display rendering
*/
this.subtitleDisplay = true;
/**
* Keeps reference to a default track id when media has not been attached yet
* @member {number}
*/
this.queuedDefaultTrack = null;
}
destroy () {
EventHandler.prototype.destroy.call(this);
}
// Listen for subtitle track change, then extract the current track ID.
onMediaAttached (data) {
this.media = data.media;
if (!this.media) {
return;
}
if (Number.isFinite(this.queuedDefaultTrack)) {
this.subtitleTrack = this.queuedDefaultTrack;
this.queuedDefaultTrack = null;
}
this.trackChangeListener = this._onTextTracksChanged.bind(this);
this.useTextTrackPolling = !(this.media.textTracks && 'onchange' in this.media.textTracks);
if (this.useTextTrackPolling) {
this.subtitlePollingInterval = setInterval(() => {
this.trackChangeListener();
}, 500);
} else {
this.media.textTracks.addEventListener('change', this.trackChangeListener);
}
}
onMediaDetaching () {
if (!this.media) {
return;
}
if (this.useTextTrackPolling) {
clearInterval(this.subtitlePollingInterval);
} else {
this.media.textTracks.removeEventListener('change', this.trackChangeListener);
}
if (Number.isFinite(this.subtitleTrack)) {
this.queuedDefaultTrack = this.subtitleTrack;
}
const textTracks = filterSubtitleTracks(this.media.textTracks);
// Clear loaded cues on media detachment from tracks
textTracks.forEach((track) => {
clearCurrentCues(track);
});
// Disable all subtitle tracks before detachment so when reattached only tracks in that content are enabled.
this.subtitleTrack = -1;
this.media = null;
}
// Fired whenever a new manifest is loaded.
onManifestLoaded (data) {
let tracks = data.subtitles || [];
this.tracks = tracks;
this.hls.trigger(Event.SUBTITLE_TRACKS_UPDATED, { subtitleTracks: tracks });
// loop through available subtitle tracks and autoselect default if needed
// TODO: improve selection logic to handle forced, etc
tracks.forEach(track => {
if (track.default) {
// 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.subtitleTrack = track.id;
} else {
this.queuedDefaultTrack = track.id;
}
}
});
}
onSubtitleTrackLoaded (data) {
const { id, details } = data;
const { trackId, tracks } = this;
const currentTrack = tracks[trackId];
if (id >= tracks.length || id !== trackId || !currentTrack || this.stopped) {
this._clearReloadTimer();
return;
}
logger.log(`subtitle track ${id} loaded`);
if (details.live) {
const reloadInterval = computeReloadInterval(currentTrack.details, details, data.stats.trequest);
logger.log(`Reloading live subtitle playlist in ${reloadInterval}ms`);
this.timer = setTimeout(() => {
this._loadCurrentTrack();
}, reloadInterval);
} else {
this._clearReloadTimer();
}
}
startLoad () {
this.stopped = false;
this._loadCurrentTrack();
}
stopLoad () {
this.stopped = true;
this._clearReloadTimer();
}
/** get alternate subtitle tracks list from playlist **/
get subtitleTracks () {
return this.tracks;
}
/** get index of the selected subtitle track (index in subtitle track lists) **/
get subtitleTrack () {
return this.trackId;
}
/** select a subtitle track, based on its index in subtitle track lists**/
set subtitleTrack (subtitleTrackId) {
if (this.trackId !== subtitleTrackId) {
this._toggleTrackModes(subtitleTrackId);
this._setSubtitleTrackInternal(subtitleTrackId);
}
}
_clearReloadTimer () {
if (this.timer) {
clearTimeout(this.timer);
this.timer = null;
}
}
_loadCurrentTrack () {
const { trackId, tracks, hls } = this;
const currentTrack = tracks[trackId];
if (trackId < 0 || !currentTrack || (currentTrack.details && !currentTrack.details.live)) {
return;
}
logger.log(`Loading subtitle track ${trackId}`);
hls.trigger(Event.SUBTITLE_TRACK_LOADING, { url: currentTrack.url, id: trackId });
}
/**
* 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.
* @param newId - The id of the next track to enable
* @private
*/
_toggleTrackModes (newId) {
const { media, subtitleDisplay, trackId } = this;
if (!media) {
return;
}
const textTracks = filterSubtitleTracks(media.textTracks);
if (newId === -1) {
[].slice.call(textTracks).forEach(track => {
track.mode = 'disabled';
});
} else {
const oldTrack = textTracks[trackId];
if (oldTrack) {
oldTrack.mode = 'disabled';
}
}
const nextTrack = textTracks[newId];
if (nextTrack) {
nextTrack.mode = subtitleDisplay ? 'showing' : 'hidden';
}
}
/**
* 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.
* @param newId - The id of the subtitle track to activate.
*/
_setSubtitleTrackInternal (newId) {
const { hls, tracks } = this;
if (!Number.isFinite(newId) || newId < -1 || newId >= tracks.length) {
return;
}
this.trackId = newId;
logger.log(`Switching to subtitle track ${newId}`);
hls.trigger(Event.SUBTITLE_TRACK_SWITCH, { id: newId });
this._loadCurrentTrack();
}
_onTextTracksChanged () {
// Media is undefined when switching streams via loadSource()
if (!this.media || !this.hls.config.renderTextTracksNatively) {
return;
}
let trackId = -1;
let tracks = filterSubtitleTracks(this.media.textTracks);
for (let id = 0; id < tracks.length; id++) {
if (tracks[id].mode === 'hidden') {
// Do not break in case there is a following track with showing.
trackId = id;
} else if (tracks[id].mode === 'showing') {
trackId = id;
break;
}
}
// Setting current subtitleTrack will invoke code.
this.subtitleTrack = trackId;
}
}
function filterSubtitleTracks (textTrackList) {
let tracks = [];
for (let i = 0; i < textTrackList.length; i++) {
const track = textTrackList[i];
// Edge adds a track without a label; we don't want to use it
if (track.kind === 'subtitles' && track.label) {
tracks.push(textTrackList[i]);
}
}
return tracks;
}
export default SubtitleTrackController;