From 9cafa433b53a4b356f358ae8c5a1a1e4be22f34e Mon Sep 17 00:00:00 2001 From: Rob Walch Date: Tue, 4 Apr 2023 18:23:42 -0700 Subject: [PATCH] Do not clear subtitle cues or fragment tracking when updated subtitle tracks are identical (#5365) Fixes #5361 --- src/controller/subtitle-stream-controller.ts | 18 +++++--- src/controller/timeline-controller.ts | 27 ++++++----- src/utils/media-option-attributes.ts | 45 +++++++++++++++++++ .../controller/subtitle-stream-controller.js | 13 +++++- tests/unit/controller/timeline-controller.js | 17 +++---- 5 files changed, 95 insertions(+), 25 deletions(-) create mode 100644 src/utils/media-option-attributes.ts diff --git a/src/controller/subtitle-stream-controller.ts b/src/controller/subtitle-stream-controller.ts index b1ecf80612f..11a12534593 100644 --- a/src/controller/subtitle-stream-controller.ts +++ b/src/controller/subtitle-stream-controller.ts @@ -7,6 +7,7 @@ import { FragmentState } from './fragment-tracker'; import BaseStreamController, { State } from './base-stream-controller'; import { PlaylistLevelType } from '../types/loader'; import { Level } from '../types/level'; +import { subtitleOptionsIdentical } from '../utils/media-option-attributes'; import { ErrorDetails, ErrorTypes } from '../errors'; import type { NetworkComponentAPI } from '../types/component-api'; import type Hls from '../hls'; @@ -221,19 +222,24 @@ export class SubtitleStreamController event: Events.SUBTITLE_TRACKS_UPDATED, { subtitleTracks }: SubtitleTracksUpdatedData ) { + if (subtitleOptionsIdentical(this.levels, subtitleTracks)) { + this.levels = subtitleTracks.map( + (mediaPlaylist) => new Level(mediaPlaylist) + ); + return; + } this.tracksBuffered = []; - this.levels = subtitleTracks.map( - (mediaPlaylist) => new Level(mediaPlaylist) - ); + this.levels = subtitleTracks.map((mediaPlaylist) => { + const level = new Level(mediaPlaylist); + this.tracksBuffered[level.id] = []; + return level; + }); this.fragmentTracker.removeFragmentsInRange( 0, Number.POSITIVE_INFINITY, PlaylistLevelType.SUBTITLE ); this.fragPrevious = null; - this.levels.forEach((level: Level) => { - this.tracksBuffered[level.id] = []; - }); this.mediaBuffer = null; } diff --git a/src/controller/timeline-controller.ts b/src/controller/timeline-controller.ts index 269949ef446..d8c2a5b1f5a 100644 --- a/src/controller/timeline-controller.ts +++ b/src/controller/timeline-controller.ts @@ -8,6 +8,7 @@ import { addCueToTrack, removeCuesInRange, } 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'; @@ -329,20 +330,23 @@ export class TimelineController implements ComponentAPI { event: Events.SUBTITLE_TRACKS_UPDATED, data: SubtitleTracksUpdatedData ) { - this.textTracks = []; const tracks: Array = data.subtitleTracks || []; const hasIMSC1 = tracks.some((track) => track.textCodec === IMSC1_CODEC); if (this.config.enableWebVTT || (hasIMSC1 && this.config.enableIMSC1)) { - const sameTracks = - this.tracks && tracks && this.tracks.length === tracks.length; - this.tracks = tracks || []; + const listIsIdentical = subtitleOptionsIdentical(this.tracks, tracks); + if (listIsIdentical) { + this.tracks = tracks; + return; + } + this.textTracks = []; + this.tracks = tracks; if (this.config.renderTextTracksNatively) { - const inUseTracks = this.media ? this.media.textTracks : []; + const inUseTracks = this.media ? this.media.textTracks : null; this.tracks.forEach((track, index) => { let textTrack: TextTrack | undefined; - if (index < inUseTracks.length) { + if (inUseTracks && index < inUseTracks.length) { let inUseTrack: TextTrack | null = null; for (let i = 0; i < inUseTracks.length; i++) { @@ -376,7 +380,7 @@ export class TimelineController implements ComponentAPI { this.textTracks.push(textTrack); } }); - } else if (!sameTracks && this.tracks && this.tracks.length) { + } else if (this.tracks.length) { // Create a list of tracks for the provider to consume const tracksList = this.tracks.map((track) => { return { @@ -396,7 +400,7 @@ export class TimelineController implements ComponentAPI { private _captionsOrSubtitlesFromCharacteristics( track: MediaPlaylist ): TextTrackKind { - if (track.attrs?.CHARACTERISTICS) { + if (track.attrs.CHARACTERISTICS) { const transcribesSpokenDialog = /transcribes-spoken-dialog/gi.test( track.attrs.CHARACTERISTICS ); @@ -728,9 +732,12 @@ export class TimelineController implements ComponentAPI { } } -function canReuseVttTextTrack(inUseTrack, manifestTrack): boolean { +function canReuseVttTextTrack( + inUseTrack: (TextTrack & { textTrack1?; textTrack2? }) | null, + manifestTrack: MediaPlaylist +): boolean { return ( - inUseTrack && + !!inUseTrack && inUseTrack.label === manifestTrack.name && !(inUseTrack.textTrack1 || inUseTrack.textTrack2) ); diff --git a/src/utils/media-option-attributes.ts b/src/utils/media-option-attributes.ts new file mode 100644 index 00000000000..db063a41cdd --- /dev/null +++ b/src/utils/media-option-attributes.ts @@ -0,0 +1,45 @@ +import type { Level } from '../types/level'; +import type { MediaAttributes, MediaPlaylist } from '../types/media-playlist'; + +export function subtitleOptionsIdentical( + trackList1: MediaPlaylist[] | Level[], + trackList2: MediaPlaylist[] +): boolean { + if (trackList1.length !== trackList2.length) { + return false; + } + for (let i = 0; i < trackList1.length; i++) { + if ( + !subtitleAttributesIdentical( + trackList1[i].attrs as MediaAttributes, + trackList2[i].attrs + ) + ) { + return false; + } + } + return true; +} + +export function subtitleAttributesIdentical( + attrs1: MediaAttributes, + attrs2: MediaAttributes +): boolean { + // Media options with the same rendition ID must be bit identical + const stableRenditionId = attrs1['STABLE-RENDITION-ID']; + if (stableRenditionId) { + return stableRenditionId === attrs2['STABLE-RENDITION-ID']; + } + // When rendition ID is not present, compare attributes + return ![ + 'LANGUAGE', + 'NAME', + 'CHARACTERISTICS', + 'AUTOSELECT', + 'DEFAULT', + 'FORCED', + ].some( + (subtitleAttribute) => + attrs1[subtitleAttribute] !== attrs2[subtitleAttribute] + ); +} diff --git a/tests/unit/controller/subtitle-stream-controller.js b/tests/unit/controller/subtitle-stream-controller.js index 0f79ee98d1c..cef9f4547c0 100644 --- a/tests/unit/controller/subtitle-stream-controller.js +++ b/tests/unit/controller/subtitle-stream-controller.js @@ -12,7 +12,17 @@ const mediaMock = { removeEventListener() {}, }; -const tracksMock = [{ id: 0, details: { url: '', fragments: [] } }, { id: 1 }]; +const tracksMock = [ + { + id: 0, + details: { url: '', fragments: [] }, + attrs: {}, + }, + { + id: 1, + attrs: {}, + }, +]; describe('SubtitleStreamController', function () { let hls; @@ -41,6 +51,7 @@ describe('SubtitleStreamController', function () { subtitleStreamController.onMediaDetaching(Events.MEDIA_DETACHING, { media: mediaMock, }); + hls.destroy(); }); describe('onSubtitleTracksUpdate', function () { diff --git a/tests/unit/controller/timeline-controller.js b/tests/unit/controller/timeline-controller.js index 81686783233..a4e1da8b619 100644 --- a/tests/unit/controller/timeline-controller.js +++ b/tests/unit/controller/timeline-controller.js @@ -22,8 +22,8 @@ describe('TimelineController', function () { Events.SUBTITLE_TRACKS_UPDATED, { subtitleTracks: [ - { id: 0, name: 'en' }, - { id: 1, name: 'ru' }, + { id: 0, name: 'en', attrs: { LANGUAGE: 'en', NAME: 'en' } }, + { id: 1, name: 'ru', attrs: { LANGUAGE: 'ru', NAME: 'ru' } }, ], } ); @@ -41,8 +41,8 @@ describe('TimelineController', function () { Events.SUBTITLE_TRACKS_UPDATED, { subtitleTracks: [ - { id: 0, name: 'en' }, - { id: 1, name: 'ru' }, + { id: 0, name: 'en', attrs: { LANGUAGE: 'en', NAME: 'en' } }, + { id: 1, name: 'ru', attrs: { LANGUAGE: 'ru', NAME: 'ru' } }, ], } ); @@ -62,8 +62,8 @@ describe('TimelineController', function () { timelineController.onSubtitleTracksUpdated(Events.MANIFEST_LOADED, { subtitleTracks: [ - { id: 0, name: 'en' }, - { id: 1, name: 'ru' }, + { id: 0, name: 'en', attrs: { LANGUAGE: 'en', NAME: 'en' } }, + { id: 1, name: 'ru', attrs: { LANGUAGE: 'ru', NAME: 'ru' } }, ], }); @@ -78,8 +78,8 @@ describe('TimelineController', function () { timelineController.onSubtitleTracksUpdated(Events.MANIFEST_LOADED, { subtitleTracks: [ - { id: 0, name: 'ru' }, - { id: 1, name: 'en' }, + { id: 0, name: 'ru', attrs: { LANGUAGE: 'ru', NAME: 'ru' } }, + { id: 1, name: 'en', attrs: { LANGUAGE: 'en', NAME: 'en' } }, ], }); @@ -146,6 +146,7 @@ describe('TimelineController', function () { { id: 0, name: 'en', + attrs: {}, }, ], });