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