fix(hls): audio selection forgotten

This commit is contained in:
2026-08-05 13:08:27 +02:00
parent 43de2dbe8a
commit 99d5d9feb4
2 changed files with 133 additions and 5 deletions
+27 -1
View File
@@ -56,6 +56,8 @@ export function HlsJsMediaMediaTracksMixin<Base extends Constructor<MediaTracksH
engine.on(Hls.Events.MANIFEST_PARSED, this.#onManifestParsed);
engine.on(Hls.Events.AUDIO_TRACKS_UPDATED, this.#onAudioTracksUpdated);
engine.on(Hls.Events.AUDIO_TRACK_SWITCHING, this.#onAudioTrackSwitched);
engine.on(Hls.Events.AUDIO_TRACK_SWITCHED, this.#onAudioTrackSwitched);
engine.on(Hls.Events.LEVELS_UPDATED, this.#onLevelsUpdated);
engine.on(Hls.Events.LEVEL_SWITCHED, this.#onLevelSwitched);
engine.once(Hls.Events.DESTROYING, this.#teardown);
@@ -87,16 +89,38 @@ export function HlsJsMediaMediaTracksMixin<Base extends Constructor<MediaTracksH
};
#onAudioTracksUpdated = (_event: string, data: { audioTracks: HlsJsMediaAudioTrack[] }) => {
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<Base extends Constructor<MediaTracksH
engine?.off(Hls.Events.MANIFEST_PARSED, this.#onManifestParsed);
engine?.off(Hls.Events.AUDIO_TRACKS_UPDATED, this.#onAudioTracksUpdated);
engine?.off(Hls.Events.AUDIO_TRACK_SWITCHING, this.#onAudioTrackSwitched);
engine?.off(Hls.Events.AUDIO_TRACK_SWITCHED, this.#onAudioTrackSwitched);
engine?.off(Hls.Events.LEVELS_UPDATED, this.#onLevelsUpdated);
engine?.off(Hls.Events.LEVEL_SWITCHED, this.#onLevelSwitched);
engine?.off(Hls.Events.DESTROYING, this.#teardown);
@@ -48,6 +48,9 @@ const manifestParsed = (engine: Hls, levels: Array<Record<string, unknown>>) =>
const audioTracksUpdated = (engine: Hls, audioTracks: Array<Record<string, unknown>>) =>
(engine as any).emit(Hls.Events.AUDIO_TRACKS_UPDATED, { audioTracks });
const audioTrackSwitched = (engine: Hls, track: Record<string, unknown>) =>
(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);