feat(player): native video on Linux, and one contract for every player (v0.11.0)
mpv now decodes video on Linux, drawn into a framebuffer we own and blitted
into the default vbox's draw handler. Tauri's widget tree is untouched, so an
upgrade that assumes its own layout cannot invalidate this. Direct play means
the original file, hardware decoding, and no server transcode at all — where
previously every desktop video was re-encoded to h264 for the browser engine,
whatever the file actually was. Off by default: JELLYTAU_NATIVE_VIDEO=1.
That settles finding 2 of playback-backend-unification.md — "native video
cannot be composited with a Tauri webview" — by demonstration rather than
argument, on X11 and Wayland both.
Turning it on exposed nine defects, none of them mpv's. Each was the same
mistake in a different place: a capability written down as a compile-time fact
about the platform, or a state asserted instead of confirmed.
DR-238/246 a seek routed by the stream's container rather than by what the
engine could do with it - correct only while one player handled
those streams, silent the moment another did
DR-239 a property handled but never observed, so the play/pause button
waited for an event that could not arrive
DR-240 fullscreen expanding the document while the window stayed put
DR-241 a seek issued before the engine had a file, failed, and discarded
- which is why resume began at zero
DR-247 a Linux-only gate outliving the caller that made it Linux-only,
breaking the Android build outright
DR-250 a stop aimed at whichever renderer bookkeeping believed was in
charge, missing the one actually making sound
DR-251 a duration of zero believed, leaving the seek bar no scale
DR-252 a junk float converted to a Duration, panicking the backend the
instant a length-less stream appeared
So the MediaPlayer contract (DR-242 … DR-247): `open` carries a start position,
so no caller sequences load-then-seek and none can race an engine's load;
`seek` states a destination and leaves in-place-versus-re-open to the engine;
`snapshot` is one coherent read; and `Phase::Opening` names the window where
intent used to be lost. One conformance suite runs against every engine —
FakePlayer and mpv under cargo test, ExoPlayer instrumented on a device — so an
engine is either correct or visibly failing.
Two of the nine were introduced during this work and caught on hardware, not by
any suite: an over-broad capability that grouped ExoPlayer with mpv, and the
Duration panic. The suites test engines that behave. That is recorded in
docs/native-player-verification.md, which asks for the exact action sequences
that found them.
Verified: all automated gates, conformance (mpv 9/9, legacy 8/9 by design,
ExoPlayer 7/7 on device), and manual desktop and Android passes on real
hardware.
Known open and deliberately shipped: resume reads local progress and never the
server's; the background-audio handoff still declares a state swap it does not
confirm (the symptom is now impossible, the race is not); and `bun run
android:dev` builds an APK carrying the release application id, whose failure
message advises an uninstall that would destroy app data. Fix that last one
before anyone else builds for Android.
Squashed from worktree-linux-native-video, which keeps the per-defect history.
This commit is contained in:
@@ -10,6 +10,23 @@ 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>> = {};
|
||||
@@ -54,7 +71,7 @@ function makeBridge(overrides: Partial<Html5ElementBridge> = {}): Html5ElementBr
|
||||
setSeekOffset: vi.fn((o: number) => {
|
||||
offset = o;
|
||||
}),
|
||||
setStreamUrl: vi.fn(),
|
||||
setStreamSelection: vi.fn(),
|
||||
destroyHls: vi.fn(),
|
||||
getMediaSourceId: () => "msid-1",
|
||||
...overrides,
|
||||
@@ -184,7 +201,7 @@ describe("Html5PlayerAdapter", () => {
|
||||
|
||||
it("reloadSource() runs the invariant teardown->swap->resume sequence", async () => {
|
||||
video.paused = false; // was playing → should resume
|
||||
const p = adapter.reloadSource("http://new/master.m3u8", 120);
|
||||
const p = adapter.reloadSource(testSelection("http://new/master.m3u8"), 120);
|
||||
|
||||
// Teardown happened synchronously before the awaited canplay wait.
|
||||
expect(video.pause).toHaveBeenCalled();
|
||||
@@ -194,7 +211,9 @@ describe("Html5PlayerAdapter", () => {
|
||||
|
||||
// Allow the internal 100ms settle delay, then fire canplay to resume.
|
||||
await new Promise((r) => setTimeout(r, 110));
|
||||
expect(bridge.setStreamUrl).toHaveBeenCalledWith("http://new/master.m3u8");
|
||||
expect(bridge.setStreamSelection).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ url: "http://new/master.m3u8", transport: { type: "hls" } }),
|
||||
);
|
||||
video._fire("canplay");
|
||||
video._fire("seeked");
|
||||
await p;
|
||||
@@ -217,7 +236,7 @@ describe("Html5PlayerAdapter", () => {
|
||||
*/
|
||||
it("reloadSource() seeks to the position and clears the transcode offset", async () => {
|
||||
video.paused = false;
|
||||
const p = adapter.reloadSource("http://new/master.m3u8", 1200);
|
||||
const p = adapter.reloadSource(testSelection("http://new/master.m3u8"), 1200);
|
||||
|
||||
await new Promise((r) => setTimeout(r, 110));
|
||||
expect(bridge.setSeekOffset).toHaveBeenCalledWith(0);
|
||||
@@ -238,7 +257,7 @@ describe("Html5PlayerAdapter", () => {
|
||||
/** 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("http://new/master.m3u8", 0);
|
||||
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
|
||||
@@ -258,7 +277,7 @@ describe("Html5PlayerAdapter", () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
video.paused = false;
|
||||
const p = adapter.reloadSource("http://new/master.m3u8", 120);
|
||||
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;
|
||||
@@ -270,7 +289,7 @@ describe("Html5PlayerAdapter", () => {
|
||||
|
||||
it("reloadSource() does not resume when it was paused", async () => {
|
||||
video.paused = true;
|
||||
const p = adapter.reloadSource("http://new/master.m3u8", 30);
|
||||
const p = adapter.reloadSource(testSelection("http://new/master.m3u8"), 30);
|
||||
await new Promise((r) => setTimeout(r, 110));
|
||||
video._fire("canplay");
|
||||
video._fire("seeked");
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
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>`
|
||||
@@ -24,6 +25,39 @@ 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
|
||||
@@ -36,8 +70,16 @@ export interface Html5ElementBridge {
|
||||
/** Current seek offset (seconds) for transcoded streams. */
|
||||
getSeekOffset(): number;
|
||||
setSeekOffset(offset: number): void;
|
||||
/** Update the stream URL the component renders (triggers its HLS $effect). */
|
||||
setStreamUrl(url: string): 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. */
|
||||
@@ -86,12 +128,13 @@ export class Html5PlayerAdapter implements PlayerAdapter {
|
||||
this.attachedElement = element;
|
||||
}
|
||||
|
||||
async load(streamUrl: string, _options: PlayerLoadOptions): Promise<void> {
|
||||
async load(streamUrl: string, options: PlayerLoadOptions): Promise<void> {
|
||||
// The component's reactive HLS $effect performs the actual attach/load when
|
||||
// the stream URL is set; loading is therefore driven by setStreamUrl. The
|
||||
// component's canplay/frag-buffered path reports readiness through the host.
|
||||
// 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.setStreamUrl(streamUrl);
|
||||
this.bridge.setStreamSelection(selectionForLoad(streamUrl, options));
|
||||
this.host.onState("loading");
|
||||
}
|
||||
|
||||
@@ -171,12 +214,12 @@ export class Html5PlayerAdapter implements PlayerAdapter {
|
||||
*
|
||||
* TRACES: UR-004, UR-005 | DR-181 | UT-183
|
||||
*/
|
||||
async reloadSource(url: string, positionSeconds: number): Promise<void> {
|
||||
async reloadSource(selection: StreamSelection, positionSeconds: number): Promise<void> {
|
||||
const el = this.element;
|
||||
if (!el) {
|
||||
// Still update the stream URL so the component's HLS $effect can pick it up.
|
||||
// Still update the selection so the component's HLS $effect can pick it up.
|
||||
this.bridge.setSeekOffset(0);
|
||||
this.bridge.setStreamUrl(url);
|
||||
this.bridge.setStreamSelection(selection);
|
||||
return;
|
||||
}
|
||||
const wasPlaying = !el.paused;
|
||||
@@ -189,7 +232,7 @@ export class Html5PlayerAdapter implements PlayerAdapter {
|
||||
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.setStreamUrl(url);
|
||||
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
|
||||
|
||||
@@ -28,6 +28,23 @@ vi.mock("$lib/api/bindings", () => ({
|
||||
import { NativePlayerAdapter } from "./nativeAdapter";
|
||||
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;
|
||||
}
|
||||
|
||||
function makeHost(): AdapterHost {
|
||||
return {
|
||||
onState: vi.fn(),
|
||||
@@ -67,7 +84,7 @@ describe("NativePlayerAdapter", () => {
|
||||
it("records position on seek/reload primitives (backend does the real work)", async () => {
|
||||
await adapter.seekElement(55, 0);
|
||||
expect(adapter.getPosition()).toBe(55);
|
||||
await adapter.reloadSource("ignored", 200);
|
||||
await adapter.reloadSource(testSelection("ignored"), 200);
|
||||
expect(adapter.getPosition()).toBe(200);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { StreamSelection } from "$lib/api/bindings";
|
||||
/**
|
||||
* NativePlayerAdapter — the Android/ExoPlayer PlayerAdapter implementation.
|
||||
*
|
||||
@@ -89,7 +90,7 @@ export class NativePlayerAdapter implements PlayerAdapter {
|
||||
* performed the reload+seek internally as part of the seek decision; nothing
|
||||
* to do on the frontend beyond recording position.
|
||||
*/
|
||||
async reloadSource(_url: string, offset: number): Promise<void> {
|
||||
async reloadSource(_selection: StreamSelection, offset: number): Promise<void> {
|
||||
this.position = offset;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
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).
|
||||
@@ -42,6 +43,19 @@ export interface PlayerLoadOptions {
|
||||
knownDuration: number;
|
||||
/** Subtitle tracks available for this media. */
|
||||
subtitleTracks: SubtitleTrackInput[];
|
||||
/**
|
||||
* The backend's decision about this stream, when it made one.
|
||||
*
|
||||
* Present for anything negotiated through `repository_get_stream_selection`.
|
||||
* Null for the paths that never negotiate — a local file, a live channel, a
|
||||
* plugin's direct URL — where the adapter falls back to what the other
|
||||
* options already say rather than to reading the URL.
|
||||
*
|
||||
* TRACES: UR-079 | DR-225
|
||||
*/
|
||||
selection?: StreamSelection | null;
|
||||
/** The source is a file on disk (or the loopback server in front of one). */
|
||||
isLocalFile?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -106,12 +120,16 @@ export interface PlayerAdapter {
|
||||
seekElement(positionSeconds: number, offset: number): Promise<void>;
|
||||
|
||||
/**
|
||||
* Compound reload: swap to `url` and resume at `offset` seconds. Runs the
|
||||
* invariant mechanical sequence for this platform (html5: pause → hls teardown
|
||||
* → clear src → set new url → wait ready → resume; native: ExoPlayer setMediaItem
|
||||
* + seekTo). No decision is made here — the backend already decided to reload.
|
||||
* 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
|
||||
* already decided to reload, and `selection.transport` says how to open it, so
|
||||
* no adapter has to infer that from the URL.
|
||||
*
|
||||
* TRACES: UR-079 | DR-225
|
||||
*/
|
||||
reloadSource(url: string, offset: number): Promise<void>;
|
||||
reloadSource(selection: StreamSelection, offset: number): Promise<void>;
|
||||
|
||||
setVolume(volume: number): void; // 0..1
|
||||
setMuted(muted: boolean): void;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
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).
|
||||
@@ -104,8 +105,8 @@ export class WebviewAudioAdapter implements PlayerAdapter {
|
||||
}
|
||||
|
||||
/** No transcode-reload concept for direct audio; treat as a fresh load. */
|
||||
async reloadSource(url: string, offset: number): Promise<void> {
|
||||
await this.load(url, {
|
||||
async reloadSource(selection: StreamSelection, offset: number): Promise<void> {
|
||||
await this.load(selection.url, {
|
||||
mediaId: "",
|
||||
mediaSourceId: null,
|
||||
needsTranscoding: false,
|
||||
|
||||
+20
-10
@@ -22,6 +22,7 @@ import type {
|
||||
PlayAlbumTrackRequest,
|
||||
PlayItemRequest,
|
||||
StreamingQuality,
|
||||
StreamSelection,
|
||||
} from "$lib/api/bindings";
|
||||
import { auth } from "$lib/stores/auth";
|
||||
import type { PlayerAdapter } from "./adapters/types";
|
||||
@@ -150,12 +151,12 @@ async function seekVideo(
|
||||
audioTrackIndex,
|
||||
adapter.kind === "html5",
|
||||
)) as any;
|
||||
// Serde keeps these snake_case (only the "strategy" tag is camelCase).
|
||||
// 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.new_url ?? "", response.seek_offset ?? positionSeconds);
|
||||
await adapter.reloadSource(response.selection, response.seek_offset ?? positionSeconds);
|
||||
} else {
|
||||
await adapter.seekElement(response.position ?? positionSeconds, 0);
|
||||
}
|
||||
@@ -182,26 +183,32 @@ async function switchAudioTrack(
|
||||
mediaSourceId,
|
||||
)) as any;
|
||||
if (response.strategy === "reloadStream") {
|
||||
await adapter.reloadSource(response.new_url!, response.position!);
|
||||
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 URL for the same `reloadSource` primitive
|
||||
* the audio-track switch uses. Requires an active video adapter.
|
||||
* backend itself, and hands HTML5 a selection for the same `reloadSource`
|
||||
* primitive the audio-track switch uses. Requires an active video adapter.
|
||||
*
|
||||
* TRACES: UR-074 | DR-162
|
||||
* The change applies to **this playback only** — the backend sets a per-playback
|
||||
* override that the next item clears, leaving the durable Settings default
|
||||
* alone. Returns the negotiated selection so the caller can show what it
|
||||
* actually got, which is not always what was asked for: a ceiling above the
|
||||
* source bitrate is the source.
|
||||
*
|
||||
* TRACES: UR-074, UR-079 | DR-162, DR-226
|
||||
*/
|
||||
async function setStreamQuality(
|
||||
quality: StreamingQuality,
|
||||
currentPosition: number | null,
|
||||
mediaSourceId: string | null,
|
||||
audioTrackIndex: number | null,
|
||||
): Promise<void> {
|
||||
): Promise<StreamSelection | null> {
|
||||
const adapter = activeAdapter;
|
||||
if (!adapter) return;
|
||||
if (!adapter) return null;
|
||||
const response = (await commands.playerSetStreamQuality(
|
||||
requireHandle(),
|
||||
quality,
|
||||
@@ -210,10 +217,13 @@ async function setStreamQuality(
|
||||
mediaSourceId,
|
||||
audioTrackIndex,
|
||||
)) as any;
|
||||
// Serde keeps these snake_case (only the "strategy" tag is camelCase).
|
||||
if (response.strategy === "reloadStream") {
|
||||
await adapter.reloadSource(response.new_url ?? "", response.position ?? currentPosition ?? 0);
|
||||
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;
|
||||
}
|
||||
|
||||
async function next() {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { downloadedFilePath, resolveVideoSource } from "./localSource";
|
||||
import { downloadedFilePath } from "./localSource";
|
||||
|
||||
describe("downloadedFilePath", () => {
|
||||
// The download worker rewrites `downloads.file_path` to the absolute path it
|
||||
@@ -29,74 +29,3 @@ describe("downloadedFilePath", () => {
|
||||
expect(downloadedFilePath("C:\\Users\\u\\AppData\\jellytau", stored)).toBe(stored);
|
||||
});
|
||||
});
|
||||
|
||||
// A stand-in for Tauri's convertFileSrc, so the module stays pure.
|
||||
const toAssetUrl = (p: string) => `asset://localhost/${encodeURIComponent(p)}`;
|
||||
|
||||
describe("resolveVideoSource", () => {
|
||||
it("plays the downloaded file when one exists", () => {
|
||||
const decision = resolveVideoSource({
|
||||
localPath: "/home/u/.local/share/jellytau/movie.mp4",
|
||||
remoteUrl: "https://server/Videos/abc/master.m3u8",
|
||||
remoteNeedsTranscoding: true,
|
||||
toAssetUrl,
|
||||
});
|
||||
|
||||
expect(decision.isLocal).toBe(true);
|
||||
expect(decision.url).toBe(toAssetUrl("/home/u/.local/share/jellytau/movie.mp4"));
|
||||
});
|
||||
|
||||
it("never marks a local file as needing transcoding, even when the remote did", () => {
|
||||
// The transcoded path re-requests a whole new stream URL on every seek.
|
||||
// A local file seeks natively; sending it down that route would ask the
|
||||
// server for a stream we deliberately avoided.
|
||||
const decision = resolveVideoSource({
|
||||
localPath: "/downloads/film.mkv",
|
||||
remoteUrl: "https://server/Videos/abc/master.m3u8",
|
||||
remoteNeedsTranscoding: true,
|
||||
toAssetUrl,
|
||||
});
|
||||
|
||||
expect(decision.needsTranscoding).toBe(false);
|
||||
});
|
||||
|
||||
it("streams when nothing is downloaded, preserving the transcoding flag", () => {
|
||||
const decision = resolveVideoSource({
|
||||
localPath: null,
|
||||
remoteUrl: "https://server/Videos/abc/master.m3u8",
|
||||
remoteNeedsTranscoding: true,
|
||||
toAssetUrl,
|
||||
});
|
||||
|
||||
expect(decision).toEqual({
|
||||
url: "https://server/Videos/abc/master.m3u8",
|
||||
needsTranscoding: true,
|
||||
isLocal: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("streams a direct-play remote without claiming it transcodes", () => {
|
||||
const decision = resolveVideoSource({
|
||||
localPath: null,
|
||||
remoteUrl: "https://server/Videos/abc/stream.mp4",
|
||||
remoteNeedsTranscoding: false,
|
||||
toAssetUrl,
|
||||
});
|
||||
|
||||
expect(decision.needsTranscoding).toBe(false);
|
||||
expect(decision.isLocal).toBe(false);
|
||||
});
|
||||
|
||||
it("falls back to streaming for a blank path rather than building a dead asset URL", () => {
|
||||
for (const localPath of ["", " "]) {
|
||||
const decision = resolveVideoSource({
|
||||
localPath,
|
||||
remoteUrl: "https://server/stream",
|
||||
remoteNeedsTranscoding: false,
|
||||
toAssetUrl,
|
||||
});
|
||||
expect(decision.isLocal).toBe(false);
|
||||
expect(decision.url).toBe("https://server/stream");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,41 +1,3 @@
|
||||
/**
|
||||
* Choosing between a downloaded file and a server stream for video playback.
|
||||
*
|
||||
* Audio has preferred local files since the queue is built (the Rust queue
|
||||
* resolves `MediaSource::Local`), but video asks the repository for a stream URL
|
||||
* and never consults `downloads` — so a downloaded film was streamed anyway,
|
||||
* spending bandwidth that had already been spent and failing outright offline.
|
||||
*
|
||||
* Pure so it can be unit-tested: the component only supplies the two inputs and
|
||||
* the asset-URL converter.
|
||||
*
|
||||
* TRACES: UR-071 | DR-123 | UT-118
|
||||
*/
|
||||
|
||||
export interface VideoSourceInputs {
|
||||
/** Absolute on-disk path of a completed download, or null to stream. */
|
||||
localPath: string | null;
|
||||
/** Stream URL the repository resolved (already transcoded if it had to be). */
|
||||
remoteUrl: string;
|
||||
/** Whether the *remote* stream is a transcode. */
|
||||
remoteNeedsTranscoding: boolean;
|
||||
/** Usually Tauri's `convertFileSrc`; injected so this module stays pure. */
|
||||
toAssetUrl: (path: string) => string;
|
||||
}
|
||||
|
||||
export interface VideoSourceDecision {
|
||||
/** What to hand the `<video>` element. */
|
||||
url: string;
|
||||
/**
|
||||
* Local files are never transcodes, so this is always false for them. It
|
||||
* matters because the transcoded path re-requests a whole new stream URL on
|
||||
* every seek; a local file seeks natively and must not go down that route.
|
||||
*/
|
||||
needsTranscoding: boolean;
|
||||
/** True when playing from disk — for logging and the offline badge. */
|
||||
isLocal: boolean;
|
||||
}
|
||||
|
||||
/** Absolute on POSIX (`/…`), Windows (`C:\…`, `C:/…`) or a UNC share (`\\…`). */
|
||||
function isAbsolute(path: string): boolean {
|
||||
return path.startsWith("/") || path.startsWith("\\") || /^[A-Za-z]:[\\/]/.test(path);
|
||||
@@ -57,15 +19,3 @@ function isAbsolute(path: string): boolean {
|
||||
export function downloadedFilePath(storageRoot: string, filePath: string): string {
|
||||
return isAbsolute(filePath) ? filePath : `${storageRoot}/${filePath}`;
|
||||
}
|
||||
|
||||
export function resolveVideoSource(inputs: VideoSourceInputs): VideoSourceDecision {
|
||||
const { localPath, remoteUrl, remoteNeedsTranscoding, toAssetUrl } = inputs;
|
||||
|
||||
// Treat blank/whitespace paths as absent — a malformed `downloads` row must
|
||||
// not produce an asset URL pointing at nothing.
|
||||
if (localPath && localPath.trim() !== "") {
|
||||
return { url: toAssetUrl(localPath), needsTranscoding: false, isLocal: true };
|
||||
}
|
||||
|
||||
return { url: remoteUrl, needsTranscoding: remoteNeedsTranscoding, isLocal: false };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* 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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* 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