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.
289 lines
11 KiB
TypeScript
289 lines
11 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
|
import { readFileSync } from "node:fs";
|
|
import { resolve } from "node:path";
|
|
import {
|
|
subtitleStreamsOf,
|
|
subtitleTrackLabel,
|
|
resolveSubtitleTracks,
|
|
nativeSubtitleTracks,
|
|
nativeSubtitleArrayIndex,
|
|
type SubtitleStreamLike,
|
|
} from "./subtitleTracks";
|
|
|
|
/**
|
|
* Subtitle resolution — the list the play request carries.
|
|
*
|
|
* TRACES: UR-020 | DR-023 | UT-143, UT-144
|
|
*
|
|
* URLs are resolved into plain strings before they reach the player: the
|
|
* original markup bound the *Promise* returned by an async function to a
|
|
* `<track src>`, so every track's src stringified to "[object Promise]".
|
|
*/
|
|
|
|
const SUBS: SubtitleStreamLike[] = [
|
|
{ index: 2, kind: "subtitle", language: "eng", displayTitle: "English (SRT)", isDefault: true },
|
|
{ index: 3, kind: "subtitle", language: "fre", displayTitle: "French", isDefault: false },
|
|
];
|
|
|
|
const STREAMS: SubtitleStreamLike[] = [
|
|
{ index: 0, kind: "video", language: null, displayTitle: "1080p" },
|
|
{ index: 1, kind: "audio", language: "eng", displayTitle: "English AAC" },
|
|
...SUBS,
|
|
];
|
|
|
|
const url = (i: number) => `http://jelly.example/Videos/x/Subtitles/${i}/0/subtitles.vtt?api_key=k`;
|
|
|
|
describe("subtitleStreamsOf", () => {
|
|
it("keeps only subtitle streams, in stream order", () => {
|
|
expect(subtitleStreamsOf(STREAMS).map((s) => s.index)).toEqual([2, 3]);
|
|
});
|
|
|
|
it("tolerates missing media streams", () => {
|
|
expect(subtitleStreamsOf(null)).toEqual([]);
|
|
expect(subtitleStreamsOf(undefined)).toEqual([]);
|
|
});
|
|
|
|
/**
|
|
* A subtitle the app cannot draw must not reach the picker. Image-based
|
|
* tracks (PGS/DVD/DVB) are bitmaps: the only way to show one is for the server
|
|
* to composite it into the video, which this app deliberately never asks for
|
|
* (DR-176). Offering it anyway produced the reported symptom's twin — a menu
|
|
* entry that selects, ticks, and shows nothing.
|
|
*
|
|
* The verdict is the backend's (`supportsExternalDelivery`); the codec
|
|
* vocabulary behind it stays in Rust.
|
|
*
|
|
* TRACES: UR-020 | DR-176 | UT-168
|
|
*/
|
|
it("drops subtitles the backend says it cannot deliver as a sidecar", () => {
|
|
const streams: SubtitleStreamLike[] = [
|
|
{
|
|
index: 2,
|
|
kind: "subtitle",
|
|
displayTitle: "English PGS SDH",
|
|
supportsExternalDelivery: false,
|
|
},
|
|
{
|
|
index: 3,
|
|
kind: "subtitle",
|
|
displayTitle: "English Text SDH",
|
|
supportsExternalDelivery: true,
|
|
},
|
|
];
|
|
|
|
expect(subtitleStreamsOf(streams).map((s) => s.index)).toEqual([3]);
|
|
});
|
|
|
|
/**
|
|
* Only an explicit "no" hides a track. A stream that carries no verdict at all
|
|
* predates the field (or came from somewhere that does not set it), and
|
|
* hiding those would silently empty the menu for sources that work today.
|
|
*
|
|
* TRACES: UR-020 | DR-176 | UT-168
|
|
*/
|
|
it("keeps subtitles that carry no verdict", () => {
|
|
const streams: SubtitleStreamLike[] = [
|
|
{ index: 2, kind: "subtitle", displayTitle: "English" },
|
|
{ index: 3, kind: "subtitle", displayTitle: "French", supportsExternalDelivery: null },
|
|
];
|
|
|
|
expect(subtitleStreamsOf(streams).map((s) => s.index)).toEqual([2, 3]);
|
|
});
|
|
|
|
/**
|
|
* The same list feeds the `<track>` children and the native play request, so
|
|
* an undeliverable track must not even have its URL fetched — that request is
|
|
* the one that 404s, and the sideloaded track it would produce is the dead
|
|
* entry all over again.
|
|
*
|
|
* TRACES: UR-020 | DR-176 | UT-168
|
|
*/
|
|
it("never resolves a URL for a subtitle it dropped", async () => {
|
|
const asked: number[] = [];
|
|
const tracks = await resolveSubtitleTracks(
|
|
[
|
|
{ index: 2, kind: "subtitle", displayTitle: "PGS", supportsExternalDelivery: false },
|
|
{ index: 3, kind: "subtitle", displayTitle: "SRT", supportsExternalDelivery: true },
|
|
],
|
|
async (index) => {
|
|
asked.push(index);
|
|
return url(index);
|
|
},
|
|
);
|
|
|
|
expect(asked).toEqual([3]);
|
|
expect(tracks.map((t) => t.streamIndex)).toEqual([3]);
|
|
});
|
|
});
|
|
|
|
describe("subtitleTrackLabel", () => {
|
|
it("prefers the display title, then language, then the index", () => {
|
|
expect(subtitleTrackLabel({ index: 2, displayTitle: "English (SRT)", language: "eng" })).toBe(
|
|
"English (SRT)",
|
|
);
|
|
expect(subtitleTrackLabel({ index: 2, displayTitle: null, language: "eng" })).toBe("eng");
|
|
expect(subtitleTrackLabel({ index: 2 })).toBe("Track 2");
|
|
});
|
|
});
|
|
|
|
describe("resolveSubtitleTracks", () => {
|
|
it("resolves real string URLs — never a Promise — for every subtitle stream", async () => {
|
|
const tracks = await resolveSubtitleTracks(STREAMS, async (i) => url(i));
|
|
|
|
expect(tracks).toHaveLength(2);
|
|
for (const track of tracks) {
|
|
expect(typeof track.url).toBe("string");
|
|
// The exact regression: a Promise bound to src stringifies to this.
|
|
expect(String(track.url)).not.toContain("[object Promise]");
|
|
expect(track.url).toContain("subtitles.vtt");
|
|
}
|
|
// The adapter matches <track> elements by data-stream-index, so the stream
|
|
// index has to survive resolution.
|
|
expect(tracks.map((t) => t.streamIndex)).toEqual([2, 3]);
|
|
expect(tracks.map((t) => t.label)).toEqual(["English (SRT)", "French"]);
|
|
expect(tracks.map((t) => t.srclang)).toEqual(["eng", "fre"]);
|
|
expect(tracks[0].isDefault).toBe(true);
|
|
});
|
|
|
|
it("drops tracks whose URL cannot be built instead of rendering a dead src", async () => {
|
|
const tracks = await resolveSubtitleTracks(SUBS, async (i) => {
|
|
if (i === 2) throw new Error("no repository");
|
|
return url(i);
|
|
});
|
|
expect(tracks.map((t) => t.streamIndex)).toEqual([3]);
|
|
});
|
|
|
|
it("drops empty and non-string URLs", async () => {
|
|
const tracks = await resolveSubtitleTracks(SUBS, async (i) =>
|
|
i === 2 ? " " : (undefined as unknown as string),
|
|
);
|
|
expect(tracks).toEqual([]);
|
|
});
|
|
|
|
it("returns nothing when there are no subtitle streams", async () => {
|
|
expect(await resolveSubtitleTracks([STREAMS[0]], async (i) => url(i))).toEqual([]);
|
|
expect(await resolveSubtitleTracks(null, async (i) => url(i))).toEqual([]);
|
|
});
|
|
});
|
|
|
|
/**
|
|
* Subtitles on the Android / ExoPlayer native path.
|
|
*
|
|
* TRACES: UR-020 | IR-016, JA-008 | UT-147
|
|
*
|
|
* The bug this guards: VideoPlayer built a fully-resolved subtitle array in
|
|
* onMount and then never sent it — `commands.playerPlayItem({...})` passed only
|
|
* streamUrl/title/id/videoCodec/needsTranscoding — so every MediaItem reached
|
|
* ExoPlayer with zero SubtitleConfigurations and `setSubtitleTrack(n)` logged
|
|
* "Invalid subtitle track index".
|
|
*
|
|
* And the second half: `setSubtitleTrack(n)` indexes ExoPlayer's *text track
|
|
* groups*, i.e. the position of the sideloaded configuration — not the Jellyfin
|
|
* stream index. The menu used to pass its own row position, which is a position
|
|
* in the *unresolved* stream list; the moment one subtitle URL failed to
|
|
* resolve, the two lists diverged and every track below the gap selected the
|
|
* wrong subtitle.
|
|
*/
|
|
describe("nativeSubtitleTracks", () => {
|
|
it("maps to the wire shape Rust deserializes and Kotlin parses", async () => {
|
|
const resolved = await resolveSubtitleTracks(SUBS, async (i) => url(i));
|
|
const payload = nativeSubtitleTracks(resolved);
|
|
|
|
expect(payload).toHaveLength(2);
|
|
// Kotlin reads url/language/label/mime_type; Rust's SubtitleTrack keeps
|
|
// snake_case for exactly that reason, and so does the generated binding.
|
|
for (const track of payload) {
|
|
expect(Object.keys(track).sort()).toEqual(
|
|
["index", "label", "language", "mime_type", "url"].sort(),
|
|
);
|
|
expect(track).not.toHaveProperty("mimeType");
|
|
expect(track.mime_type).toBe("text/vtt");
|
|
}
|
|
// Jellyfin serves every subtitle stream as WebVTT here, and the stream index
|
|
// rides along so the UI can keep talking in stream indices.
|
|
expect(payload.map((t) => t.index)).toEqual([2, 3]);
|
|
expect(payload[0].url).toContain("subtitles.vtt");
|
|
expect(payload[0].language).toBe("eng");
|
|
expect(payload[0].label).toBe("English (SRT)");
|
|
});
|
|
|
|
it("preserves stream order, because that order is the selection index", async () => {
|
|
const resolved = await resolveSubtitleTracks(STREAMS, async (i) => url(i));
|
|
expect(nativeSubtitleTracks(resolved).map((t) => t.index)).toEqual(
|
|
resolved.map((t) => t.streamIndex),
|
|
);
|
|
});
|
|
|
|
it("has nothing to send when no subtitle URL resolved", async () => {
|
|
expect(nativeSubtitleTracks(await resolveSubtitleTracks(SUBS, async () => ""))).toEqual([]);
|
|
expect(nativeSubtitleTracks([])).toEqual([]);
|
|
});
|
|
|
|
it("carries a null language/label through rather than inventing one", () => {
|
|
const payload = nativeSubtitleTracks([
|
|
{ streamIndex: 5, url: "u.vtt", srclang: "und", label: "Track 5", isDefault: false },
|
|
]);
|
|
expect(payload[0].language).toBeNull();
|
|
expect(payload[0].label).toBe("Track 5");
|
|
});
|
|
});
|
|
|
|
describe("nativeSubtitleArrayIndex", () => {
|
|
it("returns the position in the list that was actually sent, not the stream index", async () => {
|
|
const resolved = await resolveSubtitleTracks(SUBS, async (i) => url(i));
|
|
expect(nativeSubtitleArrayIndex(resolved, 2)).toBe(0);
|
|
expect(nativeSubtitleArrayIndex(resolved, 3)).toBe(1);
|
|
});
|
|
|
|
it("stays aligned when a subtitle URL failed to resolve (the mis-selection bug)", async () => {
|
|
// Stream 2 has no URL, so it is not among the sideloaded configurations.
|
|
// The menu's own row for stream 3 is position 1, but ExoPlayer only has one
|
|
// text track group — position 0. Sending 1 would select nothing.
|
|
const resolved = await resolveSubtitleTracks(SUBS, async (i) => {
|
|
if (i === 2) throw new Error("no repository");
|
|
return url(i);
|
|
});
|
|
expect(resolved).toHaveLength(1);
|
|
expect(nativeSubtitleArrayIndex(resolved, 3)).toBe(0);
|
|
});
|
|
|
|
it("maps 'Off' to null so the backend disables text instead of selecting track 0", async () => {
|
|
const resolved = await resolveSubtitleTracks(SUBS, async (i) => url(i));
|
|
expect(nativeSubtitleArrayIndex(resolved, null)).toBeNull();
|
|
});
|
|
|
|
it("maps a track that was never sent to null rather than to a wrong position", async () => {
|
|
const resolved = await resolveSubtitleTracks(SUBS, async (i) => url(i));
|
|
expect(nativeSubtitleArrayIndex(resolved, 99)).toBeNull();
|
|
expect(nativeSubtitleArrayIndex([], 3)).toBeNull();
|
|
});
|
|
});
|
|
|
|
/**
|
|
* The half of the Android fix that lives in the component: the resolved list has
|
|
* to actually be handed to `playerPlayItem`, and the index sent to the backend
|
|
* has to be computed from that same list.
|
|
*
|
|
* TRACES: UR-020 | IR-016 | UT-147
|
|
*/
|
|
describe("VideoPlayer -> playerPlayItem (the tracks that were built and thrown away)", () => {
|
|
const source = readFileSync(resolve(__dirname, "VideoPlayer.svelte"), "utf-8");
|
|
|
|
/** The playerPlayItem({...}) argument object. */
|
|
const playItemCall = (() => {
|
|
const start = source.indexOf("commands.playerPlayItem(");
|
|
expect(start).toBeGreaterThan(-1);
|
|
return source.slice(start, source.indexOf("});", start) + 3);
|
|
})();
|
|
|
|
it("sends the subtitle tracks it resolved", () => {
|
|
expect(playItemCall).toMatch(/\bsubtitles:/);
|
|
});
|
|
|
|
it("selects by position in the sent list, not by the menu's row number", () => {
|
|
expect(source).toContain("nativeSubtitleArrayIndex");
|
|
// The old code forwarded the `{#each}` index straight to the backend.
|
|
expect(source).not.toMatch(/playerSetSubtitleTrack\(\s*arrayIndex\s*\)/);
|
|
});
|
|
});
|