diff --git a/src/lib/player/media.ts b/src/lib/player/media.ts index 3a5bea8..f327b6d 100644 --- a/src/lib/player/media.ts +++ b/src/lib/player/media.ts @@ -1,6 +1,5 @@ import { m } from "#lib/i18n/index.js"; -/** The native multi-audio-track API : real in Chromium/Firefox for a file - * that muxes more than one audio track, but missing from lib.dom's types. */ +/** The optional native multi-audio-track API, absent in many browsers. */ export interface NativeAudioTrack { label: string; language: string; @@ -49,7 +48,7 @@ export function nativeAudioSnapshot(list: NativeAudioTrackList): { /** Wires the native `audioTracks` list, if the browser exposes one for this * element, to `onChange` : called once immediately, then on every list * change. Returns a cleanup, or `undefined` when there's no such list - * (Safari, or a single-track file). */ + * (browsers without native audio-track selection). */ export function attachNativeAudioTracks( el: VideoWithAudioTracks, onChange: (tracks: AudioTrackOption[], active: number) => void, diff --git a/src/lib/player/state/media.svelte.ts b/src/lib/player/state/media.svelte.ts index ee0be12..c15d0a1 100644 --- a/src/lib/player/state/media.svelte.ts +++ b/src/lib/player/state/media.svelte.ts @@ -199,9 +199,9 @@ function createSourceFix( * menu. HLS multi-language streams come from hls.js's own track list; a direct * file (mp4/mkv/…) that muxes more than one audio track comes from the * browser's native `HTMLMediaElement.audioTracks` instead (see - * `attachNativeAudioTracks` in `player-media.ts`) : Chromium and Firefox both - * populate it, Safari doesn't, so a single-track or unsupported source just - * never grows past the empty list and the settings menu hides that section. + * `attachNativeAudioTracks` in `media.ts`). Browsers without that API use the + * transmux pipeline for supported multi-track direct files. A single-track + * or unsupported source does not expose a choice in the settings menu. * `fix()` swaps a direct file the browser can't play as is (undecodable audio, * refused container) over to the transmux pipeline at the current position, * when the host allows it. diff --git a/src/lib/player/transmux-session.test.ts b/src/lib/player/transmux-session.test.ts new file mode 100644 index 0000000..50a4108 --- /dev/null +++ b/src/lib/player/transmux-session.test.ts @@ -0,0 +1,116 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { prepareTransmux } from "./transmux-session.ts"; + +const source = vi.hoisted(() => ({ + tracks: [] as { + id: number; + languageCode: string; + name: string; + codec: string; + getCodecParameterString: () => Promise; + }[], + dispose: vi.fn(), +})); + +vi.mock("@mediabunny/ac3", () => ({ registerAc3Decoder: vi.fn() })); +vi.mock("@mediabunny/dts", () => ({ registerDtsDecoder: vi.fn() })); +vi.mock("#lib/i18n/index.js", () => ({ + m: { + player_audio_track_fallback: ({ number }: { number: number }) => + `Track ${number}`, + }, +})); +vi.mock("mediabunny", () => ({ + Input: class { + getPrimaryVideoTrack = async () => ({ + getCodecParameterString: async () => "avc1.42001e", + }); + getAudioTracks = async () => source.tracks; + getPrimaryAudioTrack = async () => source.tracks[0]; + computeDuration = async () => 60; + dispose = source.dispose; + }, + UrlSource: class {}, + EncodedPacketSink: class {}, + Mp4OutputFormat: class { + getSupportedCodecs = () => ["aac"]; + }, + getFirstEncodableAudioCodec: async () => "aac", + Conversion: class {}, + Output: class {}, + StreamTarget: class {}, + MATROSKA: {}, + MP4: {}, +})); + +describe("direct-file audio selection", () => { + beforeEach(() => { + vi.useFakeTimers(); + source.dispose.mockClear(); + source.tracks = ["eng", "hin"].map((languageCode, id) => ({ + id, + languageCode, + name: languageCode, + codec: "aac", + getCodecParameterString: async () => "mp4a.40.2", + })); + vi.stubGlobal("MediaSource", { isTypeSupported: () => true }); + vi.stubGlobal("document", { + createElement: () => ({ canPlayType: () => "probably" }), + }); + }); + + afterEach(() => { + vi.clearAllTimers(); + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + it("exposes both playable tracks when the browser has no native selector", async () => { + const session = await prepareTransmux( + "https://example.test/multi.mp4", + "probe", + { languages: [], track: null }, + ); + expect(session?.audioTracks.map((track) => track.language)).toEqual([ + "eng", + "hin", + ]); + expect(session?.converting).toBe(false); + session?.dispose(); + expect(source.dispose).toHaveBeenCalledOnce(); + }); + + it("keeps a playable single-track file on native playback", async () => { + source.tracks = source.tracks.slice(0, 1); + expect( + await prepareTransmux("https://example.test/single.mp4", "probe", { + languages: [], + track: null, + }), + ).toBeNull(); + expect(source.dispose).toHaveBeenCalledOnce(); + }); + + it("keeps native playback when the browser provides track selection", async () => { + vi.stubGlobal("document", { + createElement: () => ({ audioTracks: [], canPlayType: () => "probably" }), + }); + expect( + await prepareTransmux("https://example.test/multi.mp4", "probe", { + languages: [], + track: null, + }), + ).toBeNull(); + }); + + it("starts the fallback on the preferred language", async () => { + const session = await prepareTransmux( + "https://example.test/multi.mp4", + "probe", + { languages: ["hi"], track: null }, + ); + expect(session?.activeAudioTrack).toBe(1); + session?.dispose(); + }); +}); diff --git a/src/lib/player/transmux-session.ts b/src/lib/player/transmux-session.ts index f2f9853..51f8457 100644 --- a/src/lib/player/transmux-session.ts +++ b/src/lib/player/transmux-session.ts @@ -38,7 +38,7 @@ type EncodedCodec = keyof typeof ENCODED_CODEC_STRING; /** * Why the player asks : `probe` (every direct file, as playback starts : fix - * only when the browser can't decode its audio), `silent` (it plays without + * when the browser can't decode its audio or switch its tracks), `silent` (it plays without * sound), or `container` (`