Home Reference Source

src/controller/subtitle-track-controller.ts

  1. import { Events } from '../events';
  2. import { clearCurrentCues } from '../utils/texttrack-utils';
  3. import BasePlaylistController from './base-playlist-controller';
  4. import type { HlsUrlParameters } from '../types/level';
  5. import type Hls from '../hls';
  6. import type {
  7. TrackLoadedData,
  8. MediaAttachedData,
  9. SubtitleTracksUpdatedData,
  10. ManifestParsedData,
  11. } from '../types/events';
  12. import type { MediaPlaylist } from '../types/media-playlist';
  13. import { ErrorData, LevelLoadingData } from '../types/events';
  14. import { PlaylistContextType } from '../types/loader';
  15.  
  16. class SubtitleTrackController extends BasePlaylistController {
  17. private media: HTMLMediaElement | null = null;
  18. private tracks: MediaPlaylist[] = [];
  19. private groupId: string | null = null;
  20. private tracksInGroup: MediaPlaylist[] = [];
  21. private trackId: number = -1;
  22. private selectDefaultTrack: boolean = true;
  23. private queuedDefaultTrack: number = -1;
  24. private trackChangeListener: () => void = () => this.onTextTracksChanged();
  25. private useTextTrackPolling: boolean = false;
  26. private subtitlePollingInterval: number = -1;
  27.  
  28. public subtitleDisplay: boolean = true; // Enable/disable subtitle display rendering
  29.  
  30. constructor(hls: Hls) {
  31. super(hls, '[subtitle-track-controller]');
  32. this.registerListeners();
  33. }
  34.  
  35. public destroy() {
  36. this.unregisterListeners();
  37. super.destroy();
  38. }
  39.  
  40. private registerListeners() {
  41. const { hls } = this;
  42. hls.on(Events.MEDIA_ATTACHED, this.onMediaAttached, this);
  43. hls.on(Events.MEDIA_DETACHING, this.onMediaDetaching, this);
  44. hls.on(Events.MANIFEST_LOADING, this.onManifestLoading, this);
  45. hls.on(Events.MANIFEST_PARSED, this.onManifestParsed, this);
  46. hls.on(Events.LEVEL_LOADING, this.onLevelLoading, this);
  47. hls.on(Events.SUBTITLE_TRACK_LOADED, this.onSubtitleTrackLoaded, this);
  48. hls.on(Events.ERROR, this.onError, this);
  49. }
  50.  
  51. private unregisterListeners() {
  52. const { hls } = this;
  53. hls.off(Events.MEDIA_ATTACHED, this.onMediaAttached, this);
  54. hls.off(Events.MEDIA_DETACHING, this.onMediaDetaching, this);
  55. hls.off(Events.MANIFEST_LOADING, this.onManifestLoading, this);
  56. hls.off(Events.MANIFEST_PARSED, this.onManifestParsed, this);
  57. hls.off(Events.LEVEL_LOADING, this.onLevelLoading, this);
  58. hls.off(Events.SUBTITLE_TRACK_LOADED, this.onSubtitleTrackLoaded, this);
  59. hls.off(Events.ERROR, this.onError, this);
  60. }
  61.  
  62. // Listen for subtitle track change, then extract the current track ID.
  63. protected onMediaAttached(
  64. event: Events.MEDIA_ATTACHED,
  65. data: MediaAttachedData
  66. ): void {
  67. this.media = data.media;
  68. if (!this.media) {
  69. return;
  70. }
  71.  
  72. if (this.queuedDefaultTrack > -1) {
  73. this.subtitleTrack = this.queuedDefaultTrack;
  74. this.queuedDefaultTrack = -1;
  75. }
  76.  
  77. this.useTextTrackPolling = !(
  78. this.media.textTracks && 'onchange' in this.media.textTracks
  79. );
  80. if (this.useTextTrackPolling) {
  81. self.clearInterval(this.subtitlePollingInterval);
  82. this.subtitlePollingInterval = self.setInterval(() => {
  83. this.trackChangeListener();
  84. }, 500);
  85. } else {
  86. this.media.textTracks.addEventListener(
  87. 'change',
  88. this.trackChangeListener
  89. );
  90. }
  91. }
  92.  
  93. protected onMediaDetaching(): void {
  94. if (!this.media) {
  95. return;
  96. }
  97.  
  98. if (this.useTextTrackPolling) {
  99. self.clearInterval(this.subtitlePollingInterval);
  100. } else {
  101. this.media.textTracks.removeEventListener(
  102. 'change',
  103. this.trackChangeListener
  104. );
  105. }
  106.  
  107. if (this.trackId > -1) {
  108. this.queuedDefaultTrack = this.trackId;
  109. }
  110.  
  111. const textTracks = filterSubtitleTracks(this.media.textTracks);
  112. // Clear loaded cues on media detachment from tracks
  113. textTracks.forEach((track) => {
  114. clearCurrentCues(track);
  115. });
  116. // Disable all subtitle tracks before detachment so when reattached only tracks in that content are enabled.
  117. this.subtitleTrack = -1;
  118. this.media = null;
  119. }
  120.  
  121. protected onManifestLoading(): void {
  122. this.tracks = [];
  123. this.groupId = null;
  124. this.tracksInGroup = [];
  125. this.trackId = -1;
  126. this.selectDefaultTrack = true;
  127. }
  128.  
  129. // Fired whenever a new manifest is loaded.
  130. protected onManifestParsed(
  131. event: Events.MANIFEST_PARSED,
  132. data: ManifestParsedData
  133. ): void {
  134. this.tracks = data.subtitleTracks;
  135. }
  136.  
  137. protected onSubtitleTrackLoaded(
  138. event: Events.SUBTITLE_TRACK_LOADED,
  139. data: TrackLoadedData
  140. ): void {
  141. const { id, details } = data;
  142. const { trackId } = this;
  143. const currentTrack = this.tracksInGroup[trackId];
  144.  
  145. if (!currentTrack) {
  146. this.warn(`Invalid subtitle track id ${id}`);
  147. return;
  148. }
  149.  
  150. const curDetails = currentTrack.details;
  151. currentTrack.details = data.details;
  152. this.log(
  153. `subtitle track ${id} loaded [${details.startSN}-${details.endSN}]`
  154. );
  155.  
  156. if (id === this.trackId) {
  157. this.retryCount = 0;
  158. this.playlistLoaded(id, data, curDetails);
  159. }
  160. }
  161.  
  162. protected onLevelLoading(
  163. event: Events.LEVEL_LOADING,
  164. data: LevelLoadingData
  165. ): void {
  166. const levelInfo = this.hls.levels[data.level];
  167.  
  168. if (!levelInfo?.textGroupIds) {
  169. return;
  170. }
  171.  
  172. const textGroupId = levelInfo.textGroupIds[levelInfo.urlId];
  173. if (this.groupId !== textGroupId) {
  174. const lastTrack = this.tracksInGroup
  175. ? this.tracksInGroup[this.trackId]
  176. : undefined;
  177. const initialTrackId =
  178. this.findTrackId(lastTrack?.name) || this.findTrackId();
  179. const subtitleTracks = this.tracks.filter(
  180. (track): boolean => !textGroupId || track.groupId === textGroupId
  181. );
  182. this.groupId = textGroupId;
  183. this.tracksInGroup = subtitleTracks;
  184. const subtitleTracksUpdated: SubtitleTracksUpdatedData = {
  185. subtitleTracks,
  186. };
  187. this.log(
  188. `Updating subtitle tracks, ${subtitleTracks.length} track(s) found in "${textGroupId}" group-id`
  189. );
  190. this.hls.trigger(Events.SUBTITLE_TRACKS_UPDATED, subtitleTracksUpdated);
  191.  
  192. if (initialTrackId !== -1) {
  193. this.setSubtitleTrack(initialTrackId, lastTrack);
  194. }
  195. }
  196. }
  197.  
  198. private findTrackId(name?: string): number {
  199. const audioTracks = this.tracksInGroup;
  200. for (let i = 0; i < audioTracks.length; i++) {
  201. const track = audioTracks[i];
  202. if (!this.selectDefaultTrack || track.default) {
  203. if (!name || name === track.name) {
  204. return track.id;
  205. }
  206. }
  207. }
  208. return -1;
  209. }
  210.  
  211. protected onError(event: Events.ERROR, data: ErrorData): void {
  212. super.onError(event, data);
  213. if (data.fatal || !data.context) {
  214. return;
  215. }
  216.  
  217. if (
  218. data.context.type === PlaylistContextType.SUBTITLE_TRACK &&
  219. data.context.id === this.trackId &&
  220. data.context.groupId === this.groupId
  221. ) {
  222. this.retryLoadingOrFail(data);
  223. }
  224. }
  225.  
  226. /** get alternate subtitle tracks list from playlist **/
  227. get subtitleTracks(): MediaPlaylist[] {
  228. return this.tracksInGroup;
  229. }
  230.  
  231. /** get index of the selected subtitle track (index in subtitle track lists) **/
  232. get subtitleTrack(): number {
  233. return this.trackId;
  234. }
  235.  
  236. /** select a subtitle track, based on its index in subtitle track lists**/
  237. set subtitleTrack(newId: number) {
  238. this.selectDefaultTrack = false;
  239. const lastTrack = this.tracksInGroup
  240. ? this.tracksInGroup[this.trackId]
  241. : undefined;
  242. this.setSubtitleTrack(newId, lastTrack);
  243. }
  244.  
  245. protected loadPlaylist(hlsUrlParameters?: HlsUrlParameters): void {
  246. const currentTrack = this.tracksInGroup[this.trackId];
  247. if (this.shouldLoadTrack(currentTrack)) {
  248. const id = currentTrack.id;
  249. const groupId = currentTrack.groupId as string;
  250. let url = currentTrack.url;
  251. if (hlsUrlParameters) {
  252. try {
  253. url = hlsUrlParameters.addDirectives(url);
  254. } catch (error) {
  255. this.warn(
  256. `Could not construct new URL with HLS Delivery Directives: ${error}`
  257. );
  258. }
  259. }
  260. this.log(`Loading subtitle playlist for id ${id}`);
  261. this.hls.trigger(Events.SUBTITLE_TRACK_LOADING, {
  262. url,
  263. id,
  264. groupId,
  265. deliveryDirectives: hlsUrlParameters || null,
  266. });
  267. }
  268. }
  269.  
  270. /**
  271. * Disables the old subtitleTrack and sets current mode on the next subtitleTrack.
  272. * This operates on the DOM textTracks.
  273. * A value of -1 will disable all subtitle tracks.
  274. */
  275. private toggleTrackModes(newId: number): void {
  276. const { media, subtitleDisplay, trackId } = this;
  277. if (!media) {
  278. return;
  279. }
  280.  
  281. const textTracks = filterSubtitleTracks(media.textTracks);
  282. const groupTracks = textTracks.filter(
  283. (track) => (track as any).groupId === this.groupId
  284. );
  285. if (newId === -1) {
  286. [].slice.call(textTracks).forEach((track) => {
  287. track.mode = 'disabled';
  288. });
  289. } else {
  290. const oldTrack = groupTracks[trackId];
  291. if (oldTrack) {
  292. oldTrack.mode = 'disabled';
  293. }
  294. }
  295.  
  296. const nextTrack = groupTracks[newId];
  297. if (nextTrack) {
  298. nextTrack.mode = subtitleDisplay ? 'showing' : 'hidden';
  299. }
  300. }
  301.  
  302. /**
  303. * This method is responsible for validating the subtitle index and periodically reloading if live.
  304. * Dispatches the SUBTITLE_TRACK_SWITCH event, which instructs the subtitle-stream-controller to load the selected track.
  305. */
  306. private setSubtitleTrack(
  307. newId: number,
  308. lastTrack: MediaPlaylist | undefined
  309. ): void {
  310. const tracks = this.tracksInGroup;
  311.  
  312. // setting this.subtitleTrack will trigger internal logic
  313. // if media has not been attached yet, it will fail
  314. // we keep a reference to the default track id
  315. // and we'll set subtitleTrack when onMediaAttached is triggered
  316. if (!this.media) {
  317. this.queuedDefaultTrack = newId;
  318. return;
  319. }
  320.  
  321. if (this.trackId !== newId) {
  322. this.toggleTrackModes(newId);
  323. }
  324.  
  325. // exit if track id as already set or invalid
  326. if (
  327. (this.trackId === newId && (newId === -1 || tracks[newId]?.details)) ||
  328. newId < -1 ||
  329. newId >= tracks.length
  330. ) {
  331. return;
  332. }
  333.  
  334. // stopping live reloading timer if any
  335. this.clearTimer();
  336.  
  337. const track = tracks[newId];
  338. this.log(`Switching to subtitle track ${newId}`);
  339. this.trackId = newId;
  340. if (track) {
  341. const { url, type, id } = track;
  342. this.hls.trigger(Events.SUBTITLE_TRACK_SWITCH, { id, type, url });
  343. const hlsUrlParameters = this.switchParams(track.url, lastTrack?.details);
  344. this.loadPlaylist(hlsUrlParameters);
  345. } else {
  346. // switch to -1
  347. this.hls.trigger(Events.SUBTITLE_TRACK_SWITCH, { id: newId });
  348. }
  349. }
  350.  
  351. private onTextTracksChanged(): void {
  352. // Media is undefined when switching streams via loadSource()
  353. if (!this.media || !this.hls.config.renderTextTracksNatively) {
  354. return;
  355. }
  356.  
  357. let trackId: number = -1;
  358. const tracks = filterSubtitleTracks(this.media.textTracks);
  359. for (let id = 0; id < tracks.length; id++) {
  360. if (tracks[id].mode === 'hidden') {
  361. // Do not break in case there is a following track with showing.
  362. trackId = id;
  363. } else if (tracks[id].mode === 'showing') {
  364. trackId = id;
  365. break;
  366. }
  367. }
  368.  
  369. // Setting current subtitleTrack will invoke code.
  370. this.subtitleTrack = trackId;
  371. }
  372. }
  373.  
  374. function filterSubtitleTracks(textTrackList: TextTrackList): TextTrack[] {
  375. const tracks: TextTrack[] = [];
  376. for (let i = 0; i < textTrackList.length; i++) {
  377. const track = textTrackList[i];
  378. // Edge adds a track without a label; we don't want to use it
  379. if (track.kind === 'subtitles' && track.label) {
  380. tracks.push(textTrackList[i]);
  381. }
  382. }
  383. return tracks;
  384. }
  385.  
  386. export default SubtitleTrackController;