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:
2026-09-24 23:11:17 -04:00
parent bb3ab1edd7
commit 1677f5f299
69 changed files with 4014 additions and 7330 deletions
+41 -90
View File
@@ -21,7 +21,7 @@ async playerPlayItem(item: PlayItemRequest) : Promise<PlayerStatus> {
},
/**
* Exit background-audio mode: stop the native audio player and return its final
* position so the frontend can reload the WebView `<video>` there (UR-040).
* position so the frontend can reload the video there (UR-040).
*
* Returns the position in seconds. The sleep timer is intentionally left
* untouched — if it fired while backgrounded, playback is already stopped and
@@ -52,8 +52,8 @@ async playerBackgroundAction(backgroundAudioArmed: boolean, inPictureInPicture:
* `stream_url` MUST be an audio-only URL (see
* `get_audio_only_stream_url_for_video`). The item is created as
* `MediaType::Audio` so it starts an audio session and loads into the native
* backend with `mediaType="audio"` — the WebView `<video>` is torn down on the
* frontend side, so exactly one audio source is ever active.
* backend with `mediaType="audio"`, replacing the video, so exactly one audio
* source is ever active.
*
* This deliberately goes through the queue-based `play_item` path (NOT a
* side-channel) so end-of-track lands in `on_playback_ended`, which already
@@ -129,11 +129,11 @@ async playerSeek(position: number) : Promise<PlayerStatus> {
* - Direct play streams: Use native seeking
* - Transcoded non-HLS: Request new stream URL from server starting at seek position
*
* For native (non-HTML5) backends, this command handles the entire stream reload
* internally. For HTML5 backends, it returns the new URL for the frontend to handle.
* The backend always handles the seek itself, including re-opening a stream,
* since every video renderer is native (DR-235).
*/
async playerSeekVideo(repositoryHandle: string, position: number, mediaSourceId: string | null, audioStreamIndex: number | null, useHtml5: boolean) : Promise<VideoSeekResponse> {
return await TAURI_INVOKE("player_seek_video", { repositoryHandle, position, mediaSourceId, audioStreamIndex, useHtml5 });
async playerSeekVideo(repositoryHandle: string, position: number, mediaSourceId: string | null, audioStreamIndex: number | null) : Promise<VideoSeekResponse> {
return await TAURI_INVOKE("player_seek_video", { repositoryHandle, position, mediaSourceId, audioStreamIndex });
},
async playerSetVolume(volume: number) : Promise<PlayerStatus> {
return await TAURI_INVOKE("player_set_volume", { volume });
@@ -157,9 +157,6 @@ async playerSetAudioTrack(streamIndex: number) : Promise<PlayerStatus> {
* carries the requested track at all** — see
* [`determine_audio_track_switch_strategy`]:
*
* - An HTML5 `<video>` element has no track-selection API, so the stream is
* always re-opened at the chosen `AudioStreamIndex` and the frontend seeks
* the reloaded element back to `position`.
* - A native backend playing a **direct play** holds the source file with
* every track in it, so ExoPlayer selects in place by track-group index.
* - A native backend playing a **transcode** does not. Jellyfin builds a
@@ -175,23 +172,21 @@ async playerSetAudioTrack(streamIndex: number) : Promise<PlayerStatus> {
* audio track index` and dropped the request — the default track just kept
* playing, with nothing in the UI saying so.
*
* libmpv implements neither selection nor reload here — it is the audio-only
* backend and leaves `PlayerBackend::set_audio_track` at its
* `not_implemented()` default, which is why IR-019 is met by these paths
* rather than by MPV.
* mpv selects in place the same way (`mpv_tracks::select_audio`, by position in
* the file's audio tracks), and re-opens a transcode through the same path.
*
* TRACES: UR-021, UR-005 | IR-019, DR-024, DR-258
*/
async playerSwitchAudioTrack(repositoryHandle: string, streamIndex: number, arrayIndex: number, useHtml5: boolean, currentPosition: number | null, mediaSourceId: string | null) : Promise<AudioTrackSwitchResponse> {
return await TAURI_INVOKE("player_switch_audio_track", { repositoryHandle, streamIndex, arrayIndex, useHtml5, currentPosition, mediaSourceId });
async playerSwitchAudioTrack(repositoryHandle: string, streamIndex: number, arrayIndex: number, currentPosition: number | null, mediaSourceId: string | null) : Promise<AudioTrackSwitchResponse> {
return await TAURI_INVOKE("player_switch_audio_track", { repositoryHandle, streamIndex, arrayIndex, currentPosition, mediaSourceId });
},
/**
* Set (or clear, with `None`) the active subtitle track on a native backend.
*
* On Android this indexes ExoPlayer's *text track groups* — i.e. the position
* of the sideloaded `MediaItem.SubtitleConfiguration`, not the Jellyfin stream
* index. The HTML5 path never reaches here; it toggles its own `<track>`
* children. libmpv implements neither, leaving the trait default in place.
* index. mpv gives it the same meaning: the position in the sideloaded WebVTT
* list, loaded as external subtitle files (`mpv_tracks`).
*
* TRACES: UR-020 | IR-018, DR-023
*/
@@ -283,9 +278,7 @@ async playerGetStreamingQualities() : Promise<([StreamingQuality, string, string
* A cap is a property of the stream the server is producing, so unlike a volume
* change it cannot be applied to a stream already in flight — the stream has to
* be re-opened at the new quality and resumed at the current position. That is
* the same reload the transcoded-seek and audio-track paths use, and the same
* two-sided split: HTML5 gets the URL back and reloads its own element, while a
* native backend is reloaded here.
* the same reload the transcoded-seek and audio-track paths use, done here.
*
* The change applies to **this playback only**. The in-player picker is a
* "this film, this connection" control and its doc has always said so, but it
@@ -299,8 +292,8 @@ async playerGetStreamingQualities() : Promise<([StreamingQuality, string, string
*
* TRACES: UR-074, UR-079 | DR-162, DR-226
*/
async playerSetStreamQuality(repositoryHandle: string, quality: StreamingQuality, useHtml5: boolean, currentPosition: number | null, mediaSourceId: string | null, audioStreamIndex: number | null) : Promise<StreamQualityResponse> {
return await TAURI_INVOKE("player_set_stream_quality", { repositoryHandle, quality, useHtml5, currentPosition, mediaSourceId, audioStreamIndex });
async playerSetStreamQuality(repositoryHandle: string, quality: StreamingQuality, currentPosition: number | null, mediaSourceId: string | null, audioStreamIndex: number | null) : Promise<StreamQualityResponse> {
return await TAURI_INVOKE("player_set_stream_quality", { repositoryHandle, quality, currentPosition, mediaSourceId, audioStreamIndex });
},
/**
* Set sleep timer mode
@@ -347,7 +340,7 @@ async playerPlayNextEpisode(item: PlayItemRequest) : Promise<PlayerStatus> {
/**
* Handle playback ended event - triggers autoplay decision logic
* This is called from:
* - Frontend when HTML5 video ends (Linux/desktop) - passes itemId + repositoryHandle for the video
* - Frontend when a video ends - passes itemId + repositoryHandle for the video
* - Frontend when audio track ends via backend event - no itemId/repositoryHandle needed
* - Android JNI callback also triggers this logic directly
*
@@ -380,20 +373,20 @@ async playerRecoverStream() : Promise<boolean> {
return await TAURI_INVOKE("player_recover_stream");
},
/**
* Report an HTML5 <video> state change (playing/paused/loading/stopped/idle).
* Report a webview media element's state change (playing/paused/loading/stopped/idle).
*/
async playerReportState(state: string, mediaId: string | null) : Promise<null> {
return await TAURI_INVOKE("player_report_state", { state, mediaId });
},
/**
* Report an HTML5 <video> position tick (seconds). The adapter should throttle
* Report a webview media element's position tick (seconds). The adapter should throttle
* these to roughly match the native backends' ~250ms cadence.
*/
async playerReportPosition(position: number, duration: number) : Promise<null> {
return await TAURI_INVOKE("player_report_position", { position, duration });
},
/**
* Report that the HTML5 <video> finished loading and knows its duration.
* Report that a webview media element finished loading and knows its duration.
*/
async playerReportMediaLoaded(duration: number) : Promise<null> {
return await TAURI_INVOKE("player_report_media_loaded", { duration });
@@ -2134,11 +2127,7 @@ export type AudioTrackSwitchResponse =
/**
* Native backend handled it (Android ExoPlayer)
*/
{ strategy: "native"; success: boolean } |
/**
* HTML5 needs to reload stream with new audio track
*/
{ strategy: "reloadStream"; selection: StreamSelection; position: number }
{ strategy: "native"; success: boolean }
/**
* Authentication result
*/
@@ -2836,9 +2825,8 @@ seriesId?: string | null;
/**
* Subtitle tracks to sideload, with URLs the frontend has already resolved.
*
* Only the native backends use these: on Android they become the
* `MediaItem.SubtitleConfiguration`s ExoPlayer renders. The HTML5 path
* builds its own `<track>` children instead and ignores this list.
* On Android they become the `MediaItem.SubtitleConfiguration`s ExoPlayer
* renders; mpv loads them as external subtitle files (`mpv_tracks`).
*
* **Order is the contract.** `player_set_subtitle_track(n)` reaches
* `JellyTauPlayer.setSubtitleTrack(n)`, which indexes into ExoPlayer's
@@ -2907,22 +2895,14 @@ startPosition?: number | null }
export type PlaybackCapabilities = {
/**
* True when audio is rendered by a webview `<audio>` element rather than a
* native backend. Native audio exists on Linux (mpv) and Android
* (ExoPlayer); everything else (Windows, future desktops) uses the webview.
* native backend. Native audio exists on Linux and Windows (mpv) and
* Android (ExoPlayer); only an unported desktop uses the webview.
*
* Video has no counterpart: it is always drawn by the native backend, behind
* the transparent webview (DR-235) — there is no webview video renderer
* left to report.
*/
usesWebviewAudio: boolean;
/**
* True when video is rendered by a native surface composited *behind* a
* transparent webview: ExoPlayer's SurfaceView on Android, mpv's GL area on
* Linux.
*/
supportsNativeVideo: boolean;
/**
* True when the user may send video to the webview element instead of the
* native renderer — the frontend offers the switch only then, and honours
* the stored preference only then. False on every platform since DR-235.
*/
webviewVideoFallback: boolean }
usesWebviewAudio: boolean }
/**
* Playback information
*/
@@ -3134,14 +3114,6 @@ export type PlayerState =
* Response for player state queries
*/
export type PlayerStatus = { state: PlayerState; position: number; duration: number | null; volume: number; muted: boolean; shuffle: boolean; repeat: RepeatMode;
/**
* Backend being used (native = ExoPlayer/libmpv, html5 = fallback)
*/
backend: VideoBackend;
/**
* Whether frontend should render HTML5 video element
*/
useHtml5Element: boolean;
/**
* Media item from either local queue or remote session
*/
@@ -3197,9 +3169,8 @@ export type PlayerStatusEvent =
{ type: "sleep_timer_changed"; mode: SleepTimerMode; remaining_seconds: number } |
/**
* Time-based sleep timer expired: playback must stop. The backend stops
* its own (MPV/ExoPlayer) playback, but HTML5 video on Linux plays in the
* webview outside the backend's control — the frontend pauses it on this
* event.
* its own (MPV/ExoPlayer) playback; the frontend pauses the active adapter
* on this event, which reaches a webview `<audio>` element where one plays.
*/
{ type: "sleep_timer_expired" } |
/**
@@ -3244,19 +3215,19 @@ export type PlayerStatusEvent =
{ type: "remote_disconnect_requested" } |
/**
* Backend-originated control command targeting the active frontend player
* adapter (the HTML5 <video> that lives in the webview, which Rust cannot
* drive directly). Emitted by control paths like the sleep timer, lockscreen,
* or remote so they can pause/play/seek/stop the webview element.
* adapter — the webview `<audio>` element, which Rust cannot drive
* directly. Emitted by control paths like the sleep timer, lockscreen, or
* remote so they can pause/play/seek/stop it.
* `playerEvents.ts` routes this to the active PlayerAdapter via the facade.
*/
{ type: "control_command"; action: string; position: number | null } |
/**
* Ask the frontend webview `<audio>` element to load and play a stream.
*
* Emitted by `WebviewAudioBackend` on platforms with no native audio
* backend (e.g. Windows): audio-only playback is rendered by an `<audio>`
* element in the webview, mirroring how all video already renders through
* the webview `<video>`. The element then reports its state/position back
* Emitted by `WebviewAudioBackend` on a desktop with no native audio
* backend (none that ships: Linux and Windows have mpv): audio-only
* playback is rendered by an `<audio>` element in the webview. The element
* then reports its state/position back
* through the `player_report_*` commands, so the Rust controller stays the
* single source of truth. Subsequent play/pause/seek/stop reach the element
* via `ControlCommand`.
@@ -3611,11 +3582,7 @@ export type StreamQualityResponse =
*
* TRACES: UR-074, UR-079 | DR-226, DR-227
*/
{ strategy: "native"; selection: StreamSelection; position: number } |
/**
* HTML5 must reload its element with this selection.
*/
{ strategy: "reloadStream"; selection: StreamSelection; position: number }
{ strategy: "native"; selection: StreamSelection; position: number }
/**
* Everything a player backend needs to open a stream, and everything the UI
* needs to describe it.
@@ -3844,18 +3811,6 @@ playbackPositionMs?: number | null; isPlayed?: boolean | null; isFavorite?: bool
* User info returned to frontend
*/
export type UserInfo = { id: string; serverId: string; username: string; isActive: boolean }
/**
* Backend type for video playback
*/
export type VideoBackend =
/**
* Native backend (ExoPlayer on Android, libmpv on Linux)
*/
"native" |
/**
* HTML5 video element fallback
*/
"html5"
/**
* Response for video seek operations
*/
@@ -3863,11 +3818,7 @@ export type VideoSeekResponse =
/**
* Use native seeking (HLS or direct stream)
*/
{ strategy: "native"; position: number } |
/**
* Reload stream from new position (transcoded non-HLS)
*/
{ strategy: "reloadStream"; selection: StreamSelection; seek_offset: number }
{ strategy: "native"; position: number }
/**
* Video playback settings
*/
@@ -1,78 +1,27 @@
/**
* VideoPlayer scrub regression tests (Android backend path)
* VideoPlayer scrub regression tests.
*
* 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).
* Root cause history: native init called onDestroy() after an await ->
* lifecycle_outside_component -> the catch treated init as failed and silently
* switched seeks to the (since deleted) webview `<video>` path while the native
* player kept playing.
*
* 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.
* These tests pin that scrubbing reaches the backend and holds its position
* with a sleep timer active. Every video renderer is native now (DR-235).
*/
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,
},
};
});
// The webview path only exists where Rust offers a fallback from the native
// renderer — beside mpv native video on Linux; never on Android since DR-293,
// where a stored "off" is ignored. These tests guard that path's scrubbing, so
// they declare a platform that has it.
vi.mock("$lib/services/playbackCapabilities", () => ({
getPlaybackCapabilities: async () => ({
usesWebviewAudio: false,
supportsNativeVideo: true,
webviewVideoFallback: true,
}),
}));
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) => ({
@@ -164,7 +113,7 @@ function sleepTimerTick(remaining = 2) {
});
}
async function mountAndroidPlayer() {
async function mountPlayer() {
const utils = render(VideoPlayer, {
props: {
media: makeEpisode(),
@@ -175,63 +124,41 @@ async function mountAndroidPlayer() {
},
});
// 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 };
return { ...utils, slider };
}
/** Scrub the seek bar to `target` seconds like a user drag. */
async function scrubTo(slider: HTMLInputElement, video: HTMLVideoElement, target: number) {
async function scrubTo(slider: HTMLInputElement, 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)", () => {
describe("VideoPlayer scrubbing with active sleep timer", () => {
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 through the backend and keeps the new position", async () => {
const { slider } = await mountPlayer();
it("scrubbing without a timer seeks via the HTML5 path and keeps the new position", async () => {
const { slider, video } = await mountAndroidPlayer();
await scrubTo(slider, 600);
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
),
);
await waitFor(() => expect(playerSeekVideo).toHaveBeenCalledWith("repo-1", 600, "src-1", null));
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();
const { slider } = await mountPlayer();
// Enable "2 more episodes" timer; backend then ticks every second.
sleepTimerTick(2);
@@ -239,7 +166,7 @@ describe("VideoPlayer scrubbing with active sleep timer (Android)", () => {
sleepTimerTick(2);
await tick();
await scrubTo(slider, video, 600);
await scrubTo(slider, 600);
await waitFor(() => expect(playerSeekVideo).toHaveBeenCalledTimes(1));
expect(parseFloat(slider.value)).toBeCloseTo(600);
@@ -249,13 +176,13 @@ describe("VideoPlayer scrubbing with active sleep timer (Android)", () => {
expect(parseFloat(slider.value)).toBeCloseTo(600);
// A second scrub must also work.
await scrubTo(slider, video, 900);
await scrubTo(slider, 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 { slider } = await mountPlayer();
const before = slider.value;
for (let i = 0; i < 5; i++) {
File diff suppressed because it is too large Load Diff
@@ -21,8 +21,9 @@
* rather than internals.
*
* The specific traps encoded here, each a bug that shipped:
* - pausing renders a full-screen <button> play overlay OVER the video, so the
* second tap of a double tap lands on a button, not the video;
* - pausing renders a full-screen <button> play overlay OVER the video
* surface, so the second tap of a double tap lands on a button, not the
* surface;
* - the browser synthesizes a `click` after a touch tap, which must not toggle
* a second time, on ANY layered target;
* - the bottom controls bar must drive its own buttons and NOT the container's
@@ -35,6 +36,7 @@ import { tick } from "svelte";
import { invoke } from "@tauri-apps/api/core";
import VideoPlayer from "./VideoPlayer.svelte";
import { SEEK_FORWARD_SECONDS } from "./tapGestures";
import { player } from "$lib/stores/player";
/**
* A `StreamSelection` for tests that only care about the URL. Transcoded HLS is
@@ -94,18 +96,10 @@ vi.mock("$lib/player/adapters/rustReportHost", () => ({
}),
}));
vi.mock("$lib/player/html5Adapter", () => ({
reportState: vi.fn(),
reportPosition: vi.fn(),
reportMediaLoaded: vi.fn(),
resetReporting: vi.fn(),
}));
vi.mock("$lib/utils/pictureInPicture", () => ({
isPipSupported: () => false,
enterPip: vi.fn(),
setAutoEnterEnabled: vi.fn(),
setHtml5VideoState: vi.fn(),
}));
vi.mock("$lib/stores/auth", () => ({
@@ -143,9 +137,22 @@ function renderPlayer() {
});
}
/**
* The transparent area the native picture shows through — the video surface.
* The first `[data-player-surface]`; the play overlay, when raised, is another.
*/
function videoSurface(container: HTMLElement): Element {
const surface = container.querySelector("[data-player-surface]");
expect(surface).toBeTruthy();
return surface!;
}
describe("VideoPlayer tap surface (real component)", () => {
beforeEach(() => {
vi.clearAllMocks();
// Playing, as the backend would report it, so no play overlay covers the
// surface to begin with.
player.setPlaying(MEDIA, 0, 600);
// This file deliberately does NOT mock `$lib/api/bindings` — it renders the
// real component against the real bindings, which bottom out in the globally
// mocked `invoke`. That mock resolves `undefined` for every command, so the
@@ -167,17 +174,17 @@ describe("VideoPlayer tap surface (real component)", () => {
it("a single tap on the video toggles play/pause exactly once", async () => {
const { container } = renderPlayer();
const video = container.querySelector("video");
expect(video).toBeTruthy();
await tick();
touchAt(video!, 900);
touchAt(videoSurface(container), 900);
expect(toggleSpy).toHaveBeenCalledTimes(1);
});
it("the synthesized click after a tap does not toggle a second time", async () => {
const { container } = renderPlayer();
const video = container.querySelector("video")!;
await tick();
const video = videoSurface(container);
touchAt(video, 900);
// The compatibility click the browser fires after a touch tap. detail=0 is
@@ -195,21 +202,21 @@ describe("VideoPlayer tap surface (real component)", () => {
// guard that does not know about that overlay discards it and seeking dies.
//
// Reproducing it requires the overlay to actually render, which means
// driving `isPlaying` the way the real element does: via its `pause` event.
// driving `isPlaying` the way production does: the player reports paused.
vi.useFakeTimers();
try {
const { container } = renderPlayer();
const video = container.querySelector("video")!;
await tick();
// Tap 1 on the video.
touchAt(video, 900);
// Tap 1 on the video surface.
touchAt(videoSurface(container), 900);
// The element reports it paused → isPlaying=false → overlay renders.
video.dispatchEvent(new Event("pause"));
// The player reports it paused → isPlaying=false → overlay renders.
player.setPaused(MEDIA, 0, 600);
await Promise.resolve();
await tick();
const overlay = container.querySelector("[data-player-surface]");
const overlay = container.querySelector('[data-testid="play-overlay"]');
expect(overlay, "the play overlay should be covering the video").toBeTruthy();
vi.advanceTimersByTime(120); // inside DOUBLE_TAP_WINDOW_MS
@@ -25,56 +25,11 @@ 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,
},
};
});
// The webview path only exists where Rust offers a fallback from the native
// renderer — beside mpv native video on Linux; never on Android since DR-293,
// where a stored "off" is ignored. These tests guard that path's scrubbing, so
// they declare a platform that has it.
vi.mock("$lib/services/playbackCapabilities", () => ({
getPlaybackCapabilities: async () => ({
usesWebviewAudio: false,
supportsNativeVideo: true,
webviewVideoFallback: true,
}),
}));
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) => ({
@@ -166,12 +121,10 @@ async function mountAndroidPlayer() {
});
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 };
return { ...utils, slider };
}
function touch(x: number, y: number) {
@@ -184,7 +137,7 @@ function touch(x: number, y: number) {
* 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) {
async function touchScrubTo(slider: HTMLInputElement, 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.
@@ -194,31 +147,27 @@ async function touchScrubTo(slider: HTMLInputElement, video: HTMLVideoElement, t
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();
const { slider } = await mountAndroidPlayer();
await touchScrubTo(slider, video, 600);
await touchScrubTo(slider, 600);
await waitFor(() =>
expect(playerSeekVideo).toHaveBeenCalledWith("repo-1", 600, "src-1", null, true),
);
await waitFor(() => expect(playerSeekVideo).toHaveBeenCalledWith("repo-1", 600, "src-1", null));
expect(parseFloat(slider.value)).toBeCloseTo(600);
});
it("a touch drag on the seek bar never toggles play/pause", async () => {
const { slider, video } = await mountAndroidPlayer();
const { slider } = await mountAndroidPlayer();
await touchScrubTo(slider, video, 600);
await touchScrubTo(slider, 600);
// The container gesture layer must stay out of a control drag entirely:
// no swipe mis-read, so no play/pause correction.
@@ -226,7 +175,7 @@ describe("VideoPlayer seek bar — touch drag (Android)", () => {
});
it("commits the seek on touchend even when the engine never fires `change`", async () => {
const { slider, video } = await mountAndroidPlayer();
const { slider } = 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
@@ -235,32 +184,29 @@ describe("VideoPlayer seek bar — touch drag (Android)", () => {
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),
);
await waitFor(() => expect(playerSeekVideo).toHaveBeenCalledWith("repo-1", 600, "src-1", null));
});
it("commits the seek exactly once when both touchend and change fire", async () => {
const { slider, video } = await mountAndroidPlayer();
const { slider } = await mountAndroidPlayer();
await touchScrubTo(slider, video, 600);
await touchScrubTo(slider, 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();
const { slider, 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)");
}
// Mid-drag, with the finger still down: a mis-read swipe raises the
// brightness indicator for as long as the swipe lasts.
await fireEvent.touchStart(slider, { touches: [touch(100, 700)] });
await fireEvent.touchMove(slider, { touches: [touch(400, 600)] });
await fireEvent.touchMove(slider, { touches: [touch(700, 500)] });
await tick();
expect(container.textContent).not.toContain("Brightness");
await fireEvent.touchEnd(slider, { touches: [] });
});
});
@@ -97,9 +97,8 @@ describe("backgroundAudioHandoff", () => {
//
// TRACES: UR-040, UR-003 | DR-196
describe("planHandoffReturn", () => {
it("restarts the native backend when the native path is rendering", () => {
it("restarts the native backend", () => {
const plan = planHandoffReturn({
useHtml5Element: false,
position: 4214,
wasPlaying: true,
nativeStateKind: "playing",
@@ -109,19 +108,8 @@ describe("backgroundAudioHandoff", () => {
expect(plan.shouldPlay).toBe(true);
});
it("reloads the webview element when HTML5 is rendering", () => {
const plan = planHandoffReturn({
useHtml5Element: true,
position: 120,
wasPlaying: true,
nativeStateKind: "playing",
});
expect(plan.target).toBe("html5-element");
});
it("honours a lockscreen pause over the handoff snapshot", () => {
const plan = planHandoffReturn({
useHtml5Element: false,
position: 300,
wasPlaying: true,
nativeStateKind: "paused",
@@ -131,7 +119,6 @@ describe("backgroundAudioHandoff", () => {
it("never returns a negative resume position", () => {
const plan = planHandoffReturn({
useHtml5Element: false,
position: -3,
wasPlaying: false,
nativeStateKind: undefined,
@@ -146,7 +133,6 @@ describe("backgroundAudioHandoff", () => {
// TRACES: UR-040, UR-023 | DR-296 | UT-265
it("switches to the item the backend advanced to", () => {
const plan = planHandoffReturn({
useHtml5Element: false,
position: 95,
wasPlaying: true,
nativeStateKind: "playing",
@@ -160,21 +146,19 @@ describe("backgroundAudioHandoff", () => {
it("reloads in place when the backend is still on the mounted item", () => {
const plan = planHandoffReturn({
useHtml5Element: true,
position: 95,
wasPlaying: true,
nativeStateKind: "playing",
mountedItemId: "ep1",
resumeItemId: "ep1",
});
expect(plan.target).toBe("html5-element");
expect(plan.target).toBe("native-backend");
});
it("reloads in place when the backend reports no item", () => {
// Queue emptied while backgrounded (e.g. the sleep timer): there is no
// other item to go to, so the mounted one is the best we have.
const plan = planHandoffReturn({
useHtml5Element: false,
position: 95,
wasPlaying: false,
nativeStateKind: undefined,
@@ -82,7 +82,7 @@ export interface HandoffReturn {
* no longer on the item this player was mounted with, so the player must
* switch to `itemId` instead of reloading itself.
*/
target: "html5-element" | "native-backend" | "other-item";
target: "native-backend" | "other-item";
/** The item to switch to; set only for `other-item`. */
itemId?: string;
/** Absolute position the background audio reached. */
@@ -94,19 +94,14 @@ export interface HandoffReturn {
/**
* How to come back when the app returns to the foreground.
*
* The two render paths resume by completely different means, and conflating
* them is what broke the native one:
* The native backend owns no element, and nothing reacts to the stream URL on
* its behalf. Native playback is only ever started by an explicit backend load,
* which the component issues once, from `onMount`. So the return has to
* re-issue it; reassigning the URL restarts nothing.
*
* - **html5-element** — assigning the stream URL is enough. An `$effect` in the
* component watches it, (re)initialises HLS or sets `videoElement.src`, and
* `canplay` then drives the seek and play.
* - **native-backend** — ExoPlayer owns no element, and nothing reacts to the
* stream URL on its behalf. Native playback is only ever started by an
* explicit backend load, which the component issues once, from `onMount`. So
* the return has to re-issue it; reassigning the URL restarts nothing.
*
* The component previously did only the URL assignment, for both paths. On the
* native path that left the backend holding no item at all: a black screen with
* The component once did only the URL assignment — which is how the deleted
* webview `<video>` path came back. On the native path that left the backend
* holding no item at all: a black screen with
* a play overlay, a play button that did nothing, and the position pinned at
* 0:00 — the handoff's own audio player having been stopped on the way out.
*
@@ -122,7 +117,6 @@ export interface HandoffReturn {
* TRACES: UR-040, UR-003, UR-023 | DR-196, DR-296 | UT-060, UT-265
*/
export function planHandoffReturn(opts: {
useHtml5Element: boolean;
position: number;
wasPlaying: boolean;
nativeStateKind: string | undefined;
@@ -134,11 +128,7 @@ export function planHandoffReturn(opts: {
if (opts.resumeItemId && opts.resumeItemId !== opts.mountedItemId) {
return { target: "other-item", itemId: opts.resumeItemId, position, shouldPlay };
}
return {
target: opts.useHtml5Element ? "html5-element" : "native-backend",
position,
shouldPlay,
};
return { target: "native-backend", position, shouldPlay };
}
/**
@@ -2,14 +2,9 @@ import { describe, it, expect } from "vitest";
import { planFullscreen } from "./fullscreenTarget";
describe("planFullscreen", () => {
it("fullscreens only the document when an in-document <video> renders", () => {
// Unchanged behaviour: WebKit scales the element, the window need not move.
expect(planFullscreen(false)).toEqual({ document: true, osWindow: false });
});
it("also fullscreens the OS window when a native surface renders", () => {
it("fullscreens the OS window as well as the document", () => {
// The picture is drawn behind the webview at window size, so a
// document-only fullscreen leaves it at the old size.
expect(planFullscreen(true)).toEqual({ document: true, osWindow: true });
expect(planFullscreen()).toEqual({ document: true, osWindow: true });
});
});
@@ -27,9 +27,9 @@ export interface FullscreenPlan {
}
/**
* @param rendersNatively true when a native surface (mpv/ExoPlayer) draws the
* picture rather than an in-document `<video>` element.
* Every video renderer is a native surface since the webview `<video>` path was
* deleted (DR-235), so the OS window always has to move with the document.
*/
export function planFullscreen(rendersNatively: boolean): FullscreenPlan {
return { document: true, osWindow: rendersNatively };
export function planFullscreen(): FullscreenPlan {
return { document: true, osWindow: true };
}
@@ -1,64 +0,0 @@
import { describe, it, expect } from "vitest";
import { fatalNetworkErrorAction } from "./hlsRecovery";
/**
* A fatal hls.js network error mid-film must be retried, not reported as the
* end of the stream — reporting "ended" hands control to autoplay and skips to
* the next item while the user is still watching this one.
*
* The position the player displays is *already absolute*: the RAF loop sets
* `currentTime = seekOffset + element.currentTime`. Anything that adds the
* offset a second time doubles the apparent position, and after a quality
* switch or a transcoded seek the offset is the whole resume position — so past
* roughly the halfway mark the doubled value clears the near-end threshold and
* every transient error is misread as the end.
*
* TRACES: UR-004, UR-074 | DR-177 | UT-174
*/
describe("fatalNetworkErrorAction", () => {
it("retries a mid-film failure after a quality switch instead of ending playback", () => {
// 90-minute film, quality switched at the 50-minute mark: the reloaded
// stream's timeline starts at 0, so seekOffset carries the 50 minutes and
// the displayed position — already absolute — is 3000s of 5400s, 56%
// through and nowhere near the end.
const action = fatalNetworkErrorAction({
positionSeconds: 3000,
knownDurationSeconds: 5400,
attempts: 1,
});
expect(action).toBe("retry");
});
it("treats a failure in the last tenth of the stream as the end", () => {
// Jellyfin's transcoded HLS does not always emit #EXT-X-ENDLIST, so a
// genuine end-of-stream arrives as a fatal network error.
const action = fatalNetworkErrorAction({
positionSeconds: 5300,
knownDurationSeconds: 5400,
attempts: 1,
});
expect(action).toBe("ended");
});
it("stops retrying once the recovery budget is spent", () => {
const action = fatalNetworkErrorAction({
positionSeconds: 60,
knownDurationSeconds: 5400,
attempts: 4,
});
expect(action).toBe("giveUp");
});
it("retries when the runtime is not known yet", () => {
const action = fatalNetworkErrorAction({
positionSeconds: 120,
knownDurationSeconds: 0,
attempts: 1,
});
expect(action).toBe("retry");
});
});
-52
View File
@@ -1,52 +0,0 @@
/**
* What to do about a *fatal* hls.js network error.
*
* Jellyfin's transcoded HLS streams do not always terminate with an
* `#EXT-X-ENDLIST`, so a stream that has simply run out looks identical to one
* that broke: both arrive as a fatal network error. The only thing separating
* them is how far playback had got, which is why this decision is worth
* isolating from the player component — read the position wrong and a
* recoverable stall turns into a skip to the next item.
*
* TRACES: UR-004, UR-074 | DR-177 | UT-174
*/
/** Fraction of the runtime past which a fatal error reads as "the stream ended". */
const NEAR_END_FRACTION = 0.9;
/** How many times to ask hls.js to resume before giving up on the stream. */
export const MAX_FATAL_NETWORK_RECOVERIES = 3;
export type FatalNetworkErrorAction = "ended" | "retry" | "giveUp";
export interface FatalNetworkErrorInput {
/**
* Absolute position in the media, in seconds — the value the player displays.
*
* It is already absolute (`seekOffset + element.currentTime`): do NOT add the
* transcode seek offset again. After a quality switch or a transcoded seek the
* offset *is* the resume position, so double-counting it puts an apparent
* position past the near-end threshold from roughly halfway through, and every
* transient error then ends playback.
*/
positionSeconds: number;
/** Known runtime in seconds; 0 or negative when the runtime isn't known yet. */
knownDurationSeconds: number;
/** Recovery attempts already made against this hls.js instance. */
attempts: number;
}
/** Whether a failure at this position should be read as the stream ending. */
export function isNearEndOfStream(positionSeconds: number, knownDurationSeconds: number): boolean {
if (knownDurationSeconds <= 0 || positionSeconds <= 0) return false;
return positionSeconds / knownDurationSeconds > NEAR_END_FRACTION;
}
export function fatalNetworkErrorAction({
positionSeconds,
knownDurationSeconds,
attempts,
}: FatalNetworkErrorInput): FatalNetworkErrorAction {
if (isNearEndOfStream(positionSeconds, knownDurationSeconds)) return "ended";
return attempts <= MAX_FATAL_NETWORK_RECOVERIES ? "retry" : "giveUp";
}
@@ -5,30 +5,19 @@ import {
subtitleStreamsOf,
subtitleTrackLabel,
resolveSubtitleTracks,
reconcileSelectedSubtitle,
videoCrossOriginMode,
nativeSubtitleTracks,
nativeSubtitleArrayIndex,
type SubtitleStreamLike,
} from "./subtitleTracks";
/**
* Subtitles on the Linux / WebKitGTK HTML5 `<video>` path.
* Subtitle resolution — the list the play request carries.
*
* TRACES: UR-020 | DR-023 | UT-143, UT-144
*
* The bug this guards: VideoPlayer rendered no `<track>` children at all (the
* block was commented out "to debug playback issues"), so
* `Html5PlayerAdapter.selectSubtitle()` walked an empty `textTracks` list and
* the subtitle menu was inert on Linux. The reason it had to be disabled is
* visible in the original markup — `src={getSubtitleUrl(track.index)}` bound the
* *Promise* returned by an async function to the attribute, so every track's src
* stringified to "[object Promise]", an unloadable resource hanging off the
* media element.
*
* So the fix has two halves and both are tested here: URLs must be resolved into
* plain strings *before* they reach the markup, and the markup must actually
* render the tracks (with the `data-stream-index` the adapter matches on).
* 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[] = [
@@ -177,56 +166,6 @@ describe("resolveSubtitleTracks", () => {
});
});
describe("reconcileSelectedSubtitle", () => {
it("starts off (null) and keeps 'off' selectable", async () => {
const tracks = await resolveSubtitleTracks(SUBS, async (i) => url(i));
expect(reconcileSelectedSubtitle(tracks, null)).toBeNull();
});
it("keeps a selection that is still renderable", async () => {
const tracks = await resolveSubtitleTracks(SUBS, async (i) => url(i));
expect(reconcileSelectedSubtitle(tracks, 3)).toBe(3);
});
it("falls back to off when the selected track is gone (new item / failed URL)", async () => {
const tracks = await resolveSubtitleTracks(SUBS, async (i) => url(i));
expect(reconcileSelectedSubtitle(tracks, 9)).toBeNull();
expect(reconcileSelectedSubtitle([], 3)).toBeNull();
});
it("never auto-selects the server's default track", async () => {
// The menu opens on "Off" and a <track default> would auto-show, so the UI
// would claim subtitles are off while they are burned over the picture.
const tracks = await resolveSubtitleTracks(SUBS, async (i) => url(i));
expect(tracks[0].isDefault).toBe(true);
expect(reconcileSelectedSubtitle(tracks, null)).toBeNull();
});
});
describe("videoCrossOriginMode", () => {
it("opts into CORS for a server stream that has subtitles", () => {
expect(videoCrossOriginMode("http://jelly.example/Videos/x/master.m3u8", 2)).toBe("anonymous");
expect(videoCrossOriginMode("https://jelly.example/Videos/x/stream.mp4", 1)).toBe("anonymous");
});
it("leaves a local/offline source alone so playback cannot regress", () => {
expect(videoCrossOriginMode("asset://localhost/movie.mkv", 2)).toBeUndefined();
expect(videoCrossOriginMode("file:///home/u/movie.mkv", 2)).toBeUndefined();
});
it("stays out of the way when there is nothing to load", () => {
expect(videoCrossOriginMode("http://jelly.example/x.m3u8", 0)).toBeUndefined();
expect(videoCrossOriginMode("", 0)).toBeUndefined();
});
it("is decided by inputs known at first render, so it cannot flip mid-load", () => {
// Same answer before and after the async URL resolution completes.
const before = videoCrossOriginMode("http://jelly.example/x.m3u8", SUBS.length);
const after = videoCrossOriginMode("http://jelly.example/x.m3u8", SUBS.length);
expect(before).toBe(after);
});
});
/**
* Subtitles on the Android / ExoPlayer native path.
*
@@ -320,31 +259,6 @@ describe("nativeSubtitleArrayIndex", () => {
});
});
describe("VideoPlayer markup (the regression that made the menu inert)", () => {
const source = readFileSync(resolve(__dirname, "VideoPlayer.svelte"), "utf-8");
it("renders <track> elements instead of leaving them commented out", () => {
expect(source).not.toContain("Temporarily disabled to debug playback issues");
expect(source).toMatch(/<track\b/);
expect(source).toContain('kind="subtitles"');
});
/** The rendered element, not a `<track>` mentioned in prose. */
const trackElement = source.slice(source.search(/<track\s/), source.search(/<track\s/) + 400);
it("keeps data-stream-index — Html5PlayerAdapter.selectSubtitle matches on it", () => {
expect(trackElement).toContain("data-stream-index");
});
it("never binds the async getSubtitleUrl() Promise to src", () => {
expect(source).not.toMatch(/src=\{\s*getSubtitleUrl\(/);
});
it("does not mark any track default (a default track auto-shows)", () => {
expect(trackElement).not.toMatch(/\bdefault=/);
});
});
/**
* 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
+6 -65
View File
@@ -125,72 +125,13 @@ export async function resolveSubtitleTracks(
return resolved.filter((t): t is RenderableSubtitleTrack => t !== null);
}
/**
* The selection to keep once the rendered track list changes.
*
* Subtitles are OFF unless the user turns them on: `null` in, `null` out. The
* server's `isDefault` flag is deliberately NOT promoted to a selection (and the
* markup deliberately omits the `default` attribute, which would auto-show the
* track) — the menu opens on "Off", so auto-enabling would make the UI lie about
* what is on screen, and it would change behaviour for every user who has never
* asked for subtitles.
*
* A selection that is no longer renderable (new item, or a URL that failed to
* resolve) collapses to off, so the menu's checkmark can never point at a track
* that does not exist on the element.
*/
export function reconcileSelectedSubtitle(
tracks: readonly RenderableSubtitleTrack[],
selected: number | null,
): number | null {
if (selected === null) return null;
return tracks.some((t) => t.streamIndex === selected) ? selected : null;
}
function originOf(url: string): string | null {
try {
const parsed = new URL(url);
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null;
return parsed.origin;
} catch {
return null;
}
}
/**
* The `crossorigin` value for the `<video>` element, or undefined for none.
*
* Text-track fetches are CORS-enabled per the HTML spec and use the *media
* element's* CORS setting, so a cross-origin `<track>` never loads unless the
* element opts in. The webview page's origin is `tauri://localhost`, so every
* subtitle served by Jellyfin is cross-origin.
*
* Opting in is only safe when the media itself comes from an http(s) server —
* the same Jellyfin that already answers hls.js' cross-origin XHRs, so we know
* it sends the headers. For a local/offline source (`file:`/`asset:`) we leave
* the attribute off: subtitles staying dark there is the status quo, whereas
* forcing CORS onto the video fetch could break playback outright.
*
* Deliberately keyed on the *count of subtitle streams* rather than on the
* resolved tracks: both inputs are known at first render, so the attribute is
* decided before the element starts loading and never flips underneath an
* in-flight media fetch.
*/
export function videoCrossOriginMode(
streamUrl: string,
subtitleStreamCount: number,
): "anonymous" | undefined {
if (subtitleStreamCount <= 0) return undefined;
return originOf(streamUrl) ? "anonymous" : undefined;
}
// ===== Native (Android / ExoPlayer) path ====================================
// ===== The native player =====================================================
//
// The HTML5 element gets `<track>` children; the native backend instead gets the
// list *up front*, as part of the play request, because ExoPlayer sideloads
// subtitles as `MediaItem.SubtitleConfiguration`s that must exist before
// `prepare()`. There is no "add a subtitle later" — a track absent from the
// MediaItem simply does not exist as far as the player is concerned.
// The native backend gets the list *up front*, as part of the play request:
// ExoPlayer sideloads subtitles as `MediaItem.SubtitleConfiguration`s that must
// exist before `prepare()`, and mpv queues them as external files for the load.
// There is no "add a subtitle later" — a track absent from the request simply
// does not exist as far as the player is concerned.
/**
* Map resolved tracks onto the wire shape `PlayItemRequest.subtitles` carries.
@@ -1,45 +0,0 @@
import { describe, it, expect } from "vitest";
import { shouldApplyTimeUpdate } from "./timeTracking";
/**
* TRACES: UT-245 | DR-265
*/
describe("shouldApplyTimeUpdate", () => {
const base = { isPlaying: false, isSeeking: false, isDraggingSeekBar: false, readyState: 4 };
it("applies the update while the video is PLAYING", () => {
// THE REPORTED BUG. `timeupdate` was the only position source that still
// fires once requestAnimationFrame stops -- which is exactly what happens
// when the activity is paused behind a picture-in-picture window. Gating it
// on `!isPlaying` disabled it precisely when it was the only thing left,
// so the component's `currentTime` froze at the moment PiP was entered
// while the element played on. The background-audio handoff then resumed
// the audio-only stream at that frozen position.
expect(shouldApplyTimeUpdate({ ...base, isPlaying: true })).toBe(true);
});
it("still applies the update while paused", () => {
// The case it always handled: RAF is stopped, timeupdate carries the seek.
expect(shouldApplyTimeUpdate(base)).toBe(true);
});
it("yields to an in-flight seek", () => {
// A seek owns the position until it settles; a stale element read landing
// mid-seek is what makes a scrubbed video snap back.
expect(shouldApplyTimeUpdate({ ...base, isSeeking: true })).toBe(false);
expect(shouldApplyTimeUpdate({ ...base, isPlaying: true, isSeeking: true })).toBe(false);
});
it("yields while the user is dragging the seek bar", () => {
expect(shouldApplyTimeUpdate({ ...base, isDraggingSeekBar: true })).toBe(false);
expect(shouldApplyTimeUpdate({ ...base, isPlaying: true, isDraggingSeekBar: true })).toBe(
false,
);
});
it("ignores an element with no usable data yet", () => {
// readyState < HAVE_CURRENT_DATA reads 0, which would rewind the position.
expect(shouldApplyTimeUpdate({ ...base, readyState: 1 })).toBe(false);
expect(shouldApplyTimeUpdate({ ...base, isPlaying: true, readyState: 0 })).toBe(false);
});
});
-45
View File
@@ -1,45 +0,0 @@
/**
* Pure helpers for keeping the player's position variable honest.
*
* TRACES: UR-004, UR-041 | DR-265 | UT-245
*
* `VideoPlayer.svelte` tracks the absolute playback position in its own
* `currentTime` variable rather than reading `videoElement.currentTime` at the
* point of use — transcoded HLS resets the element to 0 on every segment
* rebuild, so only the component's running total is meaningful. Everything
* downstream reads that variable: the seek bar, the progress reports, the
* position mirrored into Rust, and the background-audio handoff.
*
* Which makes "who is allowed to write it" a correctness question, not a
* rendering detail — hence a pure module with tests rather than a condition
* buried in an event handler.
*/
export interface TimeUpdateGate {
/**
* Deliberately does NOT gate the update, and is accepted only to say so.
*
* `timeupdate` was written as a fallback "for when RAF isn't running" and so
* excluded itself whenever `isPlaying` was true. But RAF is driven by the
* document being rendered, and an Android activity behind a picture-in-picture
* window is paused: the loop stops while the element plays on, and the one
* remaining position source had switched itself off. Both writing the same
* derived value costs nothing — the element is the authority either way.
*/
isPlaying?: boolean;
isSeeking: boolean;
isDraggingSeekBar: boolean;
readyState: number;
}
/**
* Whether a `timeupdate` event may write the component's position.
*
* Kept free of Svelte/DOM so the rule is unit-testable without mounting the
* player.
*/
export function shouldApplyTimeUpdate(opts: TimeUpdateGate): boolean {
// An in-flight seek or a drag owns the position until it settles, and an
// element with no current data reads 0, which would rewind it.
return !opts.isSeeking && !opts.isDraggingSeekBar && opts.readyState >= 2;
}
@@ -1,21 +0,0 @@
import { describe, it, expect } from "vitest";
import { videoFitClass } from "./videoFit";
describe("videoFitClass", () => {
it("fills the container instead of capping at the source's intrinsic size", () => {
const cls = videoFitClass();
// max-w/max-h only shrink oversized media; a 480p source would stay a small
// box in the middle of a large window.
expect(cls).not.toContain("max-w-full");
expect(cls).not.toContain("max-h-full");
expect(cls).toContain("w-full");
expect(cls).toContain("h-full");
});
it("preserves aspect ratio while fitting (letterbox, never crop)", () => {
const cls = videoFitClass();
expect(cls).toContain("object-contain");
expect(cls).not.toContain("object-cover");
expect(cls).not.toContain("object-fill");
});
});
-17
View File
@@ -1,17 +0,0 @@
// Sizing rules for the HTML5 <video> element in the full-screen player.
// Extracted from VideoPlayer.svelte so the fit behaviour is unit-testable.
/**
* Classes applied to the <video> element so it fits the player viewport.
*
* TRACES: UR-005
*
* `max-w-full max-h-full` only ever *shrinks* oversized media, so a source
* smaller than the window (e.g. 480p on a 1080p display) rendered at its
* intrinsic size - a small box in the middle of a black screen. Filling the
* container and letting `object-contain` do the scaling fits the picture to
* whichever axis constrains it, in both directions, preserving aspect ratio.
*/
export function videoFitClass(): string {
return "w-full h-full object-contain";
}
@@ -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);
});
});
-317
View File
@@ -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);
});
}
}
+7 -66
View File
@@ -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);
}
+11 -24
View File
@@ -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
+8 -17
View File
@@ -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>;
}
+4 -10
View File
@@ -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));
}
-19
View File
@@ -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
View File
@@ -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;
}
-93
View File
@@ -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");
});
});
-88
View File
@@ -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 };
+2 -16
View File
@@ -20,26 +20,14 @@ const log = createLogger("capabilities");
export interface PlaybackCapabilities {
/** Audio renders through a webview `<audio>` element, not a native backend. */
usesWebviewAudio: boolean;
/** Video can render on a native surface behind a transparent webview. */
supportsNativeVideo: boolean;
/**
* The user may send video to the webview element instead of the native
* renderer. False on Android, where ExoPlayer is the only video renderer
* (DR-293). Rust decides; see `webview_video_fallback`.
*/
webviewVideoFallback: boolean;
}
/**
* Conservative defaults for when the backend cannot be reached (very early
* startup, or a command failure). Both false = "assume no special platform
* facilities": no stray `<audio>` element is mounted, and video stays on the
* HTML5 path, which is the safe behaviour everywhere.
* Conservative default for when the backend cannot be reached (very early
* startup, or a command failure): no stray `<audio>` element is mounted.
*/
const FALLBACK: PlaybackCapabilities = {
usesWebviewAudio: false,
supportsNativeVideo: false,
webviewVideoFallback: false,
};
let cached: PlaybackCapabilities | null = null;
@@ -58,8 +46,6 @@ export async function getPlaybackCapabilities(): Promise<PlaybackCapabilities> {
const caps = (await commands.playerGetCapabilities()) as PlaybackCapabilities;
cached = {
usesWebviewAudio: !!caps?.usesWebviewAudio,
supportsNativeVideo: !!caps?.supportsNativeVideo,
webviewVideoFallback: !!caps?.webviewVideoFallback,
};
return cached;
} catch (err) {
@@ -1,75 +0,0 @@
import { describe, it, expect, beforeEach, beforeAll, afterAll, vi } from "vitest";
import { get } from "svelte/store";
/**
* The stored value of the native-video preference, and what it means.
*
* The default has moved four times (see the history on `load()` in
* nativeVideo.ts), so the risk here is not "which way is it pointing" — it is
* that a flip silently overrides people who chose. The old reader was
* `getItem(KEY) === "true"`, which conflates "never chose" with "chose off";
* flipping the default under that reader re-enables the native path for
* everyone who deliberately turned it off. So the three cases are pinned
* separately rather than through the default alone.
*
* TRACES: UR-003, UR-004 | DR-188
*/
const STORAGE_KEY = "jellytau-experimental-native-video";
// jsdom here doesn't expose localStorage; stand in a minimal implementation,
// matching the viewMode/searchGroupOrder store tests.
const backing = new Map<string, string>();
const localStorageShim = {
getItem: (key: string) => backing.get(key) ?? null,
setItem: (key: string, value: string) => void backing.set(key, value),
removeItem: (key: string) => void backing.delete(key),
clear: () => backing.clear(),
};
beforeAll(() => {
vi.stubGlobal("localStorage", localStorageShim);
});
afterAll(() => {
vi.unstubAllGlobals();
});
async function freshStore() {
// The default is read at module init, so each case needs a fresh module.
vi.resetModules();
return await import("./nativeVideo");
}
describe("experimentalNativeVideo default", () => {
beforeEach(() => {
localStorage.clear();
});
it("defaults to ON when the user has never chosen", async () => {
const { experimentalNativeVideo } = await freshStore();
expect(get(experimentalNativeVideo)).toBe(true);
});
it("stays OFF for someone who deliberately turned it off", async () => {
// The regression the null check exists for: an explicit opt-out must
// survive the default flip, not be re-enabled by it.
localStorage.setItem(STORAGE_KEY, "false");
const { experimentalNativeVideo } = await freshStore();
expect(get(experimentalNativeVideo)).toBe(false);
});
it("stays ON for someone who deliberately turned it on", async () => {
localStorage.setItem(STORAGE_KEY, "true");
const { experimentalNativeVideo } = await freshStore();
expect(get(experimentalNativeVideo)).toBe(true);
});
it("persists an explicit choice in both directions", async () => {
const { experimentalNativeVideo } = await freshStore();
experimentalNativeVideo.set(false);
expect(localStorage.getItem(STORAGE_KEY)).toBe("false");
experimentalNativeVideo.set(true);
expect(localStorage.getItem(STORAGE_KEY)).toBe("true");
});
});
+9 -125
View File
@@ -1,138 +1,22 @@
// Native-video compositing state.
//
// TRACES: UR-003, UR-004 | DR-150, DR-152
// TRACES: UR-003, UR-004 | DR-150, DR-152, DR-235
//
// Two separate concerns live here, deliberately:
// `nativeVideoActive` — whether a native video surface is on screen right now.
// Setting it toggles `data-native-video` on <html>, which is what the CSS in
// app.css keys off to clear the app's opaque backgrounds so the video surface
// behind the webview is visible. The backgrounds must come back the moment the
// player unmounts.
//
// 1. `experimentalNativeVideo` — the user-facing opt-in flag. Rust already
// decides *which backend this platform has* (`useHtml5Element` from
// `player_play_item`); this flag only *suppresses* that decision so a
// half-working spike cannot ship as a regression. It never turns native on
// where Rust says HTML5.
//
// 2. `nativeVideoActive` — whether a native surface is on screen right now.
// Setting it toggles `data-native-video` on <html>, which is what the CSS in
// app.css keys off to clear the app's opaque backgrounds so the SurfaceView
// behind the WebView is visible. It is deliberately NOT derived from the
// flag: the backgrounds must come back the moment the player unmounts.
//
// Frontend-only preference, stored in localStorage per the `jellytau-view-mode`
// precedent in library.ts — no Rust settings command backs this.
// There used to be a second concern here: `experimentalNativeVideo`, a stored
// user switch that could force video back to the webview `<video>` element.
// That element is gone (DR-235), so there is nothing left to switch to.
import { writable } from "svelte/store";
const STORAGE_KEY = "jellytau-experimental-native-video";
/** The attribute app.css keys its transparency rules off. */
const NATIVE_VIDEO_ATTR = "data-native-video";
/**
* Whether the native path is on, defaulting to **on** when the user has never
* chosen.
*
* This default has moved three times, so the history is the documentation:
*
* - **off** while the path was a spike (DR-150).
* - **on** for picture-in-picture (DR-161), which shipped as *audio with no
* picture* — ExoPlayer decoded correctly into a live SurfaceView while the
* page stayed opaque over it.
* - **off** again (DR-172), which named the compositing as the suspect but did
* not find it.
* - **on** now, because the four defects behind that symptom were found and
* each is fixed and verified on a device: the app shell painted over the
* surface through a CSS rule targeting an attribute nothing set (DR-185); the
* poster card had no way to lift on a path with no `<video>` element
* (DR-182); the JS bridges raced the page load, so `setTransparent(true)`
* could never arrive (DR-183); and the SurfaceView was never detached
* (DR-184). Two further UI defects that only this path could show — the play
* overlay never clearing (DR-186) and the system bars staying over the player
* (DR-187) — are fixed with it.
*
* The picture is genuinely fixed and device-verified — `WebView transparent =
* true` and `Marking media ready` now appear in logcat with video on screen,
* the pair DR-172 went looking for and could not find. The default nonetheless
* stayed **off** for a further release, because turning it on surfaced a
* different gap: the background-audio handoff (UR-040) could only *return*
* through the HTML5 element, so coming back from background audio left playback
* dead. That was the same shape of mistake as DR-161 — a verified sub-path
* shipped as a default over an unverified one — so the flip waited (DR-190).
*
* - **on** now. The two defects that were holding it back are fixed and
* verified on a device: the handoff return restarts the renderer that is
* actually on screen rather than only ever reloading the `<video>` element
* (DR-196), and the letterbox bars are painted instead of retaining whatever
* was last in the framebuffer (DR-194). The evidence standard this default
* has been held to since DR-161 is met for both: audio handoff at 69:54
* returning to video playing at 70:18, and clean bars across playback, the
* control bar and a rotation round-trip.
*
* An explicit stored choice still wins in both directions, so anyone who turned
* it off keeps it off — hence the `null` check rather than a bare `=== "true"`,
* which would silently re-enable it for people who opted out.
*
* TRACES: UR-003, UR-004 | DR-188
*/
function load(): boolean {
if (typeof localStorage === "undefined") return true;
try {
const stored = localStorage.getItem(STORAGE_KEY);
// Never chosen → on. Chosen → honour it, in both directions.
return stored === null ? true : stored === "true";
} catch {
// Private-mode / disabled storage — same default as a fresh install.
return true;
}
}
function persist(enabled: boolean) {
if (typeof localStorage === "undefined") return;
try {
localStorage.setItem(STORAGE_KEY, String(enabled));
} catch {
// Quota or private-mode failure — keep the in-memory value.
}
}
function createExperimentalNativeVideoStore() {
const { subscribe, set } = writable<boolean>(load());
return {
subscribe,
set(enabled: boolean) {
persist(enabled);
set(enabled);
},
/** Read the current value without subscribing (init-time decisions). */
current: load,
};
}
/**
* User preference for the native Android video path. **Defaults to on** — see
* `load()`. The name still says "experimental" because the flag remains a
* suppressor of Rust's backend choice, not a promoter of it: turning it off
* forces the webview element, turning it on never produces a native backend
* where Rust says HTML5.
*/
export const experimentalNativeVideo = createExperimentalNativeVideoStore();
/**
* Whether video should take the native path, given the user's stored choice
* and whether this platform lets the user choose at all.
*
* On Android the answer is always native: ExoPlayer is the only video renderer
* there, and the webview element decodes none of the AC-3/E-AC-3/DTS/TrueHD
* that ExoPlayer plays through the FFmpeg extension — so a stored "off" would
* turn every original-file download into a silent film (DR-293). Rust reports
* whether a fallback exists (`webviewVideoFallback`); only then does the
* stored choice count.
*
* TRACES: UR-003, UR-071 | DR-293 | UT-262
*/
export function nativeVideoWanted(storedChoice: boolean, webviewVideoFallback: boolean): boolean {
return webviewVideoFallback ? storedChoice : true;
}
function createNativeVideoActiveStore() {
const { subscribe, set } = writable<boolean>(false);
-17
View File
@@ -1,17 +0,0 @@
import { describe, it, expect } from "vitest";
import { nativeVideoWanted } from "./nativeVideo";
// TRACES: UR-003, UR-071 | DR-293 | UT-262
describe("nativeVideoWanted", () => {
it("ignores a stored 'off' where there is no webview fallback (Android)", () => {
// Someone who once switched native video off on Android must not be
// routed to the webview, which plays original-file downloads silent.
expect(nativeVideoWanted(false, false)).toBe(true);
expect(nativeVideoWanted(true, false)).toBe(true);
});
it("honours the stored choice where a fallback exists (Linux beside mpv)", () => {
expect(nativeVideoWanted(false, true)).toBe(false);
expect(nativeVideoWanted(true, true)).toBe(true);
});
});
-34
View File
@@ -22,7 +22,6 @@ interface AndroidPictureInPictureBridge {
isSupported(): boolean;
canEnterPip(): boolean;
setAutoEnterEnabled(enabled: boolean): void;
setHtml5VideoState(active: boolean, width: number, height: number, playing: boolean): void;
}
declare global {
@@ -89,36 +88,3 @@ export function setAutoEnterEnabled(enabled: boolean): void {
log.warn("Failed to set auto-enter:", err);
}
}
/**
* Tell native that a WebView `<video>` is (or is no longer) the playback surface.
*
* This is what makes PiP work on the HTML5 path. The native side only ever knew
* about the ExoPlayer surface, and that path is behind `experimentalNativeVideo`,
* which defaulted to off when this was written — so `canEnterPip` was always
* false and pressing the button did nothing. Reporting the element's state gives
* native a surface it can legitimately shrink into, plus the intrinsic size it
* needs for the PiP window's aspect ratio and the play state for its play/pause
* action.
*
* The flag is back to defaulting **off** (DR-172, after native video shipped as
* audio with no picture), so this is once again the path Android normally takes —
* which is why PiP does not depend on that flag being on.
*
* Pass `active: false` when the element goes away, or PiP would be offered over a
* video that is no longer there.
*
* TRACES: UR-041 | DR-160
*/
export function setHtml5VideoState(
active: boolean,
width: number,
height: number,
playing: boolean,
): void {
try {
bridge()?.setHtml5VideoState(active, Math.round(width), Math.round(height), playing);
} catch (err) {
log.warn("Failed to report HTML5 video state:", err);
}
}
+1 -2
View File
@@ -55,8 +55,7 @@ function bridge(): AndroidVideoSurfaceBridge | undefined {
/**
* Whether the native-surface bridge exists on this platform. This reports only
* that the *plumbing* is present; whether native video should actually be used
* is Rust's decision (`player_get_capabilities`) gated by the user's
* `experimentalNativeVideo` flag.
* is Rust's decision.
*/
export function isNativeSurfaceBridgeAvailable(): boolean {
try {
-1
View File
@@ -37,7 +37,6 @@
} from "$lib/services/playbackReporting";
import { reportSkippedEpisode, shouldSuppressStopReport } from "$lib/services/skipReporting";
import { cleanup as cleanupNextEpisode } from "$lib/services/nextEpisodeService";
import * as html5Adapter from "$lib/player/html5Adapter";
import { createLogger } from "$lib/utils/logger";
const log = createLogger("PlayerPage");
+1 -60
View File
@@ -1,6 +1,6 @@
<!-- TRACES: UR-023, UR-025, UR-027, UR-029, UR-057, UR-076 | DR-030, DR-048, DR-077, DR-086, DR-132, DR-209 -->
<script lang="ts">
import { onDestroy, onMount } from "svelte";
import { onMount } from "svelte";
import { commands } from "$lib/api/bindings";
import { profiles } from "$lib/stores/profiles";
import ProfileSecuritySettings from "$lib/components/settings/ProfileSecuritySettings.svelte";
@@ -31,8 +31,6 @@
import { library, viewMode } from "$lib/stores/library";
import { auth } from "$lib/stores/auth";
import { isNetworkDetectionSupported, reportNetworkState } from "$lib/services/networkType";
import { experimentalNativeVideo } from "$lib/stores/nativeVideo";
import { getPlaybackCapabilities } from "$lib/services/playbackCapabilities";
import { createLogger } from "$lib/utils/logger";
import { openUrl, revealItemInDir } from "@tauri-apps/plugin-opener";
import {
@@ -131,26 +129,6 @@
{ label: "Unlimited", bytes: 0 },
];
// Native-video switch. Shown only where Rust reports a webview fallback —
// beside mpv native video on Linux. Never on Android: ExoPlayer is the only
// video renderer there, and the webview would play original-file downloads
// silent (DR-293). Rust owns the decision; the toggle is hidden where it
// cannot apply.
let offerNativeVideoSwitch = $state(false);
let nativeVideoEnabled = $state(false);
const unsubscribeNativeVideo = experimentalNativeVideo.subscribe((v) => {
nativeVideoEnabled = v;
});
function handleNativeVideoToggle() {
experimentalNativeVideo.set(!nativeVideoEnabled);
}
// Not returned from onMount: that callback is async, so its return value is a
// Promise and Svelte would never invoke it as a teardown.
onDestroy(unsubscribeNativeVideo);
// Mirrors the stored setting; the picker itself always appears for a
// PIN-protected profile regardless of this. (DR-274)
let askOnStart = $state(false);
@@ -158,7 +136,6 @@
onMount(async () => {
await loadSettings();
askOnStart = await commands.profilesGetAskOnStart();
offerNativeVideoSwitch = (await getPlaybackCapabilities()).webviewVideoFallback;
// Which update story this platform gets. Android cannot install its own
// APK, so it is offered the releases page instead of an install button.
@@ -956,42 +933,6 @@
<!-- Native video. Only rendered where Rust reports a webview fallback
(Linux beside mpv native video); never on Android (DR-293). -->
{#if offerNativeVideoSwitch}
<div class="bg-[var(--color-surface)] rounded-lg p-6 mt-4">
<div class="flex items-center justify-between">
<div class="pr-4">
<h3 class="text-xl font-semibold text-white">
Native Video
<span
class="ml-2 align-middle text-xs font-medium uppercase tracking-wide text-amber-400 border border-amber-400/40 rounded px-1.5 py-0.5"
>
Experimental
</span>
</h3>
<p class="text-sm text-gray-400 mt-1">
Decode video with the device's hardware decoder instead of the built-in web
player, for better performance and battery life, and so picture-in-picture shows
the video rather than the app. On by default. Turn it off to fall back to the
built-in web player if a video misbehaves.
</p>
</div>
<button
onclick={handleNativeVideoToggle}
class="relative inline-flex h-8 w-14 shrink-0 items-center rounded-full transition-colors {nativeVideoEnabled
? 'bg-[var(--color-jellyfin)]'
: 'bg-gray-600'}"
aria-label="Toggle native video"
>
<span
class="inline-block h-6 w-6 transform rounded-full bg-white transition-transform {nativeVideoEnabled
? 'translate-x-7'
: 'translate-x-1'}"
></span>
</button>
</div>
<p class="text-xs text-gray-500 mt-3">Takes effect the next time you start a video.</p>
</div>
{/if}
</div>
<!-- Profiles. Deliberately minimal here: adding, removing and PIN changes