Playing a video meant asking the server to re-encode it, always. That
decision was made nowhere and written down nowhere, so whoever needed it
re-derived it downstream — the player worked out whether it had been handed
a playlist by looking for ".m3u8" in the URL, in two places. A viewer paid
for a transcode of a file their device could have played untouched, and the
app could not tell them which it was.
One negotiation now produces one self-describing StreamSelection — direct
play, remux or transcode; over a playlist, a plain HTTP file, or a local one
— and every renderer consumes that same answer.
Measured against the development server (Jellyfin 10.11.5), 400 items
sampled for codec mix and 40 put through a real PlaybackInfo negotiation
per profile:
Linux / WebKitGTK (h264 only, 2ch) 3/40 — 7% direct play
Android / ExoPlayer (hevc, ac3/eac3, 6ch) 34/40 — 85% direct play
The library is ~80% hevc, which is why the two diverge so hard. The payoff
is overwhelmingly Android, where 85% of plays were starting a transcode
nobody needed. Linux stays near 7% until libmpv decodes the picture — the
h264-only profile is a WebKitGTK constraint, not a JellyTau choice.
DR-219 StreamSelection: url + tagged Transport (hls/progressive/localFile)
+ PlaybackKind (directPlay/directStream/transcode) + the negotiated
rendition + this source's ladder + a needs_transcoding flag derived
in Rust so the rule is answered once. Both enums are serde-tagged
so the frontend matches a discriminant, not a substring. The paths
that never negotiate get the same shape from Rust rather than
assembling one — media_local_selection for a downloaded file,
LiveStreamInfo.transport for a live channel — so there is no second
place where a transport is decided.
DR-220 The ceiling becomes two levels: a durable device default (Settings,
persisted) and a per-playback override the in-player picker sets.
The picker had called itself a "this film, this connection" control
since it was written but wrote the process-wide default, so dropping
one awkward film to 2 Mbps silently capped every video played
afterwards for the rest of the process, with Settings still showing
the old value. The override is cleared whenever playback moves to a
new item, which stops it surviving into an autoplayed next episode.
effective_streaming_quality() is the single resolution point.
DR-221 The quality picker is filled from what this media source can offer.
Rust marks a rung exceeds_source when its ceiling is at or above the
source's own bitrate — such a rung is another way to spell Original
— and the frontend does not draw those. Original is never marked; a
source whose bitrate the server does not report marks nothing, which
keeps every rung offered.
DR-222 Direct play and direct stream are negotiated, with two client-side
overrides on top because the server's answer is right about the file
and wrong about what this app will do with it: undecodable audio
(Jellyfin 10.11.5 honours a DirectPlayProfile's container and video
codec but ignores its audio codec, so it offers direct play for an
E-AC-3 track the webview renders in silence) and a viewer-pinned
audio track the file does not default to. A direct stream is a remux
and is deliberately not counted as transcoding.
DR-223 Dropped on measurement, not deferred. A master playlist from this
server carries exactly one EXT-X-STREAM-INF: Jellyfin builds it from
the single rendition the request asked for rather than publishing a
ladder. So there is no adaptation for hls.js to be preserving and
none mpv would lose — the claim that there was, in
playback-backend-unification.md, does not hold. Recorded rather than
deleted because it is a measurement: a server that does publish a
ladder would change the answer.
DR-224 Every backend consumes the same selection. The queue item carries
the transport, so player_seek_video picks its seek strategy from the
backend's decision instead of the last stream_url.contains(".m3u8")
in the codebase. Items queued by a path that never negotiated carry
None and fall back to needs_transcoding, which is exact rather than
a guess because every transcode this app requests is HLS (DR-140).
The frontend loader decision moves to streamTransport.ts so it can be
tested: the two cases that pin it are the ones that failed against the old
implementation — a progressive stream whose URL contains ".m3u8" must not
get an HLS loader, and an HLS stream whose URL contains none must.
Also verified the URL the direct-play branch builds actually serves playable
bytes: 206, video/mp4, valid ISO-BMFF, and a mid-file range works, so
seeking a direct play works.
The spec is folded into docs/architecture/{01,02,03} and deleted, per the
rule that docs/specs holds only work that has not shipped. DR-121 leaves
read-through-media-cache.md with a pointer; that spec keeps its capture half.
Not verified: real playback on a device. Direct play changes what actually
gets played, and neither fixtures nor curl prove the WebKitGTK and ExoPlayer
paths render it.
255 lines
9.0 KiB
TypeScript
255 lines
9.0 KiB
TypeScript
/**
|
|
* VideoPlayer seek-bar TOUCH scrub regression tests (Android).
|
|
*
|
|
* Reported bug: on Android, dragging the progress bar does not change the
|
|
* playback location.
|
|
*
|
|
* The gesture listener lives on the outer container and touch events bubble.
|
|
* `handleTouchStart` ignores touches that land on a control (the seek bar is an
|
|
* <input>, inside `data-player-controls`) — but `handleTouchMove` does not, so a
|
|
* seek-bar drag is still interpreted as a container swipe. That mis-read swipe
|
|
* fires `togglePlayPause()` (undoing a first-tap toggle that never happened) and
|
|
* hijacks the drag into brightness control.
|
|
*
|
|
* The existing scrub regression tests only drive the slider with MOUSE events,
|
|
* which never reach the touch handlers — which is why this survived.
|
|
*
|
|
* The seek was also committed only from `change`, which Android's WebView does
|
|
* not reliably fire for a touch interaction on a range input — so a tap moved
|
|
* the thumb and no seek ever ran. Release now commits from touchend/mouseup too.
|
|
*
|
|
* TRACES: UR-005, UR-061 | DR-098, DR-099 | UT-089, UT-090
|
|
*/
|
|
|
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
|
|
// ---- Mocks (must precede component import) --------------------------------
|
|
|
|
const channelHandlers: Record<string, (event: any) => void> = {};
|
|
// These tests pin the **flag-off** behaviour: when `experimentalNativeVideo` is
|
|
// off, VideoPlayer overrides Android's native backend response to HTML5
|
|
// rendering and stops the native backend. That is the default again (DR-172,
|
|
// after native video shipped as audio with no picture), so this mock now agrees
|
|
// with the default rather than opposing it — kept explicit so the tests state
|
|
// which path they guard instead of inheriting whatever the default happens to be.
|
|
vi.mock("$lib/stores/nativeVideo", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("$lib/stores/nativeVideo")>();
|
|
return {
|
|
...actual,
|
|
experimentalNativeVideo: {
|
|
subscribe: (run: (v: boolean) => void) => {
|
|
run(false);
|
|
return () => {};
|
|
},
|
|
set: () => {},
|
|
current: () => false,
|
|
},
|
|
};
|
|
});
|
|
|
|
vi.mock("@tauri-apps/api/event", () => ({
|
|
listen: vi.fn(async (channel: string, handler: any) => {
|
|
channelHandlers[channel] = handler;
|
|
return () => {
|
|
delete channelHandlers[channel];
|
|
};
|
|
}),
|
|
}));
|
|
|
|
vi.mock("@tauri-apps/api/core", () => ({
|
|
invoke: vi.fn(),
|
|
}));
|
|
|
|
const playerPlayItem = vi.fn(async () => ({
|
|
useHtml5Element: false,
|
|
backend: "exoplayer",
|
|
state: { kind: "playing" },
|
|
}));
|
|
const playerSeekVideo = vi.fn(async (_h: string, position: number) => ({
|
|
strategy: "native",
|
|
position,
|
|
}));
|
|
const playerStop = vi.fn(async () => ({}));
|
|
const playerToggle = vi.fn(async () => ({ state: "playing" }));
|
|
|
|
vi.mock("$lib/api/bindings", () => ({
|
|
commands: {
|
|
playerPlayItem: (...a: any[]) => playerPlayItem(...(a as [])),
|
|
playerSeekVideo: (...a: any[]) => playerSeekVideo(...(a as [string, number])),
|
|
playerStop: (...a: any[]) => playerStop(...(a as [])),
|
|
playerToggle: (...a: any[]) => playerToggle(...(a as [])),
|
|
playerPlay: vi.fn(async () => ({})),
|
|
playerPause: vi.fn(async () => ({})),
|
|
playerSetSleepTimer: vi.fn(async () => ({})),
|
|
playerCancelSleepTimer: vi.fn(async () => ({})),
|
|
playerSetSubtitleTrack: vi.fn(async () => ({})),
|
|
playerSwitchAudioTrack: vi.fn(async () => ({})),
|
|
// The player loads the streaming-quality picker on mount; without these the
|
|
// mock throws and every test in the file fails before it starts.
|
|
playerGetStreamingQualities: vi.fn(async () => []),
|
|
playerGetVideoSettings: vi.fn(async () => ({ streamingQuality: "original" })),
|
|
storageGetSeriesAudioPreference: vi.fn(async () => null),
|
|
storageSaveSeriesAudioPreference: vi.fn(async () => ({})),
|
|
},
|
|
events: {
|
|
playerStatusEvent: { listen: vi.fn(async () => () => {}) },
|
|
},
|
|
}));
|
|
|
|
vi.mock("$lib/stores/auth", () => ({
|
|
auth: {
|
|
getUserId: () => "user-1",
|
|
getRepository: () => ({
|
|
getHandle: () => "repo-1",
|
|
getSubtitleUrl: async () => "",
|
|
jrayActorsAt: async () => [],
|
|
}),
|
|
},
|
|
}));
|
|
|
|
vi.mock("$app/navigation", () => ({
|
|
goto: vi.fn(),
|
|
}));
|
|
|
|
import { render, fireEvent, waitFor } from "@testing-library/svelte";
|
|
import { tick } from "svelte";
|
|
import VideoPlayer from "./VideoPlayer.svelte";
|
|
import type { MediaItem } from "$lib/api/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 makeEpisode(): MediaItem {
|
|
return {
|
|
id: "ep1",
|
|
name: "Episode 1",
|
|
kind: "episode",
|
|
durationMs: 24 * 60 * 1000, // 24 min
|
|
} as MediaItem;
|
|
}
|
|
|
|
async function mountAndroidPlayer() {
|
|
const utils = render(VideoPlayer, {
|
|
props: {
|
|
media: makeEpisode(),
|
|
selection: testSelection("http://server/videos/ep1/master.m3u8"),
|
|
mediaSourceId: "src-1",
|
|
needsTranscoding: false,
|
|
onClose: vi.fn(),
|
|
},
|
|
});
|
|
|
|
await waitFor(() => expect(playerPlayItem).toHaveBeenCalled());
|
|
await waitFor(() => expect(playerStop).toHaveBeenCalled());
|
|
|
|
const slider = utils.container.querySelector('input[type="range"]') as HTMLInputElement;
|
|
const video = utils.container.querySelector("video") as HTMLVideoElement;
|
|
expect(slider).not.toBeNull();
|
|
return { ...utils, slider, video };
|
|
}
|
|
|
|
function touch(x: number, y: number) {
|
|
return { clientX: x, clientY: y } as Touch;
|
|
}
|
|
|
|
/**
|
|
* Drag the seek bar with TOUCH events, the way a finger does on Android.
|
|
*
|
|
* A real drag along the bar moves the finger far enough that the container's
|
|
* swipe detector (50px) would trigger if it were still listening.
|
|
*/
|
|
async function touchScrubTo(slider: HTMLInputElement, video: HTMLVideoElement, target: number) {
|
|
await fireEvent.touchStart(slider, { touches: [touch(100, 700)] });
|
|
// Finger travels across the bar. Small vertical wander is normal for a thumb
|
|
// drag; the horizontal travel is what matters.
|
|
await fireEvent.touchMove(slider, { touches: [touch(400, 690)] });
|
|
slider.value = String(target);
|
|
await fireEvent.input(slider);
|
|
await fireEvent.touchMove(slider, { touches: [touch(700, 705)] });
|
|
await fireEvent.change(slider);
|
|
await fireEvent.touchEnd(slider, { touches: [] });
|
|
if (video) await fireEvent(video, new Event("seeked"));
|
|
await tick();
|
|
}
|
|
|
|
describe("VideoPlayer seek bar — touch drag (Android)", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
for (const key of Object.keys(channelHandlers)) delete channelHandlers[key];
|
|
});
|
|
|
|
it("a touch drag on the seek bar seeks to the dragged position", async () => {
|
|
const { slider, video } = await mountAndroidPlayer();
|
|
|
|
await touchScrubTo(slider, video, 600);
|
|
|
|
await waitFor(() =>
|
|
expect(playerSeekVideo).toHaveBeenCalledWith("repo-1", 600, "src-1", null, true),
|
|
);
|
|
expect(parseFloat(slider.value)).toBeCloseTo(600);
|
|
});
|
|
|
|
it("a touch drag on the seek bar never toggles play/pause", async () => {
|
|
const { slider, video } = await mountAndroidPlayer();
|
|
|
|
await touchScrubTo(slider, video, 600);
|
|
|
|
// The container gesture layer must stay out of a control drag entirely:
|
|
// no swipe mis-read, so no play/pause correction.
|
|
expect(playerToggle).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("commits the seek on touchend even when the engine never fires `change`", async () => {
|
|
const { slider, video } = await mountAndroidPlayer();
|
|
|
|
// Android's WebView does not reliably fire `change` for a touch interaction
|
|
// on a range input. A tap on the track still moves the thumb and fires
|
|
// `input` — the seek must be committed on release regardless.
|
|
await fireEvent.touchStart(slider, { touches: [touch(400, 700)] });
|
|
slider.value = "600";
|
|
await fireEvent.input(slider);
|
|
await fireEvent.touchEnd(slider, { touches: [] });
|
|
if (video) await fireEvent(video, new Event("seeked"));
|
|
await tick();
|
|
|
|
await waitFor(() =>
|
|
expect(playerSeekVideo).toHaveBeenCalledWith("repo-1", 600, "src-1", null, true),
|
|
);
|
|
});
|
|
|
|
it("commits the seek exactly once when both touchend and change fire", async () => {
|
|
const { slider, video } = await mountAndroidPlayer();
|
|
|
|
await touchScrubTo(slider, video, 600);
|
|
|
|
expect(playerSeekVideo).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("a touch drag on the seek bar does not hijack into brightness control", async () => {
|
|
const { slider, video, container } = await mountAndroidPlayer();
|
|
|
|
await touchScrubTo(slider, video, 600);
|
|
|
|
// Brightness is applied as a CSS filter on the <video>; a control drag must
|
|
// leave it untouched.
|
|
const el = container.querySelector("video") as HTMLVideoElement | null;
|
|
if (el) {
|
|
expect(el.style.filter).toBe("brightness(1)");
|
|
}
|
|
});
|
|
});
|