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:
+41
-90
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,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
|
||||
|
||||
Reference in New Issue
Block a user