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);