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