refactor(player): delete the webview video path; mpv selects its own tracks
DR-235 phase 3. Every video renderer is native now: mpv on Linux and Windows, ExoPlayer on Android, all drawing behind the transparent webview. The HTML5 <video> path is gone, not bypassed: - Frontend: hls.js, Html5PlayerAdapter and its compatibility shim, the createAdapter factory, streamTransport, hlsRecovery, timeTracking, videoFit, the <video>/<track> markup and every element handler in VideoPlayer (3277 -> 2144 lines), the experimentalNativeVideo store and its Settings toggle, webviewVideoFallback/supportsNativeVideo, and the setHtml5VideoState PiP bridge call. NativePlayerAdapter is the one video adapter; webview audio gets its own adapter kind. - Rust: use_html5 dropped from player_seek_video, player_switch_audio_track and player_set_stream_quality with the Html5* strategies and ReloadStream responses; use_html5_element and VideoBackend dropped from PlayerStatus; player_play_item always loads the backend (set_current_item removed); Capabilities::webview removed; the WebKitGTK GStreamer/VAAPI setup (and its gst-inspect spawn) removed. - Android: the HTML5 video state in PictureInPictureManager and ScreenWakeManager, and the bridge method feeding it. - CSP: connect-src loses http:/https: and worker-src loses blob: - both existed for hls.js; with it gone they were only an exfiltration channel and a blob worker for injected script. A test now keeps them out. mpv takes over what the <video> element did (mpv_tracks, UT-275): subtitles are the WebVTT list the play request carries, queued on sub-files and selected by position in that list, starting off; audio tracks are selected by position in the file; sid/aid are reset before each load. Without this, Linux video had no subtitle selection and a direct-play audio switch failed since mpv became its renderer. Verified: Rust 948 passing, and the same 948 cross-compiled for Windows under wine against the shipped DLL (track tests included); frontend 1111 passing; aarch64 debug APK builds. Lint warnings 158 -> 146, CI ratchet tightened to match. Not yet seen on Windows hardware.
This commit is contained in:
@@ -1,95 +0,0 @@
|
||||
/**
|
||||
* Adapter-selection regression guards.
|
||||
*
|
||||
* TRACES: UR-003, UR-004 | DR-150 | UT-149
|
||||
*
|
||||
* The selection rule has two inputs and one hard safety property:
|
||||
*
|
||||
* - Rust says which backend the platform has (`backendKind`).
|
||||
* - The user opts in with `experimentalNativeVideo`.
|
||||
* - **The flag off must force HTML5 even when Rust says native.** That is the
|
||||
* regression guard: a broken spike must not be able to ship as the default.
|
||||
*
|
||||
* These are pure functions, so the whole matrix is testable without a device.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createAdapter } from "./index";
|
||||
import { Html5PlayerAdapter } from "./html5Adapter";
|
||||
import { NativePlayerAdapter } from "./nativeAdapter";
|
||||
import type { AdapterHost } from "./types";
|
||||
|
||||
const host: AdapterHost = {
|
||||
reportState: () => {},
|
||||
reportPosition: () => {},
|
||||
reportEnded: () => {},
|
||||
} as unknown as AdapterHost;
|
||||
|
||||
const bridge = {
|
||||
getElement: () => null,
|
||||
} as any;
|
||||
|
||||
describe("createAdapter", () => {
|
||||
it("returns the native adapter when Rust says native and the flag is on", () => {
|
||||
const adapter = createAdapter({
|
||||
backendKind: "native",
|
||||
host,
|
||||
bridge,
|
||||
experimentalNativeVideo: true,
|
||||
});
|
||||
expect(adapter).toBeInstanceOf(NativePlayerAdapter);
|
||||
expect(adapter.kind).toBe("native");
|
||||
});
|
||||
|
||||
// The regression guard: the flag is a suppressor, so off must beat Rust.
|
||||
it("forces HTML5 when the flag is off even though Rust says native", () => {
|
||||
const adapter = createAdapter({
|
||||
backendKind: "native",
|
||||
host,
|
||||
bridge,
|
||||
experimentalNativeVideo: false,
|
||||
});
|
||||
expect(adapter).toBeInstanceOf(Html5PlayerAdapter);
|
||||
expect(adapter.kind).toBe("html5");
|
||||
});
|
||||
|
||||
it("returns the HTML5 adapter when Rust says html5 and the flag is off", () => {
|
||||
const adapter = createAdapter({
|
||||
backendKind: "html5",
|
||||
host,
|
||||
bridge,
|
||||
experimentalNativeVideo: false,
|
||||
});
|
||||
expect(adapter).toBeInstanceOf(Html5PlayerAdapter);
|
||||
});
|
||||
|
||||
// The flag must never *promote* a platform Rust said has no native backend
|
||||
// (e.g. Linux, where WebKitGTK cannot composite a surface behind the webview).
|
||||
it("stays on HTML5 when Rust says html5 even with the flag on", () => {
|
||||
const adapter = createAdapter({
|
||||
backendKind: "html5",
|
||||
host,
|
||||
bridge,
|
||||
experimentalNativeVideo: true,
|
||||
});
|
||||
expect(adapter).toBeInstanceOf(Html5PlayerAdapter);
|
||||
});
|
||||
|
||||
it("defaults to HTML5 when the flag is omitted entirely", () => {
|
||||
const adapter = createAdapter({ backendKind: "native", host, bridge });
|
||||
expect(adapter).toBeInstanceOf(Html5PlayerAdapter);
|
||||
});
|
||||
|
||||
it("requires a bridge for the HTML5 adapter", () => {
|
||||
expect(() =>
|
||||
createAdapter({ backendKind: "html5", host, experimentalNativeVideo: false }),
|
||||
).toThrow(/bridge/i);
|
||||
});
|
||||
|
||||
// The native adapter owns no DOM element, so it must not demand a bridge.
|
||||
it("does not require a bridge for the native adapter", () => {
|
||||
expect(() =>
|
||||
createAdapter({ backendKind: "native", host, experimentalNativeVideo: true }),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -1,340 +0,0 @@
|
||||
/**
|
||||
* Unit tests for Html5PlayerAdapter.
|
||||
*
|
||||
* The Option-1 primitive design makes the adapter pure, decision-free mechanics
|
||||
* — it takes a mock <video> element + bridge + host, so we can assert each
|
||||
* primitive drives the element correctly without any real DOM or backend.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { Html5PlayerAdapter, type Html5ElementBridge } from "./html5Adapter";
|
||||
import type { AdapterHost } from "./types";
|
||||
|
||||
/**
|
||||
* A `StreamSelection` for tests that only care about the URL. Transcoded HLS is
|
||||
* what these paths exercised before the contract carried a transport.
|
||||
*/
|
||||
function testSelection(url: string, transport: "hls" | "progressive" | "localFile" = "hls") {
|
||||
return {
|
||||
url,
|
||||
transport: { type: transport },
|
||||
playbackKind: { type: transport === "hls" ? "transcode" : "directPlay" },
|
||||
rendition: null,
|
||||
available: [],
|
||||
mediaSourceId: null,
|
||||
playSessionId: null,
|
||||
needsTranscoding: transport === "hls",
|
||||
} as import("$lib/api/bindings").StreamSelection;
|
||||
}
|
||||
|
||||
/** A minimal fake <video> element that records mutations and fires events. */
|
||||
function makeFakeVideo() {
|
||||
const listeners: Record<string, Array<() => void>> = {};
|
||||
const el: any = {
|
||||
paused: true,
|
||||
currentTime: 0,
|
||||
volume: 1,
|
||||
muted: false,
|
||||
src: "blob:existing",
|
||||
play: vi.fn(async () => {
|
||||
el.paused = false;
|
||||
}),
|
||||
pause: vi.fn(() => {
|
||||
el.paused = true;
|
||||
}),
|
||||
load: vi.fn(),
|
||||
removeAttribute: vi.fn((attr: string) => {
|
||||
if (attr === "src") el.src = "";
|
||||
}),
|
||||
addEventListener: (event: string, cb: () => void) => {
|
||||
(listeners[event] ??= []).push(cb);
|
||||
},
|
||||
removeEventListener: (event: string, cb: () => void) => {
|
||||
listeners[event] = (listeners[event] ?? []).filter((f) => f !== cb);
|
||||
},
|
||||
// Test helper: fire an event so waitForEvent resolves immediately.
|
||||
_fire: (event: string) => {
|
||||
(listeners[event] ?? []).slice().forEach((f) => f());
|
||||
},
|
||||
querySelectorAll: () => [] as any,
|
||||
textTracks: [] as any,
|
||||
};
|
||||
return el;
|
||||
}
|
||||
type FakeVideo = ReturnType<typeof makeFakeVideo>;
|
||||
|
||||
function makeBridge(overrides: Partial<Html5ElementBridge> = {}): Html5ElementBridge {
|
||||
let offset = 0;
|
||||
return {
|
||||
getElement: () => null,
|
||||
getSeekOffset: () => offset,
|
||||
setSeekOffset: vi.fn((o: number) => {
|
||||
offset = o;
|
||||
}),
|
||||
setStreamSelection: vi.fn(),
|
||||
destroyHls: vi.fn(),
|
||||
getMediaSourceId: () => "msid-1",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeHost(): AdapterHost {
|
||||
return {
|
||||
onState: vi.fn(),
|
||||
onPosition: vi.fn(),
|
||||
onMediaLoaded: vi.fn(),
|
||||
onEnded: vi.fn(),
|
||||
onError: vi.fn(),
|
||||
onStreamUrlChanged: vi.fn(),
|
||||
onBuffering: vi.fn(),
|
||||
onReady: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
describe("Html5PlayerAdapter", () => {
|
||||
let host: AdapterHost;
|
||||
let bridge: Html5ElementBridge;
|
||||
let adapter: Html5PlayerAdapter;
|
||||
let video: ReturnType<typeof makeFakeVideo>;
|
||||
|
||||
beforeEach(() => {
|
||||
host = makeHost();
|
||||
bridge = makeBridge();
|
||||
adapter = new Html5PlayerAdapter(host, bridge);
|
||||
video = makeFakeVideo();
|
||||
adapter.attach(video);
|
||||
});
|
||||
|
||||
it("is an html5-kind adapter", () => {
|
||||
expect(adapter.kind).toBe("html5");
|
||||
});
|
||||
|
||||
it("play() calls element.play()", async () => {
|
||||
await adapter.play();
|
||||
expect(video.play).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// A stalling HLS stream makes hls.js' gap-controller nudge the element, which
|
||||
// aborts an in-flight play(). That AbortError is transient — the element is
|
||||
// still trying to play — so it must not be surfaced as a player error, or the
|
||||
// UI reports failure ~once a second for the whole stall.
|
||||
it("play() does not report an interrupted-by-pause AbortError as an error", async () => {
|
||||
const abort = new DOMException(
|
||||
"The play() request was interrupted by a call to pause().",
|
||||
"AbortError",
|
||||
);
|
||||
video.play = vi.fn(async () => {
|
||||
throw abort;
|
||||
});
|
||||
|
||||
await adapter.play();
|
||||
|
||||
expect(host.onError).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("play() still reports a genuine failure", async () => {
|
||||
video.play = vi.fn(async () => {
|
||||
throw new DOMException("no supported source", "NotSupportedError");
|
||||
});
|
||||
|
||||
await adapter.play();
|
||||
|
||||
expect(host.onError).toHaveBeenCalledTimes(1);
|
||||
expect(String((host.onError as any).mock.calls[0][0])).toContain("play() failed");
|
||||
});
|
||||
|
||||
it("play() coalesces concurrent attempts into one element.play() call", async () => {
|
||||
// During a stall the UI and recovery paths can both ask to play. Stacking
|
||||
// element.play() calls is what generates the AbortError storm.
|
||||
let resolvePlay: () => void = () => {};
|
||||
video.play = vi.fn(
|
||||
() =>
|
||||
new Promise<void>((r) => {
|
||||
resolvePlay = () => {
|
||||
video.paused = false;
|
||||
r();
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
const first = adapter.play();
|
||||
const second = adapter.play();
|
||||
resolvePlay();
|
||||
await Promise.all([first, second]);
|
||||
|
||||
expect(video.play).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("play() works again after a previous attempt settled", async () => {
|
||||
await adapter.play();
|
||||
await adapter.play();
|
||||
expect(video.play).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("pause() calls element.pause()", async () => {
|
||||
video.paused = false;
|
||||
await adapter.pause();
|
||||
expect(video.pause).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("toggle() plays when paused and reports the resulting state", async () => {
|
||||
video.paused = true;
|
||||
const playing = await adapter.toggle();
|
||||
expect(video.play).toHaveBeenCalled();
|
||||
expect(playing).toBe(true);
|
||||
});
|
||||
|
||||
it("toggle() pauses when playing", async () => {
|
||||
video.paused = false;
|
||||
const playing = await adapter.toggle();
|
||||
expect(video.pause).toHaveBeenCalled();
|
||||
expect(playing).toBe(false);
|
||||
});
|
||||
|
||||
it("seekElement() sets currentTime, offset, and waits for 'seeked'", async () => {
|
||||
const p = adapter.seekElement(42, 0);
|
||||
expect(video.currentTime).toBe(42);
|
||||
expect(bridge.setSeekOffset).toHaveBeenCalledWith(0);
|
||||
video._fire("seeked"); // resolve the wait
|
||||
await p;
|
||||
});
|
||||
|
||||
it("reloadSource() runs the invariant teardown->swap->resume sequence", async () => {
|
||||
video.paused = false; // was playing → should resume
|
||||
const p = adapter.reloadSource(testSelection("http://new/master.m3u8"), 120);
|
||||
|
||||
// Teardown happened synchronously before the awaited canplay wait.
|
||||
expect(video.pause).toHaveBeenCalled();
|
||||
expect(bridge.destroyHls).toHaveBeenCalledTimes(1);
|
||||
expect(video.removeAttribute).toHaveBeenCalledWith("src");
|
||||
expect(video.load).toHaveBeenCalled();
|
||||
|
||||
// Allow the internal 100ms settle delay, then fire canplay to resume.
|
||||
await new Promise((r) => setTimeout(r, 110));
|
||||
expect(bridge.setStreamSelection).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ url: "http://new/master.m3u8", transport: { type: "hls" } }),
|
||||
);
|
||||
video._fire("canplay");
|
||||
video._fire("seeked");
|
||||
await p;
|
||||
expect(video.play).toHaveBeenCalled(); // resumed because it was playing
|
||||
});
|
||||
|
||||
/**
|
||||
* The reload lands the viewer at the position they asked for — by *seeking*,
|
||||
* with no transcode offset left over.
|
||||
*
|
||||
* This used to be inverted: the offset was set to the position and nothing
|
||||
* seeked, which was right only while the reloaded URL itself began there via
|
||||
* `StartTimeTicks`. DR-181 removes that parameter, because on an HLS playlist
|
||||
* the server copies it onto every segment URI and then rejects each one with
|
||||
* `400`. With the URL starting at the item's zero, the old arithmetic leaves
|
||||
* `currentTime = offset + 0` — the scrubber reading 20:00 over the opening
|
||||
* titles, and the seek silently never happening.
|
||||
*
|
||||
* TRACES: UR-004, UR-005 | DR-181 | UT-183
|
||||
*/
|
||||
it("reloadSource() seeks to the position and clears the transcode offset", async () => {
|
||||
video.paused = false;
|
||||
const p = adapter.reloadSource(testSelection("http://new/master.m3u8"), 1200);
|
||||
|
||||
await new Promise((r) => setTimeout(r, 110));
|
||||
expect(bridge.setSeekOffset).toHaveBeenCalledWith(0);
|
||||
expect(bridge.setSeekOffset).not.toHaveBeenCalledWith(1200);
|
||||
|
||||
// Nothing may seek before the new source is playable — the element drops it.
|
||||
expect(video.currentTime).not.toBe(1200);
|
||||
|
||||
video._fire("canplay");
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect(video.currentTime).toBe(1200);
|
||||
|
||||
video._fire("seeked");
|
||||
await p;
|
||||
expect(video.play).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
/** A reload to the very start has nothing to seek to; it must not stall. */
|
||||
it("reloadSource() at position 0 does not wait for a seek", async () => {
|
||||
video.paused = false;
|
||||
const p = adapter.reloadSource(testSelection("http://new/master.m3u8"), 0);
|
||||
await new Promise((r) => setTimeout(r, 110));
|
||||
video._fire("canplay");
|
||||
await p; // resolves without any "seeked" event
|
||||
expect(video.play).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
/**
|
||||
* A reload that never becomes playable must be reported as a failure. It used
|
||||
* to resolve on the timeout, so a quality switch whose new stream the server
|
||||
* refused to serve (Jellyfin 400s the first segment when two transcode jobs
|
||||
* collide) looked like a success: the picker showed the new quality selected
|
||||
* over a stream that never played, and the caller had nothing to revert to.
|
||||
*
|
||||
* TRACES: UR-074 | DR-177 | UT-175
|
||||
*/
|
||||
it("reloadSource() rejects when the new stream never becomes playable", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
video.paused = false;
|
||||
const p = adapter.reloadSource(testSelection("http://new/master.m3u8"), 120);
|
||||
const assertion = expect(p).rejects.toThrow(/canplay/i);
|
||||
await vi.advanceTimersByTimeAsync(11_000); // past the 10s readiness budget
|
||||
await assertion;
|
||||
expect(video.play).not.toHaveBeenCalled(); // nothing to resume into
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("reloadSource() does not resume when it was paused", async () => {
|
||||
video.paused = true;
|
||||
const p = adapter.reloadSource(testSelection("http://new/master.m3u8"), 30);
|
||||
await new Promise((r) => setTimeout(r, 110));
|
||||
video._fire("canplay");
|
||||
video._fire("seeked");
|
||||
await p;
|
||||
expect(video.play).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("setVolume() clamps to 0..1", () => {
|
||||
adapter.setVolume(1.5);
|
||||
expect(video.volume).toBe(1);
|
||||
adapter.setVolume(-0.5);
|
||||
expect(video.volume).toBe(0);
|
||||
adapter.setVolume(0.4);
|
||||
expect(video.volume).toBeCloseTo(0.4);
|
||||
});
|
||||
|
||||
it("setMuted() sets the element muted flag", () => {
|
||||
adapter.setMuted(true);
|
||||
expect(video.muted).toBe(true);
|
||||
});
|
||||
|
||||
it("getPosition() returns element time plus the transcode offset", () => {
|
||||
video.currentTime = 10;
|
||||
(bridge.getSeekOffset as any) = () => 100;
|
||||
// Rebuild adapter with the offset-returning bridge.
|
||||
const a = new Html5PlayerAdapter(host, bridge);
|
||||
a.attach(video);
|
||||
expect(a.getPosition()).toBe(110);
|
||||
});
|
||||
|
||||
it("dispose() tears down hls and clears the element", async () => {
|
||||
await adapter.dispose();
|
||||
expect(bridge.destroyHls).toHaveBeenCalled();
|
||||
expect(video.pause).toHaveBeenCalled();
|
||||
// After dispose, primitives are no-ops (element detached).
|
||||
await adapter.play();
|
||||
// play was called once during dispose teardown? no — play only on reload/resume.
|
||||
expect(video.play).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("primitives are safe no-ops before an element is attached", async () => {
|
||||
const bare = new Html5PlayerAdapter(host, bridge);
|
||||
await expect(bare.play()).resolves.toBeUndefined();
|
||||
await expect(bare.pause()).resolves.toBeUndefined();
|
||||
await expect(bare.seekElement(5, 0)).resolves.toBeUndefined();
|
||||
expect(await bare.toggle()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,317 +0,0 @@
|
||||
import type { StreamSelection } from "$lib/api/bindings";
|
||||
/**
|
||||
* Html5PlayerAdapter — the Linux/desktop (and interim Android) PlayerAdapter
|
||||
* implementation. It owns the high-level control surface for an HTML5 `<video>`
|
||||
* element and reports the element's lifecycle back into Rust via its
|
||||
* {@link AdapterHost}.
|
||||
*
|
||||
* Design note on the split with VideoPlayer.svelte:
|
||||
* The delicate, timing-sensitive parts (hls.js instance lifecycle, the transcode
|
||||
* "reload stream" seek/audio-track dance with its dual-audio teardown and
|
||||
* canplay waits) are inherently coupled to Svelte reactive state and the DOM
|
||||
* element. Rather than relocate that reactive machinery wholesale (high
|
||||
* regression risk), the adapter receives an {@link Html5ElementBridge} of narrow
|
||||
* callbacks the owning component supplies. The adapter is the single OWNER of the
|
||||
* control contract (play/pause/seek/track/volume) and of reporting; the bridge is
|
||||
* the seam to the component's element/HLS/reactive state. This keeps all control
|
||||
* intents flowing through the PlayerAdapter interface while preserving the
|
||||
* hard-won element behavior verbatim.
|
||||
*
|
||||
* TRACES: UR-003, UR-005, UR-020, UR-021 | DR-001, DR-023, DR-024, DR-028, DR-096
|
||||
*/
|
||||
|
||||
import type { AdapterHost, PlayerAdapter, PlayerLoadOptions } from "./types";
|
||||
import { createLogger } from "$lib/utils/logger";
|
||||
|
||||
const log = createLogger("Html5PlayerAdapter");
|
||||
|
||||
/**
|
||||
* The selection for a plain `load(url)` call.
|
||||
*
|
||||
* `PlayerLoadOptions` carries the backend's selection when the caller has one.
|
||||
* When it does not — a local file, a live stream, a direct URL — the transport
|
||||
* is inferred *once, here*, from what the caller already knows rather than from
|
||||
* the URL text: a local path is a local file, and anything the backend flagged
|
||||
* as transcoded is HLS, because every transcode this app requests is HLS.
|
||||
*
|
||||
* This is the one place a fallback is tolerable, and it is explicitly a
|
||||
* fallback: the negotiated path never reaches it.
|
||||
*
|
||||
* TRACES: UR-079 | DR-225
|
||||
*/
|
||||
function selectionForLoad(streamUrl: string, options: PlayerLoadOptions): StreamSelection {
|
||||
if (options.selection) return options.selection;
|
||||
const transport: StreamSelection["transport"] = options.isLocalFile
|
||||
? { type: "localFile" }
|
||||
: options.needsTranscoding
|
||||
? { type: "hls" }
|
||||
: { type: "progressive" };
|
||||
return {
|
||||
url: streamUrl,
|
||||
transport,
|
||||
playbackKind: options.needsTranscoding ? { type: "transcode" } : { type: "directPlay" },
|
||||
rendition: null,
|
||||
available: [],
|
||||
mediaSourceId: options.mediaSourceId ?? null,
|
||||
playSessionId: null,
|
||||
needsTranscoding: options.needsTranscoding,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrow seam the owning component provides so the adapter can execute the
|
||||
* element/HLS-coupled parts of a control action without re-implementing the
|
||||
* component's reactive HLS lifecycle. Every function here is a thin wrapper over
|
||||
* work the component already does.
|
||||
*/
|
||||
export interface Html5ElementBridge {
|
||||
/** The bound <video> element, or null before mount / after teardown. */
|
||||
getElement(): HTMLVideoElement | null;
|
||||
/** Current seek offset (seconds) for transcoded streams. */
|
||||
getSeekOffset(): number;
|
||||
setSeekOffset(offset: number): void;
|
||||
/**
|
||||
* Update the stream the component renders (triggers its HLS $effect).
|
||||
*
|
||||
* Carries the whole [`StreamSelection`], not just the URL: the component's
|
||||
* effect has to know the transport to choose a loader, and deriving that from
|
||||
* the URL is the substring check DR-225 removes.
|
||||
*
|
||||
* TRACES: UR-079 | DR-225
|
||||
*/
|
||||
setStreamSelection(selection: StreamSelection): void;
|
||||
/** Tear down the component-owned hls.js instance (dual-audio prevention). */
|
||||
destroyHls(): void;
|
||||
/** Media source id for seek/audio-track URLs. */
|
||||
getMediaSourceId(): string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* True for the `AbortError` the browser raises when a pending `play()` promise is
|
||||
* cancelled by a `pause()` (or a source/seek change). It signals "that specific
|
||||
* play attempt was superseded", not "playback failed" — hls.js' stall recovery
|
||||
* produces it routinely, so it must not reach the player's error channel.
|
||||
*/
|
||||
function isPlayInterruptedError(err: unknown): boolean {
|
||||
if (!err || typeof err !== "object") return false;
|
||||
const { name, message } = err as { name?: string; message?: string };
|
||||
return name === "AbortError" || (message ?? "").includes("interrupted");
|
||||
}
|
||||
|
||||
export class Html5PlayerAdapter implements PlayerAdapter {
|
||||
readonly kind = "html5" as const;
|
||||
|
||||
private attachedElement: HTMLVideoElement | null = null;
|
||||
/** In-flight play() attempt, so concurrent callers share one element.play(). */
|
||||
private pendingPlay: Promise<void> | null = null;
|
||||
private host: AdapterHost;
|
||||
private bridge: Html5ElementBridge;
|
||||
|
||||
constructor(host: AdapterHost, bridge: Html5ElementBridge) {
|
||||
this.host = host;
|
||||
this.bridge = bridge;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the LIVE <video> element. The bridge's `getElement()` returns the
|
||||
* component's current reactive `videoElement`, which is authoritative: the
|
||||
* element can be re-bound when the {#if} block re-renders, so a value captured
|
||||
* once in `attach()` may go stale (this caused play/pause to silently no-op).
|
||||
* Falls back to the attach()-captured element for unit tests whose bridge
|
||||
* returns null.
|
||||
*/
|
||||
private get element(): HTMLVideoElement | null {
|
||||
return this.bridge.getElement() ?? this.attachedElement;
|
||||
}
|
||||
|
||||
attach(element: HTMLVideoElement | null): void {
|
||||
this.attachedElement = element;
|
||||
}
|
||||
|
||||
async load(streamUrl: string, options: PlayerLoadOptions): Promise<void> {
|
||||
// The component's reactive HLS $effect performs the actual attach/load when
|
||||
// the selection is set; loading is therefore driven by setStreamSelection.
|
||||
// The component's canplay/frag-buffered path reports readiness through the
|
||||
// host.
|
||||
this.bridge.setSeekOffset(0);
|
||||
this.bridge.setStreamSelection(selectionForLoad(streamUrl, options));
|
||||
this.host.onState("loading");
|
||||
}
|
||||
|
||||
async play(): Promise<void> {
|
||||
const el = this.element;
|
||||
if (!el) return;
|
||||
// Coalesce concurrent attempts. While an HLS stream stalls, the UI and the
|
||||
// gap-controller recovery path can both ask to play; stacking element.play()
|
||||
// calls is what turns one stall into an AbortError storm.
|
||||
if (this.pendingPlay) return this.pendingPlay;
|
||||
|
||||
this.pendingPlay = (async () => {
|
||||
try {
|
||||
await el.play();
|
||||
// handlePlay on the element reports "playing"; no double-report here.
|
||||
} catch (err) {
|
||||
// A play() aborted by a pause() is transient, not a failure: hls.js
|
||||
// nudges the element to recover from a stall, which cancels the pending
|
||||
// play promise while the element keeps trying. Surfacing it would report
|
||||
// an error roughly once a second for the duration of the stall.
|
||||
if (isPlayInterruptedError(err)) {
|
||||
log.debug("play() interrupted by pause (stall recovery)");
|
||||
} else {
|
||||
this.host.onError(`play() failed: ${err}`);
|
||||
}
|
||||
} finally {
|
||||
this.pendingPlay = null;
|
||||
}
|
||||
})();
|
||||
|
||||
return this.pendingPlay;
|
||||
}
|
||||
|
||||
async pause(): Promise<void> {
|
||||
this.element?.pause();
|
||||
}
|
||||
|
||||
async toggle(): Promise<boolean> {
|
||||
const el = this.element;
|
||||
if (!el) return false;
|
||||
if (el.paused) {
|
||||
await this.play();
|
||||
return true;
|
||||
}
|
||||
await this.pause();
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* PRIMITIVE: in-place element seek (no reload). The backend already decided
|
||||
* this seek does not need a transcode reload.
|
||||
*/
|
||||
async seekElement(positionSeconds: number, offset: number): Promise<void> {
|
||||
const el = this.element;
|
||||
if (!el) return;
|
||||
el.currentTime = positionSeconds;
|
||||
this.bridge.setSeekOffset(offset);
|
||||
await this.waitForEvent(el, "seeked", 2000);
|
||||
}
|
||||
|
||||
/**
|
||||
* PRIMITIVE: compound reload — swap the source and resume at
|
||||
* `positionSeconds`, an **absolute** position on the item's own timeline.
|
||||
* Contains NO strategy decision; the backend already decided to reload and
|
||||
* supplied the url/position. Preserves the hard-won dual-audio teardown and
|
||||
* canplay wait.
|
||||
*
|
||||
* The position is reached by *seeking the element*, and the transcode offset
|
||||
* is cleared to zero. It used to be the other way round — the offset was set
|
||||
* to the position and nothing seeked — which was correct only while the
|
||||
* reloaded URL itself began there, via `StartTimeTicks`. DR-181 removes that
|
||||
* parameter (on an HLS playlist it makes the server reject every segment with
|
||||
* `400`), so a reloaded stream now always starts at the beginning of the item.
|
||||
* Leaving the old arithmetic in place would have left `currentTime` reading
|
||||
* `offset + 0` — the scrubber showing 20:00 while the opening titles play, and
|
||||
* no seek ever happening.
|
||||
*
|
||||
* TRACES: UR-004, UR-005 | DR-181 | UT-183
|
||||
*/
|
||||
async reloadSource(selection: StreamSelection, positionSeconds: number): Promise<void> {
|
||||
const el = this.element;
|
||||
if (!el) {
|
||||
// Still update the selection so the component's HLS $effect can pick it up.
|
||||
this.bridge.setSeekOffset(0);
|
||||
this.bridge.setStreamSelection(selection);
|
||||
return;
|
||||
}
|
||||
const wasPlaying = !el.paused;
|
||||
el.pause();
|
||||
this.bridge.destroyHls();
|
||||
if (el.src) {
|
||||
el.removeAttribute("src");
|
||||
el.load();
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
// The reloaded stream begins at the item's zero, so there is no base to add.
|
||||
this.bridge.setSeekOffset(0);
|
||||
this.bridge.setStreamSelection(selection);
|
||||
// A source that never becomes playable is a failed reload, not a slow one:
|
||||
// the caller (quality switch, transcoded seek) has to know so it can revert
|
||||
// its selection and surface the error instead of leaving the UI claiming a
|
||||
// stream that is not playing.
|
||||
const ready = await this.waitForEvent(el, "canplay", 10000);
|
||||
if (!ready) {
|
||||
throw new Error(`Reloaded stream never fired "canplay" within 10000ms`);
|
||||
}
|
||||
// Now that the new source is playable, put it where the caller asked for.
|
||||
// Seeking before `canplay` is dropped by the element, which is why this
|
||||
// follows the wait rather than riding along with the URL swap.
|
||||
if (positionSeconds > 0) {
|
||||
el.currentTime = positionSeconds;
|
||||
await this.waitForEvent(el, "seeked", 2000);
|
||||
}
|
||||
if (wasPlaying) await el.play();
|
||||
}
|
||||
|
||||
setVolume(volume: number): void {
|
||||
if (this.element) this.element.volume = Math.max(0, Math.min(1, volume));
|
||||
}
|
||||
|
||||
setMuted(muted: boolean): void {
|
||||
if (this.element) this.element.muted = muted;
|
||||
}
|
||||
|
||||
/** Subtitle selection: HTML5 toggles textTracks on the element directly. */
|
||||
async selectSubtitle(streamIndex: number | null, _arrayIndex?: number): Promise<void> {
|
||||
const el = this.element;
|
||||
if (!el || !el.textTracks) return;
|
||||
for (let i = 0; i < el.textTracks.length; i++) {
|
||||
el.textTracks[i].mode = "disabled";
|
||||
}
|
||||
if (streamIndex !== null) {
|
||||
const tracks = el.querySelectorAll("track");
|
||||
tracks.forEach((track) => {
|
||||
const idx = parseInt(track.getAttribute("data-stream-index") || "-1");
|
||||
if (idx === streamIndex && track.track) {
|
||||
track.track.mode = "showing";
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
getPosition(): number {
|
||||
const el = this.element;
|
||||
if (!el) return 0;
|
||||
return el.currentTime + this.bridge.getSeekOffset();
|
||||
}
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
this.bridge.destroyHls();
|
||||
const el = this.element;
|
||||
if (el) {
|
||||
el.pause();
|
||||
el.removeAttribute("src");
|
||||
el.load();
|
||||
}
|
||||
this.attachedElement = null;
|
||||
}
|
||||
|
||||
/** Resolve when `event` fires on `el`, or after `timeoutMs` as a fallback. */
|
||||
/**
|
||||
* Resolves `true` when the event fires, `false` if the budget runs out. The
|
||||
* distinction is the caller's to act on: a missing `seeked` is cosmetic, a
|
||||
* missing `canplay` means the reload failed.
|
||||
*/
|
||||
private waitForEvent(el: HTMLVideoElement, event: string, timeoutMs: number): Promise<boolean> {
|
||||
return new Promise<boolean>((resolve) => {
|
||||
const done = (fired: boolean) => {
|
||||
el.removeEventListener(event, listener);
|
||||
clearTimeout(timer);
|
||||
resolve(fired);
|
||||
};
|
||||
const listener = () => done(true);
|
||||
el.addEventListener(event, listener);
|
||||
// `done` closes over `timer`, but can only run once the listener fires or
|
||||
// the timeout elapses — both strictly after this assignment.
|
||||
const timer: ReturnType<typeof setTimeout> = setTimeout(() => done(false), timeoutMs);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,73 +1,14 @@
|
||||
/**
|
||||
* Player adapter factory + public exports.
|
||||
* Player adapter public exports.
|
||||
*
|
||||
* `createAdapter` selects the concrete PlayerAdapter for the current platform.
|
||||
* Rust decides *which backend this platform has* (`useHtml5Element` from
|
||||
* `player_play_item`); this factory consumes that decision rather than
|
||||
* re-deriving it.
|
||||
* Video is always drawn by a native player — mpv on the desktop, ExoPlayer on
|
||||
* Android — behind the transparent webview, so there is one video adapter,
|
||||
* `NativePlayerAdapter`. The webview `<video>` adapter and the factory that
|
||||
* chose between the two were deleted with that path (DR-235).
|
||||
* `WebviewAudioAdapter` remains for audio on a desktop without mpv.
|
||||
*
|
||||
* The `experimentalNativeVideo` flag is a **suppressor, never a promoter**: it
|
||||
* can force the HTML5 path when Rust says native (so an in-progress spike cannot
|
||||
* ship as a regression), but it can never select native on a platform whose Rust
|
||||
* backend reported HTML5 — Linux has no way to composite a surface behind a
|
||||
* WebKitGTK webview, so promoting there would produce a black screen.
|
||||
*
|
||||
* The previous unconditional HTML5 override cited tauri#10152 as an upstream
|
||||
* blocker. That was stale: #10152 is a dormant *feature request*, the capability
|
||||
* shipped in tauri 27d01834, and the black-screen bug (tauri#8381, #9408) was a
|
||||
* broken `setBackgroundColor` JNI signature fixed in wry 0.39.4 — we ship 0.53.x.
|
||||
*
|
||||
* TRACES: UR-003, UR-004 | DR-004, DR-150 | UT-149
|
||||
* TRACES: UR-003, UR-004 | DR-004, DR-235
|
||||
*/
|
||||
|
||||
import { Html5PlayerAdapter, type Html5ElementBridge } from "./html5Adapter";
|
||||
import { NativePlayerAdapter } from "./nativeAdapter";
|
||||
import type { AdapterHost, PlayerAdapter } from "./types";
|
||||
|
||||
export type { PlayerAdapter, AdapterHost, PlayerLoadOptions, SubtitleTrackInput } from "./types";
|
||||
export type { Html5ElementBridge } from "./html5Adapter";
|
||||
export { Html5PlayerAdapter } from "./html5Adapter";
|
||||
export { NativePlayerAdapter } from "./nativeAdapter";
|
||||
|
||||
/** What the Rust `player_play_item` response says it chose. */
|
||||
export type BackendKind = "html5" | "native";
|
||||
|
||||
export interface CreateAdapterArgs {
|
||||
/** Backend kind reported by `player_play_item` (`useHtml5Element`). */
|
||||
backendKind: BackendKind;
|
||||
host: AdapterHost;
|
||||
/** Required for the HTML5 adapter; ignored by the native adapter. */
|
||||
bridge?: Html5ElementBridge;
|
||||
/**
|
||||
* User opt-in for the native video path. Defaults to **off**, so omitting it
|
||||
* yields today's behaviour (HTML5 everywhere) rather than silently enabling
|
||||
* the spike.
|
||||
*/
|
||||
experimentalNativeVideo?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the adapter for this platform/stream.
|
||||
*
|
||||
* Native is chosen only when Rust reports a native backend AND the user has
|
||||
* opted in. Every other combination is HTML5.
|
||||
*/
|
||||
export function createAdapter({
|
||||
backendKind,
|
||||
host,
|
||||
bridge,
|
||||
experimentalNativeVideo = false,
|
||||
}: CreateAdapterArgs): PlayerAdapter {
|
||||
const effectiveKind: BackendKind =
|
||||
backendKind === "native" && experimentalNativeVideo ? "native" : "html5";
|
||||
|
||||
if (effectiveKind === "native") {
|
||||
// The native surface is owned by the backend — no DOM element, no bridge.
|
||||
return new NativePlayerAdapter(host);
|
||||
}
|
||||
|
||||
if (!bridge) {
|
||||
throw new Error("createAdapter: Html5ElementBridge is required for the HTML5 adapter");
|
||||
}
|
||||
return new Html5PlayerAdapter(host, bridge);
|
||||
}
|
||||
|
||||
@@ -1,29 +1,19 @@
|
||||
import type { StreamSelection } from "$lib/api/bindings";
|
||||
/**
|
||||
* NativePlayerAdapter — the Android/ExoPlayer PlayerAdapter implementation.
|
||||
* NativePlayerAdapter — the video PlayerAdapter, for every platform.
|
||||
*
|
||||
* ExoPlayer is driven entirely by the Rust backend (JNI), which already emits
|
||||
* PlayerStatusEvents and handles seek/audio-track internally. So this adapter is
|
||||
* a thin delegate to backend commands; there is no DOM element to touch and no
|
||||
* hls.js. State reporting is unnecessary here because the native backend emits
|
||||
* events directly — the adapter's job is only to forward control intents.
|
||||
* The native player (mpv on the desktop, ExoPlayer on Android) is driven
|
||||
* entirely by the Rust backend, which emits PlayerStatusEvents and handles
|
||||
* seek/audio-track/quality internally. So this adapter is a thin delegate to
|
||||
* backend commands; there is no DOM element to touch. State reporting is
|
||||
* unnecessary because the backend emits events directly — the adapter's job is
|
||||
* only to forward control intents.
|
||||
*
|
||||
* NOTE: This adapter is currently unreachable — `createAdapter()` hardcodes the
|
||||
* HTML5 kind, so Android video runs through Html5PlayerAdapter.
|
||||
* It used to be the opt-in alternative to an HTML5 `<video>` adapter; that path
|
||||
* was deleted (DR-235), so this is the one video adapter. The compositing it
|
||||
* relies on is described in docs/architecture/05-platform-backends.md.
|
||||
*
|
||||
* That override was introduced citing tauri#10152 as an upstream blocker. That
|
||||
* is no longer accurate: #10152 is a stale *feature request* (dead since
|
||||
* 2024-07-01) asking that `transparent` not be desktop-only, and the capability
|
||||
* shipped in tauri commit 27d01834 (2024-09-02). The related black/white-screen
|
||||
* bug (tauri#8381, #9408) was a broken JNI signature for setBackgroundColor,
|
||||
* fixed in wry 0.39.4; we ship wry 0.55.x.
|
||||
*
|
||||
* What is genuinely unproven is SurfaceView-behind-WebView *compositing* on
|
||||
* Tauri Android — nothing upstream blocks it, and nothing upstream demonstrates
|
||||
* it either. docs/architecture/05-platform-backends.md ("Native Video
|
||||
* Compositing") describes the path that shipped.
|
||||
*
|
||||
* TRACES: UR-003, UR-005 | DR-004, DR-028
|
||||
* TRACES: UR-003, UR-005 | DR-004, DR-028, DR-235
|
||||
*/
|
||||
|
||||
import { commands } from "$lib/api/bindings";
|
||||
@@ -40,9 +30,6 @@ export class NativePlayerAdapter implements PlayerAdapter {
|
||||
this.host = host;
|
||||
}
|
||||
|
||||
// The native surface is owned by the backend; nothing to attach in the DOM.
|
||||
attach(_element: HTMLVideoElement | null): void {}
|
||||
|
||||
async load(_streamUrl: string, options: PlayerLoadOptions): Promise<void> {
|
||||
// player_play_item already initiated native playback before this adapter is
|
||||
// created, so there is no stream to load here — but it carries no start
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { StreamSelection } from "$lib/api/bindings";
|
||||
/**
|
||||
* PlayerAdapter contract — the decoupled boundary between the UI/backend and a
|
||||
* concrete video player implementation (Linux HTML5+hls.js, or Android native).
|
||||
* concrete player implementation (the native video player, or webview audio).
|
||||
*
|
||||
* The whole point: UI components and the Rust backend interact with video ONLY
|
||||
* through this interface. All element / hls.js / ExoPlayer / textTracks detail —
|
||||
* through this interface. All player detail —
|
||||
* and the backend seek/audio-track *strategy* round-trip — is internal to an
|
||||
* implementation. A control intent (from UI or a backend lockscreen/remote/sleep
|
||||
* event) reaches the element by the facade dispatching to the active adapter.
|
||||
@@ -16,7 +16,7 @@ import type { StreamSelection } from "$lib/api/bindings";
|
||||
* TRACES: UR-003, UR-005, UR-020, UR-021 | DR-001, DR-023, DR-024, DR-028
|
||||
*/
|
||||
|
||||
/** A subtitle track handed to the adapter at load time (WebVTT for HTML5). */
|
||||
/** A subtitle track handed to the adapter at load time (WebVTT). */
|
||||
export interface SubtitleTrackInput {
|
||||
index: number;
|
||||
url: string;
|
||||
@@ -90,14 +90,7 @@ export interface AdapterHost {
|
||||
*/
|
||||
export interface PlayerAdapter {
|
||||
/** Which platform backend this adapter represents. */
|
||||
readonly kind: "html5" | "native";
|
||||
|
||||
/**
|
||||
* Bind the output target. For the HTML5 adapter this is the `<video>` element
|
||||
* (pass null on teardown); the native adapter ignores it (ExoPlayer renders to
|
||||
* its own surface).
|
||||
*/
|
||||
attach(element: HTMLVideoElement | null): void;
|
||||
readonly kind: "native" | "webview-audio";
|
||||
|
||||
/** Load a stream and begin playback at `options.initialPosition`. */
|
||||
load(streamUrl: string, options: PlayerLoadOptions): Promise<void>;
|
||||
@@ -121,9 +114,7 @@ export interface PlayerAdapter {
|
||||
|
||||
/**
|
||||
* Compound reload: swap to `selection` and resume at `offset` seconds. Runs
|
||||
* the invariant mechanical sequence for this platform (html5: pause → hls
|
||||
* teardown → clear src → set new selection → wait ready → resume; native:
|
||||
* ExoPlayer setMediaItem + seekTo). No decision is made here — the backend
|
||||
* the invariant mechanical sequence for this platform. No decision is made here — the backend
|
||||
* already decided to reload, and `selection.transport` says how to open it, so
|
||||
* no adapter has to infer that from the URL.
|
||||
*
|
||||
@@ -134,12 +125,12 @@ export interface PlayerAdapter {
|
||||
setVolume(volume: number): void; // 0..1
|
||||
setMuted(muted: boolean): void;
|
||||
|
||||
/** Enable a subtitle track (null disables) — DOM textTracks is a webview primitive. */
|
||||
/** Enable a subtitle track (null disables). */
|
||||
selectSubtitle(streamIndex: number | null, arrayIndex?: number): Promise<void>;
|
||||
|
||||
/** Current position in seconds (adapter's own truth, e.g. element.currentTime + offset). */
|
||||
/** Current position in seconds (adapter's own truth). */
|
||||
getPosition(): number;
|
||||
|
||||
/** Tear down: destroy hls, detach element, stop reporting. Idempotent. */
|
||||
/** Tear down and stop reporting. Idempotent. */
|
||||
dispose(): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import type { StreamSelection } from "$lib/api/bindings";
|
||||
/**
|
||||
* Webview audio adapter — plays audio-only media through a hidden `<audio>`
|
||||
* element on platforms with no native audio backend (currently Windows).
|
||||
*
|
||||
* All *video* already renders through the webview `<video>` element on every
|
||||
* platform; libmpv/ExoPlayer only drive audio-only playback. On Windows there is
|
||||
* no native audio backend, so the Rust `WebviewAudioBackend` hands the stream URL
|
||||
* element on a desktop with no native audio backend — none that ships: Linux
|
||||
* and Windows play through mpv, Android through ExoPlayer. There the Rust
|
||||
* `WebviewAudioBackend` hands the stream URL
|
||||
* to the frontend via a `webview_audio_load` event and drives play/pause/seek
|
||||
* through `control_command`. This adapter owns the `<audio>` element that plays
|
||||
* it and reports state/position/duration/ended back to Rust through the same
|
||||
@@ -22,7 +20,7 @@ import type { StreamSelection } from "$lib/api/bindings";
|
||||
import type { AdapterHost, PlayerAdapter, PlayerLoadOptions } from "./types";
|
||||
|
||||
export class WebviewAudioAdapter implements PlayerAdapter {
|
||||
readonly kind = "html5" as const;
|
||||
readonly kind = "webview-audio" as const;
|
||||
|
||||
private audio: HTMLAudioElement;
|
||||
private host: AdapterHost;
|
||||
@@ -118,10 +116,6 @@ export class WebviewAudioAdapter implements PlayerAdapter {
|
||||
});
|
||||
}
|
||||
|
||||
attach(_element: HTMLVideoElement | null): void {
|
||||
// The audio element is owned by the controller, not attached here.
|
||||
}
|
||||
|
||||
setVolume(volume: number): void {
|
||||
this.audio.volume = Math.max(0, Math.min(1, volume));
|
||||
}
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
/**
|
||||
* Compatibility shim.
|
||||
*
|
||||
* The HTML5 → Rust reporting functions moved to `adapters/rustReportHost.ts` as
|
||||
* part of the PlayerAdapter refactor. Existing callers import the reporter as
|
||||
* `import * as html5Adapter from "$lib/player/html5Adapter"`; this shim keeps
|
||||
* that working while the migration proceeds. New adapter code should depend on
|
||||
* the `AdapterHost` interface (see `adapters/types.ts`) instead.
|
||||
*/
|
||||
|
||||
export {
|
||||
reportState,
|
||||
reportPosition,
|
||||
reportMediaLoaded,
|
||||
resetReporting,
|
||||
} from "./adapters/rustReportHost";
|
||||
|
||||
/** @deprecated states are defined on the AdapterHost interface now. */
|
||||
export type Html5PlayerState = "playing" | "paused" | "loading" | "stopped" | "idle";
|
||||
+21
-44
@@ -118,21 +118,21 @@ async function stop() {
|
||||
}
|
||||
|
||||
async function seek(positionSeconds: number) {
|
||||
// Audio path: backend seeks the native backend directly.
|
||||
if (!activeAdapter) {
|
||||
// Audio (and webview audio): the backend seeks its player directly.
|
||||
if (activeAdapter?.kind !== "native") {
|
||||
await commands.playerSeek(positionSeconds);
|
||||
return;
|
||||
}
|
||||
// Video path: ask the backend to DECIDE the strategy (in-place vs reload), then
|
||||
// execute the matching adapter primitive. The decision logic stays in Rust
|
||||
// (player_seek_video); the adapter only runs the chosen mechanical primitive.
|
||||
// Video: the backend decides the strategy (in place vs re-open) and carries
|
||||
// it out (player_seek_video).
|
||||
await seekVideo(positionSeconds, null, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Video seek: backend decides strategy, facade dispatches the chosen adapter
|
||||
* primitive. `mediaSourceId`/`audioTrackIndex` come from the video view (they are
|
||||
* needed for the transcode reload URL). Requires an active video adapter.
|
||||
* Video seek. The backend decides whether the stream can be moved in place or
|
||||
* has to be re-opened, and does either itself — every video renderer is a
|
||||
* native player (DR-235). `mediaSourceId`/`audioTrackIndex` come from the video
|
||||
* view (they are needed for the re-open URL).
|
||||
*/
|
||||
async function seekVideo(
|
||||
positionSeconds: number,
|
||||
@@ -144,27 +144,18 @@ async function seekVideo(
|
||||
await commands.playerSeek(positionSeconds);
|
||||
return;
|
||||
}
|
||||
const response = (await commands.playerSeekVideo(
|
||||
const response = await commands.playerSeekVideo(
|
||||
requireHandle(),
|
||||
positionSeconds,
|
||||
mediaSourceId,
|
||||
audioTrackIndex,
|
||||
adapter.kind === "html5",
|
||||
)) as any;
|
||||
// Serde keeps `seek_offset` snake_case (only the "strategy" tag is camelCase).
|
||||
if (response.strategy === "reloadStream") {
|
||||
// `seek_offset` is the ABSOLUTE position to resume at, not a base to add to
|
||||
// the element's clock: the reloaded stream starts at the item's zero since
|
||||
// DR-181, so reloadSource seeks there. (The name is the wire field's.)
|
||||
await adapter.reloadSource(response.selection, response.seek_offset ?? positionSeconds);
|
||||
} else {
|
||||
await adapter.seekElement(response.position ?? positionSeconds, 0);
|
||||
}
|
||||
);
|
||||
await adapter.seekElement(response.position ?? positionSeconds, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch audio track: backend decides (may reload the stream), facade dispatches
|
||||
* the resulting primitive. Requires an active video adapter.
|
||||
* Switch audio track. The backend selects in place or re-opens the stream
|
||||
* itself. Requires an active video adapter.
|
||||
*/
|
||||
async function switchAudioTrack(
|
||||
streamIndex: number,
|
||||
@@ -172,26 +163,20 @@ async function switchAudioTrack(
|
||||
currentPosition: number | null,
|
||||
mediaSourceId: string | null,
|
||||
): Promise<void> {
|
||||
const adapter = activeAdapter;
|
||||
if (!adapter) return;
|
||||
const response = (await commands.playerSwitchAudioTrack(
|
||||
if (!activeAdapter) return;
|
||||
await commands.playerSwitchAudioTrack(
|
||||
requireHandle(),
|
||||
streamIndex,
|
||||
arrayIndex,
|
||||
adapter.kind === "html5",
|
||||
currentPosition,
|
||||
mediaSourceId,
|
||||
)) as any;
|
||||
if (response.strategy === "reloadStream") {
|
||||
await adapter.reloadSource(response.selection, response.position!);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Change the bandwidth ceiling of the video playing now. The backend re-opens
|
||||
* the stream at the new quality and decides who reloads: it handles a native
|
||||
* backend itself, and hands HTML5 a selection for the same `reloadSource`
|
||||
* primitive the audio-track switch uses. Requires an active video adapter.
|
||||
* the stream at the new quality and resumes it. Requires an active video
|
||||
* adapter.
|
||||
*
|
||||
* The change applies to **this playback only** — the backend sets a per-playback
|
||||
* override that the next item clears, leaving the durable Settings default
|
||||
@@ -207,22 +192,14 @@ async function setStreamQuality(
|
||||
mediaSourceId: string | null,
|
||||
audioTrackIndex: number | null,
|
||||
): Promise<StreamSelection | null> {
|
||||
const adapter = activeAdapter;
|
||||
if (!adapter) return null;
|
||||
const response = (await commands.playerSetStreamQuality(
|
||||
if (!activeAdapter) return null;
|
||||
const response = await commands.playerSetStreamQuality(
|
||||
requireHandle(),
|
||||
quality,
|
||||
adapter.kind === "html5",
|
||||
currentPosition,
|
||||
mediaSourceId,
|
||||
audioTrackIndex,
|
||||
)) as any;
|
||||
if (response.strategy === "reloadStream") {
|
||||
await adapter.reloadSource(response.selection, response.position ?? currentPosition ?? 0);
|
||||
return response.selection;
|
||||
}
|
||||
// The native backend reloaded itself, but still reports what it opened — the
|
||||
// caller needs it to show the rung actually in force.
|
||||
);
|
||||
return response.selection ?? null;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
/**
|
||||
* The loader is chosen from the backend's `transport` tag, never from the URL.
|
||||
*
|
||||
* TRACES: UR-079 | DR-225 | UT-214
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { elementSrcFor, videoLoaderFor, type LoaderCapabilities } from "./streamTransport";
|
||||
import type { StreamSelection, Transport } from "$lib/api/bindings";
|
||||
|
||||
const MODERN: LoaderCapabilities = { hlsJsSupported: true, nativeHlsSupported: false };
|
||||
const SAFARI: LoaderCapabilities = { hlsJsSupported: false, nativeHlsSupported: true };
|
||||
const NEITHER: LoaderCapabilities = { hlsJsSupported: false, nativeHlsSupported: false };
|
||||
|
||||
function selection(transport: Transport, url: string): Pick<StreamSelection, "url" | "transport"> {
|
||||
return { url, transport };
|
||||
}
|
||||
|
||||
describe("videoLoaderFor", () => {
|
||||
it("attaches hls.js when the backend says HLS and hls.js is available", () => {
|
||||
expect(videoLoaderFor(selection({ type: "hls" }, "https://s/master.m3u8"), MODERN)).toBe(
|
||||
"hlsjs",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to the element's own HLS loader when hls.js is unavailable", () => {
|
||||
expect(videoLoaderFor(selection({ type: "hls" }, "https://s/master.m3u8"), SAFARI)).toBe(
|
||||
"nativeHls",
|
||||
);
|
||||
});
|
||||
|
||||
it("loads a progressive stream directly", () => {
|
||||
expect(
|
||||
videoLoaderFor(
|
||||
selection({ type: "progressive" }, "https://s/Videos/1/stream?static=true"),
|
||||
MODERN,
|
||||
),
|
||||
).toBe("direct");
|
||||
});
|
||||
|
||||
it("loads a local file directly", () => {
|
||||
expect(
|
||||
videoLoaderFor(selection({ type: "localFile" }, "http://127.0.0.1:9/media/x.mkv"), MODERN),
|
||||
).toBe("direct");
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// The two cases the `.m3u8` substring check gets wrong. These are the
|
||||
// reason the field exists; both fail against a URL-sniffing implementation.
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
it("does NOT attach hls.js to a progressive stream whose URL happens to end .m3u8", () => {
|
||||
// A direct play served from a path containing the substring — nothing stops
|
||||
// a server, a proxy, or a local cache from producing this.
|
||||
expect(
|
||||
videoLoaderFor(selection({ type: "progressive" }, "https://s/files/movie.m3u8.mp4"), MODERN),
|
||||
).toBe("direct");
|
||||
expect(
|
||||
videoLoaderFor(selection({ type: "progressive" }, "https://s/x?name=master.m3u8"), MODERN),
|
||||
).toBe("direct");
|
||||
});
|
||||
|
||||
it("DOES attach hls.js to an HLS stream whose URL does not contain .m3u8", () => {
|
||||
// Jellyfin's own transcoding URLs are not required to end in `.m3u8`, and a
|
||||
// DASH or query-routed playlist endpoint never would.
|
||||
expect(videoLoaderFor(selection({ type: "hls" }, "https://s/Videos/1/hls"), MODERN)).toBe(
|
||||
"hlsjs",
|
||||
);
|
||||
expect(
|
||||
videoLoaderFor(selection({ type: "hls" }, "https://s/stream?format=playlist"), SAFARI),
|
||||
).toBe("nativeHls");
|
||||
});
|
||||
|
||||
it("falls back to direct when HLS is requested but nothing can play it", () => {
|
||||
expect(videoLoaderFor(selection({ type: "hls" }, "https://s/master.m3u8"), NEITHER)).toBe(
|
||||
"direct",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("elementSrcFor", () => {
|
||||
it("empties the element's src only when hls.js drives it", () => {
|
||||
expect(elementSrcFor(selection({ type: "hls" }, "https://s/master.m3u8"), MODERN)).toBe("");
|
||||
expect(elementSrcFor(selection({ type: "hls" }, "https://s/master.m3u8"), SAFARI)).toBe(
|
||||
"https://s/master.m3u8",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the src for a progressive stream that looks like a playlist", () => {
|
||||
const s = selection({ type: "progressive" }, "https://s/files/movie.m3u8.mp4");
|
||||
expect(elementSrcFor(s, MODERN)).toBe("https://s/files/movie.m3u8.mp4");
|
||||
});
|
||||
});
|
||||
@@ -1,88 +0,0 @@
|
||||
/**
|
||||
* Which loader opens a stream in the webview `<video>` element.
|
||||
*
|
||||
* Extracted from `VideoPlayer.svelte` so the decision can be unit-tested — the
|
||||
* same pattern as `episodeStrip.ts` and `TrackList.logic.test.ts`.
|
||||
*
|
||||
* TRACES: UR-079 | DR-225 | UT-214
|
||||
*/
|
||||
|
||||
import type { StreamSelection, Transport } from "$lib/api/bindings";
|
||||
|
||||
/** How the element should be fed. */
|
||||
export type VideoLoader =
|
||||
/** hls.js drives a MediaSource; the element's own `src` stays empty. */
|
||||
| "hlsjs"
|
||||
/** The element loads the playlist itself (Safari/WebKit native HLS). */
|
||||
| "nativeHls"
|
||||
/** The element loads the URL directly — a progressive file or a local one. */
|
||||
| "direct";
|
||||
|
||||
/** What the running browser can do, passed in so the decision stays pure. */
|
||||
export interface LoaderCapabilities {
|
||||
/** `Hls.isSupported()` */
|
||||
hlsJsSupported: boolean;
|
||||
/** `video.canPlayType("application/vnd.apple.mpegurl")` was non-empty */
|
||||
nativeHlsSupported: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the loader from the backend's tagged `transport`.
|
||||
*
|
||||
* This used to read `url.includes(".m3u8")`, in two places in
|
||||
* `VideoPlayer.svelte`. Rust *builds* that URL and knows exactly what it is;
|
||||
* re-deriving the answer here by substring match is a domain fact reconstructed
|
||||
* in the presentation layer — the same error as leaking item-type taxonomy, and
|
||||
* one that fails silently in both directions: a progressive file served from a
|
||||
* path containing `.m3u8` gets an HLS loader, and a playlist served from a path
|
||||
* without it does not.
|
||||
*
|
||||
* The transport is the *stream's* property; whether a given loader exists is the
|
||||
* *browser's*. Only the second is decided here.
|
||||
*/
|
||||
export function videoLoaderFor(
|
||||
selection: Pick<StreamSelection, "url" | "transport">,
|
||||
capabilities: LoaderCapabilities,
|
||||
): VideoLoader {
|
||||
return loaderForTransport(selection.transport.type, capabilities);
|
||||
}
|
||||
|
||||
/**
|
||||
* The same decision, taken from the transport *tag* alone.
|
||||
*
|
||||
* Exists because a Svelte `$effect` that reads the whole selection re-runs
|
||||
* whenever the selection **object** is replaced — even with an identical URL and
|
||||
* transport — and the HLS effect's teardown/rebuild is not idempotent: it
|
||||
* destroys the hls.js instance and reattaches, which leaves the element with no
|
||||
* video until something forces another cycle. The pre-DR-225 code read a plain
|
||||
* URL *string*, so re-assigning the same value was a no-op and the effect stayed
|
||||
* put. Passing primitives restores that.
|
||||
*
|
||||
* TRACES: UR-079 | DR-225 | UT-214
|
||||
*/
|
||||
export function loaderForTransport(
|
||||
transport: Transport["type"],
|
||||
capabilities: LoaderCapabilities,
|
||||
): VideoLoader {
|
||||
if (transport !== "hls") {
|
||||
// Progressive and local files are what the element loads natively. No
|
||||
// MediaSource, no playlist parsing.
|
||||
return "direct";
|
||||
}
|
||||
if (capabilities.hlsJsSupported) return "hlsjs";
|
||||
if (capabilities.nativeHlsSupported) return "nativeHls";
|
||||
// Nothing here can parse a playlist. Handing the URL to the element is very
|
||||
// likely to fail, but it is the only remaining move and it surfaces a real
|
||||
// media error rather than silently doing nothing.
|
||||
return "direct";
|
||||
}
|
||||
|
||||
/** Convenience for the template: does the element's `src` stay empty? */
|
||||
export function elementSrcFor(
|
||||
selection: Pick<StreamSelection, "url" | "transport">,
|
||||
capabilities: LoaderCapabilities,
|
||||
): string {
|
||||
return videoLoaderFor(selection, capabilities) === "hlsjs" ? "" : selection.url;
|
||||
}
|
||||
|
||||
export type { Transport };
|
||||
Reference in New Issue
Block a user