From cd120944fc401efde0bdc48f2fbc69f29e2f8cb1 Mon Sep 17 00:00:00 2001 From: Rob Walch Date: Wed, 10 Jan 2024 15:42:05 -0800 Subject: [PATCH] Fix regression where subtitle options with AUTOSELECT and FORCED are enabled at start (#6094) * Do not enable subtitle options with AUTOSELECT=YES attribute * Update and add initial selection tests for subtitle-controller * Only pick forced subtitle option if it is the only one Add default field to audio and subtitle selection options and forced field to subtitle selection option * Address TextTrack change event overriding subtitle preference Fix _TRACKS_UPDATED and _TRACK_SWITCH event order when preference is selected * Do not auto select subtitle options with FORCED=YES attribute --- api-extractor/report/hls.js.api.md | 3 + src/controller/audio-track-controller.ts | 20 +- src/controller/subtitle-track-controller.ts | 44 +- src/types/media-playlist.ts | 3 + src/utils/rendition-helper.ts | 12 +- .../controller/subtitle-track-controller.js | 372 ----------- .../controller/subtitle-track-controller.ts | 579 ++++++++++++++++++ 7 files changed, 638 insertions(+), 395 deletions(-) delete mode 100644 tests/unit/controller/subtitle-track-controller.js create mode 100644 tests/unit/controller/subtitle-track-controller.ts diff --git a/api-extractor/report/hls.js.api.md b/api-extractor/report/hls.js.api.md index d2be7789ad7..24aa184abd6 100644 --- a/api-extractor/report/hls.js.api.md +++ b/api-extractor/report/hls.js.api.md @@ -129,6 +129,7 @@ export type AudioSelectionOption = { name?: string; audioCodec?: string; groupId?: string; + default?: boolean; }; // Warning: (ae-missing-release-tag) "AudioStreamController" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) @@ -3164,6 +3165,8 @@ export type SubtitleSelectionOption = { characteristics?: string; name?: string; groupId?: string; + default?: boolean; + forced?: boolean; }; // Warning: (ae-missing-release-tag) "SubtitleStreamController" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) diff --git a/src/controller/audio-track-controller.ts b/src/controller/audio-track-controller.ts index bfb876842e4..2cb4b65f416 100644 --- a/src/controller/audio-track-controller.ts +++ b/src/controller/audio-track-controller.ts @@ -157,17 +157,31 @@ class AudioTrackController extends BasePlaylistController { // Do not dispatch AUDIO_TRACKS_UPDATED when there were and are no tracks return; } + this.tracksInGroup = audioTracks; - if (!currentTrack) { - currentTrack = this.setAudioOption(this.hls.config.audioPreference); + // Find preferred track + const audioPreference = this.hls.config.audioPreference; + if (!currentTrack && audioPreference) { + const groupIndex = findMatchingOption( + audioPreference, + audioTracks, + audioMatchPredicate, + ); + if (groupIndex > -1) { + currentTrack = audioTracks[groupIndex]; + } else { + const allIndex = findMatchingOption(audioPreference, this.tracks); + currentTrack = this.tracks[allIndex]; + } } - this.tracksInGroup = audioTracks; + // Select initial track let trackId = this.findTrackId(currentTrack); if (trackId === -1 && currentTrack) { trackId = this.findTrackId(null); } + // Dispatch events and load track if needed const audioTracksUpdated: AudioTracksUpdatedData = { audioTracks }; this.log( `Updating audio tracks, ${ diff --git a/src/controller/subtitle-track-controller.ts b/src/controller/subtitle-track-controller.ts index 24b8f03540d..75e28294a9b 100644 --- a/src/controller/subtitle-track-controller.ts +++ b/src/controller/subtitle-track-controller.ts @@ -35,7 +35,6 @@ class SubtitleTrackController extends BasePlaylistController { private currentTrack: MediaPlaylist | null = null; private selectDefaultTrack: boolean = true; private queuedDefaultTrack: number = -1; - private trackChangeListener: () => void = () => this.onTextTracksChanged(); private asyncPollTrackChange: () => void = () => this.pollTrackChange(0); private useTextTrackPolling: boolean = false; private subtitlePollingInterval: number = -1; @@ -51,7 +50,7 @@ class SubtitleTrackController extends BasePlaylistController { this.tracks.length = 0; this.tracksInGroup.length = 0; this.currentTrack = null; - this.trackChangeListener = this.asyncPollTrackChange = null as any; + this.onTextTracksChanged = this.asyncPollTrackChange = null as any; super.destroy(); } @@ -121,7 +120,7 @@ class SubtitleTrackController extends BasePlaylistController { private pollTrackChange(timeout: number) { self.clearInterval(this.subtitlePollingInterval); this.subtitlePollingInterval = self.setInterval( - this.trackChangeListener, + this.onTextTracksChanged, timeout, ); } @@ -231,12 +230,10 @@ class SubtitleTrackController extends BasePlaylistController { !subtitleGroups || subtitleGroups.indexOf(track.groupId) !== -1, ); if (subtitleTracks.length) { - // Disable selectDefaultTrack if there are no default or forced tracks + // Disable selectDefaultTrack if there are no default tracks if ( this.selectDefaultTrack && - !subtitleTracks.some( - (track) => track.default || track.forced || track.autoselect, - ) + !subtitleTracks.some((track) => track.default) ) { this.selectDefaultTrack = false; } @@ -248,19 +245,31 @@ class SubtitleTrackController extends BasePlaylistController { // Do not dispatch SUBTITLE_TRACKS_UPDATED when there were and are no tracks return; } + this.tracksInGroup = subtitleTracks; - if (!currentTrack) { - currentTrack = this.setSubtitleOption( - this.hls.config.subtitlePreference, + // Find preferred track + const subtitlePreference = this.hls.config.subtitlePreference; + if (!currentTrack && subtitlePreference) { + this.selectDefaultTrack = false; + const groupIndex = findMatchingOption( + subtitlePreference, + subtitleTracks, ); + if (groupIndex > -1) { + currentTrack = subtitleTracks[groupIndex]; + } else { + const allIndex = findMatchingOption(subtitlePreference, this.tracks); + currentTrack = this.tracks[allIndex]; + } } - this.tracksInGroup = subtitleTracks; + // Select initial track let trackId = this.findTrackId(currentTrack); if (trackId === -1 && currentTrack) { trackId = this.findTrackId(null); } + // Dispatch events and load track if needed const subtitleTracksUpdated: SubtitleTracksUpdatedData = { subtitleTracks, }; @@ -286,10 +295,7 @@ class SubtitleTrackController extends BasePlaylistController { for (let i = 0; i < tracks.length; i++) { const track = tracks[i]; if ( - (selectDefault && - !track.default && - !track.forced && - !track.autoselect) || + (selectDefault && !track.default) || (!selectDefault && !currentTrack) ) { continue; @@ -492,7 +498,7 @@ class SubtitleTrackController extends BasePlaylistController { } // exit if track id as already set or invalid - if (newId < -1 || newId >= tracks.length) { + if (newId < -1 || newId >= tracks.length || !Number.isFinite(newId)) { this.warn(`Invalid subtitle track id: ${newId}`); return; } @@ -511,7 +517,7 @@ class SubtitleTrackController extends BasePlaylistController { this.hls.trigger(Events.SUBTITLE_TRACK_SWITCH, { id: newId }); return; } - const trackLoaded = track.details && !track.details.live; + const trackLoaded = !!track.details && !track.details.live; if (newId === this.trackId && track === lastTrack && trackLoaded) { return; } @@ -533,7 +539,7 @@ class SubtitleTrackController extends BasePlaylistController { this.loadPlaylist(hlsUrlParameters); } - private onTextTracksChanged(): void { + private onTextTracksChanged = () => { if (!this.useTextTrackPolling) { self.clearInterval(this.subtitlePollingInterval); } @@ -559,7 +565,7 @@ class SubtitleTrackController extends BasePlaylistController { if (this.subtitleTrack !== trackId) { this.setSubtitleTrack(trackId); } - } + }; } export default SubtitleTrackController; diff --git a/src/types/media-playlist.ts b/src/types/media-playlist.ts index 5bb510ac3c7..33e3e2ebe05 100644 --- a/src/types/media-playlist.ts +++ b/src/types/media-playlist.ts @@ -23,6 +23,7 @@ export type AudioSelectionOption = { name?: string; audioCodec?: string; groupId?: string; + default?: boolean; }; export type SubtitleSelectionOption = { @@ -31,6 +32,8 @@ export type SubtitleSelectionOption = { characteristics?: string; name?: string; groupId?: string; + default?: boolean; + forced?: boolean; }; // audioTracks, captions and subtitles returned by `M3U8Parser.parseMasterPlaylistMedia` diff --git a/src/utils/rendition-helper.ts b/src/utils/rendition-helper.ts index 8f0fdbe213c..1e733489bb6 100644 --- a/src/utils/rendition-helper.ts +++ b/src/utils/rendition-helper.ts @@ -323,12 +323,22 @@ export function matchesOption( track: MediaPlaylist, ) => boolean, ): boolean { - const { groupId, name, lang, assocLang, characteristics } = option; + const { + groupId, + name, + lang, + assocLang, + characteristics, + default: isDefault, + } = option; + const forced = (option as SubtitleSelectionOption).forced; return ( (groupId === undefined || track.groupId === groupId) && (name === undefined || track.name === name) && (lang === undefined || track.lang === lang) && (lang === undefined || track.assocLang === assocLang) && + (isDefault === undefined || track.default === isDefault) && + (forced === undefined || track.forced === forced) && (characteristics === undefined || characteristicsMatch(characteristics, track.characteristics)) && (matchPredicate === undefined || matchPredicate(option, track)) diff --git a/tests/unit/controller/subtitle-track-controller.js b/tests/unit/controller/subtitle-track-controller.js deleted file mode 100644 index 92afdd3731d..00000000000 --- a/tests/unit/controller/subtitle-track-controller.js +++ /dev/null @@ -1,372 +0,0 @@ -import SubtitleTrackController from '../../../src/controller/subtitle-track-controller'; -import Hls from '../../../src/hls'; -import sinon from 'sinon'; -import { LoadStats } from '../../../src/loader/load-stats'; -import { LevelDetails } from '../../../src/loader/level-details'; -import { Events } from '../../../src/events'; - -describe('SubtitleTrackController', function () { - let subtitleTrackController; - let videoElement; - let sandbox; - - beforeEach(function () { - const hls = new Hls({ - renderNatively: true, - }); - - videoElement = document.createElement('video'); - subtitleTrackController = new SubtitleTrackController(hls); - subtitleTrackController.media = videoElement; - subtitleTrackController.tracks = subtitleTrackController.tracksInGroup = [ - { - id: 0, - groupId: 'default-text-group', - lang: 'en', - name: 'English', - type: 'SUBTITLES', - url: 'baz', - details: { live: false }, - }, - { - id: 1, - groupId: 'default-text-group', - lang: 'sv', - name: 'Swedish', - type: 'SUBTITLES', - url: 'bar', - }, - { - id: 2, - groupId: 'default-text-group', - lang: 'en', - name: 'Untitled CC', - type: 'SUBTITLES', - url: 'foo', - details: { live: true }, - }, - ]; - - const textTrack1 = videoElement.addTextTrack('subtitles', 'English', 'en'); - const textTrack2 = videoElement.addTextTrack('subtitles', 'Swedish', 'sv'); - const textTrack3 = videoElement.addTextTrack( - 'captions', - 'Untitled CC', - 'en', - ); - - textTrack1.groupId = 'default-text-group'; - textTrack2.groupId = 'default-text-group'; - textTrack3.groupId = 'default-text-group'; - subtitleTrackController.groupId = 'default-text-group'; - - textTrack1.mode = 'disabled'; - textTrack2.mode = 'disabled'; - textTrack3.mode = 'disabled'; - sandbox = sinon.createSandbox(); - }); - - afterEach(function () { - sandbox.restore(); - }); - - describe('onTextTrackChanged', function () { - it('should set subtitleTrack to -1 if disabled', function () { - expect(subtitleTrackController.subtitleTrack).to.equal(-1); - - videoElement.textTracks[0].mode = 'disabled'; - subtitleTrackController.onTextTracksChanged(); - - expect(subtitleTrackController.subtitleTrack).to.equal(-1); - }); - - it('should set subtitleTrack to 0 if hidden', function () { - expect(subtitleTrackController.subtitleTrack).to.equal(-1); - - videoElement.textTracks[0].mode = 'hidden'; - subtitleTrackController.onTextTracksChanged(); - - expect(subtitleTrackController.subtitleTrack).to.equal(0); - }); - - it('should set subtitleTrack to 0 if showing', function () { - expect(subtitleTrackController.subtitleTrack).to.equal(-1); - - videoElement.textTracks[0].mode = 'showing'; - subtitleTrackController.onTextTracksChanged(); - - expect(subtitleTrackController.subtitleTrack).to.equal(0); - }); - - it('should set subtitleTrack id captions track is showing', function () { - expect(subtitleTrackController.subtitleTrack).to.equal(-1); - - videoElement.textTracks[2].mode = 'showing'; - subtitleTrackController.onTextTracksChanged(); - - expect(videoElement.textTracks[2].kind).to.equal('captions'); - expect(subtitleTrackController.subtitleTrack).to.equal(2); - }); - }); - - describe('set subtitleTrack', function () { - it('should set active text track mode to showing', function () { - videoElement.textTracks[0].mode = 'disabled'; - - subtitleTrackController.subtitleDisplay = true; - subtitleTrackController.subtitleTrack = 0; - - expect(videoElement.textTracks[0].mode).to.equal('showing'); - }); - - it('should set active text track mode to hidden', function () { - videoElement.textTracks[0].mode = 'disabled'; - subtitleTrackController.subtitleDisplay = false; - subtitleTrackController.subtitleTrack = 0; - - expect(videoElement.textTracks[0].mode).to.equal('hidden'); - }); - - it('should disable previous track', function () { - // Change active track without triggering setSubtitleTrackInternal - subtitleTrackController.trackId = 0; - // Change active track and trigger setSubtitleTrackInternal - subtitleTrackController.subtitleTrack = 1; - - expect(videoElement.textTracks[0].mode).to.equal('disabled'); - }); - - it('should trigger SUBTITLE_TRACK_SWITCH', function () { - const triggerSpy = sandbox.spy(subtitleTrackController.hls, 'trigger'); - subtitleTrackController.canLoad = true; - subtitleTrackController.trackId = 0; - subtitleTrackController.subtitleTrack = 1; - - expect(triggerSpy).to.have.been.calledTwice; - expect(triggerSpy.firstCall).to.have.been.calledWith( - 'hlsSubtitleTrackSwitch', - { - id: 1, - groupId: 'default-text-group', - name: 'Swedish', - type: 'SUBTITLES', - url: 'bar', - }, - ); - }); - - it('should trigger SUBTITLE_TRACK_LOADING if the track has no details', function () { - const triggerSpy = sandbox.spy(subtitleTrackController.hls, 'trigger'); - subtitleTrackController.canLoad = true; - subtitleTrackController.trackId = 0; - subtitleTrackController.subtitleTrack = 1; - - expect(triggerSpy).to.have.been.calledTwice; - expect(triggerSpy.secondCall).to.have.been.calledWith( - 'hlsSubtitleTrackLoading', - { - url: 'bar', - id: 1, - groupId: 'default-text-group', - deliveryDirectives: null, - }, - ); - }); - - it('should not trigger SUBTITLE_TRACK_LOADING if the track has details and is not live', function () { - const triggerSpy = sandbox.spy(subtitleTrackController.hls, 'trigger'); - subtitleTrackController.trackId = 1; - subtitleTrackController.subtitleTrack = 0; - - expect(triggerSpy).to.have.been.calledOnce; - expect(triggerSpy.firstCall).to.have.been.calledWith( - 'hlsSubtitleTrackSwitch', - { - id: 0, - groupId: 'default-text-group', - name: 'English', - type: 'SUBTITLES', - url: 'baz', - }, - ); - }); - - it('should trigger SUBTITLE_TRACK_SWITCH if passed -1', function () { - const triggerSpy = sandbox.spy(subtitleTrackController.hls, 'trigger'); - subtitleTrackController.trackId = 0; - subtitleTrackController.subtitleTrack = -1; - - expect(triggerSpy.firstCall).to.have.been.calledWith( - 'hlsSubtitleTrackSwitch', - { id: -1 }, - ); - }); - - it('should trigger SUBTITLE_TRACK_LOADING if the track is live, even if it has details', function () { - const triggerSpy = sandbox.spy(subtitleTrackController.hls, 'trigger'); - subtitleTrackController.canLoad = true; - subtitleTrackController.trackId = 0; - subtitleTrackController.subtitleTrack = 2; - - expect(triggerSpy).to.have.been.calledTwice; - expect(triggerSpy.secondCall).to.have.been.calledWith( - 'hlsSubtitleTrackLoading', - { - url: 'foo', - id: 2, - groupId: 'default-text-group', - deliveryDirectives: null, - }, - ); - }); - - it('should do nothing if called with out of bound indices', function () { - const clearReloadSpy = sandbox.spy(subtitleTrackController, 'clearTimer'); - subtitleTrackController.subtitleTrack = 5; - subtitleTrackController.subtitleTrack = -2; - - expect(clearReloadSpy).to.have.not.been.called; - }); - - it('should do nothing if called with a non-number', function () { - subtitleTrackController.subtitleTrack = undefined; - subtitleTrackController.subtitleTrack = null; - }); - - describe('toggleTrackModes', function () { - // This can be the case when setting the subtitleTrack before Hls.js attaches to the mediaElement - it('should not throw an exception if trackId is out of the mediaElement text track bounds', function () { - subtitleTrackController.trackId = 3; - subtitleTrackController.toggleTrackModes(1); - }); - - it('should disable all textTracks if called with -1', function () { - [].slice.call(videoElement.textTracks).forEach((t) => { - t.mode = 'showing'; - }); - subtitleTrackController.toggleTrackModes(-1); - [].slice.call(videoElement.textTracks).forEach((t) => { - expect(t.mode).to.equal('disabled'); - }); - }); - - it('should not throw an exception if the mediaElement does not exist', function () { - subtitleTrackController.media = null; - subtitleTrackController.toggleTrackModes(1); - }); - }); - - describe('onSubtitleTrackLoaded', function () { - it('exits early if the loaded track does not match the requested track', function () { - const playlistLoadedSpy = sandbox.spy( - subtitleTrackController, - 'playlistLoaded', - ); - subtitleTrackController.canLoad = true; - subtitleTrackController.trackId = 1; - - const mockLoadedEvent = { - id: 999, - groupId: 'default-text-group', - details: { foo: 'bar' }, - stats: new LoadStats(), - }; - subtitleTrackController.onSubtitleTrackLoaded( - Events.SUBTITLE_TRACK_LOADED, - mockLoadedEvent, - ); - expect(subtitleTrackController.timer).to.equal(-1); - expect(playlistLoadedSpy).to.have.not.been.called; - - mockLoadedEvent.id = 0; - subtitleTrackController.onSubtitleTrackLoaded( - Events.SUBTITLE_TRACK_LOADED, - mockLoadedEvent, - ); - expect(subtitleTrackController.timer).to.equal(-1); - expect(playlistLoadedSpy).to.have.not.been.called; - - mockLoadedEvent.id = 1; - subtitleTrackController.onSubtitleTrackLoaded( - Events.SUBTITLE_TRACK_LOADED, - mockLoadedEvent, - ); - expect(subtitleTrackController.timer).to.equal(-1); - expect(playlistLoadedSpy).to.have.been.calledOnce; - }); - - it('does not set the reload timer if the canLoad flag is set to false', function () { - const details = new LevelDetails(''); - subtitleTrackController.canLoad = false; - subtitleTrackController.trackId = 1; - subtitleTrackController.onSubtitleTrackLoaded( - Events.SUBTITLE_TRACK_LOADED, - { - id: 1, - groupId: 'default-text-group', - details, - stats: new LoadStats(), - }, - ); - expect(subtitleTrackController.timer).to.equal(-1); - }); - - it('sets the live reload timer if the level is live', function () { - const details = new LevelDetails(''); - subtitleTrackController.canLoad = true; - subtitleTrackController.trackId = 1; - subtitleTrackController.onSubtitleTrackLoaded( - Events.SUBTITLE_TRACK_LOADED, - { - id: 1, - groupId: 'default-text-group', - details, - stats: new LoadStats(), - }, - ); - expect(subtitleTrackController.timer).to.exist; - }); - - it('stops the live reload timer if the level is not live', function () { - const details = new LevelDetails(''); - details.live = false; - subtitleTrackController.trackId = 1; - subtitleTrackController.timer = self.setTimeout(() => {}, 0); - subtitleTrackController.onSubtitleTrackLoaded( - Events.SUBTITLE_TRACK_LOADED, - { - id: 1, - groupId: 'default-text-group', - details, - stats: new LoadStats(), - }, - ); - expect(subtitleTrackController.timer).to.equal(-1); - }); - }); - - describe('stopLoad', function () { - it('stops loading', function () { - const clearReloadSpy = sandbox.spy( - subtitleTrackController, - 'clearTimer', - ); - subtitleTrackController.stopLoad(); - expect(subtitleTrackController.canLoad).to.be.false; - expect(clearReloadSpy).to.have.been.calledOnce; - }); - }); - - describe('startLoad', function () { - it('starts loading', function () { - const loadCurrentTrackSpy = sandbox.spy( - subtitleTrackController, - 'loadPlaylist', - ); - subtitleTrackController.startLoad(); - expect(subtitleTrackController.canLoad).to.be.true; - expect(loadCurrentTrackSpy).to.have.been.calledOnce; - }); - }); - }); -}); diff --git a/tests/unit/controller/subtitle-track-controller.ts b/tests/unit/controller/subtitle-track-controller.ts new file mode 100644 index 00000000000..4dafd12832a --- /dev/null +++ b/tests/unit/controller/subtitle-track-controller.ts @@ -0,0 +1,579 @@ +import SubtitleTrackController from '../../../src/controller/subtitle-track-controller'; +import Hls from '../../../src/hls'; +import { LoadStats } from '../../../src/loader/load-stats'; +import { LevelDetails } from '../../../src/loader/level-details'; +import { Events } from '../../../src/events'; +import { AttrList } from '../../../src/utils/attr-list'; +import type { + MediaAttributes, + MediaPlaylist, +} from '../../../src/types/media-playlist'; +import type { Level } from '../../../src/types/level'; +import type { + ComponentAPI, + NetworkComponentAPI, +} from '../../../src/types/component-api'; + +import sinon from 'sinon'; +import chai from 'chai'; +import sinonChai from 'sinon-chai'; + +chai.use(sinonChai); +const expect = chai.expect; + +type HlsTestable = Omit< + Hls, + 'levelController' | 'networkControllers' | 'coreComponents' +> & { + levelController: { + levels: Pick[]; + }; + coreComponents: ComponentAPI[]; + networkControllers: NetworkComponentAPI[]; +}; + +describe('SubtitleTrackController', function () { + let hls: HlsTestable; + let subtitleTrackController: SubtitleTrackController; + let subtitleTracks: MediaPlaylist[]; + let switchLevel: () => void; + let videoElement; + let sandbox; + + beforeEach(function () { + hls = new Hls() as unknown as HlsTestable; + hls.networkControllers.forEach((component) => component.destroy()); + hls.networkControllers.length = 0; + hls.coreComponents.forEach((component) => component.destroy()); + hls.coreComponents.length = 0; + subtitleTrackController = new SubtitleTrackController( + hls as unknown as Hls, + ); + hls.networkControllers.push(subtitleTrackController); + hls.levelController = { + levels: [ + { + subtitleGroups: ['default-text-group'], + }, + ], + }; + + videoElement = document.createElement('video'); + hls.trigger(Events.MEDIA_ATTACHED, { media: videoElement }); + + subtitleTracks = [ + { + attrs: new AttrList({}) as MediaAttributes, + autoselect: true, + bitrate: 0, + default: false, + forced: false, + id: 0, + groupId: 'default-text-group', + lang: 'en-US', + name: 'English', + type: 'SUBTITLES', + url: 'baz', + // details: { live: false }, + }, + { + attrs: new AttrList({}) as MediaAttributes, + autoselect: true, + bitrate: 0, + default: false, + forced: false, + id: 1, + groupId: 'default-text-group', + lang: 'sv', + name: 'Swedish', + type: 'SUBTITLES', + url: 'bar', + }, + { + attrs: new AttrList({}) as MediaAttributes, + autoselect: true, + bitrate: 0, + default: false, + forced: false, + id: 2, + groupId: 'default-text-group', + lang: 'en-US', + name: 'Untitled CC', + type: 'SUBTITLES', + url: 'foo', + // details: { live: true }, + }, + ]; + const levels = [ + { + subtitleGroups: ['default-text-group'], + }, + ] as any; + hls.trigger(Events.MANIFEST_PARSED, { + subtitleTracks, + levels, + audioTracks: [], + sessionData: null, + sessionKeys: null, + firstLevel: 0, + stats: new LoadStats(), + audio: true, + video: true, + altAudio: true, + }); + + switchLevel = () => { + hls.trigger(Events.LEVEL_LOADING, { + id: 0, + level: 0, + pathwayId: undefined, + url: '', + deliveryDirectives: null, + }); + }; + + const textTrack1 = videoElement.addTextTrack( + 'subtitles', + 'English', + 'en-US', + ); + const textTrack2 = videoElement.addTextTrack('subtitles', 'Swedish', 'sv'); + const textTrack3 = videoElement.addTextTrack( + 'captions', + 'Untitled CC', + 'en-US', + ); + + textTrack1.groupId = 'default-text-group'; + textTrack2.groupId = 'default-text-group'; + textTrack3.groupId = 'default-text-group'; + + textTrack1.mode = 'disabled'; + textTrack2.mode = 'disabled'; + textTrack3.mode = 'disabled'; + sandbox = sinon.createSandbox(); + }); + + afterEach(function () { + sandbox.restore(); + }); + + describe('onTextTracksChanged', function () { + beforeEach(function () { + switchLevel(); + }); + it('should set subtitleTrack to -1 if disabled', function () { + expect(subtitleTrackController.subtitleTrack).to.equal(-1); + + const onTextTracksChanged = sinon.spy( + subtitleTrackController, + 'onTextTracksChanged' as any, + ); + + videoElement.textTracks[0].mode = 'showing'; + + return new Promise((resolve) => { + self.setTimeout(() => { + expect(subtitleTrackController.subtitleTrack).to.equal(0); + expect(onTextTracksChanged).to.have.been.calledOnce; + videoElement.textTracks[0].mode = 'disabled'; + self.setTimeout(() => { + expect(subtitleTrackController.subtitleTrack).to.equal(-1); + expect(onTextTracksChanged).to.have.been.calledTwice; + resolve(true); + }, 500); + }, 500); + }); + }); + + it('should set subtitleTrack to 0 if hidden', function () { + expect(subtitleTrackController.subtitleTrack).to.equal(-1); + + videoElement.textTracks[0].mode = 'hidden'; + + return new Promise((resolve) => { + hls.on(Events.SUBTITLE_TRACK_SWITCH, () => { + expect(subtitleTrackController.subtitleTrack).to.equal(0); + resolve(true); + }); + }); + }); + + it('should set subtitleTrack to 0 if showing', function () { + expect(subtitleTrackController.subtitleTrack).to.equal(-1); + + videoElement.textTracks[0].mode = 'showing'; + + return new Promise((resolve) => { + hls.on(Events.SUBTITLE_TRACK_SWITCH, () => { + expect(subtitleTrackController.subtitleTrack).to.equal(0); + resolve(true); + }); + }); + }); + + it('should set subtitleTrack id captions track is showing', function () { + expect(subtitleTrackController.subtitleTrack).to.equal(-1); + + videoElement.textTracks[2].mode = 'showing'; + + return new Promise((resolve) => { + hls.on(Events.SUBTITLE_TRACK_SWITCH, () => { + expect(videoElement.textTracks[2].kind).to.equal('captions'); + expect(subtitleTrackController.subtitleTrack).to.equal(2); + resolve(true); + }); + }); + }); + }); + + describe('initial track selection', function () { + it('should not select any tracks if there are no default of forces tracks (ignoring autoselect)', function () { + switchLevel(); + expect(subtitleTracks[0].autoselect).to.equal(true); + expect(subtitleTrackController.subtitleTrack).to.equal(-1); + }); + + it('should not select forced tracks', function () { + subtitleTracks[1].forced = true; + switchLevel(); + expect(subtitleTrackController.subtitleTrack).to.equal(-1); + }); + + it('should select the default track when there are no forced tracks', function () { + subtitleTracks[2].default = true; + switchLevel(); + expect(subtitleTrackController.subtitleTrack).to.equal(2); + }); + + it('should select the first default track when there are no forced tracks', function () { + subtitleTracks[0].default = true; + subtitleTracks[1].default = true; + subtitleTracks[2].default = true; + switchLevel(); + expect(subtitleTrackController.subtitleTrack).to.equal(0); + }); + + it('should not select forced tracks over the default tracks (one forced track)', function () { + subtitleTracks[1].default = true; + subtitleTracks[2].forced = true; + switchLevel(); + expect(subtitleTrackController.subtitleTrack).to.equal(1); + }); + + it('should not select forced tracks over the default tracks (two forced track)', function () { + subtitleTracks[0].forced = true; + subtitleTracks[1].forced = true; + subtitleTracks[2].default = true; + switchLevel(); + expect(subtitleTrackController.subtitleTrack).to.equal(2); + }); + + describe('with subtitlePreference', function () { + it('should select the first track with matching lang', function () { + hls.config.subtitlePreference = { + lang: 'en-US', + }; + subtitleTracks[2].default = true; + switchLevel(); + expect(subtitleTrackController.subtitleTrack).to.equal(0); + }); + it('should select the first track with matching properties', function () { + hls.config.subtitlePreference = { + lang: 'en-US', + default: true, + }; + subtitleTracks[2].default = true; + switchLevel(); + expect(subtitleTrackController.subtitleTrack).to.equal(2); + }); + it('should not select default track if an unmatched preference is present', function () { + hls.config.subtitlePreference = { + lang: 'none', + }; + subtitleTracks[2].default = true; + switchLevel(); + expect(subtitleTrackController.subtitleTrack).to.equal(-1); + }); + }); + }); + + describe('set subtitleTrack', function () { + beforeEach(function () { + switchLevel(); + }); + it('should set active text track mode to showing', function () { + videoElement.textTracks[0].mode = 'disabled'; + + subtitleTrackController.subtitleDisplay = true; + subtitleTrackController.subtitleTrack = 0; + + expect(videoElement.textTracks[0].mode).to.equal('showing'); + }); + + it('should set active text track mode to hidden', function () { + videoElement.textTracks[0].mode = 'disabled'; + subtitleTrackController.subtitleDisplay = false; + subtitleTrackController.subtitleTrack = 0; + + expect(videoElement.textTracks[0].mode).to.equal('hidden'); + }); + + it('should disable previous track', function () { + expect(subtitleTrackController.subtitleTrack).to.equal(-1); + + const onTextTracksChanged = sinon.spy( + subtitleTrackController, + 'onTextTracksChanged' as any, + ); + + videoElement.textTracks[0].mode = 'showing'; + + return new Promise((resolve) => { + self.setTimeout(() => { + expect(subtitleTrackController.subtitleTrack).to.equal(0); + expect(videoElement.textTracks[0].mode).to.equal('showing'); + expect(onTextTracksChanged).to.have.been.calledOnce; + subtitleTrackController.subtitleTrack = 1; + self.setTimeout(() => { + expect(videoElement.textTracks[0].mode).to.equal('disabled'); + expect(videoElement.textTracks[1].mode).to.equal('showing'); + expect(onTextTracksChanged).to.have.been.calledTwice; + resolve(true); + }, 500); + }, 500); + }); + }); + + it('should disable all textTracks when set to -1', function () { + [].slice.call(videoElement.textTracks).forEach((t) => { + t.mode = 'showing'; + }); + expect(subtitleTrackController.subtitleTrack).to.equal(-1); + subtitleTrackController.subtitleTrack = -1; + [].slice.call(videoElement.textTracks).forEach((t) => { + expect(t.mode).to.equal('disabled'); + }); + }); + + it('should trigger SUBTITLE_TRACK_SWITCH', function () { + const triggerSpy = sandbox.spy(hls, 'trigger'); + subtitleTrackController.startLoad(); + subtitleTrackController.subtitleTrack = 1; + + expect(triggerSpy).to.have.been.calledTwice; + expect(triggerSpy.firstCall).to.have.been.calledWith( + 'hlsSubtitleTrackSwitch', + { + id: 1, + groupId: 'default-text-group', + name: 'Swedish', + type: 'SUBTITLES', + url: 'bar', + }, + ); + }); + + it('should trigger SUBTITLE_TRACK_LOADING if the track has no details', function () { + const triggerSpy = sandbox.spy(hls, 'trigger'); + subtitleTrackController.startLoad(); + subtitleTrackController.subtitleTrack = 1; + + expect(triggerSpy).to.have.been.calledTwice; + expect(triggerSpy.secondCall).to.have.been.calledWith( + 'hlsSubtitleTrackLoading', + { + url: 'bar', + id: 1, + groupId: 'default-text-group', + deliveryDirectives: null, + }, + ); + }); + + it('should not trigger SUBTITLE_TRACK_LOADING if the track has details and is not live', function () { + const triggerSpy = sandbox.spy(hls, 'trigger'); + subtitleTracks[0].details = { live: false } as any; + subtitleTrackController.startLoad(); + subtitleTrackController.subtitleTrack = 0; + + expect(triggerSpy).to.have.been.calledOnce; + expect(triggerSpy.firstCall).to.have.been.calledWith( + 'hlsSubtitleTrackSwitch', + { + id: 0, + groupId: 'default-text-group', + name: 'English', + type: 'SUBTITLES', + url: 'baz', + }, + ); + }); + + it('should trigger SUBTITLE_TRACK_SWITCH if passed -1', function () { + const triggerSpy = sandbox.spy(hls, 'trigger'); + subtitleTrackController.subtitleTrack = -1; + expect(triggerSpy.firstCall).to.have.been.calledWith( + 'hlsSubtitleTrackSwitch', + { id: -1 }, + ); + }); + + it('should trigger SUBTITLE_TRACK_LOADING if the track is live, even if it has details', function () { + const triggerSpy = sandbox.spy(hls, 'trigger'); + subtitleTracks[2].details = { live: true } as any; + subtitleTrackController.startLoad(); + subtitleTrackController.subtitleTrack = 2; + + expect(triggerSpy).to.have.been.calledTwice; + expect(triggerSpy.secondCall).to.have.been.calledWith( + 'hlsSubtitleTrackLoading', + { + url: 'foo', + id: 2, + groupId: 'default-text-group', + deliveryDirectives: null, + }, + ); + }); + + it('should do nothing if called with out of bound indices', function () { + const triggerSpy = sandbox.spy(hls, 'trigger'); + subtitleTrackController.subtitleTrack = 5; + subtitleTrackController.subtitleTrack = -2; + expect(triggerSpy).to.have.callCount(0); + expect(subtitleTrackController.subtitleTrack).to.equal(-1); + }); + + it('should do nothing if called with a invalid index', function () { + const triggerSpy = sandbox.spy(hls, 'trigger'); + subtitleTrackController.subtitleTrack = undefined as any; + subtitleTrackController.subtitleTrack = null as any; + expect(triggerSpy).to.have.callCount(0); + expect(subtitleTrackController.subtitleTrack).to.equal(-1); + }); + }); + + describe('toggleTrackModes', function () { + // This can be the case when setting the subtitleTrack before Hls.js attaches to the mediaElement + it('should not throw an exception if trackId is out of the mediaElement text track bounds', function () { + switchLevel(); + hls.detachMedia(); + const toggleTrackModesSpy = sandbox.spy( + subtitleTrackController, + 'toggleTrackModes', + ); + (subtitleTrackController as any).trackId = 3; + hls.trigger(Events.MEDIA_ATTACHED, { media: videoElement }); + subtitleTrackController.subtitleDisplay = true; // setting subtitleDisplay invokes `toggleTrackModes` + expect(toggleTrackModesSpy).to.have.been.calledOnce; + }); + }); + + describe('onSubtitleTrackLoaded', function () { + beforeEach(function () { + switchLevel(); + }); + it('exits early if the loaded track does not match the requested track', function () { + const playlistLoadedSpy = sandbox.spy( + subtitleTrackController, + 'playlistLoaded', + ); + subtitleTrackController.startLoad(); + (subtitleTrackController as any).trackId = 1; + (subtitleTrackController as any).currentTrack = subtitleTracks[1]; + + const mockLoadedEvent = { + id: 999, + groupId: 'default-text-group', + details: { foo: 'bar' } as any, + stats: new LoadStats(), + networkDetails: {}, + deliveryDirectives: null, + }; + hls.trigger(Events.SUBTITLE_TRACK_LOADED, mockLoadedEvent); + expect((subtitleTrackController as any).timer).to.equal(-1); + expect(playlistLoadedSpy).to.have.not.been.called; + + mockLoadedEvent.id = 0; + hls.trigger(Events.SUBTITLE_TRACK_LOADED, mockLoadedEvent); + expect((subtitleTrackController as any).timer).to.equal(-1); + expect(playlistLoadedSpy).to.have.not.been.called; + + mockLoadedEvent.id = 1; + hls.trigger(Events.SUBTITLE_TRACK_LOADED, mockLoadedEvent); + expect((subtitleTrackController as any).timer).to.equal(-1); + expect(playlistLoadedSpy).to.have.been.calledOnce; + }); + + it('does not set the reload timer if loading has not started', function () { + const details = new LevelDetails(''); + subtitleTrackController.stopLoad(); + (subtitleTrackController as any).trackId = 1; + (subtitleTrackController as any).currentTrack = subtitleTracks[1]; + hls.trigger(Events.SUBTITLE_TRACK_LOADED, { + id: 1, + groupId: 'default-text-group', + details, + stats: new LoadStats(), + networkDetails: {}, + deliveryDirectives: null, + }); + expect((subtitleTrackController as any).timer).to.equal(-1); + }); + + it('sets the live reload timer if the level is live', function () { + const details = new LevelDetails(''); + subtitleTrackController.startLoad(); + (subtitleTrackController as any).trackId = 1; + (subtitleTrackController as any).currentTrack = subtitleTracks[1]; + hls.trigger(Events.SUBTITLE_TRACK_LOADED, { + id: 1, + groupId: 'default-text-group', + details, + stats: new LoadStats(), + networkDetails: {}, + deliveryDirectives: null, + }); + expect((subtitleTrackController as any).timer).to.exist; + }); + + it('stops the live reload timer if the level is not live', function () { + const details = new LevelDetails(''); + details.live = false; + (subtitleTrackController as any).trackId = 1; + (subtitleTrackController as any).currentTrack = subtitleTracks[1]; + (subtitleTrackController as any).timer = self.setTimeout(() => {}, 0); + hls.trigger(Events.SUBTITLE_TRACK_LOADED, { + id: 1, + groupId: 'default-text-group', + details, + stats: new LoadStats(), + networkDetails: {}, + deliveryDirectives: null, + }); + expect((subtitleTrackController as any).timer).to.equal(-1); + }); + }); + + describe('stopLoad', function () { + it('stops loading', function () { + const clearReloadSpy = sandbox.spy(subtitleTrackController, 'clearTimer'); + subtitleTrackController.stopLoad(); + expect((subtitleTrackController as any).canLoad).to.be.false; + expect(clearReloadSpy).to.have.been.calledOnce; + }); + }); + + describe('startLoad', function () { + it('starts loading', function () { + const loadCurrentTrackSpy = sandbox.spy( + subtitleTrackController, + 'loadPlaylist', + ); + subtitleTrackController.startLoad(); + expect((subtitleTrackController as any).canLoad).to.be.true; + expect(loadCurrentTrackSpy).to.have.been.calledOnce; + }); + }); +});