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