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.
258 lines
9.1 KiB
TypeScript
258 lines
9.1 KiB
TypeScript
/**
|
|
* VideoPlayer scrub regression tests (Android backend path)
|
|
*
|
|
* Reproduces the reported bug: with a sleep timer active, scrubbing the
|
|
* video seek bar "seeks, then jumps back to the old position".
|
|
*
|
|
* Root cause history:
|
|
* - Native init called onDestroy() after an await -> lifecycle_outside_component
|
|
* -> the catch treated init as failed and silently flipped useHtml5Element to
|
|
* true, so seeks went down the HTML5 path while ExoPlayer kept playing.
|
|
* - The native SurfaceView has never been visible through the webview, so the
|
|
* INTERIM behavior (until the video-player API refactor) is: when the backend
|
|
* reports native mode, VideoPlayer deliberately overrides to HTML5 rendering
|
|
* and stops the native backend (single audio source, webview owns playback).
|
|
*
|
|
* These tests pin the interim behavior: Android's native response is
|
|
* overridden, the backend is stopped exactly once, and scrubbing keeps
|
|
* working (and holds its position) with a sleep timer active.
|
|
*/
|
|
|
|
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 () => ({
|
|
// What Android reports: native ExoPlayer backend
|
|
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" }));
|
|
const playerSetSleepTimer = vi.fn(async (mode: any) => ({ mode, remainingSeconds: 0 }));
|
|
const playerCancelSleepTimer = vi.fn(async () => ({ mode: { kind: "off" }, remainingSeconds: 0 }));
|
|
|
|
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 [])),
|
|
playerSetSleepTimer: (...a: any[]) => playerSetSleepTimer(...(a as [any])),
|
|
playerCancelSleepTimer: (...a: any[]) => playerCancelSleepTimer(...(a as [])),
|
|
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(),
|
|
}));
|
|
|
|
// Use the REAL sleepTimer store module so timer activation flows exactly as
|
|
// in production (playerEvents.ts writes to it on every backend tick).
|
|
|
|
import { render, fireEvent, waitFor } from "@testing-library/svelte";
|
|
import { tick } from "svelte";
|
|
import VideoPlayer from "./VideoPlayer.svelte";
|
|
import { sleepTimer, sleepTimerExpiredSignal } from "$lib/stores/sleepTimer";
|
|
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;
|
|
}
|
|
|
|
/** Simulate one backend sleep-timer tick, exactly as playerEvents.ts does. */
|
|
function sleepTimerTick(remaining = 2) {
|
|
sleepTimer.set({
|
|
mode: { kind: "episodes", remaining },
|
|
remainingSeconds: 0,
|
|
});
|
|
}
|
|
|
|
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(),
|
|
},
|
|
});
|
|
|
|
// Init: backend reports native, component overrides to HTML5 and stops it.
|
|
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();
|
|
expect(video).not.toBeNull();
|
|
return { ...utils, slider, video };
|
|
}
|
|
|
|
/** Scrub the seek bar to `target` seconds like a user drag. */
|
|
async function scrubTo(slider: HTMLInputElement, video: HTMLVideoElement, target: number) {
|
|
await fireEvent.mouseDown(slider);
|
|
slider.value = String(target);
|
|
await fireEvent.input(slider);
|
|
await fireEvent.change(slider);
|
|
await fireEvent.mouseUp(slider);
|
|
// Resolve the "wait for seeked" step of the HTML5 native-seek path.
|
|
await fireEvent(video, new Event("seeked"));
|
|
await tick();
|
|
}
|
|
|
|
describe("VideoPlayer scrubbing with active sleep timer (Android)", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
for (const key of Object.keys(channelHandlers)) delete channelHandlers[key];
|
|
sleepTimer.set({ mode: { kind: "off" }, remainingSeconds: 0 });
|
|
sleepTimerExpiredSignal.set(0);
|
|
});
|
|
|
|
it("overrides the native backend response to HTML5 rendering and stops the backend once", async () => {
|
|
await mountAndroidPlayer();
|
|
// The native backend must be stopped so it doesn't play audio behind the
|
|
// webview (frozen picture + double audio source).
|
|
expect(playerStop).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("scrubbing without a timer seeks via the HTML5 path and keeps the new position", async () => {
|
|
const { slider, video } = await mountAndroidPlayer();
|
|
|
|
await scrubTo(slider, video, 600);
|
|
|
|
await waitFor(() =>
|
|
expect(playerSeekVideo).toHaveBeenCalledWith(
|
|
"repo-1",
|
|
600,
|
|
"src-1",
|
|
null,
|
|
true, // HTML5 path: the webview owns playback after the override
|
|
),
|
|
);
|
|
expect(parseFloat(slider.value)).toBeCloseTo(600);
|
|
});
|
|
|
|
it("scrubbing still works (and holds position) after enabling an episodes sleep timer", async () => {
|
|
const { slider, video } = await mountAndroidPlayer();
|
|
|
|
// Enable "2 more episodes" timer; backend then ticks every second.
|
|
sleepTimerTick(2);
|
|
await tick();
|
|
sleepTimerTick(2);
|
|
await tick();
|
|
|
|
await scrubTo(slider, video, 600);
|
|
await waitFor(() => expect(playerSeekVideo).toHaveBeenCalledTimes(1));
|
|
expect(parseFloat(slider.value)).toBeCloseTo(600);
|
|
|
|
// Timer ticks after the seek must not snap the bar back.
|
|
sleepTimerTick(2);
|
|
await tick();
|
|
expect(parseFloat(slider.value)).toBeCloseTo(600);
|
|
|
|
// A second scrub must also work.
|
|
await scrubTo(slider, video, 900);
|
|
await waitFor(() => expect(playerSeekVideo).toHaveBeenCalledTimes(2));
|
|
expect(parseFloat(slider.value)).toBeCloseTo(900);
|
|
});
|
|
|
|
it("sleep-timer ticks alone never move the seek bar", async () => {
|
|
const { slider } = await mountAndroidPlayer();
|
|
|
|
const before = slider.value;
|
|
for (let i = 0; i < 5; i++) {
|
|
sleepTimerTick(2);
|
|
await tick();
|
|
}
|
|
|
|
expect(slider.value).toBe(before);
|
|
expect(playerSeekVideo).not.toHaveBeenCalled();
|
|
});
|
|
});
|