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