Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions src/lib/player/media.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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,
Expand Down
6 changes: 3 additions & 3 deletions src/lib/player/state/media.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
116 changes: 116 additions & 0 deletions src/lib/player/transmux-session.test.ts
Original file line number Diff line number Diff line change
@@ -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<string>;
}[],
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();
});
});
11 changes: 8 additions & 3 deletions src/lib/player/transmux-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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` (`<video>` refused the file outright).
*/
export type TransmuxReason = "probe" | "silent" | "container";
Expand Down Expand Up @@ -234,9 +234,14 @@ async function open(
}
const primary = await input.getPrimaryAudioTrack();
const { active, native } = startingTrack(audio, primary?.id, preference);
// The browser already plays the wanted track, with sound : nothing to fix.
// Direct files need MSE for track selection when the native API is absent.
if (reason === "probe" && native) {
return null;
if (audio.length < 2) {
return null;
}
if ("audioTracks" in document.createElement("video")) {
return null;
}
}
const duration = await input.computeDuration();
return { video, audio, encoded, active, duration };
Expand Down
Loading