src/controller/subtitle-track-controller.ts
- import { Events } from '../events';
- import { clearCurrentCues } from '../utils/texttrack-utils';
- import BasePlaylistController from './base-playlist-controller';
- import type { HlsUrlParameters } from '../types/level';
- import type Hls from '../hls';
- import type {
- TrackLoadedData,
- MediaAttachedData,
- SubtitleTracksUpdatedData,
- ManifestParsedData,
- LevelSwitchingData,
- } from '../types/events';
- import type { MediaPlaylist } from '../types/media-playlist';
- import { ErrorData, LevelLoadingData } from '../types/events';
- import { PlaylistContextType } from '../types/loader';
-
- class SubtitleTrackController extends BasePlaylistController {
- private media: HTMLMediaElement | null = null;
- private tracks: MediaPlaylist[] = [];
- private groupId: string | null = null;
- private tracksInGroup: MediaPlaylist[] = [];
- private trackId: number = -1;
- private selectDefaultTrack: boolean = true;
- private queuedDefaultTrack: number = -1;
- private trackChangeListener: () => void = () => this.onTextTracksChanged();
- private useTextTrackPolling: boolean = false;
- private subtitlePollingInterval: number = -1;
-
- public subtitleDisplay: boolean = true; // Enable/disable subtitle display rendering
-
- constructor(hls: Hls) {
- super(hls, '[subtitle-track-controller]');
- this.registerListeners();
- }
-
- public destroy() {
- this.unregisterListeners();
- super.destroy();
- }
-
- 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) {
- self.clearInterval(this.subtitlePollingInterval);
- this.subtitlePollingInterval = self.setInterval(() => {
- this.trackChangeListener();
- }, 500);
- } else {
- this.media.textTracks.addEventListener(
- 'change',
- this.trackChangeListener
- );
- }
- }
-
- protected onMediaDetaching(): void {
- if (!this.media) {
- return;
- }
-
- if (this.useTextTrackPolling) {
- self.clearInterval(this.subtitlePollingInterval);
- } else {
- this.media.textTracks.removeEventListener(
- 'change',
- this.trackChangeListener
- );
- }
-
- if (this.trackId > -1) {
- this.queuedDefaultTrack = this.trackId;
- }
-
- 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;
- }
-
- protected onManifestLoading(): void {
- this.tracks = [];
- this.groupId = null;
- this.tracksInGroup = [];
- this.trackId = -1;
- 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, details } = data;
- const { trackId } = this;
- const currentTrack = this.tracksInGroup[trackId];
-
- if (!currentTrack) {
- this.warn(`Invalid subtitle track id ${id}`);
- return;
- }
-
- const curDetails = currentTrack.details;
- currentTrack.details = data.details;
- this.log(
- `subtitle track ${id} loaded [${details.startSN}-${details.endSN}]`
- );
-
- if (id === this.trackId) {
- this.retryCount = 0;
- 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?.textGroupIds) {
- return;
- }
-
- const textGroupId = levelInfo.textGroupIds[levelInfo.urlId];
- if (this.groupId !== textGroupId) {
- const lastTrack = this.tracksInGroup
- ? this.tracksInGroup[this.trackId]
- : undefined;
- const initialTrackId =
- this.findTrackId(lastTrack?.name) || this.findTrackId();
- const subtitleTracks = this.tracks.filter(
- (track): boolean => !textGroupId || track.groupId === textGroupId
- );
- this.groupId = textGroupId;
- this.tracksInGroup = subtitleTracks;
- const subtitleTracksUpdated: SubtitleTracksUpdatedData = {
- subtitleTracks,
- };
- this.log(
- `Updating subtitle tracks, ${subtitleTracks.length} track(s) found in "${textGroupId}" group-id`
- );
- this.hls.trigger(Events.SUBTITLE_TRACKS_UPDATED, subtitleTracksUpdated);
-
- if (initialTrackId !== -1) {
- this.setSubtitleTrack(initialTrackId, lastTrack);
- }
- }
- }
-
- private findTrackId(name?: string): number {
- const audioTracks = this.tracksInGroup;
- for (let i = 0; i < audioTracks.length; i++) {
- const track = audioTracks[i];
- if (!this.selectDefaultTrack || track.default) {
- if (!name || name === track.name) {
- return track.id;
- }
- }
- }
- return -1;
- }
-
- protected onError(event: Events.ERROR, data: ErrorData): void {
- super.onError(event, data);
- if (data.fatal || !data.context) {
- return;
- }
-
- if (
- data.context.type === PlaylistContextType.SUBTITLE_TRACK &&
- data.context.id === this.trackId &&
- data.context.groupId === this.groupId
- ) {
- this.retryLoadingOrFail(data);
- }
- }
-
- /** 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;
- const lastTrack = this.tracksInGroup
- ? this.tracksInGroup[this.trackId]
- : undefined;
- this.setSubtitleTrack(newId, lastTrack);
- }
-
- protected loadPlaylist(hlsUrlParameters?: HlsUrlParameters): void {
- const currentTrack = this.tracksInGroup[this.trackId];
- if (this.shouldLoadTrack(currentTrack)) {
- const id = currentTrack.id;
- const groupId = currentTrack.groupId as string;
- let url = currentTrack.url;
- if (hlsUrlParameters) {
- try {
- url = hlsUrlParameters.addDirectives(url);
- } catch (error) {
- this.warn(
- `Could not construct new URL with HLS Delivery Directives: ${error}`
- );
- }
- }
- this.log(`Loading subtitle playlist for id ${id}`);
- this.hls.trigger(Events.SUBTITLE_TRACK_LOADING, {
- url,
- id,
- groupId,
- deliveryDirectives: hlsUrlParameters || null,
- });
- }
- }
-
- /**
- * 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(newId: number): void {
- const { media, subtitleDisplay, trackId } = this;
- if (!media) {
- return;
- }
-
- const textTracks = filterSubtitleTracks(media.textTracks);
- const groupTracks = textTracks.filter(
- (track) => (track as any).groupId === this.groupId
- );
- if (newId === -1) {
- [].slice.call(textTracks).forEach((track) => {
- track.mode = 'disabled';
- });
- } else {
- const oldTrack = groupTracks[trackId];
- if (oldTrack) {
- oldTrack.mode = 'disabled';
- }
- }
-
- const nextTrack = groupTracks[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.
- */
- private setSubtitleTrack(
- newId: number,
- lastTrack: MediaPlaylist | undefined
- ): 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;
- }
-
- if (this.trackId !== newId) {
- this.toggleTrackModes(newId);
- }
-
- // exit if track id as already set or invalid
- if (
- (this.trackId === newId && (newId === -1 || tracks[newId]?.details)) ||
- newId < -1 ||
- newId >= tracks.length
- ) {
- return;
- }
-
- // stopping live reloading timer if any
- this.clearTimer();
-
- const track = tracks[newId];
- this.log(`Switching to subtitle track ${newId}`);
- this.trackId = newId;
- if (track) {
- const { url, type, id } = track;
- this.hls.trigger(Events.SUBTITLE_TRACK_SWITCH, { id, type, url });
- const hlsUrlParameters = this.switchParams(track.url, lastTrack?.details);
- this.loadPlaylist(hlsUrlParameters);
- } else {
- // switch to -1
- this.hls.trigger(Events.SUBTITLE_TRACK_SWITCH, { id: newId });
- }
- }
-
- private onTextTracksChanged(): void {
- // Media is undefined when switching streams via loadSource()
- if (!this.media || !this.hls.config.renderTextTracksNatively) {
- return;
- }
-
- let trackId: number = -1;
- const 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: TextTrackList): TextTrack[] {
- const tracks: TextTrack[] = [];
- 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;