From e48894c28324337f6e2a1ab3f381dbfc9c6df28a Mon Sep 17 00:00:00 2001 From: Rob Walch Date: Wed, 4 Oct 2023 18:34:20 -0700 Subject: [PATCH] Use subtitle track name and language to find and match subtitle/captions TextTrack rather than subtitleTrack index Resolves #4571 --- src/controller/audio-track-controller.ts | 7 ++ src/controller/subtitle-track-controller.ts | 111 +++++++++++-------- src/controller/timeline-controller.ts | 47 +++++--- src/utils/texttrack-utils.ts | 17 +++ tests/unit/controller/timeline-controller.js | 8 +- 5 files changed, 123 insertions(+), 67 deletions(-) diff --git a/src/controller/audio-track-controller.ts b/src/controller/audio-track-controller.ts index 4f8b894ce2c..fdea6a382a8 100644 --- a/src/controller/audio-track-controller.ts +++ b/src/controller/audio-track-controller.ts @@ -277,6 +277,13 @@ class AudioTrackController extends BasePlaylistController { ) { return track.id; } + if ( + mediaAttributesIdentical(currentTrack.attrs, track.attrs, [ + 'LANGUAGE', + ]) + ) { + return track.id; + } } } return -1; diff --git a/src/controller/subtitle-track-controller.ts b/src/controller/subtitle-track-controller.ts index ae9bd57e785..c31a83f85d2 100644 --- a/src/controller/subtitle-track-controller.ts +++ b/src/controller/subtitle-track-controller.ts @@ -1,6 +1,9 @@ import BasePlaylistController from './base-playlist-controller'; import { Events } from '../events'; -import { clearCurrentCues } from '../utils/texttrack-utils'; +import { + clearCurrentCues, + filterSubtitleTracks, +} from '../utils/texttrack-utils'; import { PlaylistContextType } from '../types/loader'; import { mediaAttributesIdentical } from '../utils/media-option-attributes'; import type Hls from '../hls'; @@ -52,7 +55,7 @@ class SubtitleTrackController extends BasePlaylistController { public set subtitleDisplay(value: boolean) { this._subtitleDisplay = value; if (this.trackId > -1) { - this.toggleTrackModes(this.trackId); + this.toggleTrackModes(); } } @@ -251,9 +254,9 @@ class SubtitleTrackController extends BasePlaylistController { } private findTrackId(currentTrack: MediaPlaylist | null): number { - const textTracks = this.tracksInGroup; - for (let i = 0; i < textTracks.length; i++) { - const track = textTracks[i]; + const tracks = this.tracksInGroup; + for (let i = 0; i < tracks.length; i++) { + const track = tracks[i]; if ( !currentTrack || mediaAttributesIdentical(currentTrack.attrs, track.attrs) @@ -269,6 +272,27 @@ class SubtitleTrackController extends BasePlaylistController { ) { return track.id; } + if ( + mediaAttributesIdentical(currentTrack.attrs, track.attrs, ['LANGUAGE']) + ) { + return track.id; + } + } + return -1; + } + + private findTrackForTextTrack(textTrack: TextTrack | null): number { + if (textTrack) { + const tracks = this.tracksInGroup; + for (let i = 0; i < tracks.length; i++) { + const track = tracks[i]; + if ( + textTrack.label === track.name && + textTrack.language === track.lang + ) { + return i; + } + } } return -1; } @@ -337,31 +361,36 @@ class SubtitleTrackController extends BasePlaylistController { * This operates on the DOM textTracks. * A value of -1 will disable all subtitle tracks. */ - private toggleTrackModes(newId: number): void { - const { media, trackId } = this; + private toggleTrackModes(): void { + const { media } = this; if (!media) { return; } const textTracks = filterSubtitleTracks(media.textTracks); - const groupIds = this.groupIds; - const groupTracks = textTracks.filter( - (track) => !groupIds || groupIds.indexOf((track as any).groupId) !== -1, - ); - if (newId === -1) { - [].slice.call(textTracks).forEach((track) => { - track.mode = 'disabled'; - }); - } else { - const oldTrack = groupTracks[trackId]; - if (oldTrack) { - oldTrack.mode = 'disabled'; + const currentTrack = this.currentTrack; + let nextTrack; + if (currentTrack) { + const { name, lang } = currentTrack; + nextTrack = textTracks.filter( + (track) => track.label === name && track.language === lang, + )[0]; + if (!nextTrack) { + this.warn( + `Unable to find subtitle TextTrack with name "${name}" and language "${lang}"`, + ); } } - - const nextTrack = groupTracks[newId]; + [].slice.call(textTracks).forEach((track) => { + if (track.mode !== 'disabled' && track !== nextTrack) { + track.mode = 'disabled'; + } + }); if (nextTrack) { - nextTrack.mode = this.subtitleDisplay ? 'showing' : 'hidden'; + const mode = this.subtitleDisplay ? 'showing' : 'hidden'; + if (nextTrack.mode !== mode) { + nextTrack.mode = mode; + } } } @@ -381,10 +410,6 @@ class SubtitleTrackController extends BasePlaylistController { return; } - if (this.trackId !== newId) { - this.toggleTrackModes(newId); - } - // exit if track id as already set or invalid if (newId < -1 || newId >= tracks.length) { this.warn(`Invalid subtitle track id: ${newId}`); @@ -398,6 +423,7 @@ class SubtitleTrackController extends BasePlaylistController { const track: MediaPlaylist | null = tracks[newId] || null; this.trackId = newId; this.currentTrack = track; + this.toggleTrackModes(); if (!track) { // switch to -1 this.hls.trigger(Events.SUBTITLE_TRACK_SWITCH, { id: newId }); @@ -434,38 +460,25 @@ class SubtitleTrackController extends BasePlaylistController { return; } - let trackId: number = -1; + let textTrack: TextTrack | null = null; const tracks = filterSubtitleTracks(this.media.textTracks); - for (let id = 0; id < tracks.length; id++) { - if (tracks[id].mode === 'hidden') { + for (let i = 0; i < tracks.length; i++) { + if (tracks[i].mode === 'hidden') { // Do not break in case there is a following track with showing. - trackId = id; - } else if (tracks[id].mode === 'showing') { - trackId = id; + textTrack = tracks[i]; + } else if (tracks[i].mode === 'showing') { + textTrack = tracks[i]; break; } } - // Setting current subtitleTrack will invoke code. + // Find internal track index for TextTrack + const trackId = this.findTrackForTextTrack(textTrack); if (this.subtitleTrack !== trackId) { - this.subtitleTrack = trackId; - } - } -} - -function filterSubtitleTracks(textTrackList: TextTrackList): TextTrack[] { - const tracks: TextTrack[] = []; - for (let i = 0; i < textTrackList.length; i++) { - const track = textTrackList[i]; - // Edge adds a track without a label; we don't want to use it - if ( - (track.kind === 'subtitles' || track.kind === 'captions') && - track.label - ) { - tracks.push(textTrackList[i]); + this.selectDefaultTrack = false; + this.setSubtitleTrack(trackId); } } - return tracks; } export default SubtitleTrackController; diff --git a/src/controller/timeline-controller.ts b/src/controller/timeline-controller.ts index 4055b22bdb0..d5d0bc420d0 100644 --- a/src/controller/timeline-controller.ts +++ b/src/controller/timeline-controller.ts @@ -7,13 +7,14 @@ import { clearCurrentCues, addCueToTrack, removeCuesInRange, + filterSubtitleTracks, } from '../utils/texttrack-utils'; import { subtitleOptionsIdentical } from '../utils/media-option-attributes'; import { parseIMSC1, IMSC1_CODEC } from '../utils/imsc1-ttml-parser'; import { appendUint8Array } from '../utils/mp4-tools'; import { PlaylistLevelType } from '../types/loader'; import { Fragment } from '../loader/fragment'; -import { +import type { FragParsingUserdataData, FragLoadedData, FragDecryptedData, @@ -207,12 +208,12 @@ export class TimelineController implements ComponentAPI { } } - private getExistingTrack(trackName: string): TextTrack | null { + private getExistingTrack(label: string, language: string): TextTrack | null { const { media } = this; if (media) { for (let i = 0; i < media.textTracks.length; i++) { const textTrack = media.textTracks[i]; - if (textTrack[trackName]) { + if (canReuseVttTextTrack(textTrack, { name: label, lang: language })) { return textTrack; } } @@ -235,7 +236,7 @@ export class TimelineController implements ComponentAPI { const { captionsProperties, captionsTracks, media } = this; const { label, languageCode } = captionsProperties[trackName]; // Enable reuse of existing text track. - const existingTrack = this.getExistingTrack(trackName); + const existingTrack = this.getExistingTrack(label, languageCode); if (!existingTrack) { const textTrack = this.createTextTrack('captions', label, languageCode); if (textTrack) { @@ -352,21 +353,26 @@ export class TimelineController implements ComponentAPI { this.tracks = tracks; if (this.config.renderTextTracksNatively) { - const inUseTracks = this.media ? this.media.textTracks : null; + const media = this.media; + const inUseTracks: (TextTrack | null)[] | null = media + ? filterSubtitleTracks(media.textTracks) + : null; this.tracks.forEach((track, index) => { + // Reuse tracks with the same label and lang, but do not reuse 608/708 tracks let textTrack: TextTrack | undefined; - if (inUseTracks && index < inUseTracks.length) { + if (inUseTracks) { let inUseTrack: TextTrack | null = null; - for (let i = 0; i < inUseTracks.length; i++) { - if (canReuseVttTextTrack(inUseTracks[i], track)) { + if ( + inUseTracks[i] && + canReuseVttTextTrack(inUseTracks[i], track) + ) { inUseTrack = inUseTracks[i]; + inUseTracks[i] = null; break; } } - - // Reuse tracks with the same label, but do not reuse 608/708 tracks if (inUseTrack) { textTrack = inUseTrack; } @@ -386,10 +392,22 @@ export class TimelineController implements ComponentAPI { } } if (textTrack) { - (textTrack as any).groupId = track.groupId; this.textTracks.push(textTrack); } }); + // Warn when video element has captions or subtitle TextTracks carried over from another source + if (inUseTracks?.length) { + const unusedTextTracks = inUseTracks + .filter((t) => t !== null) + .map((t) => (t as TextTrack).label); + if (unusedTextTracks.length) { + logger.warn( + `Media element contains unused subtitle tracks: ${unusedTextTracks.join( + ', ', + )}. Replace media element for each source to clear TextTracks and captions menu.`, + ); + } + } } else if (this.tracks.length) { // Create a list of tracks for the provider to consume const tracksList = this.tracks.map((track) => { @@ -744,13 +762,14 @@ export class TimelineController implements ComponentAPI { } function canReuseVttTextTrack( - inUseTrack: (TextTrack & { textTrack1?; textTrack2? }) | null, - manifestTrack: MediaPlaylist, + inUseTrack: TextTrack | null, + manifestTrack: Pick, ): boolean { return ( !!inUseTrack && + (inUseTrack.kind === 'subtitles' || inUseTrack.kind === 'captions') && inUseTrack.label === manifestTrack.name && - !(inUseTrack.textTrack1 || inUseTrack.textTrack2) + inUseTrack.language.substring(0, 2) === manifestTrack.lang?.substring(0, 2) ); } diff --git a/src/utils/texttrack-utils.ts b/src/utils/texttrack-utils.ts index a07e1d1512b..b1a164ed29a 100644 --- a/src/utils/texttrack-utils.ts +++ b/src/utils/texttrack-utils.ts @@ -148,3 +148,20 @@ export function getCuesInRange( } return cuesFound; } + +export function filterSubtitleTracks( + textTrackList: TextTrackList, +): TextTrack[] { + const tracks: TextTrack[] = []; + for (let i = 0; i < textTrackList.length; i++) { + const track = textTrackList[i]; + // Edge adds a track without a label; we don't want to use it + if ( + (track.kind === 'subtitles' || track.kind === 'captions') && + track.label + ) { + tracks.push(textTrackList[i]); + } + } + return tracks; +} diff --git a/tests/unit/controller/timeline-controller.js b/tests/unit/controller/timeline-controller.js index d4ebd45ed6d..1235442bd24 100644 --- a/tests/unit/controller/timeline-controller.js +++ b/tests/unit/controller/timeline-controller.js @@ -62,8 +62,8 @@ describe('TimelineController', function () { timelineController.onSubtitleTracksUpdated(Events.MANIFEST_LOADED, { subtitleTracks: [ - { id: 0, name: 'en', attrs: { LANGUAGE: 'en', NAME: 'en' } }, - { id: 1, name: 'ru', attrs: { LANGUAGE: 'ru', NAME: 'ru' } }, + { id: 0, name: 'en', lang: 'en', attrs: { LANGUAGE: 'en', NAME: 'en' } }, + { id: 1, name: 'ru', lang: 'ru', attrs: { LANGUAGE: 'ru', NAME: 'ru' } }, ], }); @@ -78,8 +78,8 @@ describe('TimelineController', function () { timelineController.onSubtitleTracksUpdated(Events.MANIFEST_LOADED, { subtitleTracks: [ - { id: 0, name: 'ru', attrs: { LANGUAGE: 'ru', NAME: 'ru' } }, - { id: 1, name: 'en', attrs: { LANGUAGE: 'en', NAME: 'en' } }, + { id: 0, name: 'ru', lang: 'ru', attrs: { LANGUAGE: 'ru', NAME: 'ru' } }, + { id: 1, name: 'en', lang: 'en', attrs: { LANGUAGE: 'en', NAME: 'en' } }, ], });