From 99d5d9feb4c4bf8d25fa44632019c7eeae52aba4 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Wed, 5 Aug 2026 13:05:23 +0200 Subject: [PATCH] fix(hls): audio selection forgotten --- packages/media/src/dom/hls-js/media-tracks.ts | 28 ++++- .../src/dom/hls-js/tests/media-tracks.test.ts | 110 +++++++++++++++++- 2 files changed, 133 insertions(+), 5 deletions(-) diff --git a/packages/media/src/dom/hls-js/media-tracks.ts b/packages/media/src/dom/hls-js/media-tracks.ts index 652ce677..2ece99de 100644 --- a/packages/media/src/dom/hls-js/media-tracks.ts +++ b/packages/media/src/dom/hls-js/media-tracks.ts @@ -56,6 +56,8 @@ export function HlsJsMediaMediaTracksMixin { + if (this.#audioTracksMatch(data.audioTracks)) return; + this.#removeAudioTracks(); for (const hlsAudioTrack of data.audioTracks) { const kind = hlsAudioTrack.default ? 'main' : 'alternative'; const audioTrack = this.addAudioTrack(kind, hlsAudioTrack.name, hlsAudioTrack.lang); audioTrack.id = `${hlsAudioTrack.id}`; - audioTrack.enabled = Boolean(hlsAudioTrack.default); } }; + #onAudioTrackSwitched = (_event: string, data: { id: number }) => { + const selectedId = `${data.id}`; + for (const track of this.audioTracks) { + track.enabled = track.id === selectedId; + } + }; + + #audioTracksMatch(incoming: HlsJsMediaAudioTrack[]): boolean { + const current = [...this.audioTracks]; + if (current.length !== incoming.length) return false; + + return incoming.every((hlsAudioTrack, index) => { + const existing = current[index]; + return ( + existing?.id === `${hlsAudioTrack.id}` && + existing.label === (hlsAudioTrack.name ?? '') && + existing.language === (hlsAudioTrack.lang ?? '') + ); + }); + } + #switchAudioTrack = () => { const { engine } = this; if (!engine) return; @@ -153,6 +177,8 @@ export function HlsJsMediaMediaTracksMixin>) => const audioTracksUpdated = (engine: Hls, audioTracks: Array>) => (engine as any).emit(Hls.Events.AUDIO_TRACKS_UPDATED, { audioTracks }); +const audioTrackSwitched = (engine: Hls, track: Record) => + (engine as any).emit(Hls.Events.AUDIO_TRACK_SWITCHED, track); + function flush() { return Promise.resolve(); } @@ -69,7 +72,7 @@ describe('HlsJsMediaMediaTracksMixin', () => { expect([...host.videoRenditions].map((rendition) => rendition.height)).toEqual([1080, 360]); }); - it('mirrors alternate audio tracks and enables the default', () => { + it('mirrors alternate audio tracks', () => { const engine = createEngine(); const host = new HlsJsMediaMediaTracks(engine); @@ -79,9 +82,9 @@ describe('HlsJsMediaMediaTracksMixin', () => { ]); expect(host.audioTracks.length).toBe(2); - expect(host.audioTracks[0]?.enabled).toBe(true); - expect(host.audioTracks[1]?.enabled).toBe(false); - expect(host.audioTracks[1]?.label).toBe('Spanish'); + expect([...host.audioTracks].map((track) => track.id)).toEqual(['0', '1']); + expect([...host.audioTracks].map((track) => track.label)).toEqual(['English', 'Spanish']); + expect([...host.audioTracks].map((track) => track.language)).toEqual(['en', 'es']); }); it('forwards a rendition selection to engine.nextLevel', async () => { @@ -170,6 +173,105 @@ describe('HlsJsMediaMediaTracksMixin', () => { expect(spanish!.enabled).toBe(true); }); + it('preserves the audio selection when an identical track list is re-emitted', async () => { + const engine = createEngine(); + const host = new HlsJsMediaMediaTracks(engine); + + audioTracksUpdated(engine, [ + { id: 0, default: true, name: 'English', lang: 'en' }, + { id: 1, name: 'Spanish', lang: 'es' }, + ]); + + // User picks the non-default track. + const [english, spanish] = [...host.audioTracks]; + english!.enabled = false; + spanish!.enabled = true; + await flush(); + expect(engine.audioTrack).toBe(1); + + // A rendition switch moves to a different audio group; hls.js re-emits the + // same set of languages (reindexed to the same ids). The selection must + // survive rather than snap back to the default. + audioTracksUpdated(engine, [ + { id: 0, default: true, name: 'English', lang: 'en' }, + { id: 1, name: 'Spanish', lang: 'es' }, + ]); + await flush(); + + expect(host.audioTracks[0]?.enabled).toBe(false); + expect(host.audioTracks[1]?.enabled).toBe(true); + }); + + it('reflects the engine audio selection from the switch event', async () => { + const engine = createEngine(); + const host = new HlsJsMediaMediaTracks(engine); + + audioTracksUpdated(engine, [ + { id: 0, default: true, name: 'English', lang: 'en' }, + { id: 1, name: 'Spanish', lang: 'es' }, + ]); + + // hls.js selects the non-default track on its own (e.g. audioPreference). + (engine as any).audioTrack = 1; + audioTrackSwitched(engine, { id: 1, name: 'Spanish', lang: 'es' }); + await flush(); + + expect(host.audioTracks[0]?.enabled).toBe(false); + expect(host.audioTracks[1]?.enabled).toBe(true); + }); + + it('keeps the selection across a group switch that clears engine.audioTrack', async () => { + const engine = createEngine(); + const host = new HlsJsMediaMediaTracks(engine); + + audioTracksUpdated(engine, [ + { id: 0, default: true, name: 'English', lang: 'en' }, + { id: 1, name: 'Spanish', lang: 'es' }, + ]); + + // User picks Spanish. + const [english, spanish] = [...host.audioTracks]; + english!.enabled = false; + spanish!.enabled = true; + await flush(); + expect(engine.audioTrack).toBe(1); + + // Rendition switch → new audio group. hls.js clears its selection (-1), + // re-emits the same list, then re-applies the matched track. `.default` + // (English) must not win. + (engine as any).audioTrack = -1; + audioTracksUpdated(engine, [ + { id: 0, default: true, name: 'English', lang: 'en' }, + { id: 1, name: 'Spanish', lang: 'es' }, + ]); + (engine as any).audioTrack = 1; + audioTrackSwitched(engine, { id: 1, name: 'Spanish', lang: 'es' }); + await flush(); + + expect(host.audioTracks[0]?.enabled).toBe(false); + expect(host.audioTracks[1]?.enabled).toBe(true); + }); + + it('rebuilds the list when the audio track set changes', () => { + const engine = createEngine(); + const host = new HlsJsMediaMediaTracks(engine); + + audioTracksUpdated(engine, [ + { id: 0, default: true, name: 'English', lang: 'en' }, + { id: 1, name: 'Spanish', lang: 'es' }, + ]); + + // A genuinely different list (extra language) forces a rebuild. + audioTracksUpdated(engine, [ + { id: 0, default: true, name: 'English', lang: 'en' }, + { id: 1, name: 'Spanish', lang: 'es' }, + { id: 2, name: 'French', lang: 'fr' }, + ]); + + expect(host.audioTracks.length).toBe(3); + expect([...host.audioTracks].map((track) => track.label)).toEqual(['English', 'Spanish', 'French']); + }); + it('clears all media tracks on DESTROYING', () => { const engine = createEngine(); const host = new HlsJsMediaMediaTracks(engine);