Compare commits

...
7 Commits
Author SHA1 Message Date
dtourolle a67452bc80 chore(release): v0.14.0
🏗️ Build and Test JellyTau / Run Tests (push) Skipped
🏗️ Build and Test JellyTau / Android Compile Check (push) Skipped
🏗️ Build and Test JellyTau / Supply Chain (push) Successful in 7m22s
📱 Test APK / Build test APK (push) Successful in 23m39s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 3m36s
Traceability Validation / Check Requirement Traces (push) Successful in 10s
Build & Release / Run Tests (push) Successful in 10m16s
Build & Release / Build Linux (push) Successful in 14m14s
Build & Release / Build Windows (push) Successful in 11m32s
Build & Release / Build Android (push) Successful in 20m33s
Build & Release / Create Release (push) Successful in 44s
mpv plays all video on Linux and Windows, and the built-in web video
player is gone from every platform. Windows plays audio through mpv.
mpv commands can no longer be injected through a title, and mpv now
verifies TLS; the page loses its network access. Subtitles and audio
tracks work in desktop video.
2026-09-25 04:19:06 -04:00
dtourolle 1677f5f299 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.
2026-09-24 23:11:17 -04:00
dtourolle bb3ab1edd7 feat(windows): mpv draws Windows video into the app window
DR-237's video half. mpv now renders Windows video the way
tauri-plugin-libmpv does on Windows: MpvBackend is handed the main
window's HWND and sets it as `wid` before mpv initialises, so mpv draws
as a child of the app window beneath the transparent WebView2, with
vo=gpu-next,gpu. osc, default bindings, VO keyboard and cursor handling
are off, so the Svelte controls drawn over the picture are the only ones.

- video_output() decides per platform (UT-274): Window(hwnd) on Windows,
  RenderApi on Linux, Off without native video. Windows with no handle
  draws nothing rather than letting mpv open a top-level window.
- native_video::enabled() is true on Windows as well, so the frontend
  takes the native path there: NativePlayerAdapter, and the page clears
  its background while video is on screen.
- enableNativeVideoCompositing() no longer logs a missing Android bridge
  as an error on the desktop, where there is no bridge to have.

Verified: every option, wid included, is accepted by the real libmpv on
Linux and by the shipped Windows DLL under wine; the Windows unit suite
passes under wine (949). Not yet seen on real Windows hardware.
2026-09-24 22:42:42 -04:00
dtourolle 4daf172834 feat(windows): mpv plays audio on Windows, with libmpv shipped in the installer
libmpv on Windows (DR-237):
- The builder image carries zhongfly's LGPL libmpv-2.dll, pinned by
  asset name and sha256, plus an MSVC mpv.lib generated from the DLL's
  own mpv_* exports (the archive ships only a MinGW .dll.a). LGPL, not
  the GPL builds: no x264/x265, mpv -Dgpl=false; FFmpeg is version3, so
  LGPL-3.0. THIRD_PARTY_NOTICES.md records it.
- build-windows-cross.sh stages both files into src-tauri/windows-libs/;
  build.rs links mpv.lib from there and tauri.windows.conf.json bundles
  the DLL beside jellytau.exe from there, with the licence texts under
  licenses/. One directory, so the DLL shipped is the one linked.
- Workflows move to builder image 2026.09.1.

Windows audio:
- MpvBackend replaces WebviewAudioBackend on Windows (ao=wasapi), so
  volume, EQ, normalization and gapless work there as on Linux. Local
  files are passed to mpv as native paths, not file:// URLs.

Fixes found on the way:
- player_play_item decided "does the backend render video" with
  cfg!(not(linux)), true on Windows, while get_player_status sent
  Windows video to the <video> element. With mpv as the backend that
  would decode every film's soundtrack twice. All three callers now
  ask video_renders_natively() (UT-273).
- confine_queued_path rebuilt paths with PathBuf::push, so on Windows
  a queued `downloads/x` was stored as `downloads\x`, no longer the
  spelling the app built. It now keeps the caller's separator (UT-205,
  which only ever ran on Linux, failed under Windows).

Verified: jellytau.exe imports libmpv-2.dll; the full unit suite
cross-compiled for Windows passes under wine against the shipped DLL
(943/943, including the mpv injection and TLS tests); the NSIS
installer contains the DLL and licence texts. Not yet run on real
Windows hardware.
2026-09-24 22:25:31 -04:00
dtourolle 9d9d81bef3 fix(player): mpv draws all Linux video, and no longer runs text from a URL
Security (DR-298, DR-299):
- The pinned libmpv crate's Mpv::command joins its arguments and calls
  mpv_command_string, which parses `;` as a command separator. Stream
  URLs carry server-controlled ids and TranscodingUrl, and a download's
  file:// path carries its track title, so a crafted title could run any
  mpv command, `run` included. Every call now goes through
  mpv_command::command, an argv built for mpv_command. The same parse
  broke loadfile for every downloaded title containing a space.
- mpv's tls-verify defaults to no, and its URLs carry the ApiKey. Every
  handle is now hardened with tls-verify=yes and ytdl=no before its
  first loadfile, and fails construction if it cannot be.

Linux video (DR-235 phase 1):
- native_video::enabled() is unconditional on Linux; the
  JELLYTAU_NATIVE_VIDEO opt-in is retired. No platform reports a
  webview video fallback, so the Settings switch no longer appears.
  Windows keeps the webview element until mpv reaches it (DR-237).
- The Linux device profile is unchanged (still h264, DR-234), so this
  ships the configuration that was tested under the env var.
2026-09-24 20:38:32 -04:00
dtourolle fd1277746d chore(release): v0.13.3
🏗️ Build and Test JellyTau / Run Tests (push) Skipped
🏗️ Build and Test JellyTau / Android Compile Check (push) Skipped
🏗️ Build and Test JellyTau / Supply Chain (push) Successful in 3m57s
📱 Test APK / Build test APK (push) Successful in 20m27s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 3m45s
Traceability Validation / Check Requirement Traces (push) Successful in 11s
Build & Release / Run Tests (push) Successful in 10m38s
Build & Release / Build Linux (push) Successful in 14m21s
Build & Release / Build Windows (push) Successful in 10m47s
Build & Release / Build Android (push) Successful in 20m26s
Build & Release / Create Release (push) Successful in 43s
Resuming the app after time in the background no longer replaces the page
on screen with "Failed to load item".
2026-09-24 07:40:35 -04:00
dtourolle d7136aef48 fix(library): resuming the app no longer blanks the page on screen
Coming back to the app after a few minutes in the background replaced the
Frasier series page with "Failed to load item". Android cuts a backgrounded
app's network, the app declares the server offline, and on resume the
reconnect and offline-filter reloads refresh the page. Every backend call in
that refresh answered from the cache, yet something in it threw, and:

- any throw replaced the whole page with an error, although it was a
  refresh of content already on screen;
- no later successful reload of the same item cleared that error, so it
  stayed until the viewer navigated away;
- the catch logged nothing and turned every non-Error value (backend
  errors arrive as plain strings) into the generic text, so neither logcat
  nor the screen said what failed.

A failed refresh now keeps the page and logs the value actually thrown;
every successful load clears the error; failing to open an item still shows
one, with the backend's own message. The decision lives in
detailLoadError.ts so it is unit-tested.

The throw itself is not yet identified - the next occurrence names itself
in the log.

DR-297, UT-267.
2026-09-24 07:40:14 -04:00
95 changed files with 5806 additions and 7508 deletions
+4 -4
View File
@@ -28,7 +28,7 @@ jobs:
if: "!startsWith(github.event.head_commit.message, 'chore(release)')"
runs-on: linux/amd64
container:
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.08.1
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.09.1
steps:
- name: Checkout repository
@@ -109,7 +109,7 @@ jobs:
# at "warn" until its class is cleared and it can be promoted to "error".
# Lower this as you clear them. Never raise it to make a build pass.
- name: Lint
run: bun run lint -- --max-warnings=158
run: bun run lint -- --max-warnings=146
- name: Check TypeScript
run: |
@@ -187,7 +187,7 @@ jobs:
runs-on: linux/amd64
needs: test
container:
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.08.1
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.09.1
env:
ANDROID_HOME: /opt/android-sdk
ANDROID_SDK_ROOT: /opt/android-sdk
@@ -256,7 +256,7 @@ jobs:
name: Supply Chain
runs-on: linux/amd64
container:
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.08.1
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.09.1
steps:
- name: Checkout repository
+5 -5
View File
@@ -21,7 +21,7 @@ jobs:
name: Run Tests
runs-on: linux/amd64
container:
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.08.1
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.09.1
steps:
- name: Checkout repository
uses: actions/checkout@v4
@@ -94,7 +94,7 @@ jobs:
runs-on: linux/amd64
needs: test
container:
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.08.1
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.09.1
steps:
- name: Checkout repository
uses: actions/checkout@v4
@@ -235,7 +235,7 @@ jobs:
# baked into the builder image. No toolchain installs here — the image has
# cargo-xwin, clang/clang-cl, lld, llvm, nsis and the msvc target.
container:
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.08.1
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.09.1
steps:
- name: Checkout repository
uses: actions/checkout@v4
@@ -308,7 +308,7 @@ jobs:
runs-on: linux/amd64
needs: test
container:
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.08.1
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.09.1
env:
ANDROID_HOME: /opt/android-sdk
ANDROID_SDK_ROOT: /opt/android-sdk
@@ -411,7 +411,7 @@ jobs:
needs: [build-linux, build-windows, build-android]
if: startsWith(github.ref, 'refs/tags/v')
container:
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.08.1
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.09.1
steps:
- name: Checkout repository
uses: actions/checkout@v4
+1 -1
View File
@@ -85,7 +85,7 @@ jobs:
# The short-SHA output below avoids depending on it regardless.
shell: bash
container:
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.08.1
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.09.1
env:
ANDROID_HOME: /opt/android-sdk
ANDROID_SDK_ROOT: /opt/android-sdk
+1 -1
View File
@@ -21,7 +21,7 @@ jobs:
name: Build & publish docs to gitea-pages
runs-on: linux/amd64
container:
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.08.1
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.09.1
steps:
- name: Checkout code
+1 -1
View File
@@ -17,7 +17,7 @@ jobs:
runs-on: linux/amd64
name: Check Requirement Traces
container:
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.08.1
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.09.1
steps:
- name: Checkout repository
+59
View File
@@ -9,6 +9,65 @@ generated trace matrix lives in [docs/traceability.md](docs/traceability.md).
For how long each fixed defect had been shipping before it was found, see
[docs/defect-windows.md](docs/defect-windows.md).
## v0.14.0
Video on the desktop is played by mpv, on Linux and now on Windows, and the
built-in web player is gone from every platform. Windows gets real audio
playback too.
### 🔒 Security
- **A track title can no longer run a command on Linux.** mpv was handed stream
URLs and downloaded-file paths as a single command string, in which `;` starts
a new command — so a file whose title tag carried one could run a program
when it played. Every mpv command now passes its arguments separately.
(DR-298)
- **mpv checks the server's certificate.** It did not by default, and the
addresses it opens carry your login token, so anyone able to intercept the
connection could read it. It also no longer hands a failed address to
youtube-dl. (DR-299)
- **The app's web page can no longer reach the network.** It needed that only
for the web video player; everything now goes through the backend, so the
permission was just a way out for anything injected into the page.
### ✨ Features
- **mpv plays all video on Linux and Windows.** On Windows it draws into the app
window under the controls; on Linux it no longer needs the experimental
switch. Video still arrives transcoded to h264 for now — asking the server for
the original file is the next step. (DR-235, DR-237)
- **Windows plays audio through mpv**, so volume, the equalizer, volume
normalization and gapless playback now work there. The installer ships
mpv's LGPL-licensed library and its licence text. (DR-237)
### 🐛 Fixes
- **Subtitles and audio-track switching work in desktop video.** mpv now loads
the subtitle list and switches subtitles and audio tracks itself; before this
they did nothing once mpv drew the picture. (DR-023, DR-024)
- **Downloaded songs with a space in their title play offline on Linux.**
(DR-298)
- **A queued download on Windows keeps the path it was saved under**, rather
than having its separators rewritten. (DR-211)
### 🧹 Removed
- **The built-in web video player and the "Native Video" setting.** There is
nothing left to switch between: every platform plays video natively.
(DR-235)
## v0.13.3
### 🐛 Fixes
- **Coming back to the app no longer replaces the page with "Failed to load
item".** After a few minutes in the background, resuming the app on a series
page could swap the whole page for that error, and it stayed until you
navigated away. The page refreshes itself on resume; a refresh that fails now
leaves what you were looking at on screen, and the error clears as soon as a
load succeeds. When a page genuinely cannot open, it now says why instead of
the generic message. (DR-297)
## v0.13.2
A series opens with its episodes in under a second. v0.13.1 fixed the season
+17 -12
View File
@@ -2,8 +2,9 @@
A cross-platform Jellyfin client. Business logic lives in a Rust backend
(`src-tauri/`); a SvelteKit + TypeScript frontend (`src/`) handles presentation
and talks to it over Tauri v2 IPC. Targets **Linux** (libmpv, WebKitGTK HTML5
`<video>` for transcoded playback) and **Android** (ExoPlayer).
and talks to it over Tauri v2 IPC. Targets **Linux** and **Windows** (libmpv for audio and video) and **Android**
(ExoPlayer). There is no webview `<video>`: every video renderer is native,
drawing behind the transparent webview.
Package manager is **bun**.
@@ -151,9 +152,13 @@ output as a reviewed draft, not a final changelog.
- **Svelte frontend** (`src/`) — presentation only. Stores in
`src/lib/stores/`, API wrappers in `src/lib/api/`, components in
`src/lib/components/`.
- **Playback layers** — Linux uses libmpv for direct playback and a WebKitGTK
HTML5 `<video>` element for HLS-transcoded (h264) streams; Android uses
ExoPlayer with a foreground media service + `MediaSessionCompat`.
- **Playback layers** — Linux and Windows use libmpv for audio and video (mpv
draws video beneath the transparent webview: the render API into a GTK surface
on Linux, `wid` into the app window on Windows); Android uses ExoPlayer with a
foreground media service + `MediaSessionCompat`. The webview `<video>`/hls.js
path was deleted (DR-235). Every mpv command goes through
`player/mpv_command.rs` (argv, never a command string) and every handle is
hardened (`tls-verify=yes`, `ytdl=no`) — see DR-298/DR-299.
- **tauri-specta** generates TypeScript bindings and typed events from the Rust
command/event definitions (registered via the Builder in `src-tauri/src/lib.rs`).
@@ -167,7 +172,7 @@ canonical, maintained source; this file only summarizes. See
| [02-svelte-frontend.md](docs/architecture/02-svelte-frontend.md) | Stores, repository architecture, MiniPlayer, autoplay, nav guard |
| [03-data-flow.md](docs/architecture/03-data-flow.md) | Cache-first query flow, playback initiation, mode transfer |
| [04-type-sync-and-threading.md](docs/architecture/04-type-sync-and-threading.md) | **Rust↔TS type sync, the IPC camelCase convention + param table, locking** |
| [05-platform-backends.md](docs/architecture/05-platform-backends.md) | MpvBackend (Linux), ExoPlayerBackend (Android), MediaSession, HTML5 adapter |
| [05-platform-backends.md](docs/architecture/05-platform-backends.md) | MpvBackend (Linux/Windows) incl. desktop native video, ExoPlayerBackend (Android), MediaSession |
| [06-downloads-and-offline.md](docs/architecture/06-downloads-and-offline.md) | Download manager/worker, smart cache, offline commands |
| [07-connectivity.md](docs/architecture/07-connectivity.md) | HTTP retry, ConnectivityMonitor, reachability model |
| [08-database-design.md](docs/architecture/08-database-design.md) | Tables, relationships, key queries |
@@ -185,10 +190,9 @@ and [docs/build/build-release.md](docs/build/build-release.md).
player reports and never determine it.
- **Unified player boundary.** UI controls playback *only* through the frontend
facade `src/lib/player/index.ts` (`playerController`) — never by calling
`commands.player*` directly. Webview HTML5 `<video>` reports its state back
into Rust via `src/lib/player/html5Adapter.ts` and the `player_report_*`
commands, so the controller stays the single source of truth in both native
and HTML5 modes.
`commands.player*` directly. Video has one adapter, `NativePlayerAdapter`,
which only forwards intents; the backend performs every seek, track switch
and quality change itself.
- **Reachability from real traffic.** Server online/offline is derived from the
outcome of actual repository requests (reported to `ConnectivityMonitor`), not
a side-channel poller. The `/System/Info/Public` probe runs *only while
@@ -313,8 +317,9 @@ tagged responses keep the Rust field names as-is (e.g. `new_url`, not `newUrl`).
player or hold a lock — it deadlocks. On Android, bind a locked
`AutoplayDecision` to a `let` *before* matching; a tokio `MutexGuard` held in
the `match` scrutinee deadlocks the `AdvanceToNext` arm.
- **VideoPlayer native mode**: no lifecycle calls after an `await` in `onMount`
(it flips to HTML5 mode and breaks Android seek).
- **VideoPlayer `onMount`**: no lifecycle calls after an `await` — they throw
`lifecycle_outside_component` (this once silently switched seeks to a renderer
that was not playing).
- **Transcoded resume/seek**: `get_video_stream_url` must return the HLS
`master.m3u8`, not `stream.mp4`, or transcoded playback never starts.
- **Downloads** cap at 3 concurrent; the backend pump auto-starts pending rows.
+2 -2
View File
@@ -136,8 +136,8 @@ WORKDIR /app
CMD ["bash", "-c", "OUTPUT_DIR=/app/dist scripts/build-desktop-linux.sh"]
# Windows cross-compile environment (MSVC target via cargo-xwin). Video works via
# WebView2 and audio via the webview <audio> backend; NSIS installer is produced
# from Linux by cargo-xwin. Default bundles NSIS; override WIN_BUNDLES=none for
# WebView2 and audio via mpv, whose DLL the builder image carries
# (/opt/libmpv-win64); NSIS installer is produced from Linux by cargo-xwin. Default bundles NSIS; override WIN_BUNDLES=none for
# exe-only. Build runs at container-run time like above.
FROM ${BUILDER_IMAGE} AS windows-cross
WORKDIR /app
+36
View File
@@ -186,6 +186,42 @@ RUN . $HOME/.cargo/env && \
cargo cyclonedx --version && \
mdbook --version
# ---------------------------------------------------------------------------
# libmpv for Windows (DR-237) — the Windows build links it and ships the DLL.
#
# zhongfly/mpv-winbuild's **LGPL** dev build: libmpv-2.dll with FFmpeg compiled
# in, built `-Dgpl=false` with no x264/x265 and no --enable-gpl (its
# compile-lgpl-libmpv.patch). FFmpeg keeps --enable-version3, so the DLL is
# LGPL-3.0: the installer ships the licence texts and keeps the DLL a separate,
# replaceable file — THIRD_PARTY_NOTICES.md. Not the default (GPL) asset from
# the same release, and not shinchiro's, which is GPL-3.0 only.
#
# Pinned by asset name *and* sha256: GitHub prunes old releases from that repo,
# so a rebuild after pruning fails loudly here rather than quietly taking a
# newer build. To bump, change all three values.
#
# The archive ships a MinGW import library (libmpv.dll.a); the MSVC target needs
# `mpv.lib`, generated from the DLL's own mpv_* exports so it cannot name a
# symbol the DLL lacks. Output: $LIBMPV_WIN_DIR/{libmpv-2.dll,mpv.lib,include}.
ENV LIBMPV_WIN_RELEASE=2026-09-24-2a4eb8067c \
LIBMPV_WIN_ASSET=mpv-dev-lgpl-x86_64-20260924-git-2a4eb8067c.7z \
LIBMPV_WIN_SHA256=f89c6195e13bfce3e8c0cc5d7f5d889ebdcf12184a41c8288360f5acd1e7306c \
LIBMPV_WIN_DIR=/opt/libmpv-win64
RUN apt-get update && apt-get install -y --no-install-recommends libarchive-tools \
&& rm -rf /var/lib/apt/lists/* \
&& wget -q "https://github.com/zhongfly/mpv-winbuild/releases/download/${LIBMPV_WIN_RELEASE}/${LIBMPV_WIN_ASSET}" \
-O /tmp/mpv-dev.7z \
&& echo "${LIBMPV_WIN_SHA256} /tmp/mpv-dev.7z" | sha256sum -c - \
&& mkdir -p "$LIBMPV_WIN_DIR" \
&& bsdtar -xf /tmp/mpv-dev.7z -C "$LIBMPV_WIN_DIR" libmpv-2.dll include \
&& rm /tmp/mpv-dev.7z \
&& { echo "LIBRARY libmpv-2.dll"; echo "EXPORTS"; \
llvm-readobj --coff-exports "$LIBMPV_WIN_DIR/libmpv-2.dll" \
| awk '/Name: mpv_/ { print " " $2 }'; } > "$LIBMPV_WIN_DIR/mpv.def" \
&& llvm-lib /def:"$LIBMPV_WIN_DIR/mpv.def" /out:"$LIBMPV_WIN_DIR/mpv.lib" /machine:x64 \
&& test "$(grep -c '^ mpv_' "$LIBMPV_WIN_DIR/mpv.def")" -ge 50 \
&& ls -la "$LIBMPV_WIN_DIR"
WORKDIR /app
ENTRYPOINT ["/bin/bash"]
+21
View File
@@ -19,3 +19,24 @@ to the terms of the GPL-3.0**. The complete corresponding source for JellyTau is
available in this repository; JellyTau's own code remains available under MIT.
Desktop builds do not include this component.
## Windows: libmpv (LGPL-3.0)
The Windows installer bundles **`libmpv-2.dll`**, the mpv media player library
with FFmpeg compiled in, which plays audio on Windows. It is the LGPL build from
<https://github.com/zhongfly/mpv-winbuild> (mpv built `-Dgpl=false`, FFmpeg
without `--enable-gpl`), pinned by version and checksum in `Dockerfile.builder`.
- Licence: **GNU Lesser General Public License v3.0** (FFmpeg is configured
`--enable-version3`; mpv itself is LGPL-2.1-or-later in this configuration).
The installer carries the texts as `licenses/LGPL-3.0.txt` and
`licenses/GPL-3.0.txt`, which the LGPL-3.0 incorporates.
- Source: mpv <https://github.com/mpv-player/mpv> and FFmpeg
<https://ffmpeg.org>, built by the scripts at
<https://github.com/zhongfly/mpv-winbuild>; the exact mpv commit is in the
asset name in `Dockerfile.builder`.
JellyTau links it dynamically, and the DLL sits beside `jellytau.exe` as a
separate file, so it can be replaced with any compatible libmpv build. JellyTau's
own code remains under MIT. The Linux builds use the system's libmpv and do not
bundle it.
-3
View File
@@ -11,7 +11,6 @@
"@tauri-apps/plugin-os": "^2.3.2",
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-updater": "2.10.1",
"hls.js": "^1.6.15",
"svelte-dnd-action": "^0.9.69",
},
"devDependencies": {
@@ -485,8 +484,6 @@
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
"hls.js": ["hls.js@1.6.15", "", {}, "sha512-E3a5VwgXimGHwpRGV+WxRTKeSp2DW5DI5MWv34ulL3t5UNmyJWCQ1KmLEHbYzcfThfXG8amBL+fCYPneGHC4VA=="],
"html-encoding-sniffer": ["html-encoding-sniffer@6.0.0", "", { "dependencies": { "@exodus/bytes": "^1.6.0" } }, "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg=="],
"html-escaper": ["html-escaper@2.0.2", "", {}, "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg=="],
+14 -40
View File
@@ -791,36 +791,15 @@ by exactly the inset.
Unlike `addJavascriptInterface`, the inset push only writes CSS properties, so it
can safely be re-sent on resume.
## Stream Transport
## Stream Selection in the Player
**Location**: `src/lib/player/streamTransport.ts`
**TRACES**: UR-079 | DR-225 | UT-214
`videoLoaderFor(selection, capabilities)` picks the loader for the webview
`<video>` element — `hlsjs`, `nativeHls`, or `direct` — from the backend's tagged
`selection.transport`. `elementSrcFor` is its template companion: the element's
`src` is emptied only when hls.js is driving it.
The split is the point. **The transport is the stream's property and comes from
Rust; whether a given loader exists is the browser's, and is the only thing
decided here.**
> This replaced `currentStreamUrl.includes(".m3u8")`, which appeared twice in
> `VideoPlayer.svelte` — once in the HLS `$effect` and once inline in the
> template's `src`. Rust builds that URL and knows what it is; re-deriving it
> here by substring match was a domain fact reconstructed in the presentation
> layer, and it fails silently in both directions. The two tests that pin it are
> the ones that failed against the old implementation: a `progressive` stream
> whose URL contains `.m3u8` must **not** get an HLS loader, and an `hls` stream
> whose URL contains no `.m3u8` must.
>
> Logic lives in a plain `.ts` module rather than in the component for the usual
> reason — it is testable there. Same pattern as `episodeStrip.ts`.
**TRACES**: UR-079 | DR-225, DR-227
`VideoPlayer` holds a `currentSelection`, not a URL string; `currentStreamUrl` is
derived from it. A reload replaces the selection **wholesale** (the adapter's
bridge takes a `StreamSelection`, not a URL), so transport and URL can never
drift apart. The background-audio handoff states the transport it is moving to —
derived from it. A reload replaces the selection **wholesale**, so transport and
URL can never drift apart — the transport is the stream's property and comes from
Rust, never from a substring match on the URL (which is what `.m3u8` sniffing in
the component once did). The background-audio handoff states the transport it is moving to —
progressive mp3 out, HLS back — via `selectionAt()`, rather than leaving it to be
inferred.
@@ -833,21 +812,16 @@ ceiling above the source bitrate *is* the source.
## Native Video Store
**Location**: `src/lib/stores/nativeVideo.ts`
**TRACES**: UR-003, UR-004 | DR-188
**TRACES**: UR-003, UR-004 | DR-188, DR-235
Two separate concerns live here, deliberately:
- `experimentalNativeVideo` — the user-facing opt-in flag, **defaulting to on**.
Rust already decides *which backend this platform has* (`useHtml5Element` from
`player_play_item`); this flag only *suppresses* that decision. It never turns
native on where Rust says HTML5. An explicit stored choice wins in both
directions, so someone who opted out is not re-enabled by a default flip —
hence the `null` check rather than a bare `=== "true"`.
- `nativeVideoActive` — whether a native surface is on screen *right now*.
`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. It is deliberately
**not** derived from the flag: the backgrounds must come back the moment the
player unmounts.
`app.css` keys off to clear the app's opaque backgrounds so the picture behind
the webview shows. The backgrounds must come back the moment the player unmounts.
It used to hold `experimentalNativeVideo`, a stored switch that could force video
back to the webview `<video>` element. That element is gone (DR-235), and the
switch with it.
See [05-platform-backends.md](05-platform-backends.md#native-video-compositing-android)
for what is behind the WebView.
+2 -2
View File
@@ -213,8 +213,8 @@ sequenceDiagram
Online-->>Page: StreamSelection
end
Page->>VP: selection
VP->>VP: videoLoaderFor(selection, caps)
Note over VP: hls.js / native HLS / direct —<br/>from the tag, never from the URL
VP->>VP: player_play_item(selection.url, transport)
Note over VP: the native player opens it —<br/>transport from the tag, never from the URL
```
The selection travels with the stream from then on. A reload — a quality change,
+76 -67
View File
@@ -90,56 +90,75 @@ flowchart LR
**Important**: The command is `player_get_queue` (returns `QueueStatus` with `hasNext`/`hasPrevious`). There is no `player_get_queue_status` command.
## HTML5 Video Adapter (webview-rendered video)
## Video is always native (no webview `<video>`)
**Location**: `src/lib/player/html5Adapter.ts`, `src/lib/player/index.ts`, report commands in
`src-tauri/src/commands/player/timers.rs`
**TRACES**: UR-080 | DR-231, DR-235, DR-237
Video on desktop (Linux WebKitGTK) is rendered by an HTML5 `<video>`/HLS element **inside the
webview**. Android no longer uses this path for video — see *The webview is not a video renderer on
Android* below. libmpv is initialized audio-only (`vo=null`,
`video=false`), so the native backend cannot render or observe this element. The `<video>` is therefore
the real player, living outside Rust's reach.
Every video renderer is a native player drawing **behind** the transparent
webview, with the Svelte controls composited over it: ExoPlayer on Android, mpv on
Linux and Windows. There is no webview `<video>` element, no hls.js, and no
HTML5 adapter; the frontend has one video adapter, `NativePlayerAdapter`, and the
backend performs every seek, track switch and quality change itself.
To keep the `PlayerController` the single source of truth (matching the audio path), the HTML5 element
is treated as **a dumb output device that reports back into Rust**, rather than an independent state
authority:
Why the webview path was deleted rather than kept as a fallback:
```mermaid
flowchart LR
subgraph Webview["Webview"]
Video["HTML5 <video> / HLS.js"]
Adapter["html5Adapter.ts<br/>(reports DOM events)"]
end
subgraph Backend["Rust"]
Cmds["player_report_state<br/>player_report_position<br/>player_report_media_loaded"]
Controller["PlayerController"]
Emitter["TauriEventEmitter"]
end
subgraph Frontend["Frontend"]
Events["playerEvents.ts"]
Store["player store"]
end
- **The transcode was a decoder constraint.** The `<video>` element decodes
little beyond h264, so the desktop profile could only claim h264 and the server
re-encoded almost everything (7% direct play on a real library, against 85% for
the same library through ExoPlayer). The machine was never the limit — mpv was
already running for audio. (The Linux/Windows profile still claims h264 until
DR-234 widens it; see [desktop-native-video.md](../specs/desktop-native-video.md).)
- **Each renderer is another place for every bug.** Three video renderers meant
every seek strategy, track switch and lifecycle fix had three places to be got
right; the webview path was also where the renderer choice itself went wrong
(silent Linux video, a Windows soundtrack decoded twice — DR-237).
- **A fallback that decodes less is not a fallback.** Android showed it first
(DR-293): an original-file download plays silent in the webview. With no
webview path there is no silent downgrade to fall into; a failed mpv init
emits `backend-init-failed` instead.
Video -->|DOM events| Adapter --> Cmds --> Controller --> Emitter --> Events --> Store
```
What survives of the webview reporting pipeline is audio-only:
`WebviewAudioBackend` plays through a hidden `<audio>` element and reports back
through the `player_report_*` commands (`rustReportHost.ts`), for a desktop with
no mpv. No shipped platform uses it.
**Key points:**
- The adapter re-emits the *same* `PlayerStatusEvent`s (`StateChanged`, `PositionUpdate`, `MediaLoaded`)
the native backends emit, so `playerEvents.ts` needs **no** HTML5-specific branch — HTML5 is just
another event source feeding the existing pipeline.
- Position reports are throttled (~250ms) to match the MPV cadence and avoid flooding IPC from the
60fps RAF loop.
- **Boundary rule**: UI components never touch the report commands or `videoElement` state directly.
Playback *control* goes through the unified facade `src/lib/player/index.ts` (`playerController`);
HTML5 *state reporting* goes through `html5Adapter.ts`. This restores the documented invariant
("frontend only displays state and invokes commands") for the video path.
## Native video on the desktop (mpv)
**TRACES**: UR-080 | DR-231 … DR-237, DR-298, DR-299
The mpv half is shared; only the surface differs per platform
(`mpv_backend::video_output`):
| Platform | Output | Where the picture goes |
|---|---|---|
| Linux | `vo=libmpv` (render API) | An FBO drawn in the main window's own `GtkBox` `draw` handler, which GTK paints *before* its children — so beneath the webview, with no widget reparenting (`video_surface.rs`; a `GtkOverlay` aborts the process on the first click, see that module) |
| Windows | `vo=gpu-next,gpu`, `wid=<HWND>` | mpv renders as a child of the app window, beneath the transparent WebView2 — the arrangement tauri-plugin-libmpv ships. `wid` only takes effect before initialisation, so `MpvBackend::new` takes the handle; with no handle it draws nothing rather than open a window of its own. mpv's controller, bindings and cursor handling are off |
In both, the page clears its opaque backgrounds while a video is on screen
(`data-native-video`, the same CSS Android uses).
**Tracks** (`mpv_tracks.rs`): subtitles are the WebVTT list the play request
carries, queued on `sub-files` before the load and selected **by position in
that list** — the same meaning ExoPlayer gives `player_set_subtitle_track`.
Selection starts off (the menu opens on "Off") and `sid`/`aid` are reset before
every load, so a choice made for one item cannot leak into the next. Audio tracks
are selected by position in the file; a transcode carries one track and is
re-opened instead.
**Two rules for every libmpv handle** (`mpv_command.rs`):
- Commands go through `mpv_command::command`, an argv built for `mpv_command`,
never the pinned crate's `Mpv::command`, which joins its arguments into a
string that mpv parses — `;` chains a second command, so a track title in a
downloaded file's path could run `run …` (DR-298).
- Every handle is hardened before its first load: `tls-verify=yes` (mpv's
default is *no*, and its URLs carry the `ApiKey`) and `ytdl=no` (DR-299).
## MpvBackend (Linux)
**Location**: `src-tauri/src/player/mpv/`
The MPV backend uses libmpv for audio playback on Linux. Since MPV handles are not `Send`, all operations occur on a dedicated thread.
The MPV backend uses libmpv for audio **and video** playback on Linux and Windows (video: see *Native video on the desktop* above). Since MPV handles are not `Send`, all operations occur on a dedicated thread.
```mermaid
flowchart TB
@@ -319,18 +338,12 @@ remux). It costs minutes of CPU and twice the disk per film, needs a pipeline
state of its own, and does nothing for streaming. Decoding at playback fixes both
paths with no extra step.
### The webview is not a video renderer on Android
### Why the webview could not stay a fallback on Android
ExoPlayer is Android's only video renderer. The HTML5 path used to be reachable
through the `experimentalNativeVideo` setting (a *suppressor* of Rust's native
choice), but the webview decodes none of the codecs above — so with the original
file now downloaded as-is (DR-293), turning native video off would play every such
download as a silent film. Rust reports `webview_video_fallback` in
`PlaybackCapabilities`: **false on Android**, true only beside mpv native video on
Linux, where the webview is still the tested fallback. The frontend offers the
switch and honours a stored "off" only when it is true (`nativeVideoWanted` in
`stores/nativeVideo.ts`), so a user who once switched it off on Android is not
stranded on the silent path.
The webview decodes none of the codecs above — so with the original file now
downloaded as-is (DR-293), the old `experimentalNativeVideo` switch would have
played every such download as a silent film. That is what first removed the
webview video path on Android; DR-235 then removed it everywhere.
### The equalizer, and where its vocabulary lives
@@ -359,8 +372,8 @@ chain of unbounded length.
Keeping a video's **audio** alive when the app is backgrounded or the screen
locks, while video decode stops. Two verified facts drive the whole design:
1. An Android WebView `<video>` **does not** keep playing audio once the app is
backgrounded — the system throttles the WebView and media pauses.
1. Nothing in the WebView keeps playing once the app is backgrounded — the
system throttles it.
2. Keeping audio alive in the background requires a **native foreground media
service**, which already exists for music (`JellyTauPlaybackService` +
`JellyTauPlayer` + `MediaSessionCompat`).
@@ -390,18 +403,14 @@ sequenceDiagram
Details that were each a shipped defect:
- **Position is absolute.** Transcoded HLS tracks time as
`videoElement.currentTime + seekOffset` (the element resets to 0 after each
transcode reload). `computeHandoffPosition` sums both terms; using the element
time alone rewinds by the offset.
- **Position is absolute** — the position on the item's timeline the player
reports, never an offset within a re-opened transcode (`computeHandoffPosition`).
- **A downloaded episode takes no base URL and an ordinary seek** (DR-180); a
stream takes the base and no seek; a handoff at 0:00 takes neither.
- **The return must restart the renderer that is actually on screen** (DR-196).
The two paths resume by different means — the webview `<video>` reloads off its
stream URL, watched by an `$effect`; ExoPlayer owns no element and nothing
watches the URL for it, so it needs an explicit re-issue. Doing only the URL
assignment restarted nothing on the native path and left a black screen with a
play button that did nothing.
- **The return must re-issue the load** (DR-196). The native player owns no
element and nothing watches the stream URL for it, so reassigning the URL — how
the deleted webview path came back — restarted nothing and left a black screen
with a play button that did nothing.
- **`wasPlaying` is captured on the way out** so play/pause survives the round
trip, and the handoff does not silently rewind (DR-203).
- **Mutually exclusive with PiP.** Toggle on → `setAutoEnterEnabled(false)`;
@@ -422,10 +431,10 @@ non-Android platform.
**TRACES**: UR-003, UR-004 | DR-150 … DR-152, DR-182 … DR-196
Android can render video on the **native ExoPlayer surface behind a transparent
Tauri WebView**, with the Svelte controls drawn over it. This is on by default;
the HTML5 `<video>` path remains the fallback and is not being removed. The
default has been flipped and reverted twice and each revert has a named cause —
the per-defect record is in `requirements.md` (DR-150 … DR-196).
Tauri WebView**, with the Svelte controls drawn over it. It is the only video
path (DR-235). Before that it was an opt-in whose default was flipped and
reverted twice, each revert with a named cause — the per-defect record is in
`requirements.md` (DR-150 … DR-196).
```mermaid
flowchart TB
@@ -455,7 +464,7 @@ Load-bearing details, each of which was a shipped defect:
- **The app shell stops painting over the surface** (DR-185). `app.css` clears
its opaque backgrounds off `[data-native-video]`; before that, a CSS rule
targeted an attribute nothing ever set, so the fix looked applied and was not.
- **The poster card can lift on a path with no `<video>` element** (DR-182) — the
- **The poster card lifts without a `<video>` element** (DR-182) — the
native reveal fires on a `playing` state or a position tick carrying a position
or duration, and on nothing else.
- **Letterbox bars are painted**, not left holding whatever was last in the
+6 -6
View File
@@ -68,8 +68,8 @@ style-src 'self' 'unsafe-inline';
font-src 'self' data:;
img-src 'self' data: blob: asset: http://asset.localhost http: https:;
media-src 'self' blob: asset: http://asset.localhost http://127.0.0.1:* http: https:;
connect-src 'self' ipc: http://ipc.localhost http: https:;
worker-src 'self' blob:;
connect-src 'self' ipc: http://ipc.localhost;
worker-src 'self';
object-src 'none'; frame-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'
```
@@ -79,13 +79,13 @@ object-src 'none'; frame-src 'none'; base-uri 'self'; form-action 'self'; frame-
| `script-src 'self'` | The genuinely restrictive half. Bundled JS only; Tauri's build-time nonce covers the one inline `<script>` in `index.html`. Adding `'unsafe-inline'` here would silently do nothing anyway — a nonce in a directive voids it. |
| `style-src 'self' 'unsafe-inline'` | Svelte compiles `style="…"` attributes into markup, including `app.html`'s `display: contents` wrapper, and CSP treats a style *attribute* as inline. Safe only while no `<style>` **element** survives into `index.html`: Tauri would nonce it, and the nonce would then void `'unsafe-inline'`. The production build extracts all CSS to files, so it currently has none. |
| `img-src` | Thumbnails come from two places: the asset protocol (`asset://localhost/…` on Linux/macOS, `http://asset.localhost/…` on Windows/Android — the same protocol, named differently by `convertFileSrc`) and, on a cache miss, straight from the Jellyfin server. `data:`/`blob:` cover inline and generated images. |
| `media-src` | `<video>`/`<audio>` sources: HLS transcodes and progressive streams from the server, the token-guarded loopback media server on `http://127.0.0.1:<random port>` (DR-137), and `blob:` for the MSE object URL hls.js attaches. |
| `connect-src` | `ipc:` / `http://ipc.localhost` is Tauri's `invoke` transport (custom scheme on Linux/macOS, `http` host on Windows/Android) — without it every command is blocked. `http:`/`https:` is hls.js fetching manifests and segments; ordinary API traffic goes through Rust and is not subject to CSP. |
| `worker-src 'self' blob:` | hls.js runs its demuxer in a worker built from a blob (`enableWorker: true`). Without `blob:` it falls back to main-thread demuxing — playback survives but costs more CPU. |
| `media-src` | `<audio>` sources for the webview audio backend (a desktop without mpv; no shipped platform): streams from the server and the token-guarded loopback media server on `http://127.0.0.1:<random port>` (DR-137). Video never plays in the webview (DR-235). |
| `connect-src` | `ipc:` / `http://ipc.localhost` is Tauri's `invoke` transport (custom scheme on Linux/macOS, `http` host on Windows/Android) — without it every command is blocked. Nothing else: all network traffic goes through Rust. It allowed any `http:`/`https:` host while hls.js fetched manifests and segments in the page; with hls.js gone (DR-235) that grant was only an exfiltration channel for injected script, so it went too. |
| `worker-src 'self'` | No blob workers since hls.js (whose demuxer ran in one) was removed (DR-235). |
| `object-src`, `frame-src` = `'none'` | No plugins, no iframes; both are classic injection sinks. |
| `base-uri 'self'`, `form-action 'self'`, `frame-ancestors 'none'` | Block `<base>` hijacking, form exfiltration and framing. `frame-ancestors` is only honoured when the policy is delivered as a header, which is platform-dependent; it is harmless where it is not. |
**`img-src`/`media-src`/`connect-src` are deliberately permissive.** The Jellyfin
**`img-src`/`media-src` are deliberately permissive.** The Jellyfin
origin is typed in by the user at run time and is routinely plain `http` on a
LAN, so it cannot be enumerated at build time. `http: https:` is a wide grant for
*data* — but it still bars `file:`, `filesystem:` and scripting schemes, and it
+1 -2
View File
@@ -99,7 +99,7 @@ Each major subsystem is documented in its own file in this directory:
| [02 - Svelte Frontend](02-svelte-frontend.md) | Store structure, music library navigation, playback reporting, repository architecture, playback mode system, database service abstraction, component hierarchy, MiniPlayer, sleep timer, auto-play, navigation guard, playlist management UI, library mosaic, series/episode navigation, downloaded browse, safe-area insets, native-video store, logging |
| [03 - Data Flow](03-data-flow.md) | Repository query flow (cache-first), locally-indexed search, playback initiation, playback mode transfer, queue navigation, volume control |
| [04 - Type Sync & Threading](04-type-sync-and-threading.md) | Rust/TypeScript type synchronization, Tauri v2 IPC parameter naming convention, thread safety patterns |
| [05 - Platform Backends](05-platform-backends.md) | Player events system, HTML5 video adapter, MpvBackend (Linux), ExoPlayerBackend (Android) incl. audio settings parity, **native video compositing**, MediaSession & remote volume, album art caching, backend initialization |
| [05 - Platform Backends](05-platform-backends.md) | Player events system, MpvBackend (Linux), ExoPlayerBackend (Android) incl. audio settings parity, **native video compositing**, MediaSession & remote volume, album art caching, backend initialization |
| [06 - Downloads & Offline](06-downloads-and-offline.md) | Download manager, download worker, smart caching engine, **one storage model (cache entries are downloads)**, offline catalog visibility, download/offline commands, player integration, frontend store, UI components |
| [07 - Connectivity](07-connectivity.md) | HTTP client with retry logic, connectivity monitor, network resilience architecture |
| [08 - Database Design](08-database-design.md) | Entity relationships, all table definitions (servers, users, libraries, items, user_data, downloads, media_streams, sync_queue, thumbnails, playlists), key queries, data flow diagrams, storage estimates |
@@ -176,7 +176,6 @@ src/lib/
│ └── sessions.ts # SessionsApi (remote session control)
├── player/ # Unified player boundary (frontend)
│ ├── index.ts # playerController facade — the only write-side entry point for playback
│ └── html5Adapter.ts # Reports webview <video> DOM events back into Rust (player_report_*)
├── services/
│ ├── playerEvents.ts # Tauri event listener for player events
│ └── playbackReporting.ts # Thin wrapper (~50 lines)
+35 -17
View File
@@ -7,17 +7,37 @@ job / SMTC lockscreen), but it runs and plays media.
## How playback works on Windows
- **Video** — renders through the webview HTML5 `<video>` element (hls.js) on
*every* platform; on Windows that is WebView2 (Chromium/Edge), which plays HLS +
h264 fine. No Windows-specific code.
- **Audio-only (music)** — the native audio backends are libmpv (Linux) and
ExoPlayer (Android); neither exists on Windows. Instead
`create_player_backend()` in [../src-tauri/src/lib.rs](../../src-tauri/src/lib.rs)
uses `WebviewAudioBackend` on non-Linux/non-Android targets: it hands the stream
URL to a webview `<audio>` element (see
[../src/lib/services/webviewAudio.ts](../../src/lib/services/webviewAudio.ts)),
which reports state back through the same `player_report_*` round-trip the video
path uses. Pure Rust + Tauri events.
- **Video** — **mpv**, drawing into the app's own window: `MpvBackend` is
handed the main window's HWND as `wid` before mpv initialises and renders with
`vo=gpu-next,gpu` as a child of it, beneath the transparent WebView2 whose page
clears its background while a video is on screen (`data-native-video`). mpv's
own controller, key bindings and cursor handling are off — the Svelte controls
drawn over it are the only ones. This is the arrangement tauri-plugin-libmpv
ships on Windows (same zhongfly LGPL DLL). **Not yet seen on real Windows
hardware** — if the picture ends up *over* the controls, the z-order of mpv's
child window is the first thing to check.
- **Audio** — **libmpv**, the same `MpvBackend` Linux uses, with `ao=wasapi`.
Volume, EQ, normalization and gapless all go through mpv's filter graph as on
Linux. `libmpv-2.dll` ships beside `jellytau.exe` in the installer.
### libmpv on Windows
- **Which build:** zhongfly/mpv-winbuild's *LGPL* dev asset, pinned by name and
sha256 in [Dockerfile.builder](../../Dockerfile.builder), which unpacks it to
`/opt/libmpv-win64`. Licence terms: [THIRD_PARTY_NOTICES.md](../../THIRD_PARTY_NOTICES.md).
The same release also carries a GPL asset (x264/x265) — do not switch to it
casually, it moves the installer onto GPL-3.0 terms.
- **Import library:** the archive only ships a MinGW `libmpv.dll.a`. The image
generates an MSVC `mpv.lib` from the DLL's own `mpv_*` exports
(`llvm-readobj --coff-exports` → `.def` → `llvm-lib /def:`), so it cannot name
a symbol the DLL lacks.
- **One directory:** `scripts/build-windows-cross.sh` copies both files into
`src-tauri/windows-libs/` (gitignored) before building. `build.rs` links
`mpv.lib` from there and `tauri.windows.conf.json` bundles the DLL from there,
so the DLL shipped is the one linked against. Outside the image, point
`LIBMPV_WIN_DIR` at a directory holding `libmpv-2.dll` and `mpv.lib`.
- **Bumping it:** change the three `LIBMPV_WIN_*` values together, rebuild and
push the image, bump the image tag the workflows pin.
## Cross-compiling from Linux (MSVC + cargo-xwin)
@@ -35,7 +55,7 @@ Tauri CLI bundle the **NSIS installer from a Linux host**.
The builder image ([../Dockerfile.builder](../../Dockerfile.builder)) bakes in the
whole toolchain: the `x86_64-pc-windows-msvc` rust target, `cargo-xwin`, `lld`,
`llvm`, and `nsis`.
`llvm`, `nsis`, and the pinned Windows libmpv.
```bash
bun run docker:build:windows # NSIS installer + .exe -> ./dist
@@ -71,8 +91,6 @@ Outputs:
## Outstanding for a first-class Windows release
1. Gapless/crossfade + SMTC (lockscreen) — currently no-ops in the webview audio
path.
2. Downloaded (`Local` source) file playback needs `convertFileSrc` on the
frontend; streaming works today.
3. Code signing + a Windows packaging CI job.
1. SMTC (lockscreen / media keys) — not wired on Windows.
2. Code signing — the installer is unsigned.
3. First run of mpv video on real Windows hardware (DR-237).
+2 -2
View File
@@ -80,10 +80,10 @@ introduced during this work passed conformance.
## 3. Desktop (Linux)
Run with native video on, since that is what is new:
mpv draws all Linux video (DR-235) — there is no switch:
```bash
JELLYTAU_NATIVE_VIDEO=1 bun run tauri dev
bun run tauri dev
```
- [ ] **Direct play** — a file the server does not transcode. Picture and sound.
+26 -14
View File
@@ -384,7 +384,7 @@ Internal architecture, components, and application logic.
| DR-188 | Native Android video is **ready to be the default except for the background-audio handoff**, and the flip therefore waits. The picture defects behind DR-172 are all found, fixed and device-verified — DR-185 (the app shell painted over the surface through a CSS rule targeting an attribute nothing set), DR-182 (nothing could lift the poster card on a path with no `<video>` element), DR-183 (the JS bridges raced the page load, so `setTransparent(true)` could never arrive), DR-184 (the SurfaceView was never detached), plus DR-186 and DR-187, the two UI defects only this path could reveal. On a device logcat now carries `WebView transparent = true` and `Marking media ready` with video on screen, which is the pair DR-172 went looking for and could not find, and skip, seek and rotation were exercised by hand. Turning the default on then surfaced a *different* unverified sub-path: the background-audio handoff could only *return* through the HTML5 element, so coming back from the lockscreen left playback dead, and the flip waited for that rather than shipping a verified sub-path over an unverified one as DR-161 had. **The default is now on.** The two defects holding it back are fixed and device-verified — DR-196 (the handoff return restarts the renderer that is actually on screen) and DR-194 (the letterbox bars are painted rather than retaining stale framebuffer content) — with the evidence this default has been held to since DR-161: an 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 an opt-out survives the flip (the stored value is null-checked rather than compared to "true", which would have silently re-enabled it for everyone who turned it off) | Android | UR-003, UR-004 | Done |
| DR-189 | The control bar comes down on a touchscreen. Its hide timer was armed from exactly one place — the player container's `onmousemove` — and a touchscreen never fires `mousemove`, so on Android the bar was never scheduled to hide and sat over the video for the whole film. It went unnoticed for as long as the native video surface was itself invisible (DR-172/DR-185): with nothing behind it to obscure, a permanent control bar reads as the UI rather than as a defect. Two changes, because there were two faults. `revealControls()` replaces `handleMouseMove` and is called on entry and on every touch interaction as well as on mouse movement, so touch arms the countdown. And the countdown became an `$effect` over the state rather than a one-shot timer armed by the input event: the first attempt armed a timer on entry, three seconds later playback had not started, `shouldHideControls` correctly declined, and nothing ever re-armed it — the timer has to follow the conditions that *permit* hiding, which arrive on their own schedule. The decision itself is `shouldHideControls` in `controlsVisibility.ts`, pure and separated from the clock and the DOM, because what was wrong here was the conditions and not the `setTimeout`: the bar stays up while paused (a user who paused by tapping the surface has no other way back), mid-seek (the position readout is the point of the bar then), and while any track/subtitle/quality menu is open (the menus are anchored to the bar, so hiding it would take the open menu with it) | UI | UR-003, UR-066 | Done |
| DR-191 | Forcing the WebView overlay to redraw from the Activity, because with the ExoPlayer **SurfaceView** beneath it the overlay's ordinary damage stopped reaching the screen: the page kept mutating — the clock text every second, the control bar's opacity going to 0 — while the display held whatever frame it last presented, over video that animated perfectly. Not a state defect; the live DOM showed the slider advancing 476 → 479 across three seconds behind a screen showing neither. Only **structural** changes got through, which is why the play overlay always appeared to work (an `{#if}` block, added and removed) while the progress bar never did, and why rotation lost the transport UI. A CSS animation cannot help, since opacity animates on the compositor without repainting the layer. **Superseded by DR-192**: this drove `postInvalidateOnAnimation` in a loop, which treats the symptom — the cause is the SurfaceView's separate layer, and removing that removes the need. Kept as the record of how the mechanism was identified | Android | UR-003, UR-004 | Superseded by DR-192 |
| DR-195 | Play/pause works on the native path, because the frontend stops claiming a webview element is playing when there is none. `html5_playing` is Rust's record of "a webview `<video>` is active and in this state", and `toggle_playback`, `play` and `pause` all route transport to that element whenever it is set. The player route mirrored element state into it **unconditionally** — from `handleReportStart` and, fatally, from `handleReportProgress`, which VideoPlayer calls on a 10-second interval — so on the native path the frontend re-declared every ten seconds that an element was playing when none existed, and every transport intent was emitted into the void. The pause button was dead from the on-screen tap, from the control bar, and from a direct `player_toggle` invocation, while seek and skip kept working because `player_seek_video` decides elsewhere; that asymmetry is the signature. It also explains the flashing, since the control bar and the JRay overlay both key off `isPlaying`, which was being contradicted on every interval tick. DR-193 clearing the flag at load was necessary but insufficient on its own — the interval put it straight back. The mirror now lives in `mirrorElementStateToRust` in VideoPlayer, gated on `useHtml5Element`, which is the only place that knows whether an element renders at all; the route cannot tell the two paths apart, which is precisely how it came to lie. Confirmed on device by ADB: surface tap and control bar each pause (position frozen across repeated samples, transport label flipped) and resume | Playback | UR-005, UR-003 | Done |
| DR-195 | Play/pause works on the native path, because the frontend stops claiming a webview element is playing when there is none. `html5_playing` is Rust's record of "a webview `<video>` is active and in this state", and `toggle_playback`, `play` and `pause` all route transport to that element whenever it is set. The player route mirrored element state into it **unconditionally** — from `handleReportStart` and, fatally, from `handleReportProgress`, which VideoPlayer calls on a 10-second interval — so on the native path the frontend re-declared every ten seconds that an element was playing when none existed, and every transport intent was emitted into the void. The pause button was dead from the on-screen tap, from the control bar, and from a direct `player_toggle` invocation, while seek and skip kept working because `player_seek_video` decides elsewhere; that asymmetry is the signature. It also explains the flashing, since the control bar and the JRay overlay both key off `isPlaying`, which was being contradicted on every interval tick. DR-193 clearing the flag at load was necessary but insufficient on its own — the interval put it straight back. The mirror now lives in `mirrorElementStateToRust` in VideoPlayer, gated on `useHtml5Element`, which is the only place that knows whether an element renders at all; the route cannot tell the two paths apart, which is precisely how it came to lie. Confirmed on device by ADB: surface tap and control bar each pause (position frozen across repeated samples, transport label flipped) and resume | Playback | UR-005, UR-003 | Superseded by DR-235 |
| DR-196 | Returning from background audio brings the picture back on the **native** path, because the return now restarts the renderer that is actually on screen. The two paths resume by different means: the webview `<video>` reloads off its stream URL, watched by an `$effect` that reinitialises HLS and lets `canplay` drive the seek — while ExoPlayer owns no element and nothing watches the URL on its behalf, so its playback is only ever started by an explicit `player_play_item` + adapter load, issued once from `onMount`. `exitBackgroundAudioHandoff` did only the URL assignment, for both paths, so on the native path it restarted nothing: `player_exit_background_audio` had already stopped the handoff's audio player, leaving the backend holding no item at all. The symptom is a black screen with a play overlay pinned at 0:00, a seek bar at zero, and a play button that does nothing — the process alive and the frontend still logging, since nothing crashed; the transition was simply dropped. The branch is decided by `planHandoffReturn` (pure, in `backgroundAudioHandoff.ts`), which also folds in `shouldResumeOnForeground` so a lockscreen pause during the handoff still wins over the snapshot taken on the way out. Subtitle configurations are reused from the ones resolved at mount, since ExoPlayer sideloads them as `MediaItem.SubtitleConfiguration`s and cannot accept one after `prepare()`. Verified on device: handoff to audio at 69:54, return restored video playing at 70:18 | Playback | UR-040, UR-003 | Done |
| DR-197 | Continue Watching and Next Up stop showing the same episode. Jellyfin's `/Shows/NextUp` defaults `EnableResumable=true`, which returns a partially-watched episode as its own series' next up — precisely the episode `/Items/Resume` already returns — so the Home "Next Episode" row and the TV landing's Next Up row duplicated Continue Watching card for card. `build_next_up_endpoint` sends `EnableResumable=false`, and because servers predating that parameter ignore it, `filterInProgressNextUpItems` also drops any next-up entry whose id appears in the resume list. It is the mirror of DR-089 and lives beside it: same presentation-layer de-duplication over two lists the frontend already holds, no Jellyfin taxonomy involved. The resume filter still reads its frontier from the *unfiltered* Next Up list, so removing in-progress entries cannot resurrect a stale resume card. The division is then exact: Continue Watching offers episodes the viewer has started and not finished, Next Up offers the episode after the ones they finished | Repository | UR-059 | Done |
| DR-200 | The lockscreen notification is exempt from `POST_NOTIFICATIONS`, because of the **session token**, not because it belongs to a foreground service — and the difference is what the code now records. `POST_NOTIFICATIONS` was declared in the manifest and requested nowhere, so on Android 13+ it sat permanently denied; an audit read that as a threat to UR-006, since the media notification is what carries the lockscreen transport controls. It is not. Android's own wording is that the permission covers "non-exempt (including Foreground Services (FGS)) notifications", with denied users seeing FGS notices "in the Task Manager but [not] in the notification drawer" — so an FGS notification is explicitly *not* exempt — while separately "Notifications related to media sessions are exempt from this behavior change". The platform predicate is `Notification.isMediaNotification()`, which requires `MediaStyle` **and** a non-null `EXTRA_MEDIA_SESSION`, and it is byte-identical across API 33–36. `NotificationManagerService` uses it to decide whether to drop the post, and SystemUI's media carousel (`MediaDataProcessor.onNotificationAdded`) is gated on the *same* predicate — so a token-less notification is not merely absent from the shade, it never reaches the notification listener and the lockscreen/Quick-Settings controls do not exist at all. Confirmed on device (HONOR ROD2-W09, Android 16 / SDK 36): appops `POST_NOTIFICATION: ignore`, `granted=false`, and the service simultaneously `isForeground=true` with `foregroundNoti=Notification(category=transport actions=3 vis=PUBLIC)`. So **no runtime permission request is added** — a prompt the app does not need is a prompt that can be permanently denied for nothing — and no `checkSelfPermission` gate is placed on `startForeground`, which would trade a cosmetic problem for the "did not then call Service.startForeground()" kill. What is added is the guard that matches the real precondition: `mediaSessionCompat?.sessionToken` is a null-safe call, and the exemption hangs entirely on it, so both builders now bind the token once and log an error if it is ever null while the permission is denied — converting a failure that is invisible unless the tester happened to deny the permission (most grant it reflexively) into a logcat line. The manifest declaration is *kept*, unrequested, and documented: media3 does not need it (media3-session declares no permissions and the `MediaSessionService` guide asks only for the two `FOREGROUND_SERVICE` ones), but the exemption covers media and self-managed-call notifications only, so a download-completion notice (UR-011) would be an ordinary notification and silently dropped — keeping the declaration is what makes adding one a one-file change | Android | UR-006 | Done |
@@ -437,9 +437,9 @@ Internal architecture, components, and application logic.
| DR-232 | The mpv render context's lifetime is bound to the GL context it draws into: created on `realize`, freed on `unrealize`, on the same thread, with the update callback unregistered *before* the free so a callback cannot land on a freed context. This is DR-184 on Android restated — a surface outliving its player — and it is a requirement in its own right rather than a fix for a specific crash. The spike observed one SIGSEGV in a decoder thread that three targeted soaks failed to reproduce; what is not in doubt is that the spike never called `mpv_render_context_free` and never tore down on `unrealize`, so nothing defended against the GL context being recreated underneath. Removing the likeliest cause is worth doing whether or not it was the cause | Playback | UR-080 | Proposed |
| DR-233 | Frame pacing goes through mpv's update callback, with `mpv_render_context_report_swap` after each render. Recorded as a requirement because the failure mode misleads: driving the widget's frame clock every tick without reporting the swap leaves mpv with nothing to time against, which looks fine in a window and **judders at fullscreen** — reading as a compositing or GPU limit and being neither | Playback | UR-080 | Proposed |
| DR-234 | The device profile is derived from the **renderer that will decode the stream**, not from a compile-time platform constant. `video_codecs` was `#[cfg(target_os)]`, which is correct only while a build has one video renderer; once mpv and the webview element coexist it must be runtime state. This is the change that converts the measured 7% desktop direct-play rate toward the 85% the Android profile achieves on the same library, because the two differ by nothing except which component decodes. It looks like configuration and is not — it is the input that decides whether the server re-encodes, and getting it wrong fails silently, a claimed codec the renderer cannot decode being a black picture or silence (DR-148, and DR-227's audio override). The webview's narrower *audio* set stops applying to the video path once mpv decodes it, while the multichannel bound still does, since a 5.1 track direct-played into a two-channel sink is silence or inaudible dialogue | Repository | UR-080, UR-070 | In Progress |
| DR-235 | The webview video path is deleted, not merely bypassed. Staged, because a path cannot be removed while a shipped platform still needs it: Linux moves to mpv first, Windows follows, and only then do `hls.js`, `html5Adapter.ts`, `videoLoaderFor` and the `<video>` element go. The staging is the point — a Linux-only version would leave the fork alive permanently, taking video from three renderers to four and giving every seek strategy, track switch and lifecycle bug one more place to be got right. Android keeps ExoPlayer and keeps the webview as its documented opt-out; the background-audio `<audio>` path is untouched. With no HTML5 fallback left, a failed mpv init emits `backend-init-failed` and surfaces a real error rather than silently degrading to the transcode this work exists to stop paying for | Playback | UR-080 | Proposed |
| DR-235 | The webview video path is deleted, not merely bypassed. Staged, because a path cannot be removed while a shipped platform still needs it: Linux moves to mpv first, Windows follows, and only then do `hls.js`, `html5Adapter.ts`, `videoLoaderFor` and the `<video>` element go. The staging is the point — a Linux-only version would leave the fork alive permanently, taking video from three renderers to four and giving every seek strategy, track switch and lifecycle bug one more place to be got right. Android keeps ExoPlayer and keeps the webview as its documented opt-out; the background-audio `<audio>` path is untouched. With no HTML5 fallback left, a failed mpv init emits `backend-init-failed` and surfaces a real error rather than silently degrading to the transcode this work exists to stop paying for | Playback | UR-080 | Done |
| DR-236 | Hardware-decode policy is decided from what mpv reports it **selected** (`hwdec-current`), never from what it was asked for. The spike established that hardware decode works through the render API at all — the load-bearing result, since it means direct play is not bought with software decoding — but also that `auto` reached for the discrete GPU in copy-back mode on a hybrid Intel+NVIDIA laptop, the least efficient hardware path, and that `vaapi` fell back to software silently because the libva driver was absent. So zero-copy VA-API on the integrated GPU is preferred where the driver is present, `auto` is a fallback rather than the default, and a missing driver is detected and logged rather than mistaken for a compositing limit | Playback | UR-080 | Proposed |
| DR-237 | Windows reaches the same mpv path, reusing everything except the surface. The surface is genuinely different code — a native child window beneath a transparent WebView2, not GTK — but the render context, lifetime discipline, frame pacing, device profile and hwdec policy are shared, which is why none of them may be guarded on `cfg!(target_os = "linux")`. The cost is mostly build, not video: `libmpv` is currently a Linux-only dependency while Windows is cross-compiled from Linux via `x86_64-pc-windows-msvc` + `cargo-xwin`, so a Windows libmpv must reach that cross-build and its DLL must ship in the NSIS bundle, carrying the LGPL obligations DR-216 already records — dynamic linkage, licence text shipped alongside. Windows gains a native audio decoder as a side effect, which is what the long-blocked Windows audio work wants and cannot otherwise have | Playback | UR-080 | Proposed |
| DR-237 | Windows reaches the same mpv path, reusing everything except the surface. The surface is genuinely different code — a native child window beneath a transparent WebView2, not GTK — but the render context, lifetime discipline, frame pacing, device profile and hwdec policy are shared, which is why none of them may be guarded on `cfg!(target_os = "linux")`. The cost is mostly build, not video: `libmpv` is currently a Linux-only dependency while Windows is cross-compiled from Linux via `x86_64-pc-windows-msvc` + `cargo-xwin`, so a Windows libmpv must reach that cross-build and its DLL must ship in the NSIS bundle, carrying the LGPL obligations DR-216 already records — dynamic linkage, licence text shipped alongside. Windows gains a native audio decoder as a side effect, which is what the long-blocked Windows audio work wants and cannot otherwise have | Playback | UR-080 | In Progress |
| DR-238 | A transcoded seek re-negotiates the stream on every renderer, not just the webview. Jellyfin produces a transcode *from* `StartTimeTicks`, so where a seek lands is a property of the request rather than of the stream in hand. `determine_video_seek_strategy` treated `is_hls` as a proxy for "seekable in place", which held only because hls.js was always the HLS renderer — it seeks within the VOD playlist it is handed and lets the server catch up. mpv's HLS demuxer cannot make the server transcode from a new offset, so with native video on, every transcoded seek became a backend seek that silently did nothing and presented as "resume does not work". The rule is now written on `needs_transcoding` with hls.js as the stated exception; all four webview cells are unchanged | Player | UR-040 | Done |
| DR-239 | Properties the mpv event loop handles are registered with `observe_property`. libmpv delivers `PropertyChange` only for observed properties, so a `match` arm for an unobserved one is unreachable code that reads as implemented — the handler is right there. `pause` was handled and never observed, so `StateChanged` was never emitted on pause or resume and the play/pause control never moved. It stayed invisible while Linux video played in the webview, because the `<video>` element's own DOM events drove that control; native video made the UI depend on the event that never came | Player | UR-005 | Done |
| DR-240 | Fullscreen moves whatever actually owns the pixels. `requestFullscreen()` fullscreens the *document*, which sufficed while every renderer lived inside it — the HTML5 `<video>` element is part of the document, so WebKit scaled it and the OS window's real size never mattered. A native surface is drawn behind the webview at **window** size, so a document-only fullscreen expands the page and leaves the picture where it was; on WebKitGTK the result is a maximised window with decorations still holding a strip of the screen, which reads as "fullscreen is broken" rather than as a windowing problem. Android needed the same rule for the system bars (DR-157); this is its desktop half | Player | UR-066 | Done |
@@ -498,6 +498,9 @@ Internal architecture, components, and application logic.
| DR-294 | A download plays with no network. Playing a downloaded item asked the server for its `PlaybackInfo` — only to read the media-source id that subtitle URLs are keyed by — and `HybridRepository::get_playback_info` went to the server alone, so offline the call retried for seven seconds, failed, and the file on disk was never opened. A completed download for the current user now answers playback info from its download row, first and regardless of reachability: the local path, direct play, and the item id as media-source id (a download names no source, so the server served its default, which carries the item's id). Next Up had the same shape — server-only — and the TV landing page loads it in one `Promise.all` with its other rows, so offline that single failure blanked the whole page with Continue Watching and Latest sitting in the cache; it now falls back to the cache when the server cannot answer. And a slow cache read is waited for, never discarded: the cache is one SQLite connection behind one mutex, so any write in progress (the catalog sync at every launch, a download finishing) pushes a read past the 100 ms fast path, and `get_items`, the library list, genres and playlist items discarded such a read, waited on the server, and offline returned its error over data on disk — "More info" on a downloaded show failed exactly so. They keep the read running (`cache_try`) and wait for it when the server fails (`settle`); the cache-only reads (search, favourites) simply await the cache | Repository | UR-002, UR-071 | Done |
| DR-295 | A series page lists its episodes with one concurrent season fan-out. "More info" on Frasier took ~10 s: the page asked Rust for the episodes and for the current episode as two commands, each of which walked every season, and each walk fetched the eleven seasons one after another — so the wait was the sum of twenty-two listings, each a cache read slowed by whatever the database was writing (the catalog sync at launch measured it at ~4 s per walk). The seasons are now fetched together (`gather_season_episodes`, so the wait is the slowest season), and `repository_get_series_view` returns the episodes and the current episode from one walk, with Next Up and resume fetched alongside it | Repository | UR-062 | Done |
| DR-296 | Returning from background audio resumes the item the native player is actually on, not the one the video page was mounted with. An episode that ends while backgrounded advances in the backend (`advance_to_next_episode_audio_only`), but `player_exit_background_audio` returned only a position, so the webview reloaded the *previous* episode at the new episode's timestamp. The command now returns `BackgroundAudioResume { itemId, positionSeconds }` (`PlayerController::background_audio_resume`); `planHandoffReturn` yields `other-item` when the id differs from the mounted one, and the player page navigates to that episode with `resumeAt=<seconds>`, recording the outgoing episode as watched and suppressing the stale unmount stop report | Playback | UR-040, UR-023 | Done (pending device verification) |
| DR-297 | A background refresh never blanks a library detail page that is on screen, and every load that succeeds clears the page's error. Resuming the app after a few minutes in the background replaced the Frasier series page with "Failed to load item": the resume reload (reconnect / offline-filter change) is a refresh of content the cache had already answered, but any throw in it replaced the page with an error that no later successful reload of the same item cleared — and the catch logged nothing and turned backend errors (plain strings) into the generic text. A failed refresh now keeps the page and logs the thrown value; only failing to open an item shows an error, with the backend's own message | UI | UR-062 | Done |
| DR-298 | mpv receives commands as an argument vector, never as a command string. The pinned `libmpv` crate's `Mpv::command` joins its arguments with spaces and calls `mpv_command_string`, which parses input.conf syntax — whitespace splits, `;` chains a second command, `#` comments out the rest — so a stream URL carrying a server-controlled item id or `TranscodingUrl`, or a download's `file://` path built from its track title, could run any mpv command, `run` included: a crafted title tag was arbitrary code execution on the Linux desktop. The same parse broke every downloaded title containing a space, `Song.mp3` landing in `loadfile`'s flags slot. Every call goes through `mpv_command::command`, which builds a NUL-terminated `argv` for `mpv_command` so each argument reaches mpv as one opaque string, and refuses an argument containing NUL rather than loading a truncated URL | Playback | UR-003, UR-004 | Done |
| DR-299 | Every libmpv handle verifies TLS and never hands a URL to youtube-dl. mpv's `tls-verify` defaults to *no*, and the stream URLs it loads carry the account's `ApiKey`, so anyone able to present a certificate for the server's host — a hostile network, a spoofed DNS answer — read a long-lived token from the one HTTP path in the app that did not check. libmpv also loads its ytdl hook by default, which passes a URL that failed to open, token included, to an external `yt-dlp`. `mpv_command::harden` sets `tls-verify=yes` and `ytdl=no` on each handle before its first `loadfile`, and a handle that cannot be hardened fails construction instead of playing unverified | Playback | UR-012 | Done |
---
@@ -509,8 +512,8 @@ Internal architecture, components, and application logic.
|----------|-------------------------|-------------------------|
| UR-001 | IR-001, IR-002 | - |
| UR-002 | IR-013 | DR-003, DR-012, DR-013, DR-014, DR-294 |
| UR-003 | IR-003, IR-004, IR-011 | DR-002, DR-004, DR-010, DR-182, DR-183, DR-184, DR-185, DR-186, DR-187, DR-188, DR-190, DR-191, DR-192, DR-193, DR-194, DR-195, DR-196, DR-291 |
| UR-004 | IR-003, IR-004, IR-008, IR-011 | DR-002, DR-004, DR-006, DR-129, DR-171, DR-176, DR-177, DR-181, DR-182, DR-183, DR-185, DR-188, DR-203, DR-265, DR-293 |
| UR-003 | IR-003, IR-004, IR-011 | DR-002, DR-004, DR-010, DR-182, DR-183, DR-184, DR-185, DR-186, DR-187, DR-188, DR-190, DR-191, DR-192, DR-193, DR-194, DR-195, DR-196, DR-291, DR-298 |
| UR-004 | IR-003, IR-004, IR-008, IR-011 | DR-002, DR-004, DR-006, DR-129, DR-171, DR-176, DR-177, DR-181, DR-182, DR-183, DR-185, DR-188, DR-203, DR-265, DR-293, DR-298 |
| UR-005 | - | DR-001, DR-005, DR-009, DR-178, DR-179, DR-186, DR-193, DR-195 |
| UR-006 | IR-005, IR-006, IR-007, IR-008 | DR-200, DR-201 |
| UR-007 | IR-010 | DR-007, DR-008, DR-016, DR-257, DR-262, DR-277, DR-278 |
@@ -518,7 +521,7 @@ Internal architecture, components, and application logic.
| UR-009 | IR-009, IR-010, IR-011 | - |
| UR-010 | IR-012, IR-021 | DR-037, DR-059 |
| UR-011 | IR-013 | DR-003, DR-015, DR-018 |
| UR-012 | IR-009, IR-014 | DR-198 |
| UR-012 | IR-009, IR-014 | DR-198, DR-299 |
| UR-013 | IR-013 | DR-017 |
| UR-014 | IR-010 | DR-014, DR-019 |
| UR-015 | - | DR-005, DR-020 |
@@ -567,7 +570,7 @@ Internal architecture, components, and application logic.
| UR-058 | - | DR-087, DR-142 |
| UR-060 | - | DR-090, DR-091, DR-111 |
| UR-061 | - | DR-092 |
| UR-062 | - | DR-101, DR-102, DR-103, DR-104, DR-107, DR-295 |
| UR-062 | - | DR-101, DR-102, DR-103, DR-104, DR-107, DR-295, DR-297 |
| UR-063 | - | DR-105 |
| UR-064 | - | DR-106 |
| UR-065 | IR-030 | DR-108, DR-109, DR-110, DR-111 |
@@ -736,7 +739,7 @@ Internal architecture, components, and application logic.
| UT-140 | `useOfflineFilterReload` skips the value a page already loaded under and reloads on each later change | DR-143 | Done |
| UT-141 | The advertised channel cap: an unknown or zero reading falls back to stereo, a real route keeps its channels, an absurd driver reading is capped at 7.1, and mono is taken at its word | DR-141 | Done |
| UT-148 | Forcing a transcode from the client: an undecodable default track forces one, a decodable track does not, the default track decides rather than the first, the first decides when nothing is marked default, and neither an audio-less source nor an unnamed codec is second-guessed | DR-149 | Done |
| UT-149 | `createAdapter` returns the native adapter only when Rust reports native AND `experimentalNativeVideo` is on; the flag off forces HTML5 even when Rust says native, and the flag on never promotes a platform Rust reported as HTML5 | DR-150 | Done |
| UT-149 | `createAdapter` returns the native adapter only when Rust reports native AND `experimentalNativeVideo` is on; the flag off forces HTML5 even when Rust says native, and the flag on never promotes a platform Rust reported as HTML5 | DR-150 | Superseded by DR-235 |
| UT-150 | `set-version.sh` stamps all four manifests without touching dependency versions, and the Android versionCode is monotonic across an upgrade sequence, clears the 1000 floor, and survives a prerelease suffix | DR-153 | Done |
| UT-151 | An unreportable stop lands in the queue and is pushed by the existing drain; re-queueing the same item supersedes the earlier position rather than adding a row, distinct items keep their own positions, and an abandoned row is not revived by a later report | DR-154 | Done |
| UT-152 | Caching a server result mirrors its watch position locally — including for an item carrying a position but no favourite flag — without inventing a row for an item the server reported no user data for, and without pulling a still-unsynced local position backwards | DR-155 | Done |
@@ -770,7 +773,7 @@ Internal architecture, components, and application logic.
| UT-153 | Scroll handling per navigation kind: a forward move always lands at the top even when the previous page was scrolled and even when the target was visited before, Back restores that route's own saved offset (and the top when it has none), offsets are kept per route rather than shared, a repeated Back still restores, and the initial load leaves the container alone | DR-156 | Done |
| UT-142 | The audio codecs offered for video direct play: a Dolby device's real `MediaCodecList` output drops `ac3`/`eac3`, AMR and raw PCM are dropped too, a fully-supported list is passed through untouched, a list with nothing decodable still claims `aac`, and stray spacing or casing does not decide whether the user gets sound | DR-148 | Done |
| UT-143 | Subtitle URLs resolve to plain strings before they reach the markup (never a Promise), unresolvable tracks are dropped, a stale selection collapses to "Off", and a server-default track is never auto-selected | UR-020, DR-023 | Done |
| UT-144 | VideoPlayer actually renders `<track kind="subtitles">` children carrying `data-stream-index`, with no `default` attribute and no async `getSubtitleUrl()` bound to `src` | UR-020, DR-023 | Done |
| UT-144 | VideoPlayer actually renders `<track kind="subtitles">` children carrying `data-stream-index`, with no `default` attribute and no async `getSubtitleUrl()` bound to `src` | UR-020, DR-023 | Superseded by DR-235 |
| UT-145 | The frontend's subtitle payload survives the IPC hop: a camelCase `PlayItemRequest` carrying `subtitles` deserializes, `create_media_item` lands them on `MediaItem.subtitles` in the order sent, and a request without the field still defaults to empty | UR-020, IR-016 | Done |
| UT-146 | The subtitle JSON serialized across the JNI boundary uses the keys `JellyTauPlayer.load()` reads — `url`, `language`, `label` and `mime_type`, never `mimeType` | UR-020, IR-016, JA-008 | Done |
| UT-147 | The native subtitle payload and the track-selection index come from the same resolved list: the wire shape keeps `mime_type` and stream order, `playerPlayItem` actually sends it, and the index is a position in the sent list (so a track whose URL failed to resolve cannot shift the others) rather than the menu's row number | UR-020, IR-016 | Done |
@@ -778,7 +781,7 @@ Internal architecture, components, and application logic.
| UT-183 | A reloaded stream is resumed by seeking the element to the absolute position with the transcode offset cleared to zero — never by carrying the position as an offset base, which since DR-181 would display the position while playing the item from its start — and a reload to 0:00 waits for no seek | DR-181 | Done |
| UT-184 | The native reveal rule fires on `state === "playing"` and on a position tick carrying a position or a duration, and on nothing else — not `buffering`, `paused`, `stopped`, `ended` or `error`, not an empty tick, and not a negative position | DR-182 | Done |
| UT-188 | The control-bar auto-hide rule permits hiding only during uninterrupted playback: it declines while paused, while a seek is in flight, and while a track/subtitle/quality menu is open — asserted against the pure `shouldHideControls` rule rather than a clock or a DOM | DR-189 | Done |
| UT-189 | On the native path the player never calls `player_report_state` — driven through the real 10-second progress interval under fake timers, which is the call site that mattered; asserting on a freshly mounted player passes with the guard deleted and guards nothing | DR-195 | Done |
| UT-189 | On the native path the player never calls `player_report_state` — driven through the real 10-second progress interval under fake timers, which is the call site that mattered; asserting on a freshly mounted player passes with the guard deleted and guards nothing | DR-195 | Superseded by DR-235 |
| UT-187 | On the native path the play overlay follows the backend: it clears when the backend resumes after a pause and is raised again when the backend pauses, and the system bars are hidden on player entry rather than only by the fullscreen button | DR-186, DR-187 | Done |
| UT-186 | Every attribute the native-video compositing block in app.css targets is set somewhere in the app — `[data-app-shell]` in particular — so a selector aimed at nothing fails the suite instead of failing silently on a device | DR-185 | Done |
| UT-185 | Mounted on the native path (backend reports native, opt-in flag on, no `<video>` element rendered and the backend not stopped), VideoPlayer keeps the poster card up until the backend reports something, drops it on a playing state or a position tick with a duration, and keeps it up through `error` and `stopped` | DR-182 | Done |
@@ -806,9 +809,9 @@ Internal architecture, components, and application logic.
| UT-211 | The background decision: a video with the toggle off pauses (the reported defect, where the media service kept playing regardless), a video with it on hands off to audio, music keeps playing whatever the toggle says because it has no picture to lose, picture-in-picture keeps playing in every combination since the window is still visible, and the answer does not vary by renderer | DR-224 | Done |
| UT-212 | The stream-selection contract. `Transport` and `PlaybackKind` each serialise to exactly the tag the frontend matches (`{"type":"hls"}`, `{"type":"directPlay"}`, …) and round-trip; nested `StreamSelection` fields are camelCase on the wire including `playbackKind`, `mediaSourceId` and `maxBitrate`; only `Transcode` counts as transcoding, so a direct stream does not; a local file is a direct play over a local transport with no ladder. The ladder: every rung at or above a 1.12 Mbps source is marked redundant while the three that constrain it are not, `Original` is never marked for any bitrate including zero and unknown, an unreported source bitrate keeps all eight rungs offered, a 40 Mbps source marks none, and each option carries the ladder's own label and detail | DR-224, DR-226 | Done |
| UT-213 | The direct-play negotiation, one test per branch, against `PlaybackInfo` fixtures whose shapes were all observed on a live server: a supported source direct-plays; a remuxable one direct-streams and reports itself as *not* transcoding; an unsupported codec transcodes; undecodable audio overrides the server's direct-play offer (silent picture is worse than a transcode); a pinned audio track forces a transcode; a ceiling below the source bitrate transcodes even though the codec is fine, and the ladder agrees that rung constrains it; direct play wins over direct stream when both are offered. Plus the ceiling: a per-playback override governs the stream being opened without disturbing the durable default the Settings screen shows, and dropping it returns to that default | DR-225, DR-227 | Done |
| UT-214 | The loader comes from the transport, never the URL. hls.js is attached for `hls` when available and the element's own loader when not; progressive and local files load directly; the element's `src` is emptied only when hls.js drives it. The two cases that fail against a substring check, and the reason the field exists: a `progressive` stream whose URL contains `.m3u8` is *not* given an HLS loader, and an `hls` stream whose URL contains no `.m3u8` *is*. Both failed against the pre-DR-225 implementation before the fix landed | DR-224 | Done |
| UT-214 | The loader comes from the transport, never the URL. hls.js is attached for `hls` when available and the element's own loader when not; progressive and local files load directly; the element's `src` is emptied only when hls.js drives it. The two cases that fail against a substring check, and the reason the field exists: a `progressive` stream whose URL contains `.m3u8` is *not* given an HLS loader, and an `hls` stream whose URL contains no `.m3u8` *is*. Both failed against the pre-DR-225 implementation before the fix landed | DR-224 | Superseded by DR-235 |
| UT-215 | Waiting for the repository rather than racing it: it resolves immediately when the session is already restored, resolves when the session arrives later (the race the player page lost on mount), still rejects when there genuinely is no session, unsubscribes once settled so a later store change cannot re-settle it, and leaves no armed timer to reject an already-resolved promise | DR-013 | Done |
| UT-216 | The native-video opt-in is read from one place and only explicit truthy values enable it: absent, empty, `0`, `no`, `false` and anything unrecognised all mean off, because a half-set variable that half-enabled the renderer would configure mpv for video with nothing drawing it — audio over a black rectangle | DR-231 | Done |
| UT-216 | The native-video opt-in is read from one place and only explicit truthy values enable it: absent, empty, `0`, `no`, `false` and anything unrecognised all mean off, because a half-set variable that half-enabled the renderer would configure mpv for video with nothing drawing it — audio over a black rectangle | DR-231 | Superseded by UT-271 |
| UT-217 | A transcoded HLS stream on the native backend re-negotiates rather than seeking in place, while the same stream under hls.js still seeks in place — the cell that native video made reachable for the first time | DR-238 | Done |
| UT-218 | Every property name matched by the mpv event loop also appears in an `observe_property` call, asserted against the source because the registration cannot be observed at runtime without a live mpv | DR-239 | Done |
| UT-219 | A fullscreen toggle moves the document only when an in-document `<video>` renders, and moves the OS window as well when a native surface does | DR-240 | Done |
@@ -851,14 +854,23 @@ Internal architecture, components, and application logic.
| UT-255 | The offline banner shows while offline on ordinary routes and never on `/player/*`, and stays off while connected or signed out | DR-291 | Done |
| UT-257 | The server-only rule: true only offline with the reveal on and nothing on the device; never for a library tile; and not for a container whose children are downloaded (the greyed-album regression) | DR-292 | Done |
| UT-258 | The list view greys a server-only row, makes it inert to tap, offers the queue button (and the Queued badge once pending), and leaves downloaded rows and containers with device content alone | DR-292 | Done |
| UT-259 | The user may send video to the webview only beside mpv native video on Linux: never on Android, where ExoPlayer is the only video renderer, and not where the webview is the only renderer | DR-293 | Done |
| UT-259 | The user may send video to the webview only beside mpv native video on Linux: never on Android, where ExoPlayer is the only video renderer, and not where the webview is the only renderer | DR-293 | Superseded by UT-272 |
| UT-260 | A downloaded item gets playback info with the server unreachable — immediately, from its download row (local path, direct play, item id as media source) — while an unfinished download, another user's, or an item never downloaded is left to the server | DR-294 | Done |
| UT-261 | Next Up answers from the cache, rather than failing, when the server is unreachable | DR-294 | Done |
| UT-262 | The Android webview fallback is neither offered in Settings nor honoured by the player unless Rust reports it, so a stored "native video off" cannot route video to a renderer that plays the original file silent | DR-293 | Done |
| UT-262 | The Android webview fallback is neither offered in Settings nor honoured by the player unless Rust reports it, so a stored "native video off" cannot route video to a renderer that plays the original file silent | DR-293 | Superseded by UT-272 |
| UT-263 | With the database held past the 100 ms fast path and the server unreachable, `get_items`, the library list, a cache-only search and cache-only favourites all answer from the cache instead of failing | DR-294 | Done |
| UT-264 | Ten seasons whose listings each take 100 ms are gathered in well under the 1 s a sequential walk takes, and a season that fails to load leaves the other nine seasons' episodes in the result | DR-295 | Done |
| UT-265 | `planHandoffReturn` switches to the item the backend advanced to while backgrounded, and reloads in place when the backend is still on the mounted item or reports none | DR-296 | Done |
| UT-266 | After a background-audio episode advance, the controller's resume point names the new episode and carries no base from the previous one | DR-296 | Done |
| UT-267 | A failed refresh of a detail page already on screen shows no error, a successful load clears any error, a failure to open an item shows its message, and a backend error's plain-string text is shown rather than a generic fallback | DR-297 | Done |
| UT-268 | Against a real libmpv, a URL containing `;set volume 13;#` is loaded as one URL and the volume is unchanged, and an argument containing a space stays one argument | DR-298 | Done |
| UT-269 | Neither mpv player calls the string-joining `Mpv::command`; every command goes through `mpv_command::command` | DR-298 | Done |
| UT-270 | A hardened handle reads back `tls-verify=yes` and `ytdl=no`, and both mpv players harden the handle they create | DR-299 | Done |
| UT-271 | Native video is on for Linux whatever `JELLYTAU_NATIVE_VIDEO` says, including unset and explicit "off" values, and off where mpv is not the video renderer | DR-235 | Done |
| UT-272 | No platform reports a webview video fallback, Linux reports native video, and the player status on Linux never sends video to the `<video>` element | DR-235 | Done |
| UT-273 | `player_play_item`, `get_player_status` and `player_get_capabilities` answer "does a native renderer draw video here" from one function, so the backend is loaded with video exactly where the frontend is told not to use a `<video>` element — on Windows, where mpv now plays audio, the film's soundtrack is not decoded twice | DR-237 | Done |
| UT-274 | mpv's video output is decided per platform: Windows renders into the app window's HWND (`wid`, set before initialisation) with `vo=gpu-next,gpu` and mpv's own controller, bindings and cursor handling off; Windows with no handle draws nothing rather than opening a window of its own; Linux uses the render API; no native video means `video=no`. Every option is accepted by the real libmpv, on Linux and by the shipped Windows DLL | DR-237 | Done |
| UT-275 | mpv selects sideloaded subtitles and audio tracks by position, as ExoPlayer does: against a real file, a sideloaded WebVTT arrives, starts hidden, and is shown and hidden by its position in the sent list; position 1 plays the file's second audio track; and preparing the next load forgets the last item's subtitle files, subtitle choice and audio track. Positions resolve to mpv ids per kind, embedded before external | DR-023, DR-024, DR-235 | Done |
### Integration Tests
| Test ID | Test Description | Traces To | Status |
+12 -1
View File
@@ -1,6 +1,17 @@
# Spec: Desktop native video — mpv renders the picture, everywhere
**Status:** Proposed
**Status:** Partially implemented — all three phases' *code* has shipped: mpv
is the only video renderer on Linux (render API into the GTK vbox) and Windows
(`wid` into the app window, `vo=gpu-next,gpu`), and the webview video path is
deleted — hls.js, the HTML5 adapter, the `<video>` element, the frontend switch,
the `use_html5` command parameters and the Android HTML5 PiP/screen-wake state
(DR-235). mpv selects subtitles and audio tracks itself (`mpv_tracks`).
**Left:** Linux/Windows still claim only `h264` (DR-234), so video is still a
server transcode — mpv plays it, but the direct-play gain is not taken; `hwdec`
is unset, so mpv decodes in software (DR-236); a failed surface attach only
logs; and every hardware criterion is unrecorded — the X11/Wayland check and
soak on Linux, and **any** run of Windows video, which has only been verified by
unit tests under wine (options accepted by the shipped DLL), never on screen.
**Requirements:** UR-080 (new) → DR-231 … DR-237 (new); IR-033 (new)
**UX spec:** n/a — nothing about the player's appearance changes. What changes is
what is behind the controls.
+10 -3
View File
@@ -1,8 +1,15 @@
# Spec: Windows native audio backend
**Status:** Proposed — not started. Windows still runs on
`WebviewAudioBackend`. Blocked on [libmpv2-migration.md](libmpv2-migration.md),
whose crate swap has not landed either.
**Status:** Partially implemented — code and packaging done, **unverified on
Windows hardware**. `MpvBackend` is the Windows audio backend (`ao=wasapi`);
the builder image carries zhongfly's pinned LGPL `libmpv-2.dll` plus a generated
MSVC `mpv.lib`, and the NSIS installer ships the DLL beside `jellytau.exe` with
its licence texts ([build-windows.md](../build/build-windows.md)). Done *before*
the libmpv2 migration after all: the current `libmpv` pin cross-links fine, and
the migration now has one call site to keep safe (`mpv_command`, DR-298) rather
than a crate API to port twice. **Left:** every acceptance criterion that needs a
Windows machine — audible volume/EQ/normalization/gapless, seek/queue/sleep
timer, and the clean-VM install test.
**Requirements:** UR-003, UR-027, UR-032, UR-033 → DR-030, DR-035, DR-036;
⚠️ the suggested id **IR-030 has since been allocated** to the scheduled catalog
crawl — allocate a fresh id (IR-033 or later) on implementation
+3080 -2958
View File
File diff suppressed because it is too large Load Diff
+1 -2
View File
@@ -1,6 +1,6 @@
{
"name": "jellytau",
"version": "0.13.2",
"version": "0.14.0",
"description": "A cross-platform Jellyfin client built with Tauri, SvelteKit and Rust.",
"author": "Duncan Tourolle <duncan@tourolle.paris>",
"license": "MIT",
@@ -64,7 +64,6 @@
"@tauri-apps/plugin-os": "^2.3.2",
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-updater": "2.10.1",
"hls.js": "^1.6.15",
"svelte-dnd-action": "^0.9.69"
},
"devDependencies": {
+674
View File
@@ -0,0 +1,674 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
<https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.
+165
View File
@@ -0,0 +1,165 @@
GNU LESSER GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
This version of the GNU Lesser General Public License incorporates
the terms and conditions of version 3 of the GNU General Public
License, supplemented by the additional permissions listed below.
0. Additional Definitions.
As used herein, "this License" refers to version 3 of the GNU Lesser
General Public License, and the "GNU GPL" refers to version 3 of the GNU
General Public License.
"The Library" refers to a covered work governed by this License,
other than an Application or a Combined Work as defined below.
An "Application" is any work that makes use of an interface provided
by the Library, but which is not otherwise based on the Library.
Defining a subclass of a class defined by the Library is deemed a mode
of using an interface provided by the Library.
A "Combined Work" is a work produced by combining or linking an
Application with the Library. The particular version of the Library
with which the Combined Work was made is also called the "Linked
Version".
The "Minimal Corresponding Source" for a Combined Work means the
Corresponding Source for the Combined Work, excluding any source code
for portions of the Combined Work that, considered in isolation, are
based on the Application, and not on the Linked Version.
The "Corresponding Application Code" for a Combined Work means the
object code and/or source code for the Application, including any data
and utility programs needed for reproducing the Combined Work from the
Application, but excluding the System Libraries of the Combined Work.
1. Exception to Section 3 of the GNU GPL.
You may convey a covered work under sections 3 and 4 of this License
without being bound by section 3 of the GNU GPL.
2. Conveying Modified Versions.
If you modify a copy of the Library, and, in your modifications, a
facility refers to a function or data to be supplied by an Application
that uses the facility (other than as an argument passed when the
facility is invoked), then you may convey a copy of the modified
version:
a) under this License, provided that you make a good faith effort to
ensure that, in the event an Application does not supply the
function or data, the facility still operates, and performs
whatever part of its purpose remains meaningful, or
b) under the GNU GPL, with none of the additional permissions of
this License applicable to that copy.
3. Object Code Incorporating Material from Library Header Files.
The object code form of an Application may incorporate material from
a header file that is part of the Library. You may convey such object
code under terms of your choice, provided that, if the incorporated
material is not limited to numerical parameters, data structure
layouts and accessors, or small macros, inline functions and templates
(ten or fewer lines in length), you do both of the following:
a) Give prominent notice with each copy of the object code that the
Library is used in it and that the Library and its use are
covered by this License.
b) Accompany the object code with a copy of the GNU GPL and this license
document.
4. Combined Works.
You may convey a Combined Work under terms of your choice that,
taken together, effectively do not restrict modification of the
portions of the Library contained in the Combined Work and reverse
engineering for debugging such modifications, if you also do each of
the following:
a) Give prominent notice with each copy of the Combined Work that
the Library is used in it and that the Library and its use are
covered by this License.
b) Accompany the Combined Work with a copy of the GNU GPL and this license
document.
c) For a Combined Work that displays copyright notices during
execution, include the copyright notice for the Library among
these notices, as well as a reference directing the user to the
copies of the GNU GPL and this license document.
d) Do one of the following:
0) Convey the Minimal Corresponding Source under the terms of this
License, and the Corresponding Application Code in a form
suitable for, and under terms that permit, the user to
recombine or relink the Application with a modified version of
the Linked Version to produce a modified Combined Work, in the
manner specified by section 6 of the GNU GPL for conveying
Corresponding Source.
1) Use a suitable shared library mechanism for linking with the
Library. A suitable mechanism is one that (a) uses at run time
a copy of the Library already present on the user's computer
system, and (b) will operate properly with a modified version
of the Library that is interface-compatible with the Linked
Version.
e) Provide Installation Information, but only if you would otherwise
be required to provide such information under section 6 of the
GNU GPL, and only to the extent that such information is
necessary to install and execute a modified version of the
Combined Work produced by recombining or relinking the
Application with a modified version of the Linked Version. (If
you use option 4d0, the Installation Information must accompany
the Minimal Corresponding Source and Corresponding Application
Code. If you use option 4d1, you must provide the Installation
Information in the manner specified by section 6 of the GNU GPL
for conveying Corresponding Source.)
5. Combined Libraries.
You may place library facilities that are a work based on the
Library side by side in a single library together with other library
facilities that are not Applications and are not covered by this
License, and convey such a combined library under terms of your
choice, if you do both of the following:
a) Accompany the combined library with a copy of the same work based
on the Library, uncombined with any other library facilities,
conveyed under the terms of this License.
b) Give prominent notice with the combined library that part of it
is a work based on the Library, and explaining where to find the
accompanying uncombined form of the same work.
6. Revised Versions of the GNU Lesser General Public License.
The Free Software Foundation may publish revised and/or new versions
of the GNU Lesser General Public License from time to time. Such new
versions will be similar in spirit to the present version, but may
differ in detail to address new problems or concerns.
Each version is given a distinguishing version number. If the
Library as you received it specifies that a certain numbered version
of the GNU Lesser General Public License "or any later version"
applies to it, you have the option of following the terms and
conditions either of that published version or of any later version
published by the Free Software Foundation. If the Library as you
received it does not specify a version number of the GNU Lesser
General Public License, you may choose any version of the GNU Lesser
General Public License ever published by the Free Software Foundation.
If the Library as you received it specifies that a proxy can decide
whether future versions of the GNU Lesser General Public License shall
apply, that proxy's public statement of acceptance of any version is
permanent authorization for you to choose that version for the
Library.
+21 -3
View File
@@ -7,14 +7,16 @@
# officially supports for Windows (the mingw/GNU target is not), and unlike GNU
# it can bundle the NSIS installer from a Linux host.
#
# Playback on Windows: video renders via WebView2 and audio via the webview
# <audio> backend (WebviewAudioBackend) — see docs/build/build-windows.md.
# Playback on Windows: video renders via WebView2; audio via mpv, whose
# libmpv-2.dll ships beside jellytau.exe — see docs/build/build-windows.md.
#
# Requirements (present in the Docker windows-cross target / unified builder):
# - rustup target x86_64-pc-windows-msvc
# - cargo-xwin (cargo install --locked cargo-xwin)
# - lld, llvm (linker + llvm-lib used by cargo-xwin)
# - nsis (makensis) (installer generator)
# - libmpv for Windows ($LIBMPV_WIN_DIR: libmpv-2.dll + mpv.lib, pinned and
# sha256-checked in Dockerfile.builder)
#
# Usage:
# scripts/build-windows-cross.sh # exe + NSIS installer
@@ -29,10 +31,26 @@ WIN_BUNDLES="${WIN_BUNDLES:-nsis}"
echo "🪟 Cross-compiling JellyTau for Windows ($TARGET, via cargo-xwin)"
echo "================================================================"
echo "Video plays via WebView2; audio via the webview <audio> backend."
echo "Video plays via WebView2; audio via mpv (libmpv-2.dll)."
echo "Bundles: $WIN_BUNDLES"
echo ""
# Stage libmpv where build.rs links it from and tauri.windows.conf.json bundles
# it from — one directory, so the DLL shipped is the one linked against.
# Copied fresh every build: a stale DLL left from an older image would ship
# silently. TRACES: UR-004 | DR-237
LIBMPV_WIN_DIR="${LIBMPV_WIN_DIR:-/opt/libmpv-win64}"
for f in libmpv-2.dll mpv.lib; do
if [[ ! -f "$LIBMPV_WIN_DIR/$f" ]]; then
echo "❌ $LIBMPV_WIN_DIR/$f not found. Build inside the jellytau-builder image,"
echo " or set LIBMPV_WIN_DIR to a directory holding libmpv-2.dll and mpv.lib."
exit 1
fi
done
rm -rf src-tauri/windows-libs
mkdir -p src-tauri/windows-libs
cp -v "$LIBMPV_WIN_DIR/libmpv-2.dll" "$LIBMPV_WIN_DIR/mpv.lib" src-tauri/windows-libs/
bun install --frozen-lockfile 2>/dev/null || bun install
bun run build
+12 -4
View File
@@ -54,21 +54,29 @@ describe("tauri.conf.json CSP", () => {
expect(csp["img-src"]).toContain("asset:");
expect(csp["img-src"]).toContain("http://asset.localhost");
expect(csp["media-src"]).toContain("asset:");
// hls.js: MSE object URLs, and its demuxer worker built from a blob.
expect(csp["media-src"]).toContain("blob:");
expect(csp["worker-src"]).toContain("blob:");
// The token-guarded loopback media server (DR-137).
expect(csp["media-src"]).toContain("http://127.0.0.1:*");
// Tauri's invoke transport.
expect(csp["connect-src"]).toContain("ipc:");
expect(csp["connect-src"]).toContain("http://ipc.localhost");
// The user's Jellyfin server: an arbitrary run-time origin, http on a LAN.
for (const directive of ["img-src", "media-src", "connect-src"]) {
for (const directive of ["img-src", "media-src"]) {
expect(csp[directive]).toContain("http:");
expect(csp[directive]).toContain("https:");
}
});
// The page makes no network requests of its own — all traffic goes through
// Rust — so `connect-src` is IPC only. It allowed any http(s) host while
// hls.js fetched segments in the page; with hls.js deleted (DR-235) that
// grant was only an exfiltration channel for injected script. Likewise the
// blob worker was hls.js' demuxer.
it("gives injected script no network egress and no blob workers", () => {
expect(csp["connect-src"]).not.toContain("http:");
expect(csp["connect-src"]).not.toContain("https:");
expect(csp["worker-src"]).not.toContain("blob:");
});
it("never widens a data directive into script execution", () => {
for (const [name, sources] of Object.entries(csp)) {
if (name === "script-src" || name === "worker-src") {
+4
View File
@@ -22,3 +22,7 @@ local.properties
# macOS
.DS_Store
# Windows libmpv (import library + DLL), staged from the builder image by
# scripts/build-windows-cross.sh. Never committed: 100 MB of LGPL binary.
/windows-libs/
+1 -1
View File
@@ -2275,7 +2275,7 @@ dependencies = [
[[package]]
name = "jellytau"
version = "0.13.2"
version = "0.14.0"
dependencies = [
"aes-gcm",
"argon2",
+8 -4
View File
@@ -4,7 +4,7 @@ name = "jellytau"
# `player-conformance`, and a second binary makes a bare `cargo run` —
# which `tauri dev` issues — ambiguous.
default-run = "jellytau"
version = "0.13.2"
version = "0.14.0"
description = "A cross-platform Jellyfin client"
authors = ["Duncan Tourolle <duncan@tourolle.paris>"]
license = "MIT"
@@ -108,9 +108,10 @@ tiny_http = { version = "0.12.0", default-features = false }
tauri-plugin-updater = "2"
tauri-plugin-process = "2"
# Linux-specific dependencies
[target.'cfg(target_os = "linux")'.dependencies]
hostname = "0.4"
# mpv platforms: Linux (system libmpv) and Windows (libmpv-2.dll shipped in the
# installer, import library generated in the builder image — see build.rs and
# scripts/build-windows-cross.sh). TRACES: UR-004 | DR-237
[target.'cfg(any(target_os = "linux", target_os = "windows"))'.dependencies]
libc = "0.2"
# The crates.io release of libmpv predates the MPV versions we support, so this
# tracks the upstream git repo.
@@ -137,6 +138,9 @@ libmpv = { git = "https://github.com/ParadoxSpiral/libmpv-rs.git", rev = "3e6c38
# TRACES: UR-080 | DR-230, IR-033
libmpv-sys = { git = "https://github.com/ParadoxSpiral/libmpv-rs.git", rev = "3e6c389b716f52a595cc5e8e3fa1f96cb76b3de7" }
# Linux-specific dependencies
[target.'cfg(target_os = "linux")'.dependencies]
hostname = "0.4"
# Same major as the one Tauri/wry already resolve, so `gtk_window()` and
# `default_vbox()` hand back types this crate can name rather than a second,
# incompatible GTK.
@@ -85,10 +85,9 @@ class MainActivity : TauriActivity() {
super.onWebViewCreate(webView)
android.util.Log.d("MainActivity", "onWebViewCreate - installing bridges before first page load")
mediaWebView = webView
// A new WebView means a new page, which reports no video yet. Anything the
// previous one left held would otherwise pin the screen on for the life of
// the process, since a page that goes away never sends its final
// setHtml5VideoState(false, …). (DR-202)
// A new WebView means a new page, which plays no video yet. Anything held
// for the previous one would otherwise pin the screen on for the life of
// the process. (DR-202)
ScreenWakeManager.releaseAll()
installJavascriptBridges(webView)
configureWebViewSettings(webView)
@@ -327,22 +326,6 @@ class MainActivity : TauriActivity() {
autoEnterPipEnabled = enabled
}
/**
* Report the WebView `<video>` state.
*
* Without this PiP only ever knew about the native ExoPlayer surface,
* which is behind an experimental flag that defaults to off — so in the
* shipping configuration nothing ever satisfied canEnterPip and the
* button did nothing. (DR-160)
*/
@JavascriptInterface
fun setHtml5VideoState(active: Boolean, width: Int, height: Int, playing: Boolean) {
PictureInPictureManager.setHtml5VideoState(active, width, height, playing)
// The same report is what keeps the display awake on the webview
// rendering path — the WebView takes no display wake lock of its own
// for `<video>`. (DR-202)
ScreenWakeManager.onHtml5VideoState(active, playing)
}
}, "AndroidPictureInPicture")
android.util.Log.d("MainActivity", "JavaScript interface 'AndroidPictureInPicture' added")
@@ -46,52 +46,7 @@ object PictureInPictureManager {
private var receiver: BroadcastReceiver? = null
private var hiddenWebView: WebView? = null
/**
* State of an HTML5 `<video>` playing inside the WebView, reported by the
* frontend.
*
* PiP was written for the native ExoPlayer surface only — [canEnterPip]
* required a SurfaceView to be attached and rendering. But native video is
* behind `experimentalNativeVideo`, which defaults to **off**, so in the
* shipping configuration video plays in the WebView's `<video>` element and
* every one of those conditions is false. `enterPip` therefore always bailed
* with "no local video playing": PiP could not work at all, however the
* button was pressed.
*
* On this path the WebView *is* the video, which inverts two things: the
* WebView must stay visible in PiP rather than be hidden, and play/pause has
* to reach the element rather than ExoPlayer. Both are handled below.
*
* TRACES: UR-041 | DR-160
*/
@Volatile
private var html5VideoActive = false
@Volatile
private var html5VideoPlaying = false
@Volatile
private var html5AspectRatio: Rational? = null
/**
* Report the WebView `<video>` state from the frontend.
*
* @param active whether a video element is currently the playback surface
* @param width intrinsic video width, for the PiP window's aspect ratio
* @param height intrinsic video height
* @param playing whether it is playing right now, for the PiP play/pause action
*/
fun setHtml5VideoState(active: Boolean, width: Int, height: Int, playing: Boolean) {
html5VideoActive = active
html5VideoPlaying = playing
html5AspectRatio = if (active && width > 0 && height > 0) {
clampedRatio(width.toDouble() / height.toDouble())
} else {
null
}
}
/** True when PiP would be showing the native surface rather than the WebView. */
/** True when a native video surface is attached and playing. */
private fun isNativeVideoPath(): Boolean = try {
val player = JellyTauPlayer.getInstance()
player.isPlayingVideo() &&
@@ -120,10 +75,9 @@ object PictureInPictureManager {
*/
fun canEnterPip(activity: Activity): Boolean {
if (!isPipSupported(activity)) return false
// Either surface will do: the native one, or the WebView's `<video>`,
// which is what actually plays while experimentalNativeVideo is off.
// (DR-160)
return isNativeVideoPath() || html5VideoActive
// Video only ever renders on the native surface: the WebView `<video>`
// path is gone (DR-235). (DR-160)
return isNativeVideoPath()
}
/**
@@ -186,9 +140,7 @@ object PictureInPictureManager {
return clampedRatio(surface.width.toDouble() / surface.height.toDouble())
}
// No native surface: the WebView is the video, so use the intrinsic size
// the frontend reported. (DR-160)
return html5AspectRatio
return null
}
/**
@@ -207,17 +159,11 @@ object PictureInPictureManager {
@RequiresApi(Build.VERSION_CODES.O)
private fun buildPlayPauseAction(activity: Activity): RemoteAction {
// On the HTML5 path ExoPlayer is idle, so its `isPlaying` is always false
// and the button would be stuck showing "Play" mid-playback. (DR-160)
val isPlaying = if (isNativeVideoPath()) {
try {
val isPlaying = try {
JellyTauPlayer.getInstance().getExoPlayer().isPlaying
} catch (e: Exception) {
false
}
} else {
html5VideoPlaying
}
val (iconRes, title, controlType, requestCode) = if (isPlaying) {
Quad(
@@ -288,11 +234,8 @@ object PictureInPictureManager {
*/
fun onPipModeChanged(activity: Activity, isInPipMode: Boolean) {
if (isInPipMode) {
// Hiding the WebView is correct only when the video is *behind* it on
// the native surface. On the HTML5 path the WebView is the video, so
// hiding it would leave an empty black PiP window — the frontend
// instead strips its own chrome when it hears the event below.
// (DR-160)
// The video is *behind* the WebView on the native surface, so hiding
// the WebView leaves only the picture. (DR-160)
if (isNativeVideoPath()) {
hideWebView(activity)
}
@@ -315,9 +258,8 @@ object PictureInPictureManager {
/**
* Fire a DOM event into the WebView.
*
* The HTML5 PiP path is a conversation with the frontend rather than
* something native can do alone: it has to be told to strip its chrome when
* the window shrinks, and to play/pause the element. (DR-160)
* Tells the frontend the window shrank or grew, so it can strip or restore
* its chrome. (DR-160)
*/
private fun dispatchWebEvent(activity: Activity, name: String) {
val webView = findWebView(activity.window.decorView) ?: return
@@ -358,7 +300,6 @@ object PictureInPictureManager {
if (intent?.action != ACTION_MEDIA_CONTROL) return
val control = intent.getIntExtra(EXTRA_CONTROL_TYPE, 0)
if (isNativeVideoPath()) {
val player = try {
JellyTauPlayer.getInstance()
} catch (e: Exception) {
@@ -368,19 +309,6 @@ object PictureInPictureManager {
CONTROL_PLAY -> player.play()
CONTROL_PAUSE -> player.pause()
}
} else {
// The WebView owns playback here, so the command has to reach
// the `<video>` element. Driving ExoPlayer instead would do
// nothing at all, which is what a PiP button on the HTML5 path
// used to do. (DR-160)
val name = when (control) {
CONTROL_PLAY -> "jellytau-pip-play"
CONTROL_PAUSE -> "jellytau-pip-pause"
else -> return
}
dispatchWebEvent(activity, name)
html5VideoPlaying = control == CONTROL_PLAY
}
// Swap the button to reflect the new state.
updatePipActions(activity)
}
@@ -7,14 +7,12 @@ import android.view.WindowManager
import java.lang.ref.WeakReference
/**
* Which playback paths currently want the screen kept awake.
* Whether playback currently wants the screen kept awake.
*
* Pure state, deliberately free of any Android type so it can be unit-tested —
* see ScreenWakeStateTest. Two independent holders, because video can be
* rendered by either renderer and only one of them is active at a time:
*
* - **native** — ExoPlayer drawing into the TextureView (DR-192)
* - **html5** — a `<video>` inside the WebView, reported by the frontend
* see ScreenWakeStateTest. The one holder is ExoPlayer drawing video into the
* TextureView (DR-192); the WebView `<video>` that was a second holder is gone
* (DR-235).
*
* Audio is deliberately *not* a holder. Playing music with the screen off is the
* point of the audio path; only video needs the display alive.
@@ -23,11 +21,10 @@ import java.lang.ref.WeakReference
*/
class ScreenWakeState {
private var nativeVideoPlaying = false
private var html5VideoPlaying = false
/** True while any video renderer is actively playing. */
/** True while video is actively playing. */
val keepScreenOn: Boolean
get() = nativeVideoPlaying || html5VideoPlaying
get() = nativeVideoPlaying
/**
* @param playing whether ExoPlayer is playing right now
@@ -37,18 +34,9 @@ class ScreenWakeState {
nativeVideoPlaying = playing && isVideo
}
/**
* @param active whether a webview `<video>` is the current playback surface
* @param playing whether that element is playing right now
*/
fun updateHtml5(active: Boolean, playing: Boolean) {
html5VideoPlaying = active && playing
}
/** Drop every hold (teardown, or a page that can no longer be trusted). */
fun reset() {
nativeVideoPlaying = false
html5VideoPlaying = false
}
}
@@ -66,9 +54,7 @@ class ScreenWakeState {
* appeared nowhere, and neither renderer supplies one for free — ExoPlayer's
* `setWakeMode` is a *CPU/wifi* wake lock and says nothing about the display,
* and it draws into a `TextureView` we own rather than a `PlayerView`, which is
* the media3 widget that would otherwise set `keepScreenOn` itself. The WebView
* `<video>` path does not either: the display wake lock Chrome takes for video
* lives in the browser layer, not in an embedded WebView.
* the media3 widget that would otherwise set `keepScreenOn` itself.
*
* ## Approach
*
@@ -78,15 +64,9 @@ class ScreenWakeState {
* missed release the way an explicitly acquired wake lock can. It needs no
* permission. (The manifest's `WAKE_LOCK` is the media service's, unrelated.)
*
* The two renderers report independently and are OR-ed together in
* [ScreenWakeState]:
*
* - `JellyTauPlayer.onIsPlayingChanged` and its surface teardown drive the
* native path — ExoPlayer is the authoritative source of playback state, so
* the hold follows what it reports rather than what the UI intends.
* - `MainActivity`'s `AndroidPictureInPicture.setHtml5VideoState` bridge drives
* the webview path. The frontend already reports that state on every
* play/pause and on player teardown for PiP, so no new bridge is needed.
* `JellyTauPlayer.onIsPlayingChanged` and its surface teardown drive
* [ScreenWakeState] — ExoPlayer is the authoritative source of playback state,
* so the hold follows what it reports rather than what the UI intends.
*
* The Activity reference is weak and re-set on every `onCreate`, so a
* recreation (rotation) re-applies the current hold to the new window.
@@ -126,20 +106,9 @@ object ScreenWakeManager {
}
/**
* The frontend reported the webview `<video>` state. Arrives on a WebView
* binder thread, hence the synchronization and the post to the main thread.
*/
@Synchronized
fun onHtml5VideoState(active: Boolean, playing: Boolean) {
state.updateHtml5(active, playing)
apply()
}
/**
* Drop every hold. Used when a new WebView/page load invalidates whatever the
* previous page last reported — a page that goes away without a final
* `setHtml5VideoState(false, …)` would otherwise leave the screen pinned on
* for the life of the process.
* Drop every hold. Used when a new WebView/page load starts from scratch, so
* nothing held for the previous page can pin the screen on for the life of
* the process.
*/
@Synchronized
fun releaseAll() {
@@ -40,46 +40,9 @@ class ScreenWakeStateTest {
}
@Test
fun `webview video playing holds the screen on`() {
val state = ScreenWakeState()
state.updateHtml5(active = true, playing = true)
assertTrue(state.keepScreenOn)
}
@Test
fun `webview video paused releases the screen`() {
val state = ScreenWakeState()
state.updateHtml5(active = true, playing = true)
state.updateHtml5(active = true, playing = false)
assertFalse(state.keepScreenOn)
}
/** The element going away must release even if it never reported a pause. */
@Test
fun `webview video going inactive while playing releases the screen`() {
val state = ScreenWakeState()
state.updateHtml5(active = true, playing = true)
state.updateHtml5(active = false, playing = true)
assertFalse(state.keepScreenOn)
}
/** The two rendering paths are independent holders; either one is enough. */
@Test
fun `one path releasing does not release while the other still plays`() {
fun `teardown releases the hold`() {
val state = ScreenWakeState()
state.updateNative(playing = true, isVideo = true)
state.updateHtml5(active = true, playing = true)
state.updateHtml5(active = false, playing = false)
assertTrue(state.keepScreenOn)
state.updateNative(playing = false, isVideo = true)
assertFalse(state.keepScreenOn)
}
@Test
fun `teardown releases both paths`() {
val state = ScreenWakeState()
state.updateNative(playing = true, isVideo = true)
state.updateHtml5(active = true, playing = true)
state.reset()
assertFalse(state.keepScreenOn)
}
+29
View File
@@ -1,3 +1,32 @@
use std::path::PathBuf;
fn main() {
link_windows_libmpv();
tauri_build::build()
}
/// Point the MSVC linker at `mpv.lib` for a Windows target.
///
/// `libmpv-sys` emits `rustc-link-lib=mpv` and nothing else; on Linux the
/// system library satisfies it. For Windows the import library and the DLL it
/// names are staged into `src-tauri/windows-libs/` by
/// `scripts/build-windows-cross.sh` from the builder image's pinned libmpv
/// build — the same directory `tauri.windows.conf.json` bundles the DLL from,
/// so what is linked against and what is shipped cannot come from two places.
///
/// TRACES: UR-004 | DR-237
fn link_windows_libmpv() {
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") {
return;
}
let dir = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap()).join("windows-libs");
println!("cargo:rerun-if-changed={}", dir.join("mpv.lib").display());
if !dir.join("mpv.lib").is_file() {
panic!(
"{} is missing. Windows builds link libmpv from the builder image; \
build through scripts/build-windows-cross.sh, which stages it.",
dir.join("mpv.lib").display()
);
}
println!("cargo:rustc-link-search=native={}", dir.display());
}
+31 -4
View File
@@ -185,18 +185,45 @@ fn confine_to_root(root: &Path, candidate: &Path) -> Result<PathBuf, String> {
///
/// TRACES: DR-211 | UT-205
fn confine_queued_path(root: &Path, file_path: &str) -> Result<String, String> {
// The returned spelling keeps the caller's own separator. Rebuilding it with
// `PathBuf::push` rewrote `downloads/x` as `downloads\x` on Windows, so the
// stored row no longer spelled the path the app built — the contract the
// test below pins, which only ever ran on Linux.
// TRACES: DR-211 | UT-205
let sep = file_path
.chars()
.find(|c| std::path::is_separator(*c))
.unwrap_or('/');
let mut sanitized = PathBuf::new();
let mut spelled = String::new();
let mut need_sep = false;
for component in Path::new(file_path).components() {
match component {
Component::Normal(part) => sanitized.push(sanitize_filename(&part.to_string_lossy())),
let piece = match component {
Component::Normal(part) => sanitize_filename(&part.to_string_lossy()),
// Kept as they are, so `confine_to_root` is the single thing
// deciding whether what they add up to is still inside the root.
other => sanitized.push(other),
other => other.as_os_str().to_string_lossy().into_owned(),
};
sanitized.push(&piece);
match component {
Component::Prefix(_) => spelled.push_str(&piece),
Component::RootDir => {
spelled.push(sep);
need_sep = false;
continue;
}
_ => {
if need_sep {
spelled.push(sep);
}
spelled.push_str(&piece);
}
}
need_sep = !matches!(component, Component::Prefix(_));
}
confine_to_root(root, &root.join(&sanitized))?;
Ok(sanitized.to_string_lossy().to_string())
Ok(spelled)
}
/// Request payload for download_item_and_start (bundled to stay within specta's
+86 -235
View File
@@ -69,10 +69,6 @@ pub struct PlayerStatus {
pub muted: bool,
pub shuffle: bool,
pub repeat: RepeatMode,
/// Backend being used (native = ExoPlayer/libmpv, html5 = fallback)
pub backend: VideoBackend,
/// Whether frontend should render HTML5 video element
pub use_html5_element: bool,
// Merged fields (prefer remote session when available)
/// Media item from either local queue or remote session
@@ -156,16 +152,6 @@ pub struct QueueStatus {
pub has_previous: bool,
}
/// Backend type for video playback
#[derive(specta::Type, Debug, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum VideoBackend {
/// Native backend (ExoPlayer on Android, libmpv on Linux)
Native,
/// HTML5 video element fallback
Html5,
}
/// Request to play a single video item
///
/// Simplified to video playback only. Audio playback uses player_play_tracks
@@ -217,9 +203,8 @@ pub struct PlayItemRequest {
pub series_id: Option<String>,
/// 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
@@ -328,19 +313,6 @@ pub enum VideoSeekResponse {
/// Confirmed position after seek
position: f64,
},
/// Reload stream from new position (transcoded non-HLS)
ReloadStream {
/// What to open, and how — transport included, so the frontend picks
/// its loader from a tagged enum rather than by searching the URL for
/// `.m3u8`. TRACES: UR-079 | DR-225
selection: StreamSelection,
/// `seek_offset` carries the position to RESUME AT, not a base to add to
/// the element's clock. The reloaded stream starts at the item's zero —
/// a position on an HLS playlist makes the server 400 every segment
/// behind it (DR-181) — so the adapter reaches the position by seeking
/// the element and leaves the transcode offset at zero.
seek_offset: f64,
},
}
/// Response for audio track switching operations
@@ -352,13 +324,6 @@ pub enum AudioTrackSwitchResponse {
/// Confirmation message
success: bool,
},
/// HTML5 needs to reload stream with new audio track
ReloadStream {
/// What to open, and how. TRACES: UR-079 | DR-225
selection: StreamSelection,
/// Current position to resume from
position: f64,
},
}
/// Response for a mid-playback streaming-quality change.
@@ -388,16 +353,6 @@ pub enum StreamQualityResponse {
/// Position playback resumed at.
position: f64,
},
/// HTML5 must reload its element with this selection.
ReloadStream {
/// What to open, and how — already negotiated against the requested
/// ceiling. Carries `available` too, so a picker opened after a quality
/// change still describes the source correctly.
/// TRACES: UR-070, UR-079 | DR-225, DR-227
selection: StreamSelection,
/// Position to resume from.
position: f64,
},
}
/// Helper function to create MediaItem from video request
@@ -726,34 +681,18 @@ pub async fn player_play_item(
}
let controller = player.0.lock().await;
// Who gets the stream depends on who is going to *render* it, which is a
// runtime question, not a platform constant.
// The backend always gets the stream: every video renderer is native (mpv,
// ExoPlayer) since the webview path was deleted (DR-235). This used to ask
// who would render — the webview's `<video>` played it itself, so the
// backend was only told about it (`set_current_item`) — and got the answer
// wrong twice: the Linux guard silenced mpv video entirely once mpv drew the
// picture, and on Windows it loaded video into the backend while the status
// sent it to the element too.
//
// Historically Linux video was always the webview's (`use_html5_element`),
// so handing the file to MPV as well would only have started a redundant
// decode with no window to show it in — hence a `#[cfg(not(linux))]` guard
// and a queue-only path here. With mpv drawing the picture that inverts:
// the webview is no longer loading anything, so if this does not load the
// file, *nothing does*. The symptom is total silence — no picture and no
// audio — which reads like a broken stream rather than a stream nobody was
// given.
//
// This is the fifth place in this cycle where a renderer's capability was
// written as a compile-time platform fact. Same fix as the others: ask.
//
// TRACES: UR-080 | DR-231, DR-235
let renders_natively = cfg!(not(target_os = "linux")) || crate::player::native_video::enabled();
if renders_natively {
// TRACES: UR-080 | DR-231, DR-235, DR-237
controller
.play_item(media_item)
.map_err(|e| e.to_string())?;
} else {
// The webview will play it; keep the queue in sync for the UI and for a
// remote transfer without starting a second decode.
controller
.set_current_item(media_item)
.map_err(|e| e.to_string())?;
}
// Emit queue changed event
controller.emit_queue_changed();
@@ -776,8 +715,8 @@ pub async fn player_play_item(
/// `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
@@ -893,7 +832,7 @@ pub async fn player_enter_background_audio(
}
/// 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
@@ -1188,8 +1127,9 @@ pub async fn player_stop(
let mode = playback_mode.0.get_mode();
// Stopping is a state transition worth seeing in a log. Native video is
// what made its absence matter: the webview <video> stopped implicitly when
// the component unmounted, so nothing ever had to call this — and "never
// what made its absence matter: the (since deleted) webview <video> stopped
// implicitly when the component unmounted, so nothing ever had to call
// this — and "never
// called" and "called but the backend kept playing" look identical from
// outside without it.
info!("[player_stop] called (mode: {:?})", mode);
@@ -1430,8 +1370,8 @@ pub async fn player_seek(
/// - 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).
#[tauri::command]
#[specta::specta]
pub async fn player_seek_video(
@@ -1441,12 +1381,8 @@ pub async fn player_seek_video(
position: f64,
media_source_id: Option<String>,
audio_stream_index: Option<i32>,
use_html5: bool,
) -> Result<VideoSeekResponse, String> {
info!(
"[player_seek_video] Seeking to {} seconds (use_html5: {})",
position, use_html5
);
info!("[player_seek_video] Seeking to {} seconds", position);
// Get repository
let repository = repository_manager
@@ -1488,17 +1424,13 @@ pub async fn player_seek_video(
let controller = player.0.lock().await;
controller.capabilities().seeks_transcoded_in_place
};
let strategy = determine_video_seek_strategy(
is_local,
seeks_transcoded_in_place,
needs_transcoding,
use_html5,
);
let strategy =
determine_video_seek_strategy(is_local, seeks_transcoded_in_place, needs_transcoding);
info!(
"[player_seek_video] Stream analysis: is_local={}, seeks_transcoded_in_place={}, \
needs_transcoding={}, use_html5={}, strategy={:?}",
is_local, seeks_transcoded_in_place, needs_transcoding, use_html5, strategy
needs_transcoding={}, strategy={:?}",
is_local, seeks_transcoded_in_place, needs_transcoding, strategy
);
match strategy {
@@ -1509,35 +1441,6 @@ pub async fn player_seek_video(
controller.seek(position).map_err(|e| e.to_string())?;
Ok(VideoSeekResponse::Native { position })
}
VideoSeekStrategy::Html5NativeSeek => {
// HTML5 backend with HLS or direct play - frontend handles seeking
// We don't call backend.seek() because video is in HTML5 element, not in MPV
info!("[player_seek_video] HTML5 native seek - returning position for frontend");
Ok(VideoSeekResponse::Native { position })
}
VideoSeekStrategy::Html5ReloadStream => {
// Transcoded non-HLS with HTML5 - frontend handles stream reload
info!("[player_seek_video] HTML5 reload stream - requesting new stream URL");
let selection = repository
.get_stream_selection(
&jellyfin_item_id,
media_source_id.as_deref(),
audio_stream_index,
)
.await
.map_err(|e| format!("Failed to select a stream: {:?}", e))?;
info!(
"[player_seek_video] Selected {:?} over {:?} for position {}",
selection.playback_kind, selection.transport, position
);
Ok(VideoSeekResponse::ReloadStream {
selection,
seek_offset: position,
})
}
VideoSeekStrategy::BackendReloadStream => {
// Transcoded non-HLS with native backend - backend handles stream reload
info!("[player_seek_video] Backend reload stream - requesting new stream URL");
@@ -1608,9 +1511,6 @@ pub async fn player_seek_video(
/// 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
@@ -1626,10 +1526,8 @@ pub async fn player_seek_video(
/// 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
#[tauri::command]
@@ -1644,12 +1542,13 @@ pub async fn player_switch_audio_track(
repository_handle: String,
stream_index: i32,
array_index: i32,
use_html5: bool,
current_position: Option<f64>,
media_source_id: Option<String>,
) -> Result<AudioTrackSwitchResponse, String> {
info!("[player_switch_audio_track] Switching to audio track - stream_index: {}, array_index: {}, use_html5: {}",
stream_index, array_index, use_html5);
info!(
"[player_switch_audio_track] Switching to audio track - stream_index: {}, array_index: {}",
stream_index, array_index
);
// Read what the engine is playing before deciding anything — including
// where it is, which has to be captured before the stop below wipes it.
@@ -1673,11 +1572,11 @@ pub async fn player_switch_audio_track(
)
};
let strategy = determine_audio_track_switch_strategy(needs_transcoding, use_html5);
let strategy = determine_audio_track_switch_strategy(needs_transcoding);
info!(
"[player_switch_audio_track] needs_transcoding={}, use_html5={}, strategy={:?}",
needs_transcoding, use_html5, strategy
"[player_switch_audio_track] needs_transcoding={}, strategy={:?}",
needs_transcoding, strategy
);
if strategy == AudioTrackSwitchStrategy::BackendSelectInPlace {
@@ -1698,7 +1597,7 @@ pub async fn player_switch_audio_track(
// Select a stream carrying the chosen audio track. It starts at zero —
// an HLS playlist cannot carry a position (DR-181) — so the position is
// restored by seeking afterwards, here or in the frontend.
// restored by seeking afterwards.
//
// Pinning a track is itself a reason the source cannot be direct-played:
// the file has one default track and the viewer asked for another, so
@@ -1719,10 +1618,6 @@ pub async fn player_switch_audio_track(
let position = crate::player::track_switch::resume_position(current_position, engine_position);
match strategy {
AudioTrackSwitchStrategy::Html5ReloadStream => Ok(AudioTrackSwitchResponse::ReloadStream {
selection,
position,
}),
AudioTrackSwitchStrategy::BackendReloadStream => {
// The native backend re-opens its own stream, the same sequence the
// transcoded seek and quality change use: stop, repoint the queue
@@ -1781,9 +1676,7 @@ pub async fn player_switch_audio_track(
/// 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
@@ -1807,15 +1700,13 @@ pub async fn player_set_stream_quality(
repository_manager: State<'_, super::repository::RepositoryManagerWrapper>,
repository_handle: String,
quality: crate::settings::StreamingQuality,
use_html5: bool,
current_position: Option<f64>,
media_source_id: Option<String>,
audio_stream_index: Option<i32>,
) -> Result<StreamQualityResponse, String> {
info!(
"[player_set_stream_quality] Switching to {} (use_html5: {}, position: {:?})",
"[player_set_stream_quality] Switching to {} (position: {:?})",
quality.label(),
use_html5,
current_position
);
@@ -1880,14 +1771,7 @@ pub async fn player_set_stream_quality(
.map_err(|e| format!("Failed to select a stream: {:?}", e))?;
let new_url = selection.url.clone();
if use_html5 {
return Ok(StreamQualityResponse::ReloadStream {
selection,
position,
});
}
// Native backend (Android/ExoPlayer): stop, repoint the queue entry at the
// The native backend (mpv, ExoPlayer): stop, repoint the queue entry at the
// new URL, and reload — mirroring `VideoSeekStrategy::BackendReloadStream`.
// The re-opened stream begins at zero (an HLS playlist cannot carry a start
// position without 400ing every segment — DR-181), so it is seeked back to
@@ -1940,8 +1824,8 @@ pub async fn player_set_audio_track(
///
/// 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
#[tauri::command]
@@ -2184,33 +2068,13 @@ pub async fn player_get_queue(
#[serde(rename_all = "camelCase")]
pub struct 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.
pub uses_webview_audio: bool,
/// True when video can be rendered by a native surface composited *behind*
/// a transparent webview. Android only: ExoPlayer draws into a SurfaceView
/// beneath the WebView. Linux cannot do this (WebKitGTK/Wayland
/// compositing), so it stays on the HTML5 element.
pub supports_native_video: bool,
/// 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. See [`webview_video_fallback`].
pub webview_video_fallback: bool,
}
/// Whether the user may send video to the webview `<video>` element instead of
/// the native renderer.
///
/// Never on Android: ExoPlayer is its only video renderer. Downloads there are
/// the untouched source file (DR-293), and the webview decodes none of the
/// AC-3/E-AC-3/DTS/TrueHD that ExoPlayer plays through the FFmpeg extension, so
/// the fallback would be a silent film. Beside mpv's native video on Linux the
/// webview is still the tested fallback; everywhere else it is the only
/// renderer and there is nothing to switch.
///
/// TRACES: UR-003, UR-071 | DR-293 | UT-259
pub fn webview_video_fallback(is_android: bool, native_video_enabled: bool) -> bool {
!is_android && native_video_enabled
}
/// Report this platform's playback capabilities to the frontend.
@@ -2219,42 +2083,24 @@ pub fn webview_video_fallback(is_android: bool, native_video_enabled: bool) -> b
#[tauri::command]
#[specta::specta]
pub async fn player_get_capabilities() -> Result<PlaybackCapabilities, String> {
// Mirrors the cfg gates the backends themselves are built under.
let native_audio = cfg!(any(target_os = "android", target_os = "linux"));
// Mirrors the cfg gates the backends themselves are built under: mpv on
// Linux and Windows (DR-237), ExoPlayer on Android.
let native_audio = cfg!(any(
target_os = "android",
target_os = "linux",
target_os = "windows"
));
Ok(PlaybackCapabilities {
uses_webview_audio: !native_audio,
// TRACES: UR-080 | DR-235
supports_native_video: cfg!(target_os = "android")
|| crate::player::native_video::enabled(),
// TRACES: UR-003, UR-071 | DR-293
webview_video_fallback: webview_video_fallback(
cfg!(target_os = "android"),
crate::player::native_video::enabled(),
),
})
}
pub(super) fn get_player_status(controller: &PlayerController) -> PlayerStatus {
// Determine backend at compile time based on platform
let (backend, use_html5_element) = if cfg!(target_os = "android") {
// Android uses ExoPlayer native backend
(VideoBackend::Native, false)
} else if crate::player::native_video::enabled() {
// mpv draws the picture on this desktop; the frontend must not also
// load it into a <video> element or the stream decodes twice and the
// two fight over the audio. TRACES: UR-080 | DR-235
(VideoBackend::Native, false)
} else {
// Linux and other platforms use HTML5 video element in frontend
(VideoBackend::Html5, true)
};
PlayerStatus {
state: controller.state(),
// The position on the item's timeline, whichever of the three paths is
// rendering it — the native backend answers for only one of them, and
// reads 0 for webview video and for a handoff that has not ticked yet.
// The position on the item's timeline, whichever path is rendering it —
// the native backend reads 0 for a handoff that has not ticked yet.
// TRACES: UR-005 | DR-178
position: controller.absolute_position(),
duration: controller.duration(),
@@ -2262,8 +2108,6 @@ pub(super) fn get_player_status(controller: &PlayerController) -> PlayerStatus {
muted: controller.muted(),
shuffle: controller.is_shuffle(),
repeat: controller.repeat_mode(),
backend,
use_html5_element,
// Merged fields initialized to defaults (will be set by player_get_status)
merged_media: None,
@@ -3087,35 +2931,42 @@ pub async fn player_disable_jellyfin(player: State<'_, PlayerStateWrapper>) -> R
mod tests {
use crate::utils::lock::MutexSafe;
/// Android has one video renderer, ExoPlayer. The webview element could only
/// be reached by the user switching native video off, and a file downloaded
/// as the untouched original — AC-3 audio included — plays silent there,
/// so the switch is gone on Android (DR-293). Where mpv draws video on Linux
/// the webview is still the tested fallback, so the switch stays there;
/// everywhere else the webview is the only renderer and there is nothing to
/// switch.
/// Video always goes to the backend. `player_play_item` once decided per
/// platform whether the backend or the webview's `<video>` would render,
/// and each wrong answer was silence (Linux, once mpv drew the picture) or a
/// soundtrack decoded twice (Windows). With the webview video path deleted
/// there is no second renderer to route to, and the queue-only branch is
/// gone with it.
///
/// TRACES: UR-003, UR-071 | DR-293 | UT-259
/// TRACES: UR-003, UR-080 | DR-235, DR-237 | UT-273
#[test]
fn test_webview_video_fallback_is_offered_only_beside_mpv_native_video() {
use super::webview_video_fallback;
fn test_video_always_goes_to_the_backend() {
let src = include_str!("mod.rs");
let play_item = src
.split("pub async fn player_play_item(")
.nth(1)
.and_then(|rest| rest.split("\n}\n").next())
.expect("player_play_item exists");
assert!(play_item.contains(".play_item(media_item)"));
assert!(
!play_item.contains(".set_current_item("),
"player_play_item must not keep video from the backend"
);
}
assert!(
!webview_video_fallback(true, false),
"Android: ExoPlayer is the only video renderer"
);
assert!(
!webview_video_fallback(true, true),
"Android never falls back, whatever else is switched on"
);
assert!(
webview_video_fallback(false, true),
"Linux with mpv native video: the webview is the fallback"
);
assert!(
!webview_video_fallback(false, false),
"the webview is the only renderer; nothing to fall back from"
);
/// Only audio can still be the webview's, and only on a desktop with no mpv.
///
/// TRACES: UR-003, UR-080 | DR-235, DR-237 | UT-272
#[tokio::test]
async fn test_every_shipped_platform_plays_audio_natively() {
let caps = super::player_get_capabilities().await.unwrap();
if cfg!(any(
target_os = "linux",
target_os = "windows",
target_os = "android"
)) {
assert!(!caps.uses_webview_audio);
}
}
/// UT-206 — the volume the command hands on is always a real number in
+10 -9
View File
@@ -138,7 +138,7 @@ pub async fn player_play_next_episode(
/// 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
///
@@ -158,7 +158,7 @@ pub async fn player_on_playback_ended(
let controller_arc = player.0.clone();
// Run autoplay decision logic
// If item_id is provided (HTML5 video case), use the video-specific path
// If item_id is provided (a video), use the video-specific path
// that bypasses the backend queue and stale end_reason
let decision = {
let controller = controller_arc.lock().await;
@@ -326,16 +326,17 @@ pub async fn player_recover_stream(player: State<'_, PlayerStateWrapper>) -> Res
}
}
// ===== HTML5 video state-report commands =====
// ===== Webview media state-report commands =====
//
// On platforms where video renders in the webview (Linux WebKitGTK HTML5
// <video>), the real player lives outside the native backend, so the frontend
// HTML5 adapter reports DOM events back through these commands. The controller
// Where media renders in the webview — the `<audio>` element of the webview
// audio backend, on a desktop with no mpv; video never does since DR-235 — the
// real player lives outside the native backend, so the frontend adapter reports
// DOM events back through these commands. The controller
// re-emits them through the same PlayerStatusEvent pipeline the native backends
// use, keeping the Rust controller the single source of truth and the frontend
// player store fed from one place (playerEvents.ts) in both modes.
/// Report an HTML5 <video> state change (playing/paused/loading/stopped/idle).
/// Report a webview media element's state change (playing/paused/loading/stopped/idle).
#[tauri::command]
#[specta::specta]
pub async fn player_report_state(
@@ -348,7 +349,7 @@ pub async fn player_report_state(
Ok(())
}
/// 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.
#[tauri::command]
#[specta::specta]
@@ -362,7 +363,7 @@ pub async fn player_report_position(
Ok(())
}
/// Report that the HTML5 <video> finished loading and knows its duration.
/// Report that a webview media element finished loading and knows its duration.
#[tauri::command]
#[specta::specta]
pub async fn player_report_media_loaded(
+1
View File
@@ -150,6 +150,7 @@ pub fn run_engine(url: &str, engine: Engine) -> u32 {
None,
std::sync::Arc::new(tokio::sync::Mutex::new(None)),
std::sync::Arc::new(crate::playback_reporting::throttle::EventThrottler::new()),
None,
)
.expect("could not create the legacy backend"),
crate::player::media_player::Capabilities::mpv(),
+48 -126
View File
@@ -352,7 +352,7 @@ use player::{MediaSessionManager, PlayerBackend, PlayerController, TauriEventEmi
// still launch (browse library, manage downloads, see an error) instead of crashing.
use player::NullBackend;
#[cfg(target_os = "linux")]
#[cfg(any(target_os = "linux", target_os = "windows"))]
use player::MpvBackend;
use settings::VideoSettings;
use storage::Database;
@@ -694,15 +694,44 @@ fn create_player_backend(
}
}
// For Linux, use MPV backend for audio playback
#[cfg(target_os = "linux")]
// Linux and Windows: mpv. On Windows libmpv-2.dll ships beside the exe in
// the installer (DR-237); on Linux it is the system library.
#[cfg(any(target_os = "linux", target_os = "windows"))]
{
info!("Linux platform detected - initializing MPV backend for audio");
match MpvBackend::new(Some(_event_emitter), playback_reporter, position_throttler) {
info!("Initializing MPV backend");
// Windows: mpv draws video into the main window itself (DR-237), so it
// needs the HWND before it initialises. Linux draws through the render
// API into a GTK surface attached later, and needs no handle.
#[cfg(target_os = "windows")]
let video_window = {
use tauri::Manager;
app_handle
.get_webview_window("main")
.and_then(|w| w.hwnd().ok())
.map(|hwnd| hwnd.0 as i64)
};
#[cfg(not(target_os = "windows"))]
let video_window = None;
match MpvBackend::new(
Some(_event_emitter),
playback_reporter,
position_throttler,
video_window,
) {
Ok(backend) => {
info!("Successfully initialized MPV backend for Linux");
info!("Successfully initialized MPV backend");
Box::new(backend)
}
#[cfg(target_os = "windows")]
Err(e) => {
// The DLL ships in the installer, so there is no package to
// tell the user to install; a failure here is a broken install.
error!("FATAL ERROR: Failed to initialize MPV backend: {}", e);
error!("libmpv-2.dll should sit beside jellytau.exe; reinstall JellyTau.");
emit_backend_init_failed(&app_handle, "mpv", e.to_string());
Box::new(NullBackend::new())
}
#[cfg(target_os = "linux")]
Err(e) => {
error!("\n========================================");
error!("FATAL ERROR: Failed to initialize MPV backend");
@@ -731,10 +760,10 @@ fn create_player_backend(
}
}
// Platforms with no native audio backend (e.g. Windows): render audio-only
// playback through a webview <audio> element (all video already renders in
// the webview). Falls back to NullBackend only if the backend can't init.
#[cfg(not(any(target_os = "linux", target_os = "android")))]
// Platforms with no native audio backend (none that ships since Windows
// moved to mpv): render audio-only playback through a webview <audio>
// element. Falls back to NullBackend only if the backend can't init.
#[cfg(not(any(target_os = "linux", target_os = "android", target_os = "windows")))]
{
info!("No native audio backend for this platform - using webview <audio> backend");
match player::WebviewAudioBackend::new(_event_emitter) {
@@ -1073,86 +1102,6 @@ fn specta_builder() -> Builder<tauri::Wry> {
])
}
/// Configure GStreamer (the media backend behind WebKitGTK's HTML5 `<video>`
/// element on Linux) to prefer hardware-accelerated VAAPI decoding when the
/// host provides it, falling back to software decoding otherwise.
///
/// All variables are only set if the user has not already exported them, so an
/// explicit override (e.g. forcing software decode for debugging) is respected.
/// They must be applied before WebKitGTK builds its GStreamer pipeline, hence the
/// call at the very top of `run()`.
#[cfg(target_os = "linux")]
fn enable_linux_hardware_video_decoding() {
// Boost the rank of the modern stateless VAAPI decoders (gst-plugins-bad
// `va` plugin) so GStreamer selects them ahead of the software decoders. The
// `MAX` rank wins decoder autoplugging when the hardware/driver supports the
// codec; unsupported codecs simply fall through to software.
let rank_overrides = "vah264dec:MAX,vah265dec:MAX,vavp9dec:MAX,vaav1dec:MAX,\
vampeg2dec:MAX,vavp8dec:MAX";
set_env_if_unset("GST_PLUGIN_FEATURE_RANK", rank_overrides);
// Ensure WebKit keeps GStreamer's hardware/DMABUF video path enabled. Setting
// this to "0" would force software decoding, so only default it to "1".
set_env_if_unset("WEBKIT_GST_ENABLE_HW_VIDEO_DECODER", "1");
info!("[INIT] Linux hardware video decoding (VAAPI) enabled where supported");
log_available_vaapi_decoders();
}
/// Probe (via `gst-inspect-1.0`, which ships with GStreamer) which VAAPI hardware
/// video decoders GStreamer can actually load on this host, and log the result so
/// it is clear at startup whether hardware decoding is genuinely available or
/// whether playback will fall back to software.
#[cfg(target_os = "linux")]
fn log_available_vaapi_decoders() {
const HW_DECODERS: &[&str] = &[
"vah264dec",
"vah265dec",
"vavp9dec",
"vaav1dec",
"vampeg2dec",
"vavp8dec",
];
let available: Vec<&str> = HW_DECODERS
.iter()
.copied()
.filter(|name| {
std::process::Command::new("gst-inspect-1.0")
.arg(name)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
})
.collect();
if available.is_empty() {
log::warn!(
"[INIT] No VAAPI hardware video decoders found via gst-inspect-1.0; \
video will use software decoding. Install the GStreamer 'va' plugin \
(gst-plugins-bad) and a VAAPI driver to enable hardware decoding."
);
} else {
info!(
"[INIT] VAAPI hardware video decoders available to GStreamer: {}",
available.join(", ")
);
}
}
#[cfg(target_os = "linux")]
fn set_env_if_unset(key: &str, value: &str) {
if std::env::var_os(key).is_none() {
// SAFETY: called once at startup before any threads that read the
// environment (WebKitGTK/GStreamer) are spawned.
std::env::set_var(key, value);
}
}
/// Cached thumbnails are handed to the webview as asset-protocol URLs by
/// `convertFileSrc` (`asset://localhost/…` on Linux/macOS,
/// `http://asset.localhost/…` on Windows/Android). Tauri only answers that
@@ -1232,13 +1181,6 @@ pub fn run() {
// TRACES: UR-078 | DR-218
crate::utils::diagnostics::install_panic_hook();
// On Linux, video plays through WebKitGTK's HTML5 <video> element, which uses
// GStreamer as its media backend. Enable hardware-accelerated (VAAPI) decoding
// when available so video transcoding/decoding does not fall back to the CPU.
// These must be set before WebKitGTK initializes its GStreamer pipeline.
#[cfg(target_os = "linux")]
enable_linux_hardware_video_decoding();
// NOTE: TypeScript bindings are generated by the `export_typescript_bindings`
// test (`cargo test export_typescript_bindings`), NOT at runtime. Calling
// `.export()` here would try to write `../src/lib/api/bindings.ts` at app
@@ -1400,40 +1342,20 @@ pub fn run() {
// during its construction, and doing this in the order the code
// used to read produced "no mpv handle" every time — the surface was
// built before there was anything to draw from.
// Native video surface: put a GL area under Tauri's webview so mpv
// can draw beneath the controls (UR-080 / DR-231).
// Native video surface: mpv draws into the main window's own vbox,
// underneath Tauri's webview, so the Svelte controls composite over
// the picture (UR-080 / DR-231). The widget tree is left exactly as
// Tauri built it — wrapping the webview in a GtkOverlay aborts the
// process on the first click; `video_surface` explains why.
//
// 🔴 OFF BY DEFAULT — the naive reparent crashes the app on the
// first click. `tauri-runtime-wry`'s undecorated-resizing handler
// walks a hard-coded two-hop path on every button press in the
// webview:
// Unconditional on Linux since DR-235: mpv is the only Linux video
// renderer, so there is no webview path to fall back to if this
// fails — the warnings below are the whole diagnosis.
//
// webview.parent() // "This one should be GtkBox"
// .parent() // ...and this one the GtkWindow
// .downcast::<gtk::Window>().unwrap()
//
// Wrapping the webview in a GtkOverlay makes that chain
// webview → GtkOverlay → GtkBox, the downcast fails, and because the
// panic is non-unwinding it aborts the process. The decoration check
// that would otherwise make this handler inert runs *after* the
// unwrap, so no window configuration avoids it.
//
// This is the "only place Tauri-specific behaviour could still bite"
// that the spike named as the untested half of G1. It bites. The
// surface attaches perfectly and then dies on interaction, so
// "attached successfully" in the log is not the gate — a click is.
//
// Kept behind an env var rather than deleted so the next attempt has
// something to iterate on: JELLYTAU_NATIVE_VIDEO=1 bun run tauri dev
//
// TRACES: UR-080 | DR-231
// TRACES: UR-080 | DR-231, DR-235
#[cfg(target_os = "linux")]
if crate::player::native_video::enabled() {
use tauri::Manager;
log::warn!(
"[INIT] JELLYTAU_NATIVE_VIDEO=1 — attaching the experimental \
video surface (mpv drawn behind the webview, no reparenting)"
);
if let Some(window) = app.get_webview_window("main") {
match window.default_vbox() {
Ok(vbox) => {
+6 -8
View File
@@ -98,10 +98,9 @@ pub trait PlayerBackend: Send + Sync {
/// Set the active audio track by stream index
///
/// Overridden by the Android (ExoPlayer) backend. `MpvBackend` deliberately
/// does **not** override it — MPV is the audio-only backend here, so it keeps
/// this `not_implemented()` default and the Linux video path switches track by
/// re-opening the stream instead (`player_switch_audio_track`).
/// Overridden by both video backends, ExoPlayer and `MpvBackend`; the
/// argument is a position among the file's audio tracks. A transcode is
/// re-opened instead (`player_switch_audio_track`).
///
/// TRACES: UR-021 | IR-019, DR-024
fn set_audio_track(&mut self, _stream_index: i32) -> Result<(), PlayerError> {
@@ -111,10 +110,9 @@ pub trait PlayerBackend: Send + Sync {
/// Set the active subtitle track by stream index (None to disable subtitles)
///
/// Overridden by the Android (ExoPlayer) backend. `MpvBackend` deliberately
/// does **not** override it, so it keeps this `not_implemented()` default;
/// the Linux video path renders subtitles as `<track>` children of the
/// WebKitGTK HTML5 `<video>` element and never calls this.
/// Overridden by both video backends, ExoPlayer and `MpvBackend`; the
/// argument is a position in the sideloaded subtitle list the play request
/// carried.
///
/// TRACES: UR-020 | IR-018, DR-023
fn set_subtitle_track(&mut self, _stream_index: Option<i32>) -> Result<(), PlayerError> {
+9 -10
View File
@@ -85,9 +85,8 @@ pub enum PlayerStatusEvent {
remaining_seconds: u32,
},
/// 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.
SleepTimerExpired,
/// Show next episode popup with countdown
ShowNextEpisodePopup {
@@ -152,9 +151,9 @@ pub enum PlayerStatusEvent {
/// media item locally), so the native side only signals intent here.
RemoteDisconnectRequested,
/// 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.
ControlCommand {
/// One of: "play", "pause", "stop", "seek".
@@ -164,10 +163,10 @@ pub enum PlayerStatusEvent {
},
/// 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`.
+4
View File
@@ -0,0 +1,4 @@
WEBVTT
00:00.000 --> 00:02.000
fixture subtitle
Binary file not shown.
+5 -18
View File
@@ -140,7 +140,7 @@ pub struct Capabilities {
pub audio_track_switching: bool,
/// A *server-side transcode* can be seeked without re-opening the stream.
///
/// True for hls.js, which seeks within the VOD playlist it is handed and
/// True for ExoPlayer, which seeks within the VOD playlist it is handed and
/// lets the server catch up. False for mpv, whose HLS demuxer cannot make
/// the server transcode from a new offset.
///
@@ -173,9 +173,8 @@ impl Capabilities {
/// ExoPlayer.
///
/// **Can** seek a transcode in place. It is a full HLS client, so like
/// hls.js it seeks within the VOD playlist it was handed and lets the
/// server catch up. Grouping it with mpv as "a native engine" gets this
/// **Can** seek a transcode in place. It is a full HLS client, so it seeks
/// within the VOD playlist it was handed and lets the server catch up. Grouping it with mpv as "a native engine" gets this
/// exactly backwards — being native is not the property that matters here,
/// speaking HLS is, and that is the whole reason this is declared per
/// engine rather than inferred from a category.
@@ -188,18 +187,6 @@ impl Capabilities {
seeks_transcoded_in_place: true,
}
}
/// An engine that renders through the webview element, where hls.js seeks
/// within the playlist it was handed.
pub fn webview() -> Self {
Self {
video: true,
audio_settings: false,
subtitle_switching: true,
audio_track_switching: false,
seeks_transcoded_in_place: true,
}
}
}
/// A request to present an item.
@@ -242,7 +229,7 @@ impl OpenRequest {
/// Anything that can present media.
///
/// Implementations: `MpvPlayer` (Linux/Windows), `ExoPlayerPlayer` (Android),
/// `WebviewPlayer` (HTML5 element), and `FakePlayer` for tests. Every one of
/// and `FakePlayer` for tests. Every one of
/// them must pass [`super::conformance`].
pub trait MediaPlayer: Send {
/// Present `req.selection`, beginning at `req.start`.
@@ -267,7 +254,7 @@ pub trait MediaPlayer: Send {
/// Seek to an absolute position on the item's own timeline.
///
/// Whether that is an in-place seek or a re-open of the stream is the
/// engine's business: hls.js seeks within a VOD playlist, mpv's HLS demuxer
/// engine's business: ExoPlayer seeks within a VOD playlist, mpv's HLS demuxer
/// cannot make a server transcode from a new offset. Callers state the
/// destination and nothing else.
fn seek(&mut self, to: Duration) -> Result<(), PlayerError>;
+21 -45
View File
@@ -15,8 +15,12 @@ mod fake_player_conformance;
pub mod legacy_player;
pub mod media;
pub mod media_player;
#[cfg(any(target_os = "linux", target_os = "windows"))]
pub mod mpv_command;
#[cfg(target_os = "linux")]
pub mod mpv_player;
#[cfg(any(target_os = "linux", target_os = "windows"))]
pub mod mpv_tracks;
pub mod queue;
pub mod seek;
pub mod session;
@@ -40,7 +44,7 @@ pub mod jni_guard;
#[cfg(target_os = "android")]
pub mod android;
#[cfg(target_os = "linux")]
#[cfg(any(target_os = "linux", target_os = "windows"))]
pub mod mpv_backend;
/// Whether this process renders video natively — one answer, three consumers
@@ -62,9 +66,10 @@ pub mod mpv_render;
#[cfg(target_os = "linux")]
pub mod video_surface;
// Platforms with no native audio backend (e.g. Windows) render audio-only
// playback through a webview <audio> element, mirroring how all video renders.
#[cfg(not(any(target_os = "linux", target_os = "android")))]
// Platforms with no native audio backend render audio-only playback through a
// webview <audio> element. None that ships: Windows moved to mpv (DR-237); this
// remains for an unported desktop (macOS).
#[cfg(not(any(target_os = "linux", target_os = "android", target_os = "windows")))]
pub mod webview_audio_backend;
// Re-export commonly used types
@@ -86,10 +91,10 @@ pub use track_switch::{determine_audio_track_switch_strategy, AudioTrackSwitchSt
#[cfg(target_os = "android")]
pub use android::ExoPlayerBackend;
#[cfg(target_os = "linux")]
#[cfg(any(target_os = "linux", target_os = "windows"))]
pub use mpv_backend::MpvBackend;
#[cfg(not(any(target_os = "linux", target_os = "android")))]
#[cfg(not(any(target_os = "linux", target_os = "android", target_os = "windows")))]
pub use webview_audio_backend::WebviewAudioBackend;
#[cfg(target_os = "android")]
@@ -319,9 +324,10 @@ pub struct PlayerController {
background_audio_base: Arc<Mutex<f64>>,
// True while a background-audio handoff owns playback: the native audio
// player is the real player and the webview <video> has been torn down.
// player is the real player and the video has been replaced.
//
// The teardown is what makes this necessary. It fires a DOM `pause` that the
// The teardown is what made this necessary (when a webview <video> was
// torn down). It fires a DOM `pause` that the
// frontend reports like any other, which would otherwise leave the controller
// believing webview media is still active — aiming lockscreen transport at an
// element that no longer exists (see `is_html5_active`).
@@ -338,7 +344,8 @@ pub struct PlayerController {
// TRACES: UR-040 | DR-129
stream_resume: Arc<Mutex<stream_end::ResumeTracker>>,
// Last state reported by a webview-rendered HTML5 <video>/<audio> element.
// Last state reported by a webview-rendered `<audio>` element (the webview
// audio backend; video never renders in the webview since DR-235).
//
// Webview-rendered media is played by an element the native backend cannot
// reach, so the backend's own state() says nothing about it. Tracking the
@@ -418,7 +425,7 @@ impl PlayerController {
/// Configure the media repository used for next-episode lookups.
///
/// The Android ExoPlayer ended-callback calls `on_playback_ended` with no
/// repository handle (unlike the Linux HTML5 path, which passes one per
/// repository handle (unlike a frontend-reported end, which passes one per
/// call), so the controller needs a repository of its own or episode
/// autoplay silently decides Stop.
pub fn set_repository(&self, repo: Arc<dyn MediaRepository>) {
@@ -538,37 +545,6 @@ impl PlayerController {
Ok(())
}
/// Set the current queue item without loading it into the playback backend.
///
/// Used on platforms where video is rendered outside the native backend
/// (Linux WebKitGTK HTML5 <video>): the queue/UI state must reflect the
/// item, but MPV must not start a redundant decode for it.
///
/// Not gated to Linux. Its caller stopped being a `#[cfg]` branch and became
/// a runtime question — "does this renderer draw the picture?" — so the
/// `else` arm is compiled on every platform even where it never runs. The
/// gate outliving its caller broke the Android build outright, which went
/// unnoticed because nothing built for Android afterwards.
pub fn set_current_item(&self, item: MediaItem) -> Result<(), PlayerError> {
debug!(
"[PlayerController] set_current_item (no backend load): {}",
item.title
);
self.reset_autoplay_count();
// A different item is current; the last one's reported position must not
// be reported against it. This path is how webview-rendered video is
// queued (no backend load at all), so it is exactly where a stale
// reading would otherwise survive.
// TRACES: UR-005 | DR-178
self.clear_reported_time();
let mut queue = self.queue.lock_safe();
queue.set_queue(vec![item], 0);
Ok(())
}
/// Load and play an item without modifying the queue
/// Use this when the queue is already set up and you just want to play a specific item from it
pub fn load_and_play(&self, item: &MediaItem) -> Result<(), PlayerError> {
@@ -593,7 +569,7 @@ impl PlayerController {
// intermittent.
//
// The webview re-establishes its own authority the moment an element
// reports again, so nothing is lost on the HTML5 path: this is the same
// reports again, so nothing is lost on the webview path: this is the same
// "element is gone" semantics as the "stopped"/"idle" report, applied at
// the point where we can know it directly.
//
@@ -717,7 +693,7 @@ impl PlayerController {
Ok(())
}
/// True while webview-rendered media (HTML5 `<video>`/`<audio>`) is the real
/// True while webview-rendered media (a webview `<audio>`) is the real
/// player, so transport must be routed to it rather than the native backend.
///
/// TRACES: UR-005 | DR-097
@@ -769,7 +745,7 @@ impl PlayerController {
/// Toggle play/pause.
///
/// The decision is made HERE, from authoritative state — the reported webview
/// state for HTML5-rendered media, or the native backend's state otherwise.
/// state for webview-rendered audio, or the native backend's state otherwise.
/// The frontend must never decide this from the DOM (see DR-097).
///
/// TRACES: UR-005 | DR-097
@@ -1046,7 +1022,7 @@ impl PlayerController {
/// truncation comparison. `position()` alone answers for exactly one of the
/// three ways this app plays media, and reads 0 for the other two:
///
/// - **Webview `<video>`/`<audio>`**: nothing is loaded into the native
/// - **Webview `<audio>`**: nothing is loaded into the native
/// backend, so its position is a permanent 0. The element's own reports are
/// the only reading there is.
/// - **Background-audio handoff**: the audio-only stream's zero is the
+257 -26
View File
@@ -9,7 +9,6 @@ use crate::utils::conversions::{seconds_to_ticks, volume_to_percent};
use crate::utils::lock::MutexSafe;
use libmpv::Mpv;
use log::{debug, error, info, warn};
use std::process::Command;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
@@ -54,8 +53,21 @@ struct InternalState {
volume: f32,
}
/// Detect which audio system is available on the system
/// The audio output mpv should use on Windows: WASAPI, the only one it ships
/// there. Nothing to probe — and spawning `pactl` from a GUI app on Windows
/// would at best fail and at worst flash a console window.
///
/// TRACES: UR-004 | DR-237
#[cfg(target_os = "windows")]
fn detect_audio_system() -> String {
"wasapi".to_string()
}
/// Detect which audio system is available on the system
#[cfg(not(target_os = "windows"))]
fn detect_audio_system() -> String {
use std::process::Command;
info!("[MpvBackend] Detecting audio system...");
// Try PulseAudio/PipeWire first (most common on modern Linux)
@@ -95,6 +107,13 @@ fn detect_audio_system() -> String {
fn get_stream_url(media: &MediaItem) -> String {
match &media.source {
MediaSource::Remote { stream_url, .. } => stream_url.clone(),
// A Windows path is not a URL (`file://C:\...` is malformed); mpv
// takes the native path as it is. Safe to pass verbatim because it goes
// to mpv as one argv element (DR-298), not through a command string.
// TRACES: UR-004, UR-071 | DR-237
MediaSource::Local { file_path, .. } if cfg!(target_os = "windows") => {
file_path.to_string_lossy().into_owned()
}
MediaSource::Local { file_path, .. } => {
format!("file://{}", file_path.to_string_lossy())
}
@@ -121,6 +140,8 @@ static MPV_HANDLE: std::sync::OnceLock<usize> = std::sync::OnceLock::new();
/// can fail, and the app falls back to a no-op backend rather than dying).
///
/// TRACES: UR-080 | DR-231
// Only the Linux video surface reads it until Windows gets one (DR-237).
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
pub fn registered_handle() -> *mut libmpv_sys::mpv_handle {
MPV_HANDLE
.get()
@@ -128,12 +149,73 @@ pub fn registered_handle() -> *mut libmpv_sys::mpv_handle {
.unwrap_or(std::ptr::null_mut())
}
/// How mpv shows video on this platform.
///
/// TRACES: UR-080 | DR-231, DR-237
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum VideoOutput {
/// No picture: audio-only playback, or nowhere to draw.
Off,
/// Linux: frames through the render API into the GTK surface beneath the
/// webview (`video_surface`).
RenderApi,
/// Windows: mpv renders as a child of the app's own window (`wid`, set
/// before initialisation), beneath the transparent WebView2 — the
/// arrangement tauri-plugin-libmpv ships on Windows.
Window(i64),
}
impl VideoOutput {
/// Runtime options for this output. `wid` is not among them: it only takes
/// effect before initialisation, so the constructor sets it separately.
pub(crate) fn options(&self) -> Vec<(&'static str, String)> {
match self {
VideoOutput::Off => vec![("video", "no".to_string())],
VideoOutput::RenderApi => vec![("vo", "libmpv".to_string())],
VideoOutput::Window(_) => [
// libplacebo's renderer, with the classic one as fallback for a
// build or GPU that lacks it.
("vo", "gpu-next,gpu"),
// mpv is a surface here, not a player: the app's controls are
// drawn over it, so its own controller and bindings must not
// answer clicks, keys or the cursor.
("osc", "no"),
("input-default-bindings", "no"),
("input-vo-keyboard", "no"),
("input-cursor", "no"),
("cursor-autohide", "no"),
]
.into_iter()
.map(|(k, v)| (k, v.to_string()))
.collect(),
}
}
}
/// Decide the video output from whether native video is on, the platform, and
/// the app window's handle (Windows only).
///
/// TRACES: UR-080 | DR-231, DR-237 | UT-274
pub(crate) fn video_output(native: bool, is_windows: bool, window: Option<i64>) -> VideoOutput {
match (native, is_windows, window) {
(false, _, _) => VideoOutput::Off,
(true, true, Some(wid)) => VideoOutput::Window(wid),
// No handle: mpv would open a top-level window of its own.
(true, true, None) => VideoOutput::Off,
(true, false, _) => VideoOutput::RenderApi,
}
}
impl MpvBackend {
/// Create a new MPV backend
///
/// `video_window` is the app window's native handle (an HWND), which mpv
/// draws video into on Windows; `None` elsewhere.
pub fn new(
event_emitter: Option<Arc<dyn PlayerEventEmitter>>,
playback_reporter: Arc<TokioMutex<Option<PlaybackReporter>>>,
position_throttler: Arc<EventThrottler>,
video_window: Option<i64>,
) -> Result<Self, PlayerError> {
info!("[MpvBackend] Initializing MPV backend...");
@@ -145,9 +227,26 @@ impl MpvBackend {
libc::setlocale(libc::LC_NUMERIC, c_locale.as_ptr());
}
let mpv = Mpv::new().map_err(|e| PlayerError {
let output = video_output(
super::native_video::enabled(),
cfg!(target_os = "windows"),
video_window,
);
if super::native_video::enabled() && output == VideoOutput::Off {
error!("[MpvBackend] no window handle to draw video into; video will have no picture");
}
// `wid` only takes effect before initialisation. TRACES: UR-080 | DR-237
let mpv = Mpv::with_initializer(|init| {
if let VideoOutput::Window(wid) = output {
init.set_property("wid", wid)?;
}
Ok(())
})
.map_err(|e| PlayerError {
message: format!("Failed to initialize MPV: {:?}", e),
})?;
// TRACES: UR-012 | DR-299
super::mpv_command::harden(&mpv).map_err(|message| PlayerError { message })?;
// Detect and configure audio output
let audio_driver = detect_audio_system();
@@ -178,26 +277,23 @@ impl MpvBackend {
// Video is disabled unless this process is drawing it.
//
// `video: no` is why mpv has never decoded a frame here: Linux video has
// always gone through the webview, and decoding it twice would burn a
// core for a picture nobody sees. With native video on, mpv needs both
// the decoder *and* `vo=libmpv` — the render API only works through that
// output, and the default would try to open a window of its own.
// Linux video went through the webview until DR-235, and decoding it
// here too would have burned a core for a picture nobody saw — hence
// `video: no`. With native video, mpv needs the decoder *and* an output
// that draws where the app wants it: the render API on Linux (the default
// would open a window of its own), the app's window on Windows.
//
// Set at construction because mpv resolves the video output when it
// initialises; flipping it later does not re-open one.
//
// TRACES: UR-080 | DR-231, DR-235
if super::native_video::enabled() {
mpv.set_property("vo", "libmpv").map_err(|e| PlayerError {
message: format!("Failed to select the libmpv video output: {:?}", e),
})?;
info!("[MpvBackend] native video enabled (vo=libmpv)");
} else {
mpv.set_property("video", "no").map_err(|e| PlayerError {
message: format!("Failed to configure MPV video: {:?}", e),
// TRACES: UR-080 | DR-231, DR-235, DR-237
for (name, value) in output.options() {
mpv.set_property(name, value.as_str())
.map_err(|e| PlayerError {
message: format!("Failed to set {name}={value}: {:?}", e),
})?;
}
info!("[MpvBackend] video output: {:?}", output);
// Set volume to 100% (we'll control via MPV's volume property)
mpv.set_property("volume", 100i64)
@@ -288,8 +384,8 @@ impl MpvBackend {
// StateChanged rather than tracking playback itself, per the
// one-directional state rule. Unobserved, the event never came and
// the button never moved. Invisible until native video shipped,
// because the webview <video> element's own DOM events drove that
// control on Linux.
// because the (since deleted) webview <video> element's own DOM
// events drove that control on Linux.
//
// TRACES: UR-005 | DR-239
ev_ctx
@@ -600,11 +696,22 @@ impl PlayerBackend for MpvBackend {
// TRACES: UR-040, UR-005 | DR-253
*self.pending_seek.lock_safe() = None;
// Load the media file
self.mpv
.command("loadfile", &[&stream_url])
.map_err(|e| PlayerError {
message: format!("Failed to load file: {:?}", e),
// The item's own sideloaded subtitles, none shown, and its default audio
// track — whatever the previous item had chosen. Only video carries
// subtitles; for audio this just clears the last item's.
// TRACES: UR-020, UR-021 | DR-023, DR-024, DR-235
let subtitle_urls: Vec<&str> = media.subtitles.iter().map(|t| t.url.as_str()).collect();
super::mpv_tracks::prepare_load(&self.mpv, &subtitle_urls).map_err(|e| PlayerError {
message: format!("Failed to prepare tracks: {e}"),
})?;
// Load the media file. Through `mpv_command::command`, never
// `Mpv::command`: the URL carries server-controlled text.
// TRACES: UR-003, UR-004 | DR-298
super::mpv_command::command(&self.mpv, &["loadfile", &stream_url]).map_err(|e| {
PlayerError {
message: format!("Failed to load file: {e}"),
}
})?;
debug!("[MpvBackend] Load command sent successfully");
@@ -638,8 +745,8 @@ impl PlayerBackend for MpvBackend {
fn stop(&mut self) -> Result<(), PlayerError> {
debug!("[MpvBackend] Stop command");
self.mpv.command("stop", &[]).map_err(|e| PlayerError {
message: format!("Failed to stop: {:?}", e),
super::mpv_command::command(&self.mpv, &["stop"]).map_err(|e| PlayerError {
message: format!("Failed to stop: {e}"),
})?;
// Stopping ends the seek's subject along with the playback.
@@ -760,6 +867,36 @@ impl PlayerBackend for MpvBackend {
state.volume
}
/// `stream_index` is a *position*: the n-th audio track of the file, the
/// same meaning ExoPlayer gives it (`player_switch_audio_track` passes the
/// array index). Only reached for a direct play/stream — a transcode carries
/// one track and is re-opened instead.
///
/// TRACES: UR-021 | IR-019, DR-024, DR-235
fn set_audio_track(&mut self, stream_index: i32) -> Result<(), PlayerError> {
let position = usize::try_from(stream_index).map_err(|_| PlayerError {
message: format!("Invalid audio track position {stream_index}"),
})?;
super::mpv_tracks::select_audio(&self.mpv, position)
.map_err(|message| PlayerError { message })
}
/// `stream_index` is the position in the sideloaded subtitle list the play
/// request carried (`nativeSubtitleArrayIndex`), `None` to hide subtitles.
///
/// TRACES: UR-020 | IR-018, DR-023, DR-235
fn set_subtitle_track(&mut self, stream_index: Option<i32>) -> Result<(), PlayerError> {
let position = stream_index
.map(|i| {
usize::try_from(i).map_err(|_| PlayerError {
message: format!("Invalid subtitle position {i}"),
})
})
.transpose()?;
super::mpv_tracks::select_subtitle(&self.mpv, position)
.map_err(|message| PlayerError { message })
}
fn set_audio_settings(&mut self, settings: &AudioSettings) -> Result<(), PlayerError> {
info!("[MpvBackend] Applying audio settings");
self.audio_settings = settings.clone();
@@ -998,3 +1135,97 @@ mod af_filter_tests {
assert!(eq_pos < norm_pos, "normalizer runs after EQ: {af}");
}
}
#[cfg(test)]
mod video_output_tests {
use super::{video_output, VideoOutput};
/// Windows draws into the app's own window: mpv is handed its HWND before
/// initialising and renders as a child of it, beneath the transparent
/// WebView2.
///
/// TRACES: UR-080 | DR-237 | UT-274
#[test]
fn windows_native_video_renders_into_the_app_window() {
assert_eq!(
video_output(true, true, Some(0x1234)),
VideoOutput::Window(0x1234)
);
}
/// Without a handle mpv would open a top-level window of its own, a second
/// window floating beside the app. No picture is the honest failure.
///
/// TRACES: UR-080 | DR-237 | UT-274
#[test]
fn windows_without_a_window_handle_draws_nothing() {
assert_eq!(video_output(true, true, None), VideoOutput::Off);
}
/// Linux keeps the render API the GTK surface draws from.
///
/// TRACES: UR-080 | DR-231 | UT-274
#[test]
fn linux_native_video_uses_the_render_api() {
assert_eq!(video_output(true, false, None), VideoOutput::RenderApi);
}
/// TRACES: UR-080 | DR-231 | UT-274
#[test]
fn no_native_video_decodes_no_picture() {
assert_eq!(video_output(false, true, Some(1)), VideoOutput::Off);
assert_eq!(video_output(false, false, None), VideoOutput::Off);
}
/// In the app's window mpv must not act as a player of its own: its
/// on-screen controller and key/mouse bindings would compete with the
/// Svelte controls drawn over it.
///
/// TRACES: UR-080 | DR-237 | UT-274
#[test]
fn a_window_output_hands_all_input_to_the_app() {
let opts = VideoOutput::Window(7).options();
for (k, v) in [
("vo", "gpu-next,gpu"),
("osc", "no"),
("input-default-bindings", "no"),
("input-vo-keyboard", "no"),
("input-cursor", "no"),
("cursor-autohide", "no"),
] {
assert!(
opts.iter().any(|(ok, ov)| *ok == k && ov == v),
"missing {k}={v} in {opts:?}"
);
}
assert_eq!(
VideoOutput::Off.options(),
vec![("video", "no".to_string())]
);
}
/// Every option the outputs set is one this libmpv accepts, `wid` included —
/// against the real library, so a misspelt or removed option fails here
/// rather than as a player that will not start on a user's machine. Runs on
/// the Windows DLL too (under wine in the cross-build).
///
/// TRACES: UR-080 | DR-237 | UT-274
#[test]
fn libmpv_accepts_every_video_output_option() {
let mpv = libmpv::Mpv::with_initializer(|init| {
init.set_property("wid", 0i64)?;
Ok(())
})
.expect("libmpv must accept wid before initialisation");
for output in [
VideoOutput::Off,
VideoOutput::RenderApi,
VideoOutput::Window(0),
] {
for (name, value) in output.options() {
mpv.set_property(name, value.as_str())
.unwrap_or_else(|e| panic!("libmpv rejected {name}={value}: {e:?}"));
}
}
}
}
+161
View File
@@ -0,0 +1,161 @@
//! The two things every libmpv handle in this process must get right before it
//! is handed a URL.
//!
//! **Commands are an argument vector.** The pinned `libmpv` crate's
//! `Mpv::command` joins its arguments with spaces and hands the result to
//! `mpv_command_string`, which parses it as input.conf syntax: whitespace splits
//! arguments, `;` separates commands, `#` starts a comment. Every URL this app
//! loads carries server-controlled text — item and media-source ids, the
//! server's own `TranscodingUrl`, and for a download the file name, which is the
//! track title — so a title like `x;run sh -c …;#` ran a shell command the
//! moment it played. [`command`] goes through `mpv_command` instead, where each
//! argument reaches mpv as one opaque string and nothing is parsed.
//!
//! **TLS is verified.** mpv's `tls-verify` defaults to *no*, and the stream URLs
//! it loads carry the account's `ApiKey`. Every reqwest client in the app
//! verifies certificates; without [`harden`] mpv was the one path where anyone
//! able to present a certificate for the server's host could read the token.
//! `ytdl` goes with it: libmpv loads its youtube-dl hook by default and hands a
//! URL that failed to open — token included — to an external `yt-dlp`.
//!
//! TRACES: UR-003, UR-004, UR-012 | DR-298, DR-299
use std::ffi::{CStr, CString};
use std::os::raw::c_char;
use libmpv::Mpv;
/// Run an mpv command with each argument passed through verbatim.
///
/// TRACES: UR-003, UR-004 | DR-298 | UT-268
pub fn command(mpv: &Mpv, args: &[&str]) -> Result<(), String> {
if args.is_empty() {
return Err("empty mpv command".to_string());
}
// A NUL cannot be represented in a C string; refusing is the only honest
// answer, since truncating would load a different URL than the one asked.
let owned = args
.iter()
.map(|a| CString::new(*a).map_err(|_| format!("mpv argument contains NUL: {a:?}")))
.collect::<Result<Vec<_>, _>>()?;
let mut argv: Vec<*const c_char> = owned.iter().map(|a| a.as_ptr()).collect();
argv.push(std::ptr::null());
// SAFETY: `argv` is a NULL-terminated array of pointers into `owned`, which
// outlives the call; mpv copies what it keeps. `ctx` is the live handle.
let rc = unsafe { libmpv_sys::mpv_command(mpv.ctx.as_ptr(), argv.as_mut_ptr()) };
if rc < 0 {
// SAFETY: mpv_error_string returns a static string for any code.
let msg = unsafe { CStr::from_ptr(libmpv_sys::mpv_error_string(rc)) };
return Err(format!("{} ({rc})", msg.to_string_lossy()));
}
Ok(())
}
/// Configure a freshly created handle so it will not trust an unverified server.
///
/// Must run before the first `loadfile`. Failure is an error, not a warning: a
/// handle that could not be told to verify TLS is exactly the one that leaks.
///
/// TRACES: UR-012 | DR-299 | UT-270
pub fn harden(mpv: &Mpv) -> Result<(), String> {
for (name, value) in [("tls-verify", "yes"), ("ytdl", "no")] {
mpv.set_property(name, value)
.map_err(|e| format!("could not set {name}={value}: {e:?}"))?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
/// A handle that decodes nothing and opens no device, so the tests need no
/// audio system and no display.
fn null_mpv() -> Mpv {
let mpv = Mpv::new().expect("libmpv must be available to run the player tests");
mpv.set_property("ao", "null").unwrap();
mpv.set_property("vo", "null").unwrap();
mpv
}
/// The injection itself, against a real libmpv: a URL carrying `;` and a
/// second command must be loaded as one (unreachable) URL, not split and
/// executed. `set volume 13` stands in for `run …` — the same parse, but
/// observable without spawning a process.
///
/// TRACES: UR-003, UR-004 | DR-298 | UT-268
#[test]
fn a_url_cannot_smuggle_a_second_mpv_command() {
let mpv = null_mpv();
mpv.set_property("volume", 100i64).unwrap();
// Port 9 (discard) on loopback: nothing is fetched either way.
let url = "http://127.0.0.1:9/Audio/x;set volume 13;#/stream?ApiKey=k";
let _ = command(&mpv, &["loadfile", url, "replace"]);
let volume: i64 = mpv.get_property("volume").unwrap();
assert_eq!(
volume, 100,
"text inside a URL was executed as an mpv command"
);
}
/// An argument with a space in it — every downloaded title with one —
/// arrives as one argument rather than being split into the next slot.
///
/// TRACES: UR-003, UR-004 | DR-298 | UT-268
#[test]
fn an_argument_with_spaces_stays_one_argument() {
let mpv = null_mpv();
// Split on the space this would be `loadfile file:///tmp/My Song.mp3`
// — `Song.mp3` taken as the flags argument, which mpv rejects.
assert!(command(&mpv, &["loadfile", "file:///nonexistent/My Song.mp3"]).is_ok());
}
/// No playback path may call the string-joining `Mpv::command` directly;
/// they all go through [`command`]. Asserted against the source because the
/// dangerous call and the safe one have the same shape at the call site.
///
/// TRACES: UR-003, UR-004 | DR-298 | UT-269
#[test]
fn players_never_use_the_string_command_api() {
for (file, src) in [
("mpv_backend.rs", include_str!("mpv_backend.rs")),
("mpv_player.rs", include_str!("mpv_player.rs")),
] {
assert!(
!src.contains(".command(\""),
"{file} calls Mpv::command, which parses its arguments as a command string"
);
}
}
/// TRACES: UR-012 | DR-299 | UT-270
#[test]
fn a_hardened_handle_verifies_tls_and_never_hands_urls_to_ytdl() {
let mpv = null_mpv();
harden(&mpv).unwrap();
let tls: String = mpv.get_property("tls-verify").unwrap();
assert_eq!(tls, "yes");
let ytdl: String = mpv.get_property("ytdl").unwrap();
assert_eq!(ytdl, "no");
}
/// Both constructors apply [`harden`]; a handle built without it is the bug.
///
/// TRACES: UR-012 | DR-299 | UT-270
#[test]
fn every_player_hardens_its_handle() {
for (file, src) in [
("mpv_backend.rs", include_str!("mpv_backend.rs")),
("mpv_player.rs", include_str!("mpv_player.rs")),
] {
assert!(
src.contains("mpv_command::harden(&mpv)"),
"{file} creates an mpv handle without hardening it"
);
}
}
}
+7 -5
View File
@@ -85,6 +85,8 @@ impl MpvPlayer {
let mpv = Mpv::new().map_err(|e| PlayerError {
message: format!("mpv_create failed: {e:?}"),
})?;
// TRACES: UR-012 | DR-299
super::mpv_command::harden(&mpv).map_err(|message| PlayerError { message })?;
let set = |k: &str, v: &str| {
if let Err(e) = mpv.set_property(k, v) {
@@ -229,10 +231,10 @@ impl MediaPlayer for MpvPlayer {
})?;
info!("[MpvPlayer] open {} at {:?}", req.selection.url, req.start);
self.mpv
.command("loadfile", &[&req.selection.url, "replace"])
// TRACES: UR-003, UR-004 | DR-298
super::mpv_command::command(&self.mpv, &["loadfile", &req.selection.url, "replace"])
.map_err(|e| PlayerError {
message: format!("loadfile failed: {e:?}"),
message: format!("loadfile failed: {e}"),
})?;
Ok(())
}
@@ -275,8 +277,8 @@ impl MediaPlayer for MpvPlayer {
}
// Idempotent: stopping an already-stopped mpv is not an error worth
// propagating, and callers legitimately close twice on teardown.
if let Err(e) = self.mpv.command("stop", &[]) {
debug!("[MpvPlayer] stop on an idle player: {e:?}");
if let Err(e) = super::mpv_command::command(&self.mpv, &["stop"]) {
debug!("[MpvPlayer] stop on an idle player: {e}");
}
Ok(())
}
+257
View File
@@ -0,0 +1,257 @@
//! Subtitle and audio-track selection on mpv, by *position*, the way the
//! frontend asks for it.
//!
//! Until DR-235 mpv never drew video, so it never had to: subtitles were
//! `<track>` children of the webview `<video>` and an audio track change
//! re-opened the stream. With mpv the only desktop video renderer, it has to
//! answer the same two calls ExoPlayer does, with the same meaning:
//!
//! - **Subtitles** are the sideloaded WebVTT list the play request carries
//! (`MediaItem::subtitles`), and `set_subtitle_track(n)` selects the *n-th of
//! those* — the position the frontend computes with `nativeSubtitleArrayIndex`.
//! They reach mpv as external files queued on `sub-files` before the load, and
//! selection starts off, because the menu opens on "Off".
//! - **Audio** `set_audio_track(n)` selects the n-th audio track of the file —
//! the position in the item's audio streams, which is file order. Only a direct
//! play/stream carries every track; a transcode is re-opened instead
//! (`AudioTrackSwitchStrategy`).
//!
//! mpv's own track ids are not positions: they count every track of a type,
//! embedded before external, from 1. So a position is always resolved against
//! the live `track-list`.
//!
//! TRACES: UR-020, UR-021 | DR-023, DR-024, DR-235
use libmpv::Mpv;
use super::mpv_command;
/// One entry of mpv's `track-list`, reduced to what selection needs.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TrackInfo {
pub id: i64,
/// "video", "audio" or "sub".
pub kind: String,
pub external: bool,
}
/// The mpv id of the `position`-th track of `kind` (optionally only external or
/// only embedded ones), in `track-list` order.
///
/// TRACES: UR-020, UR-021 | DR-023, DR-024 | UT-275
pub fn nth_track_id(
tracks: &[TrackInfo],
kind: &str,
external: Option<bool>,
position: usize,
) -> Option<i64> {
tracks
.iter()
.filter(|t| t.kind == kind && external.is_none_or(|e| t.external == e))
.nth(position)
.map(|t| t.id)
}
/// Read the current `track-list`.
pub fn track_list(mpv: &Mpv) -> Vec<TrackInfo> {
let count: i64 = mpv.get_property("track-list/count").unwrap_or(0);
(0..count)
.filter_map(|i| {
let id: i64 = mpv.get_property(&format!("track-list/{i}/id")).ok()?;
let kind: String = mpv.get_property(&format!("track-list/{i}/type")).ok()?;
let external: bool = mpv
.get_property(&format!("track-list/{i}/external"))
.unwrap_or(false);
Some(TrackInfo { id, kind, external })
})
.collect()
}
/// Prepare the next `loadfile`: these subtitle files will be loaded with it, no
/// subtitle is shown, and the file's default audio track plays.
///
/// `sid`/`aid` set while idle become the options the next file opens with, so
/// a track chosen for the previous item cannot leak into this one.
///
/// TRACES: UR-020, UR-021 | DR-023, DR-024 | UT-275
pub fn prepare_load(mpv: &Mpv, subtitle_urls: &[&str]) -> Result<(), String> {
mpv_command::command(mpv, &["change-list", "sub-files", "clr", ""])?;
for url in subtitle_urls {
// `append` adds one item without splitting on the list separator, which
// a URL's `:` would otherwise trip. Through the argv form (DR-298), so the
// URL is never parsed as command text.
mpv_command::command(mpv, &["change-list", "sub-files", "append", url])?;
}
mpv.set_property("sid", "no")
.map_err(|e| format!("could not set sid=no: {e:?}"))?;
mpv.set_property("aid", "auto")
.map_err(|e| format!("could not set aid=auto: {e:?}"))?;
Ok(())
}
/// Show the `position`-th sideloaded subtitle, or none.
///
/// TRACES: UR-020 | DR-023 | UT-275
pub fn select_subtitle(mpv: &Mpv, position: Option<usize>) -> Result<(), String> {
let Some(position) = position else {
return mpv
.set_property("sid", "no")
.map_err(|e| format!("could not set sid=no: {e:?}"));
};
let id = nth_track_id(&track_list(mpv), "sub", Some(true), position)
.ok_or_else(|| format!("no sideloaded subtitle at position {position}"))?;
mpv.set_property("sid", id)
.map_err(|e| format!("could not set sid={id}: {e:?}"))
}
/// Play the `position`-th audio track of the file.
///
/// TRACES: UR-021 | DR-024 | UT-275
pub fn select_audio(mpv: &Mpv, position: usize) -> Result<(), String> {
let id = nth_track_id(&track_list(mpv), "audio", Some(false), position)
.ok_or_else(|| format!("no audio track at position {position}"))?;
mpv.set_property("aid", id)
.map_err(|e| format!("could not set aid={id}: {e:?}"))
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::{Duration, Instant};
fn fixture(name: &str) -> String {
format!("{}/src/player/fixtures/{name}", env!("CARGO_MANIFEST_DIR"))
}
fn track(id: i64, kind: &str, external: bool) -> TrackInfo {
TrackInfo {
id,
kind: kind.to_string(),
external,
}
}
/// mpv numbers each type from 1, embedded before external, so a position in
/// the sideloaded list is not an id. The file here has two embedded
/// subtitles; the first sideloaded one is mpv's sub 3.
///
/// TRACES: UR-020, UR-021 | DR-023, DR-024 | UT-275
#[test]
fn positions_resolve_to_mpv_ids_per_kind() {
let tracks = [
track(1, "video", false),
track(1, "audio", false),
track(2, "audio", false),
track(1, "sub", false),
track(2, "sub", false),
track(3, "sub", true),
track(4, "sub", true),
];
assert_eq!(nth_track_id(&tracks, "sub", Some(true), 0), Some(3));
assert_eq!(nth_track_id(&tracks, "sub", Some(true), 1), Some(4));
assert_eq!(nth_track_id(&tracks, "sub", Some(true), 2), None);
assert_eq!(nth_track_id(&tracks, "audio", Some(false), 1), Some(2));
assert_eq!(nth_track_id(&tracks, "audio", None, 0), Some(1));
}
fn loaded_mpv(subs: &[&str]) -> Mpv {
let mpv = Mpv::new().expect("libmpv must be available to run the player tests");
mpv.set_property("ao", "null").unwrap();
mpv.set_property("vo", "null").unwrap();
mpv.set_property("pause", true).unwrap();
prepare_load(&mpv, subs).unwrap();
mpv_command::command(&mpv, &["loadfile", &fixture("two-audio-tracks.mkv")]).unwrap();
// Video, two audio tracks, and one track per sideloaded subtitle.
let expected = 3 + subs.len() as i64;
let deadline = Instant::now() + Duration::from_secs(10);
while mpv.get_property::<i64>("track-list/count").unwrap_or(0) < expected {
assert!(
Instant::now() < deadline,
"the fixture never finished loading"
);
std::thread::sleep(Duration::from_millis(20));
}
mpv
}
/// Against a real file: the sideloaded subtitle arrives, starts hidden, and
/// is shown and hidden by position.
///
/// TRACES: UR-020 | DR-023 | UT-275
#[test]
fn a_sideloaded_subtitle_starts_off_and_is_selected_by_position() {
let vtt = fixture("sub.vtt");
let mpv = loaded_mpv(&[&vtt]);
assert_eq!(mpv.get_property::<String>("sid").unwrap(), "no");
select_subtitle(&mpv, Some(0)).unwrap();
let expected = nth_track_id(&track_list(&mpv), "sub", Some(true), 0).unwrap();
assert_eq!(mpv.get_property::<i64>("sid").unwrap(), expected);
select_subtitle(&mpv, None).unwrap();
assert_eq!(mpv.get_property::<String>("sid").unwrap(), "no");
assert!(select_subtitle(&mpv, Some(5)).is_err());
}
/// Against a real file with two audio tracks: position 1 is the second one.
///
/// TRACES: UR-021 | DR-024 | UT-275
#[test]
fn an_audio_track_is_selected_by_position_in_the_file() {
let mpv = loaded_mpv(&[]);
select_audio(&mpv, 1).unwrap();
assert_eq!(mpv.get_property::<i64>("aid").unwrap(), 2);
select_audio(&mpv, 0).unwrap();
assert_eq!(mpv.get_property::<i64>("aid").unwrap(), 1);
assert!(select_audio(&mpv, 2).is_err());
}
/// The next item opens with no subtitle and its own default audio, whatever
/// the last one had chosen, and with only its own subtitle files.
///
/// TRACES: UR-020, UR-021 | DR-023, DR-024 | UT-275
#[test]
fn preparing_a_load_forgets_the_previous_items_choices() {
let vtt = fixture("sub.vtt");
let mpv = loaded_mpv(&[&vtt]);
select_subtitle(&mpv, Some(0)).unwrap();
select_audio(&mpv, 1).unwrap();
// The next item: no subtitles of its own this time.
prepare_load(&mpv, &[]).unwrap();
mpv_command::command(
&mpv,
&["loadfile", &fixture("two-audio-tracks.mkv"), "replace"],
)
.unwrap();
let deadline = Instant::now() + Duration::from_secs(10);
loop {
let tracks = track_list(&mpv);
let settled = tracks.len() == 3 && mpv.get_property::<i64>("aid").is_ok();
if settled {
break;
}
assert!(
Instant::now() < deadline,
"the second load never settled: {tracks:?}"
);
std::thread::sleep(Duration::from_millis(20));
}
assert_eq!(mpv.get_property::<String>("sid").unwrap(), "no");
assert_eq!(
mpv.get_property::<i64>("aid").unwrap(),
1,
"default audio again"
);
assert_eq!(
nth_track_id(&track_list(&mpv), "sub", None, 0),
None,
"the previous item's subtitle file came along"
);
}
}
+23 -40
View File
@@ -2,8 +2,7 @@
//!
//! Three things need this and must agree: the mpv backend (which has to be
//! configured for video *at construction*, before anything plays), the video
//! surface (which has nothing to draw otherwise), and `get_player_status`
//! (which tells the frontend whether to use a webview `<video>` element).
//! surface (which has nothing to draw otherwise), and the device profile.
//!
//! It is a function rather than three `env::var` checks for the reason this
//! codebase keeps rediscovering: a capability answered in several places is a
@@ -14,65 +13,49 @@
//!
//! TRACES: UR-080 | DR-231, DR-235
/// The opt-in for native desktop video.
///
/// Off by default while the render path is unproven — the webview path still
/// works and is what ships. This becomes the *default* (and then the only path)
/// when DR-235 lands; the variable is how it is exercised until then.
const ENV_FLAG: &str = "JELLYTAU_NATIVE_VIDEO";
/// Whether mpv should decode and draw video in this process.
///
/// Read fresh rather than cached: it is consulted a handful of times at startup,
/// and a `OnceLock` here would only make it harder to test.
/// True wherever mpv is the desktop video renderer: Linux since DR-235 phase 1,
/// Windows since DR-237. There is no opt-out and no webview fallback — the
/// webview `<video>` path is gone, so "off" would leave nothing drawing the
/// picture. It was the `JELLYTAU_NATIVE_VIDEO` opt-in while the render path was
/// being proven; the variable is ignored.
///
/// TRACES: UR-080 | DR-231, DR-235
pub fn enabled() -> bool {
// Only where a native renderer exists. On Android ExoPlayer already does
// this and `use_html5_element` is false for entirely separate reasons.
if !cfg!(all(target_os = "linux", not(target_os = "android"))) {
return false;
}
matches!(
std::env::var(ENV_FLAG).as_deref(),
Ok("1") | Ok("true") | Ok("yes")
)
// On Android ExoPlayer draws video and `use_html5_element` is false for
// entirely separate reasons.
cfg!(any(target_os = "linux", target_os = "windows"))
}
#[cfg(test)]
mod tests {
use super::*;
/// Absent, empty, or anything unrecognised means off. A half-set variable
/// must not half-enable a renderer — the failure mode would be mpv
/// configured for video with nothing drawing it, i.e. audio playing over a
/// black rectangle.
/// mpv draws video on Linux and Windows with nothing to opt into, and the retired
/// variable cannot opt back out: with the webview path gone, "off"
/// would configure mpv for audio only with nothing else to draw the picture.
///
/// TRACES: UR-080 | DR-231 | UT-216
/// TRACES: UR-080 | DR-235, DR-237 | UT-271
#[test]
fn test_only_explicit_truthy_values_enable_it() {
let restore = std::env::var(ENV_FLAG).ok();
fn desktop_always_renders_video_natively() {
let restore = std::env::var("JELLYTAU_NATIVE_VIDEO").ok();
for value in ["", "0", "no", "false", "maybe", "2"] {
std::env::set_var(ENV_FLAG, value);
assert!(!enabled(), "{value:?} must not enable native video");
for value in [None, Some("0"), Some("false"), Some("1")] {
match value {
Some(v) => std::env::set_var("JELLYTAU_NATIVE_VIDEO", v),
None => std::env::remove_var("JELLYTAU_NATIVE_VIDEO"),
}
for value in ["1", "true", "yes"] {
std::env::set_var(ENV_FLAG, value);
assert_eq!(
enabled(),
cfg!(all(target_os = "linux", not(target_os = "android"))),
"{value:?} enables it exactly where a native renderer exists"
cfg!(any(target_os = "linux", target_os = "windows")),
"JELLYTAU_NATIVE_VIDEO={value:?} must not decide the renderer"
);
}
std::env::remove_var(ENV_FLAG);
assert!(!enabled(), "absent means off");
match restore {
Some(v) => std::env::set_var(ENV_FLAG, v),
None => std::env::remove_var(ENV_FLAG),
Some(v) => std::env::set_var("JELLYTAU_NATIVE_VIDEO", v),
None => std::env::remove_var("JELLYTAU_NATIVE_VIDEO"),
}
}
}
+33 -122
View File
@@ -5,17 +5,18 @@
//! [`VideoSeekStrategy`] into a concrete backend/frontend action.
/// Seek strategy for video playback, derived from a stream's characteristics.
///
/// Every video renderer is a native backend (mpv, ExoPlayer) since the webview
/// `<video>` path was deleted (DR-235), so the backend performs every seek; what
/// remains to decide is whether it can move the stream in place.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VideoSeekStrategy {
/// Local file - always use native seek on backend
LocalNativeSeek,
/// HLS or direct stream with HTML5 - frontend handles seek, skip backend
Html5NativeSeek,
/// HLS or direct stream with native backend - backend handles seek
/// Seekable where it sits (direct play/stream, or a transcode the engine
/// can move) - backend seeks
BackendNativeSeek,
/// Transcoded non-HLS with HTML5 - reload stream, frontend handles
Html5ReloadStream,
/// Transcoded non-HLS with native backend - reload stream, backend handles
/// A transcode the engine cannot move - re-open the stream at the target
BackendReloadStream,
}
@@ -29,12 +30,10 @@ pub enum VideoSeekStrategy {
/// can seek a server-side transcode without re-opening it. Declared by the
/// engine via `Capabilities`, never inferred from the URL or the renderer.
/// * `needs_transcoding` - Whether the content needs transcoding
/// * `use_html5` - Whether frontend is using HTML5 video element
pub fn determine_video_seek_strategy(
is_local: bool,
seeks_transcoded_in_place: bool,
needs_transcoding: bool,
use_html5: bool,
) -> VideoSeekStrategy {
// Local files always support native seeking via backend
if is_local {
@@ -42,40 +41,21 @@ pub fn determine_video_seek_strategy(
}
// A server-side transcode is produced *from* `StartTimeTicks`, so where the
// seek lands is a property of the request, not of the stream in hand.
//
// hls.js is the exception: handed a VOD playlist it seeks within it and lets
// the server catch up segment by segment. mpv's HLS demuxer cannot make
// Jellyfin transcode from a new offset, so for the native backend a
// seek lands is a property of the request, not of the stream in hand. mpv's
// HLS demuxer cannot make Jellyfin transcode from a new offset, so for it a
// transcoded seek must re-negotiate the stream regardless of container.
//
// Before native video shipped, `use_html5` was always true for HLS and the
// native+HLS+transcode cell was unreachable, which is why `is_hls` alone
// used to be a safe proxy for "seekable in place". It no longer is: turning
// native video on routed every transcoded seek into a backend seek that
// silently does nothing, and presents as "resume does not work".
if needs_transcoding {
// Whether a transcode can be seeked in place is a property of the
// engine, and the engine states it. This used to be inferred from
// `is_hls`, which held only while hls.js was the sole HLS renderer —
// and stopped holding the moment mpv became one (DR-238).
return match (seeks_transcoded_in_place, use_html5) {
(true, true) => VideoSeekStrategy::Html5NativeSeek,
(true, false) => VideoSeekStrategy::BackendNativeSeek,
(false, true) => VideoSeekStrategy::Html5ReloadStream,
(false, false) => VideoSeekStrategy::BackendReloadStream,
};
// Whether a transcode can be seeked in place is a property of the engine,
// and the engine states it. This used to be inferred from `is_hls`, which
// held only while hls.js was the sole HLS renderer — and stopped holding the
// moment mpv became one (DR-238).
if needs_transcoding && !seeks_transcoded_in_place {
return VideoSeekStrategy::BackendReloadStream;
}
// Direct play and direct stream are seekable where they sit.
if use_html5 {
// The frontend seeks via videoElement.currentTime; calling backend.seek()
// would move a player that is not the one rendering.
VideoSeekStrategy::Html5NativeSeek
} else {
// Direct play, direct stream, or a transcode the engine can move.
VideoSeekStrategy::BackendNativeSeek
}
}
// The four items below are consumed by the Android MediaSessionHandler; on other
// targets only the tests exercise them, so dead-code analysis would flag them.
@@ -224,114 +204,45 @@ mod tests {
#[test]
fn test_seek_strategy_local_file() {
// Local files always use native backend seek regardless of other flags
for (in_place, transcode) in [(false, false), (true, true), (false, true)] {
assert_eq!(
determine_video_seek_strategy(true, false, false, false),
VideoSeekStrategy::LocalNativeSeek
);
assert_eq!(
determine_video_seek_strategy(true, false, false, true),
VideoSeekStrategy::LocalNativeSeek
);
assert_eq!(
determine_video_seek_strategy(true, true, true, true),
determine_video_seek_strategy(true, in_place, transcode),
VideoSeekStrategy::LocalNativeSeek
);
}
}
/// Non-transcoded streams seek in place regardless of the engine's
/// transcode ability, which only applies to transcodes.
/// Direct play and direct stream seek in place, whatever the engine's
/// transcode ability — that only applies to transcodes.
#[test]
fn test_seek_strategy_direct_stream() {
// HTML5 renders, so the frontend seeks the element
for in_place in [false, true] {
assert_eq!(
determine_video_seek_strategy(false, true, false, true),
VideoSeekStrategy::Html5NativeSeek
);
// The native engine renders, so it seeks
assert_eq!(
determine_video_seek_strategy(false, true, false, false),
determine_video_seek_strategy(false, in_place, false),
VideoSeekStrategy::BackendNativeSeek
);
// A transcode an engine says it can move: seek in place
assert_eq!(
determine_video_seek_strategy(false, true, true, true),
VideoSeekStrategy::Html5NativeSeek
);
}
}
/// A server-side transcode cannot be seeked by the native backend.
/// A server-side transcode is re-opened by an engine that cannot move it,
/// and seeked in place by one that says it can.
///
/// Jellyfin produces a transcode from `StartTimeTicks`; hls.js can seek
/// within the VOD playlist it is handed, but mpv's HLS demuxer cannot make
/// the server transcode from a new offset, so the stream has to be
/// re-negotiated. Before native video existed, `use_html5` was always true
/// for HLS and this case was unreachable — turning native video on routed
/// every transcoded seek into a native seek that silently does nothing,
/// which presents as "resume does not work".
/// Jellyfin produces a transcode from `StartTimeTicks`; mpv's HLS demuxer
/// cannot make the server transcode from a new offset, so the stream has to
/// be re-negotiated. Inferring this from the container once routed every
/// transcoded seek into a native seek that silently does nothing, which
/// presents as "resume does not work".
///
/// TRACES: UR-040 | DR-238, DR-246 | UT-217
#[test]
fn test_transcoded_seek_follows_the_engines_declared_ability() {
// An engine that cannot move a server-side transcode re-opens it,
// whichever side is rendering.
assert_eq!(
determine_video_seek_strategy(false, false, true, false),
determine_video_seek_strategy(false, false, true),
VideoSeekStrategy::BackendReloadStream
);
assert_eq!(
determine_video_seek_strategy(false, false, true, true),
VideoSeekStrategy::Html5ReloadStream
);
// hls.js can, and says so, so it seeks in place.
assert_eq!(
determine_video_seek_strategy(false, true, true, true),
VideoSeekStrategy::Html5NativeSeek
);
// The container the stream arrives in no longer decides anything: the
// same declared ability gives the same answer on the native side.
assert_eq!(
determine_video_seek_strategy(false, true, true, false),
determine_video_seek_strategy(false, true, true),
VideoSeekStrategy::BackendNativeSeek
);
}
/// Test video seek strategy for direct play (non-transcoded) streams
#[test]
fn test_seek_strategy_direct_play() {
// Direct play with HTML5 - frontend handles seek
assert_eq!(
determine_video_seek_strategy(false, false, false, true),
VideoSeekStrategy::Html5NativeSeek
);
// Direct play with native backend - backend handles seek
assert_eq!(
determine_video_seek_strategy(false, false, false, false),
VideoSeekStrategy::BackendNativeSeek
);
}
/// Test video seek strategy for transcoded non-HLS streams
#[test]
fn test_seek_strategy_transcoded_non_hls() {
// Transcoded non-HLS with HTML5 - need to reload stream, frontend handles
assert_eq!(
determine_video_seek_strategy(false, false, true, true),
VideoSeekStrategy::Html5ReloadStream
);
// Transcoded non-HLS with native backend - need to reload stream, backend handles
assert_eq!(
determine_video_seek_strategy(false, false, true, false),
VideoSeekStrategy::BackendReloadStream
);
}
/// Test the specific bug fix: HLS + HTML5 should NOT call backend seek
/// This was the bug causing "Raw(-10)" errors
#[test]
fn test_hls_html5_does_not_use_backend_seek() {
let strategy = determine_video_seek_strategy(false, true, false, true);
// Should be Html5NativeSeek, NOT BackendNativeSeek
assert_eq!(strategy, VideoSeekStrategy::Html5NativeSeek);
assert_ne!(strategy, VideoSeekStrategy::BackendNativeSeek);
}
}
+4 -32
View File
@@ -13,10 +13,6 @@
/// How a request to change audio track has to be carried out.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AudioTrackSwitchStrategy {
/// Re-open the stream pinned to the chosen track; the frontend reloads its
/// `<video>` element. An HTML5 element cannot select an audio track at all,
/// so this holds whether or not the current stream is a transcode.
Html5ReloadStream,
/// Re-open the stream pinned to the chosen track; the backend reloads
/// itself and restores the position.
BackendReloadStream,
@@ -29,17 +25,9 @@ pub enum AudioTrackSwitchStrategy {
/// # Arguments
/// * `needs_transcoding` - Whether the stream now playing is a server-side
/// transcode, which carries exactly the one audio track it was built around.
/// * `use_html5` - Whether the frontend `<video>` element is rendering.
///
/// TRACES: UR-021 | IR-019, DR-024, DR-258 | UT-232
pub fn determine_audio_track_switch_strategy(
needs_transcoding: bool,
use_html5: bool,
) -> AudioTrackSwitchStrategy {
if use_html5 {
return AudioTrackSwitchStrategy::Html5ReloadStream;
}
pub fn determine_audio_track_switch_strategy(needs_transcoding: bool) -> AudioTrackSwitchStrategy {
if needs_transcoding {
AudioTrackSwitchStrategy::BackendReloadStream
} else {
@@ -86,8 +74,7 @@ mod tests {
assert_eq!(resume_position(None, 1337.5), 1337.5);
}
/// The HTML5 path does have an element and its clock is the honest answer
/// there, so what the caller supplies wins.
/// A caller that does know the position is believed.
#[test]
fn a_caller_that_knows_its_position_is_believed() {
assert_eq!(resume_position(Some(42.0), 1337.5), 42.0);
@@ -123,7 +110,7 @@ mod tests {
#[test]
fn a_transcode_is_re_opened_because_it_carries_only_one_track() {
assert_eq!(
determine_audio_track_switch_strategy(true, false),
determine_audio_track_switch_strategy(true),
AudioTrackSwitchStrategy::BackendReloadStream
);
}
@@ -133,23 +120,8 @@ mod tests {
#[test]
fn a_direct_play_switches_in_place() {
assert_eq!(
determine_audio_track_switch_strategy(false, false),
determine_audio_track_switch_strategy(false),
AudioTrackSwitchStrategy::BackendSelectInPlace
);
}
/// An HTML5 `<video>` element has no track-selection API, so it reloads
/// either way. This is the path that already worked, and it must keep
/// working: the fix is about the native side only.
#[test]
fn html5_always_reloads_because_the_element_cannot_select() {
assert_eq!(
determine_audio_track_switch_strategy(true, true),
AudioTrackSwitchStrategy::Html5ReloadStream
);
assert_eq!(
determine_audio_track_switch_strategy(false, true),
AudioTrackSwitchStrategy::Html5ReloadStream
);
}
}
+11 -18
View File
@@ -1,25 +1,18 @@
//! Webview audio backend — audio-only playback for platforms without a native
//! audio backend (currently Windows).
//! Webview audio backend — audio-only playback for a desktop without a native
//! audio backend. No shipped platform uses it: Linux and Windows play through
//! mpv, Android through ExoPlayer.
//!
//! ## Why this exists
//! All *video* already renders through the webview HTML5 `<video>` element on
//! every platform (see `VideoPlayer.svelte`); libmpv/ExoPlayer only ever drive
//! *audio-only* (music) playback. On Windows there is no native audio backend,
//! so `create_player_backend()` used to fall back to `NullBackend` and music was
//! silent.
//!
//! This backend fills that gap without any C dependency (so it still
//! cross-compiles from Linux): instead of decoding audio itself, it hands the
//! stream URL to a frontend `<audio>` element via a `WebviewAudioLoad` event and
//! then drives play/pause/seek/stop through `ControlCommand` events — exactly the
//! round-trip the HTML5 video path already uses. The `<audio>` element reports
//! its real state/position back through the `player_report_*` commands, so the
//! Rust `PlayerController` remains the single source of truth (the controller's
//! `report_html5_*` methods fold those reports into the normal event pipeline).
//! Instead of decoding audio itself, it hands the stream URL to a frontend
//! `<audio>` element via a `WebviewAudioLoad` event and then drives
//! play/pause/seek/stop through `ControlCommand` events. The `<audio>` element
//! reports its real state/position back through the `player_report_*` commands,
//! so the Rust `PlayerController` remains the single source of truth (the
//! controller's `report_html5_*` methods fold those reports into the normal
//! event pipeline).
//!
//! Because the reported state flows through the event pipeline (not through this
//! backend's `position()`/`state()` pollers — the timer loop does not poll the
//! backend for HTML5-rendered media), this backend only needs to keep a
//! backend for webview-rendered media), this backend only needs to keep a
//! best-effort local mirror for direct `player_get_state` queries.
//!
//! TRACES: UR-003, UR-004, UR-005 | DR-004
+3 -3
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "JellyTau",
"version": "0.13.2",
"version": "0.14.0",
"identifier": "com.dtourolle.jellytau",
"build": {
"beforeDevCommand": "bun run dev",
@@ -22,8 +22,8 @@
}
],
"security": {
"csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self' data:; img-src 'self' data: blob: asset: http://asset.localhost http: https:; media-src 'self' blob: asset: http://asset.localhost http://127.0.0.1:* http: https:; connect-src 'self' ipc: http://ipc.localhost http: https:; worker-src 'self' blob:; object-src 'none'; frame-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'",
"devCsp": "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; font-src 'self' data:; img-src 'self' data: blob: asset: http://asset.localhost http: https:; media-src 'self' blob: asset: http://asset.localhost http://127.0.0.1:* http: https:; connect-src 'self' ipc: http://ipc.localhost http: https: ws: wss:; worker-src 'self' blob:; object-src 'none'; frame-src 'none'; base-uri 'self'; form-action 'self'",
"csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self' data:; img-src 'self' data: blob: asset: http://asset.localhost http: https:; media-src 'self' blob: asset: http://asset.localhost http://127.0.0.1:* http: https:; connect-src 'self' ipc: http://ipc.localhost; worker-src 'self'; object-src 'none'; frame-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'",
"devCsp": "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; font-src 'self' data:; img-src 'self' data: blob: asset: http://asset.localhost http: https:; media-src 'self' blob: asset: http://asset.localhost http://127.0.0.1:* http: https:; connect-src 'self' ipc: http://ipc.localhost ws: wss:; worker-src 'self'; object-src 'none'; frame-src 'none'; base-uri 'self'; form-action 'self'",
"assetProtocol": {
"enable": true,
"scope": [
+12
View File
@@ -0,0 +1,12 @@
{
"$schema": "https://schema.tauri.app/config/2",
"bundle": {
"resources": {
"windows-libs/libmpv-2.dll": "libmpv-2.dll",
"../THIRD_PARTY_NOTICES.md": "licenses/THIRD_PARTY_NOTICES.md",
"../packaging/windows/LGPL-3.0.txt": "licenses/LGPL-3.0.txt",
"../packaging/windows/GPL-3.0.txt": "licenses/GPL-3.0.txt",
"../LICENSE": "licenses/LICENSE-JellyTau-MIT.txt"
}
}
}
+41 -91
View File
@@ -21,7 +21,7 @@ async playerPlayItem(item: PlayItemRequest) : Promise<PlayerStatus> {
},
/**
* Exit background-audio mode: stop the native audio player and return its final
* position so the frontend can reload the WebView `<video>` there (UR-040).
* position so the frontend can reload the video there (UR-040).
*
* Returns the position in seconds. The sleep timer is intentionally left
* untouched — if it fired while backgrounded, playback is already stopped and
@@ -52,8 +52,8 @@ async playerBackgroundAction(backgroundAudioArmed: boolean, inPictureInPicture:
* `stream_url` MUST be an audio-only URL (see
* `get_audio_only_stream_url_for_video`). The item is created as
* `MediaType::Audio` so it starts an audio session and loads into the native
* backend with `mediaType="audio"` — the WebView `<video>` is torn down on the
* frontend side, so exactly one audio source is ever active.
* backend with `mediaType="audio"`, replacing the video, so exactly one audio
* source is ever active.
*
* This deliberately goes through the queue-based `play_item` path (NOT a
* side-channel) so end-of-track lands in `on_playback_ended`, which already
@@ -129,11 +129,11 @@ async playerSeek(position: number) : Promise<PlayerStatus> {
* - Direct play streams: Use native seeking
* - Transcoded non-HLS: Request new stream URL from server starting at seek position
*
* For native (non-HTML5) backends, this command handles the entire stream reload
* internally. For HTML5 backends, it returns the new URL for the frontend to handle.
* The backend always handles the seek itself, including re-opening a stream,
* since every video renderer is native (DR-235).
*/
async playerSeekVideo(repositoryHandle: string, position: number, mediaSourceId: string | null, audioStreamIndex: number | null, useHtml5: boolean) : Promise<VideoSeekResponse> {
return await TAURI_INVOKE("player_seek_video", { repositoryHandle, position, mediaSourceId, audioStreamIndex, useHtml5 });
async playerSeekVideo(repositoryHandle: string, position: number, mediaSourceId: string | null, audioStreamIndex: number | null) : Promise<VideoSeekResponse> {
return await TAURI_INVOKE("player_seek_video", { repositoryHandle, position, mediaSourceId, audioStreamIndex });
},
async playerSetVolume(volume: number) : Promise<PlayerStatus> {
return await TAURI_INVOKE("player_set_volume", { volume });
@@ -157,9 +157,6 @@ async playerSetAudioTrack(streamIndex: number) : Promise<PlayerStatus> {
* carries the requested track at all** — see
* [`determine_audio_track_switch_strategy`]:
*
* - An HTML5 `<video>` element has no track-selection API, so the stream is
* always re-opened at the chosen `AudioStreamIndex` and the frontend seeks
* the reloaded element back to `position`.
* - A native backend playing a **direct play** holds the source file with
* every track in it, so ExoPlayer selects in place by track-group index.
* - A native backend playing a **transcode** does not. Jellyfin builds a
@@ -175,23 +172,21 @@ async playerSetAudioTrack(streamIndex: number) : Promise<PlayerStatus> {
* audio track index` and dropped the request — the default track just kept
* playing, with nothing in the UI saying so.
*
* libmpv implements neither selection nor reload here — it is the audio-only
* backend and leaves `PlayerBackend::set_audio_track` at its
* `not_implemented()` default, which is why IR-019 is met by these paths
* rather than by MPV.
* mpv selects in place the same way (`mpv_tracks::select_audio`, by position in
* the file's audio tracks), and re-opens a transcode through the same path.
*
* TRACES: UR-021, UR-005 | IR-019, DR-024, DR-258
*/
async playerSwitchAudioTrack(repositoryHandle: string, streamIndex: number, arrayIndex: number, useHtml5: boolean, currentPosition: number | null, mediaSourceId: string | null) : Promise<AudioTrackSwitchResponse> {
return await TAURI_INVOKE("player_switch_audio_track", { repositoryHandle, streamIndex, arrayIndex, useHtml5, currentPosition, mediaSourceId });
async playerSwitchAudioTrack(repositoryHandle: string, streamIndex: number, arrayIndex: number, currentPosition: number | null, mediaSourceId: string | null) : Promise<AudioTrackSwitchResponse> {
return await TAURI_INVOKE("player_switch_audio_track", { repositoryHandle, streamIndex, arrayIndex, currentPosition, mediaSourceId });
},
/**
* Set (or clear, with `None`) the active subtitle track on a native backend.
*
* On Android this indexes ExoPlayer's *text track groups* — i.e. the position
* of the sideloaded `MediaItem.SubtitleConfiguration`, not the Jellyfin stream
* index. The HTML5 path never reaches here; it toggles its own `<track>`
* children. libmpv implements neither, leaving the trait default in place.
* index. mpv gives it the same meaning: the position in the sideloaded WebVTT
* list, loaded as external subtitle files (`mpv_tracks`).
*
* TRACES: UR-020 | IR-018, DR-023
*/
@@ -283,9 +278,7 @@ async playerGetStreamingQualities() : Promise<([StreamingQuality, string, string
* A cap is a property of the stream the server is producing, so unlike a volume
* change it cannot be applied to a stream already in flight — the stream has to
* be re-opened at the new quality and resumed at the current position. That is
* the same reload the transcoded-seek and audio-track paths use, and the same
* two-sided split: HTML5 gets the URL back and reloads its own element, while a
* native backend is reloaded here.
* the same reload the transcoded-seek and audio-track paths use, done here.
*
* The change applies to **this playback only**. The in-player picker is a
* "this film, this connection" control and its doc has always said so, but it
@@ -299,8 +292,8 @@ async playerGetStreamingQualities() : Promise<([StreamingQuality, string, string
*
* TRACES: UR-074, UR-079 | DR-162, DR-226
*/
async playerSetStreamQuality(repositoryHandle: string, quality: StreamingQuality, useHtml5: boolean, currentPosition: number | null, mediaSourceId: string | null, audioStreamIndex: number | null) : Promise<StreamQualityResponse> {
return await TAURI_INVOKE("player_set_stream_quality", { repositoryHandle, quality, useHtml5, currentPosition, mediaSourceId, audioStreamIndex });
async playerSetStreamQuality(repositoryHandle: string, quality: StreamingQuality, currentPosition: number | null, mediaSourceId: string | null, audioStreamIndex: number | null) : Promise<StreamQualityResponse> {
return await TAURI_INVOKE("player_set_stream_quality", { repositoryHandle, quality, currentPosition, mediaSourceId, audioStreamIndex });
},
/**
* Set sleep timer mode
@@ -347,7 +340,7 @@ async playerPlayNextEpisode(item: PlayItemRequest) : Promise<PlayerStatus> {
/**
* Handle playback ended event - triggers autoplay decision logic
* This is called from:
* - Frontend when HTML5 video ends (Linux/desktop) - passes itemId + repositoryHandle for the video
* - Frontend when a video ends - passes itemId + repositoryHandle for the video
* - Frontend when audio track ends via backend event - no itemId/repositoryHandle needed
* - Android JNI callback also triggers this logic directly
*
@@ -380,20 +373,20 @@ async playerRecoverStream() : Promise<boolean> {
return await TAURI_INVOKE("player_recover_stream");
},
/**
* Report an HTML5 <video> state change (playing/paused/loading/stopped/idle).
* Report a webview media element's state change (playing/paused/loading/stopped/idle).
*/
async playerReportState(state: string, mediaId: string | null) : Promise<null> {
return await TAURI_INVOKE("player_report_state", { state, mediaId });
},
/**
* Report an HTML5 <video> position tick (seconds). The adapter should throttle
* Report a webview media element's position tick (seconds). The adapter should throttle
* these to roughly match the native backends' ~250ms cadence.
*/
async playerReportPosition(position: number, duration: number) : Promise<null> {
return await TAURI_INVOKE("player_report_position", { position, duration });
},
/**
* Report that the HTML5 <video> finished loading and knows its duration.
* Report that a webview media element finished loading and knows its duration.
*/
async playerReportMediaLoaded(duration: number) : Promise<null> {
return await TAURI_INVOKE("player_report_media_loaded", { duration });
@@ -2134,11 +2127,7 @@ export type AudioTrackSwitchResponse =
/**
* Native backend handled it (Android ExoPlayer)
*/
{ strategy: "native"; success: boolean } |
/**
* HTML5 needs to reload stream with new audio track
*/
{ strategy: "reloadStream"; selection: StreamSelection; position: number }
{ strategy: "native"; success: boolean }
/**
* Authentication result
*/
@@ -2836,9 +2825,8 @@ seriesId?: string | null;
/**
* Subtitle tracks to sideload, with URLs the frontend has already resolved.
*
* Only the native backends use these: on Android they become the
* `MediaItem.SubtitleConfiguration`s ExoPlayer renders. The HTML5 path
* builds its own `<track>` children instead and ignores this list.
* On Android they become the `MediaItem.SubtitleConfiguration`s ExoPlayer
* renders; mpv loads them as external subtitle files (`mpv_tracks`).
*
* **Order is the contract.** `player_set_subtitle_track(n)` reaches
* `JellyTauPlayer.setSubtitleTrack(n)`, which indexes into ExoPlayer's
@@ -2907,23 +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 can be rendered by a native surface composited *behind*
* a transparent webview. Android only: ExoPlayer draws into a SurfaceView
* beneath the WebView. Linux cannot do this (WebKitGTK/Wayland
* compositing), so it stays on the HTML5 element.
*/
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. See [`webview_video_fallback`].
*/
webviewVideoFallback: boolean }
usesWebviewAudio: boolean }
/**
* Playback information
*/
@@ -3135,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
*/
@@ -3198,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" } |
/**
@@ -3245,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`.
@@ -3612,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.
@@ -3845,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
*/
@@ -3864,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
*/
@@ -0,0 +1,43 @@
import { describe, it, expect } from "vitest";
import { errorAfterLoad, loadErrorMessage } from "./detailLoadError";
/**
* TRACES: UR-062 | DR-297 | UT-267
*
* Resuming the app after a few minutes in the background replaced a series page
* that was on screen with "Failed to load item". The resume reload is a refresh
* of content the cache had already answered, yet any throw in it blanked the
* page — and no later successful reload of the same item ever cleared it.
*/
describe("errorAfterLoad", () => {
it("clears the error when a load succeeds", () => {
expect(errorAfterLoad({ ok: true }, { refreshing: true })).toBeNull();
expect(errorAfterLoad({ ok: true }, { refreshing: false })).toBeNull();
});
it("keeps a page on screen when a refresh of it fails", () => {
expect(errorAfterLoad({ ok: false, error: "boom" }, { refreshing: true })).toBeNull();
});
it("reports a failure to open an item that is not on screen yet", () => {
expect(errorAfterLoad({ ok: false, error: new Error("Offline") }, { refreshing: false })).toBe(
"Offline",
);
});
});
describe("loadErrorMessage", () => {
it("shows the text of a backend error, which arrives as a plain string", () => {
expect(loadErrorMessage("Repository not found")).toBe("Repository not found");
});
it("shows an Error's message", () => {
expect(loadErrorMessage(new TypeError("x is undefined"))).toBe("x is undefined");
});
it("falls back to a generic message for anything else", () => {
expect(loadErrorMessage(undefined)).toBe("Failed to load item");
expect(loadErrorMessage({})).toBe("Failed to load item");
expect(loadErrorMessage("")).toBe("Failed to load item");
});
});
@@ -0,0 +1,35 @@
// What the library detail page's error slot shows after a load.
//
// Extracted from `/library/[id]/+page.svelte` so it can be unit-tested.
export type LoadOutcome = { ok: true } | { ok: false; error: unknown };
/**
* The text of whatever a load threw. Backend commands reject with a plain
* string, not an `Error`, so reading only `Error.message` replaced every
* backend error with the generic fallback and hid what actually failed.
*/
export function loadErrorMessage(error: unknown): string {
if (error instanceof Error && error.message) return error.message;
if (typeof error === "string" && error) return error;
return "Failed to load item";
}
/**
* The error to show once a load has ended.
*
* A success always clears it — a same-item reload used to leave a stale error
* up for good. A failed *refresh* of a page already on screen shows none: the
* page is still showing what the cache answered, and a background refresh
* (reconnect, resume, filter change) must not blank it. Only failing to open an
* item leaves the viewer with nothing to see.
*
* TRACES: UR-062 | DR-297 | UT-267
*/
export function errorAfterLoad(
outcome: LoadOutcome,
options: { refreshing: boolean },
): string | null {
if (outcome.ok || options.refreshing) return null;
return loadErrorMessage(outcome.error);
}
@@ -1,78 +1,27 @@
/**
* VideoPlayer scrub regression tests (Android backend path)
* VideoPlayer scrub regression tests.
*
* Reproduces the reported bug: with a sleep timer active, scrubbing the
* video seek bar "seeks, then jumps back to the old position".
*
* Root cause history:
* - Native init called onDestroy() after an await -> lifecycle_outside_component
* -> the catch treated init as failed and silently flipped useHtml5Element to
* true, so seeks went down the HTML5 path while ExoPlayer kept playing.
* - The native SurfaceView has never been visible through the webview, so the
* INTERIM behavior (until the video-player API refactor) is: when the backend
* reports native mode, VideoPlayer deliberately overrides to HTML5 rendering
* and stops the native backend (single audio source, webview owns playback).
* Root cause history: native init called onDestroy() after an await ->
* lifecycle_outside_component -> the catch treated init as failed and silently
* switched seeks to the (since deleted) webview `<video>` path while the native
* player kept playing.
*
* These tests pin the interim behavior: Android's native response is
* overridden, the backend is stopped exactly once, and scrubbing keeps
* working (and holds its position) with a sleep timer active.
* These tests pin that scrubbing reaches the backend and holds its position
* with a sleep timer active. Every video renderer is native now (DR-235).
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
// ---- Mocks (must precede component import) --------------------------------
const channelHandlers: Record<string, (event: any) => void> = {};
// These tests pin the **flag-off** behaviour: when `experimentalNativeVideo` is
// off, VideoPlayer overrides Android's native backend response to HTML5
// rendering and stops the native backend. That is the default again (DR-172,
// after native video shipped as audio with no picture), so this mock now agrees
// with the default rather than opposing it — kept explicit so the tests state
// which path they guard instead of inheriting whatever the default happens to be.
vi.mock("$lib/stores/nativeVideo", async (importOriginal) => {
const actual = await importOriginal<typeof import("$lib/stores/nativeVideo")>();
return {
...actual,
experimentalNativeVideo: {
subscribe: (run: (v: boolean) => void) => {
run(false);
return () => {};
},
set: () => {},
current: () => false,
},
};
});
// The webview path only exists where Rust offers a fallback from the native
// renderer — beside mpv native video on Linux; never on Android since DR-293,
// where a stored "off" is ignored. These tests guard that path's scrubbing, so
// they declare a platform that has it.
vi.mock("$lib/services/playbackCapabilities", () => ({
getPlaybackCapabilities: async () => ({
usesWebviewAudio: false,
supportsNativeVideo: true,
webviewVideoFallback: true,
}),
}));
vi.mock("@tauri-apps/api/event", () => ({
listen: vi.fn(async (channel: string, handler: any) => {
channelHandlers[channel] = handler;
return () => {
delete channelHandlers[channel];
};
}),
}));
vi.mock("@tauri-apps/api/core", () => ({
invoke: vi.fn(),
}));
const playerPlayItem = vi.fn(async () => ({
// What Android reports: native ExoPlayer backend
useHtml5Element: false,
backend: "exoplayer",
state: { kind: "playing" },
}));
const playerSeekVideo = vi.fn(async (_h: string, position: number) => ({
@@ -164,7 +113,7 @@ function sleepTimerTick(remaining = 2) {
});
}
async function mountAndroidPlayer() {
async function mountPlayer() {
const utils = render(VideoPlayer, {
props: {
media: makeEpisode(),
@@ -175,63 +124,41 @@ async function mountAndroidPlayer() {
},
});
// Init: backend reports native, component overrides to HTML5 and stops it.
await waitFor(() => expect(playerPlayItem).toHaveBeenCalled());
await waitFor(() => expect(playerStop).toHaveBeenCalled());
const slider = utils.container.querySelector('input[type="range"]') as HTMLInputElement;
const video = utils.container.querySelector("video") as HTMLVideoElement;
expect(slider).not.toBeNull();
expect(video).not.toBeNull();
return { ...utils, slider, video };
return { ...utils, slider };
}
/** Scrub the seek bar to `target` seconds like a user drag. */
async function scrubTo(slider: HTMLInputElement, video: HTMLVideoElement, target: number) {
async function scrubTo(slider: HTMLInputElement, target: number) {
await fireEvent.mouseDown(slider);
slider.value = String(target);
await fireEvent.input(slider);
await fireEvent.change(slider);
await fireEvent.mouseUp(slider);
// Resolve the "wait for seeked" step of the HTML5 native-seek path.
await fireEvent(video, new Event("seeked"));
await tick();
}
describe("VideoPlayer scrubbing with active sleep timer (Android)", () => {
describe("VideoPlayer scrubbing with active sleep timer", () => {
beforeEach(() => {
vi.clearAllMocks();
for (const key of Object.keys(channelHandlers)) delete channelHandlers[key];
sleepTimer.set({ mode: { kind: "off" }, remainingSeconds: 0 });
sleepTimerExpiredSignal.set(0);
});
it("overrides the native backend response to HTML5 rendering and stops the backend once", async () => {
await mountAndroidPlayer();
// The native backend must be stopped so it doesn't play audio behind the
// webview (frozen picture + double audio source).
expect(playerStop).toHaveBeenCalledTimes(1);
});
it("scrubbing without a timer seeks through the backend and keeps the new position", async () => {
const { slider } = await mountPlayer();
it("scrubbing without a timer seeks via the HTML5 path and keeps the new position", async () => {
const { slider, video } = await mountAndroidPlayer();
await scrubTo(slider, 600);
await scrubTo(slider, video, 600);
await waitFor(() =>
expect(playerSeekVideo).toHaveBeenCalledWith(
"repo-1",
600,
"src-1",
null,
true, // HTML5 path: the webview owns playback after the override
),
);
await waitFor(() => expect(playerSeekVideo).toHaveBeenCalledWith("repo-1", 600, "src-1", null));
expect(parseFloat(slider.value)).toBeCloseTo(600);
});
it("scrubbing still works (and holds position) after enabling an episodes sleep timer", async () => {
const { slider, video } = await mountAndroidPlayer();
const { slider } = await mountPlayer();
// Enable "2 more episodes" timer; backend then ticks every second.
sleepTimerTick(2);
@@ -239,7 +166,7 @@ describe("VideoPlayer scrubbing with active sleep timer (Android)", () => {
sleepTimerTick(2);
await tick();
await scrubTo(slider, video, 600);
await scrubTo(slider, 600);
await waitFor(() => expect(playerSeekVideo).toHaveBeenCalledTimes(1));
expect(parseFloat(slider.value)).toBeCloseTo(600);
@@ -249,13 +176,13 @@ describe("VideoPlayer scrubbing with active sleep timer (Android)", () => {
expect(parseFloat(slider.value)).toBeCloseTo(600);
// A second scrub must also work.
await scrubTo(slider, video, 900);
await scrubTo(slider, 900);
await waitFor(() => expect(playerSeekVideo).toHaveBeenCalledTimes(2));
expect(parseFloat(slider.value)).toBeCloseTo(900);
});
it("sleep-timer ticks alone never move the seek bar", async () => {
const { slider } = await mountAndroidPlayer();
const { slider } = await mountPlayer();
const before = slider.value;
for (let i = 0; i < 5; i++) {
File diff suppressed because it is too large Load Diff
@@ -21,8 +21,9 @@
* rather than internals.
*
* The specific traps encoded here, each a bug that shipped:
* - pausing renders a full-screen <button> play overlay OVER the video, so the
* second tap of a double tap lands on a button, not the video;
* - pausing renders a full-screen <button> play overlay OVER the video
* surface, so the second tap of a double tap lands on a button, not the
* surface;
* - the browser synthesizes a `click` after a touch tap, which must not toggle
* a second time, on ANY layered target;
* - the bottom controls bar must drive its own buttons and NOT the container's
@@ -35,6 +36,7 @@ import { tick } from "svelte";
import { invoke } from "@tauri-apps/api/core";
import VideoPlayer from "./VideoPlayer.svelte";
import { SEEK_FORWARD_SECONDS } from "./tapGestures";
import { player } from "$lib/stores/player";
/**
* A `StreamSelection` for tests that only care about the URL. Transcoded HLS is
@@ -94,18 +96,10 @@ vi.mock("$lib/player/adapters/rustReportHost", () => ({
}),
}));
vi.mock("$lib/player/html5Adapter", () => ({
reportState: vi.fn(),
reportPosition: vi.fn(),
reportMediaLoaded: vi.fn(),
resetReporting: vi.fn(),
}));
vi.mock("$lib/utils/pictureInPicture", () => ({
isPipSupported: () => false,
enterPip: vi.fn(),
setAutoEnterEnabled: vi.fn(),
setHtml5VideoState: vi.fn(),
}));
vi.mock("$lib/stores/auth", () => ({
@@ -143,9 +137,22 @@ function renderPlayer() {
});
}
/**
* The transparent area the native picture shows through — the video surface.
* The first `[data-player-surface]`; the play overlay, when raised, is another.
*/
function videoSurface(container: HTMLElement): Element {
const surface = container.querySelector("[data-player-surface]");
expect(surface).toBeTruthy();
return surface!;
}
describe("VideoPlayer tap surface (real component)", () => {
beforeEach(() => {
vi.clearAllMocks();
// Playing, as the backend would report it, so no play overlay covers the
// surface to begin with.
player.setPlaying(MEDIA, 0, 600);
// This file deliberately does NOT mock `$lib/api/bindings` — it renders the
// real component against the real bindings, which bottom out in the globally
// mocked `invoke`. That mock resolves `undefined` for every command, so the
@@ -167,17 +174,17 @@ describe("VideoPlayer tap surface (real component)", () => {
it("a single tap on the video toggles play/pause exactly once", async () => {
const { container } = renderPlayer();
const video = container.querySelector("video");
expect(video).toBeTruthy();
await tick();
touchAt(video!, 900);
touchAt(videoSurface(container), 900);
expect(toggleSpy).toHaveBeenCalledTimes(1);
});
it("the synthesized click after a tap does not toggle a second time", async () => {
const { container } = renderPlayer();
const video = container.querySelector("video")!;
await tick();
const video = videoSurface(container);
touchAt(video, 900);
// The compatibility click the browser fires after a touch tap. detail=0 is
@@ -195,21 +202,21 @@ describe("VideoPlayer tap surface (real component)", () => {
// guard that does not know about that overlay discards it and seeking dies.
//
// Reproducing it requires the overlay to actually render, which means
// driving `isPlaying` the way the real element does: via its `pause` event.
// driving `isPlaying` the way production does: the player reports paused.
vi.useFakeTimers();
try {
const { container } = renderPlayer();
const video = container.querySelector("video")!;
await tick();
// Tap 1 on the video.
touchAt(video, 900);
// Tap 1 on the video surface.
touchAt(videoSurface(container), 900);
// The element reports it paused → isPlaying=false → overlay renders.
video.dispatchEvent(new Event("pause"));
// The player reports it paused → isPlaying=false → overlay renders.
player.setPaused(MEDIA, 0, 600);
await Promise.resolve();
await tick();
const overlay = container.querySelector("[data-player-surface]");
const overlay = container.querySelector('[data-testid="play-overlay"]');
expect(overlay, "the play overlay should be covering the video").toBeTruthy();
vi.advanceTimersByTime(120); // inside DOUBLE_TAP_WINDOW_MS
@@ -25,56 +25,11 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
// ---- Mocks (must precede component import) --------------------------------
const channelHandlers: Record<string, (event: any) => void> = {};
// These tests pin the **flag-off** behaviour: when `experimentalNativeVideo` is
// off, VideoPlayer overrides Android's native backend response to HTML5
// rendering and stops the native backend. That is the default again (DR-172,
// after native video shipped as audio with no picture), so this mock now agrees
// with the default rather than opposing it — kept explicit so the tests state
// which path they guard instead of inheriting whatever the default happens to be.
vi.mock("$lib/stores/nativeVideo", async (importOriginal) => {
const actual = await importOriginal<typeof import("$lib/stores/nativeVideo")>();
return {
...actual,
experimentalNativeVideo: {
subscribe: (run: (v: boolean) => void) => {
run(false);
return () => {};
},
set: () => {},
current: () => false,
},
};
});
// The webview path only exists where Rust offers a fallback from the native
// renderer — beside mpv native video on Linux; never on Android since DR-293,
// where a stored "off" is ignored. These tests guard that path's scrubbing, so
// they declare a platform that has it.
vi.mock("$lib/services/playbackCapabilities", () => ({
getPlaybackCapabilities: async () => ({
usesWebviewAudio: false,
supportsNativeVideo: true,
webviewVideoFallback: true,
}),
}));
vi.mock("@tauri-apps/api/event", () => ({
listen: vi.fn(async (channel: string, handler: any) => {
channelHandlers[channel] = handler;
return () => {
delete channelHandlers[channel];
};
}),
}));
vi.mock("@tauri-apps/api/core", () => ({
invoke: vi.fn(),
}));
const playerPlayItem = vi.fn(async () => ({
useHtml5Element: false,
backend: "exoplayer",
state: { kind: "playing" },
}));
const playerSeekVideo = vi.fn(async (_h: string, position: number) => ({
@@ -166,12 +121,10 @@ async function mountAndroidPlayer() {
});
await waitFor(() => expect(playerPlayItem).toHaveBeenCalled());
await waitFor(() => expect(playerStop).toHaveBeenCalled());
const slider = utils.container.querySelector('input[type="range"]') as HTMLInputElement;
const video = utils.container.querySelector("video") as HTMLVideoElement;
expect(slider).not.toBeNull();
return { ...utils, slider, video };
return { ...utils, slider };
}
function touch(x: number, y: number) {
@@ -184,7 +137,7 @@ function touch(x: number, y: number) {
* A real drag along the bar moves the finger far enough that the container's
* swipe detector (50px) would trigger if it were still listening.
*/
async function touchScrubTo(slider: HTMLInputElement, video: HTMLVideoElement, target: number) {
async function touchScrubTo(slider: HTMLInputElement, target: number) {
await fireEvent.touchStart(slider, { touches: [touch(100, 700)] });
// Finger travels across the bar. Small vertical wander is normal for a thumb
// drag; the horizontal travel is what matters.
@@ -194,31 +147,27 @@ async function touchScrubTo(slider: HTMLInputElement, video: HTMLVideoElement, t
await fireEvent.touchMove(slider, { touches: [touch(700, 705)] });
await fireEvent.change(slider);
await fireEvent.touchEnd(slider, { touches: [] });
if (video) await fireEvent(video, new Event("seeked"));
await tick();
}
describe("VideoPlayer seek bar — touch drag (Android)", () => {
beforeEach(() => {
vi.clearAllMocks();
for (const key of Object.keys(channelHandlers)) delete channelHandlers[key];
});
it("a touch drag on the seek bar seeks to the dragged position", async () => {
const { slider, video } = await mountAndroidPlayer();
const { slider } = await mountAndroidPlayer();
await touchScrubTo(slider, video, 600);
await touchScrubTo(slider, 600);
await waitFor(() =>
expect(playerSeekVideo).toHaveBeenCalledWith("repo-1", 600, "src-1", null, true),
);
await waitFor(() => expect(playerSeekVideo).toHaveBeenCalledWith("repo-1", 600, "src-1", null));
expect(parseFloat(slider.value)).toBeCloseTo(600);
});
it("a touch drag on the seek bar never toggles play/pause", async () => {
const { slider, video } = await mountAndroidPlayer();
const { slider } = await mountAndroidPlayer();
await touchScrubTo(slider, video, 600);
await touchScrubTo(slider, 600);
// The container gesture layer must stay out of a control drag entirely:
// no swipe mis-read, so no play/pause correction.
@@ -226,7 +175,7 @@ describe("VideoPlayer seek bar — touch drag (Android)", () => {
});
it("commits the seek on touchend even when the engine never fires `change`", async () => {
const { slider, video } = await mountAndroidPlayer();
const { slider } = await mountAndroidPlayer();
// Android's WebView does not reliably fire `change` for a touch interaction
// on a range input. A tap on the track still moves the thumb and fires
@@ -235,32 +184,29 @@ describe("VideoPlayer seek bar — touch drag (Android)", () => {
slider.value = "600";
await fireEvent.input(slider);
await fireEvent.touchEnd(slider, { touches: [] });
if (video) await fireEvent(video, new Event("seeked"));
await tick();
await waitFor(() =>
expect(playerSeekVideo).toHaveBeenCalledWith("repo-1", 600, "src-1", null, true),
);
await waitFor(() => expect(playerSeekVideo).toHaveBeenCalledWith("repo-1", 600, "src-1", null));
});
it("commits the seek exactly once when both touchend and change fire", async () => {
const { slider, video } = await mountAndroidPlayer();
const { slider } = await mountAndroidPlayer();
await touchScrubTo(slider, video, 600);
await touchScrubTo(slider, 600);
expect(playerSeekVideo).toHaveBeenCalledTimes(1);
});
it("a touch drag on the seek bar does not hijack into brightness control", async () => {
const { slider, video, container } = await mountAndroidPlayer();
const { slider, container } = await mountAndroidPlayer();
await touchScrubTo(slider, video, 600);
// Brightness is applied as a CSS filter on the <video>; a control drag must
// leave it untouched.
const el = container.querySelector("video") as HTMLVideoElement | null;
if (el) {
expect(el.style.filter).toBe("brightness(1)");
}
// Mid-drag, with the finger still down: a mis-read swipe raises the
// brightness indicator for as long as the swipe lasts.
await fireEvent.touchStart(slider, { touches: [touch(100, 700)] });
await fireEvent.touchMove(slider, { touches: [touch(400, 600)] });
await fireEvent.touchMove(slider, { touches: [touch(700, 500)] });
await tick();
expect(container.textContent).not.toContain("Brightness");
await fireEvent.touchEnd(slider, { touches: [] });
});
});
@@ -97,9 +97,8 @@ describe("backgroundAudioHandoff", () => {
//
// TRACES: UR-040, UR-003 | DR-196
describe("planHandoffReturn", () => {
it("restarts the native backend when the native path is rendering", () => {
it("restarts the native backend", () => {
const plan = planHandoffReturn({
useHtml5Element: false,
position: 4214,
wasPlaying: true,
nativeStateKind: "playing",
@@ -109,19 +108,8 @@ describe("backgroundAudioHandoff", () => {
expect(plan.shouldPlay).toBe(true);
});
it("reloads the webview element when HTML5 is rendering", () => {
const plan = planHandoffReturn({
useHtml5Element: true,
position: 120,
wasPlaying: true,
nativeStateKind: "playing",
});
expect(plan.target).toBe("html5-element");
});
it("honours a lockscreen pause over the handoff snapshot", () => {
const plan = planHandoffReturn({
useHtml5Element: false,
position: 300,
wasPlaying: true,
nativeStateKind: "paused",
@@ -131,7 +119,6 @@ describe("backgroundAudioHandoff", () => {
it("never returns a negative resume position", () => {
const plan = planHandoffReturn({
useHtml5Element: false,
position: -3,
wasPlaying: false,
nativeStateKind: undefined,
@@ -146,7 +133,6 @@ describe("backgroundAudioHandoff", () => {
// TRACES: UR-040, UR-023 | DR-296 | UT-265
it("switches to the item the backend advanced to", () => {
const plan = planHandoffReturn({
useHtml5Element: false,
position: 95,
wasPlaying: true,
nativeStateKind: "playing",
@@ -160,21 +146,19 @@ describe("backgroundAudioHandoff", () => {
it("reloads in place when the backend is still on the mounted item", () => {
const plan = planHandoffReturn({
useHtml5Element: true,
position: 95,
wasPlaying: true,
nativeStateKind: "playing",
mountedItemId: "ep1",
resumeItemId: "ep1",
});
expect(plan.target).toBe("html5-element");
expect(plan.target).toBe("native-backend");
});
it("reloads in place when the backend reports no item", () => {
// Queue emptied while backgrounded (e.g. the sleep timer): there is no
// other item to go to, so the mounted one is the best we have.
const plan = planHandoffReturn({
useHtml5Element: false,
position: 95,
wasPlaying: false,
nativeStateKind: undefined,
@@ -82,7 +82,7 @@ export interface HandoffReturn {
* no longer on the item this player was mounted with, so the player must
* switch to `itemId` instead of reloading itself.
*/
target: "html5-element" | "native-backend" | "other-item";
target: "native-backend" | "other-item";
/** The item to switch to; set only for `other-item`. */
itemId?: string;
/** Absolute position the background audio reached. */
@@ -94,19 +94,14 @@ export interface HandoffReturn {
/**
* How to come back when the app returns to the foreground.
*
* The two render paths resume by completely different means, and conflating
* them is what broke the native one:
* The native backend owns no element, and nothing reacts to the stream URL on
* its behalf. Native playback is only ever started by an explicit backend load,
* which the component issues once, from `onMount`. So the return has to
* re-issue it; reassigning the URL restarts nothing.
*
* - **html5-element** — assigning the stream URL is enough. An `$effect` in the
* component watches it, (re)initialises HLS or sets `videoElement.src`, and
* `canplay` then drives the seek and play.
* - **native-backend** — ExoPlayer owns no element, and nothing reacts to the
* stream URL on its behalf. Native playback is only ever started by an
* explicit backend load, which the component issues once, from `onMount`. So
* the return has to re-issue it; reassigning the URL restarts nothing.
*
* The component previously did only the URL assignment, for both paths. On the
* native path that left the backend holding no item at all: a black screen with
* The component once did only the URL assignment — which is how the deleted
* webview `<video>` path came back. On the native path that left the backend
* holding no item at all: a black screen with
* a play overlay, a play button that did nothing, and the position pinned at
* 0:00 — the handoff's own audio player having been stopped on the way out.
*
@@ -122,7 +117,6 @@ export interface HandoffReturn {
* TRACES: UR-040, UR-003, UR-023 | DR-196, DR-296 | UT-060, UT-265
*/
export function planHandoffReturn(opts: {
useHtml5Element: boolean;
position: number;
wasPlaying: boolean;
nativeStateKind: string | undefined;
@@ -134,11 +128,7 @@ export function planHandoffReturn(opts: {
if (opts.resumeItemId && opts.resumeItemId !== opts.mountedItemId) {
return { target: "other-item", itemId: opts.resumeItemId, position, shouldPlay };
}
return {
target: opts.useHtml5Element ? "html5-element" : "native-backend",
position,
shouldPlay,
};
return { target: "native-backend", position, shouldPlay };
}
/**
@@ -2,14 +2,9 @@ import { describe, it, expect } from "vitest";
import { planFullscreen } from "./fullscreenTarget";
describe("planFullscreen", () => {
it("fullscreens only the document when an in-document <video> renders", () => {
// Unchanged behaviour: WebKit scales the element, the window need not move.
expect(planFullscreen(false)).toEqual({ document: true, osWindow: false });
});
it("also fullscreens the OS window when a native surface renders", () => {
it("fullscreens the OS window as well as the document", () => {
// The picture is drawn behind the webview at window size, so a
// document-only fullscreen leaves it at the old size.
expect(planFullscreen(true)).toEqual({ document: true, osWindow: true });
expect(planFullscreen()).toEqual({ document: true, osWindow: true });
});
});
@@ -27,9 +27,9 @@ export interface FullscreenPlan {
}
/**
* @param rendersNatively true when a native surface (mpv/ExoPlayer) draws the
* picture rather than an in-document `<video>` element.
* Every video renderer is a native surface since the webview `<video>` path was
* deleted (DR-235), so the OS window always has to move with the document.
*/
export function planFullscreen(rendersNatively: boolean): FullscreenPlan {
return { document: true, osWindow: rendersNatively };
export function planFullscreen(): FullscreenPlan {
return { document: true, osWindow: true };
}
@@ -1,64 +0,0 @@
import { describe, it, expect } from "vitest";
import { fatalNetworkErrorAction } from "./hlsRecovery";
/**
* A fatal hls.js network error mid-film must be retried, not reported as the
* end of the stream — reporting "ended" hands control to autoplay and skips to
* the next item while the user is still watching this one.
*
* The position the player displays is *already absolute*: the RAF loop sets
* `currentTime = seekOffset + element.currentTime`. Anything that adds the
* offset a second time doubles the apparent position, and after a quality
* switch or a transcoded seek the offset is the whole resume position — so past
* roughly the halfway mark the doubled value clears the near-end threshold and
* every transient error is misread as the end.
*
* TRACES: UR-004, UR-074 | DR-177 | UT-174
*/
describe("fatalNetworkErrorAction", () => {
it("retries a mid-film failure after a quality switch instead of ending playback", () => {
// 90-minute film, quality switched at the 50-minute mark: the reloaded
// stream's timeline starts at 0, so seekOffset carries the 50 minutes and
// the displayed position — already absolute — is 3000s of 5400s, 56%
// through and nowhere near the end.
const action = fatalNetworkErrorAction({
positionSeconds: 3000,
knownDurationSeconds: 5400,
attempts: 1,
});
expect(action).toBe("retry");
});
it("treats a failure in the last tenth of the stream as the end", () => {
// Jellyfin's transcoded HLS does not always emit #EXT-X-ENDLIST, so a
// genuine end-of-stream arrives as a fatal network error.
const action = fatalNetworkErrorAction({
positionSeconds: 5300,
knownDurationSeconds: 5400,
attempts: 1,
});
expect(action).toBe("ended");
});
it("stops retrying once the recovery budget is spent", () => {
const action = fatalNetworkErrorAction({
positionSeconds: 60,
knownDurationSeconds: 5400,
attempts: 4,
});
expect(action).toBe("giveUp");
});
it("retries when the runtime is not known yet", () => {
const action = fatalNetworkErrorAction({
positionSeconds: 120,
knownDurationSeconds: 0,
attempts: 1,
});
expect(action).toBe("retry");
});
});
-52
View File
@@ -1,52 +0,0 @@
/**
* What to do about a *fatal* hls.js network error.
*
* Jellyfin's transcoded HLS streams do not always terminate with an
* `#EXT-X-ENDLIST`, so a stream that has simply run out looks identical to one
* that broke: both arrive as a fatal network error. The only thing separating
* them is how far playback had got, which is why this decision is worth
* isolating from the player component — read the position wrong and a
* recoverable stall turns into a skip to the next item.
*
* TRACES: UR-004, UR-074 | DR-177 | UT-174
*/
/** Fraction of the runtime past which a fatal error reads as "the stream ended". */
const NEAR_END_FRACTION = 0.9;
/** How many times to ask hls.js to resume before giving up on the stream. */
export const MAX_FATAL_NETWORK_RECOVERIES = 3;
export type FatalNetworkErrorAction = "ended" | "retry" | "giveUp";
export interface FatalNetworkErrorInput {
/**
* Absolute position in the media, in seconds — the value the player displays.
*
* It is already absolute (`seekOffset + element.currentTime`): do NOT add the
* transcode seek offset again. After a quality switch or a transcoded seek the
* offset *is* the resume position, so double-counting it puts an apparent
* position past the near-end threshold from roughly halfway through, and every
* transient error then ends playback.
*/
positionSeconds: number;
/** Known runtime in seconds; 0 or negative when the runtime isn't known yet. */
knownDurationSeconds: number;
/** Recovery attempts already made against this hls.js instance. */
attempts: number;
}
/** Whether a failure at this position should be read as the stream ending. */
export function isNearEndOfStream(positionSeconds: number, knownDurationSeconds: number): boolean {
if (knownDurationSeconds <= 0 || positionSeconds <= 0) return false;
return positionSeconds / knownDurationSeconds > NEAR_END_FRACTION;
}
export function fatalNetworkErrorAction({
positionSeconds,
knownDurationSeconds,
attempts,
}: FatalNetworkErrorInput): FatalNetworkErrorAction {
if (isNearEndOfStream(positionSeconds, knownDurationSeconds)) return "ended";
return attempts <= MAX_FATAL_NETWORK_RECOVERIES ? "retry" : "giveUp";
}
@@ -5,30 +5,19 @@ import {
subtitleStreamsOf,
subtitleTrackLabel,
resolveSubtitleTracks,
reconcileSelectedSubtitle,
videoCrossOriginMode,
nativeSubtitleTracks,
nativeSubtitleArrayIndex,
type SubtitleStreamLike,
} from "./subtitleTracks";
/**
* Subtitles on the Linux / WebKitGTK HTML5 `<video>` path.
* Subtitle resolution — the list the play request carries.
*
* TRACES: UR-020 | DR-023 | UT-143, UT-144
*
* The bug this guards: VideoPlayer rendered no `<track>` children at all (the
* block was commented out "to debug playback issues"), so
* `Html5PlayerAdapter.selectSubtitle()` walked an empty `textTracks` list and
* the subtitle menu was inert on Linux. The reason it had to be disabled is
* visible in the original markup — `src={getSubtitleUrl(track.index)}` bound the
* *Promise* returned by an async function to the attribute, so every track's src
* stringified to "[object Promise]", an unloadable resource hanging off the
* media element.
*
* So the fix has two halves and both are tested here: URLs must be resolved into
* plain strings *before* they reach the markup, and the markup must actually
* render the tracks (with the `data-stream-index` the adapter matches on).
* URLs are resolved into plain strings before they reach the player: the
* original markup bound the *Promise* returned by an async function to a
* `<track src>`, so every track's src stringified to "[object Promise]".
*/
const SUBS: SubtitleStreamLike[] = [
@@ -177,56 +166,6 @@ describe("resolveSubtitleTracks", () => {
});
});
describe("reconcileSelectedSubtitle", () => {
it("starts off (null) and keeps 'off' selectable", async () => {
const tracks = await resolveSubtitleTracks(SUBS, async (i) => url(i));
expect(reconcileSelectedSubtitle(tracks, null)).toBeNull();
});
it("keeps a selection that is still renderable", async () => {
const tracks = await resolveSubtitleTracks(SUBS, async (i) => url(i));
expect(reconcileSelectedSubtitle(tracks, 3)).toBe(3);
});
it("falls back to off when the selected track is gone (new item / failed URL)", async () => {
const tracks = await resolveSubtitleTracks(SUBS, async (i) => url(i));
expect(reconcileSelectedSubtitle(tracks, 9)).toBeNull();
expect(reconcileSelectedSubtitle([], 3)).toBeNull();
});
it("never auto-selects the server's default track", async () => {
// The menu opens on "Off" and a <track default> would auto-show, so the UI
// would claim subtitles are off while they are burned over the picture.
const tracks = await resolveSubtitleTracks(SUBS, async (i) => url(i));
expect(tracks[0].isDefault).toBe(true);
expect(reconcileSelectedSubtitle(tracks, null)).toBeNull();
});
});
describe("videoCrossOriginMode", () => {
it("opts into CORS for a server stream that has subtitles", () => {
expect(videoCrossOriginMode("http://jelly.example/Videos/x/master.m3u8", 2)).toBe("anonymous");
expect(videoCrossOriginMode("https://jelly.example/Videos/x/stream.mp4", 1)).toBe("anonymous");
});
it("leaves a local/offline source alone so playback cannot regress", () => {
expect(videoCrossOriginMode("asset://localhost/movie.mkv", 2)).toBeUndefined();
expect(videoCrossOriginMode("file:///home/u/movie.mkv", 2)).toBeUndefined();
});
it("stays out of the way when there is nothing to load", () => {
expect(videoCrossOriginMode("http://jelly.example/x.m3u8", 0)).toBeUndefined();
expect(videoCrossOriginMode("", 0)).toBeUndefined();
});
it("is decided by inputs known at first render, so it cannot flip mid-load", () => {
// Same answer before and after the async URL resolution completes.
const before = videoCrossOriginMode("http://jelly.example/x.m3u8", SUBS.length);
const after = videoCrossOriginMode("http://jelly.example/x.m3u8", SUBS.length);
expect(before).toBe(after);
});
});
/**
* Subtitles on the Android / ExoPlayer native path.
*
@@ -320,31 +259,6 @@ describe("nativeSubtitleArrayIndex", () => {
});
});
describe("VideoPlayer markup (the regression that made the menu inert)", () => {
const source = readFileSync(resolve(__dirname, "VideoPlayer.svelte"), "utf-8");
it("renders <track> elements instead of leaving them commented out", () => {
expect(source).not.toContain("Temporarily disabled to debug playback issues");
expect(source).toMatch(/<track\b/);
expect(source).toContain('kind="subtitles"');
});
/** The rendered element, not a `<track>` mentioned in prose. */
const trackElement = source.slice(source.search(/<track\s/), source.search(/<track\s/) + 400);
it("keeps data-stream-index — Html5PlayerAdapter.selectSubtitle matches on it", () => {
expect(trackElement).toContain("data-stream-index");
});
it("never binds the async getSubtitleUrl() Promise to src", () => {
expect(source).not.toMatch(/src=\{\s*getSubtitleUrl\(/);
});
it("does not mark any track default (a default track auto-shows)", () => {
expect(trackElement).not.toMatch(/\bdefault=/);
});
});
/**
* The half of the Android fix that lives in the component: the resolved list has
* to actually be handed to `playerPlayItem`, and the index sent to the backend
+6 -65
View File
@@ -125,72 +125,13 @@ export async function resolveSubtitleTracks(
return resolved.filter((t): t is RenderableSubtitleTrack => t !== null);
}
/**
* The selection to keep once the rendered track list changes.
*
* Subtitles are OFF unless the user turns them on: `null` in, `null` out. The
* server's `isDefault` flag is deliberately NOT promoted to a selection (and the
* markup deliberately omits the `default` attribute, which would auto-show the
* track) — the menu opens on "Off", so auto-enabling would make the UI lie about
* what is on screen, and it would change behaviour for every user who has never
* asked for subtitles.
*
* A selection that is no longer renderable (new item, or a URL that failed to
* resolve) collapses to off, so the menu's checkmark can never point at a track
* that does not exist on the element.
*/
export function reconcileSelectedSubtitle(
tracks: readonly RenderableSubtitleTrack[],
selected: number | null,
): number | null {
if (selected === null) return null;
return tracks.some((t) => t.streamIndex === selected) ? selected : null;
}
function originOf(url: string): string | null {
try {
const parsed = new URL(url);
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null;
return parsed.origin;
} catch {
return null;
}
}
/**
* The `crossorigin` value for the `<video>` element, or undefined for none.
*
* Text-track fetches are CORS-enabled per the HTML spec and use the *media
* element's* CORS setting, so a cross-origin `<track>` never loads unless the
* element opts in. The webview page's origin is `tauri://localhost`, so every
* subtitle served by Jellyfin is cross-origin.
*
* Opting in is only safe when the media itself comes from an http(s) server —
* the same Jellyfin that already answers hls.js' cross-origin XHRs, so we know
* it sends the headers. For a local/offline source (`file:`/`asset:`) we leave
* the attribute off: subtitles staying dark there is the status quo, whereas
* forcing CORS onto the video fetch could break playback outright.
*
* Deliberately keyed on the *count of subtitle streams* rather than on the
* resolved tracks: both inputs are known at first render, so the attribute is
* decided before the element starts loading and never flips underneath an
* in-flight media fetch.
*/
export function videoCrossOriginMode(
streamUrl: string,
subtitleStreamCount: number,
): "anonymous" | undefined {
if (subtitleStreamCount <= 0) return undefined;
return originOf(streamUrl) ? "anonymous" : undefined;
}
// ===== Native (Android / ExoPlayer) path ====================================
// ===== The native player =====================================================
//
// The HTML5 element gets `<track>` children; the native backend instead gets the
// list *up front*, as part of the play request, because ExoPlayer sideloads
// subtitles as `MediaItem.SubtitleConfiguration`s that must exist before
// `prepare()`. There is no "add a subtitle later" — a track absent from the
// MediaItem simply does not exist as far as the player is concerned.
// The native backend gets the list *up front*, as part of the play request:
// ExoPlayer sideloads subtitles as `MediaItem.SubtitleConfiguration`s that must
// exist before `prepare()`, and mpv queues them as external files for the load.
// There is no "add a subtitle later" — a track absent from the request simply
// does not exist as far as the player is concerned.
/**
* Map resolved tracks onto the wire shape `PlayItemRequest.subtitles` carries.
@@ -1,45 +0,0 @@
import { describe, it, expect } from "vitest";
import { shouldApplyTimeUpdate } from "./timeTracking";
/**
* TRACES: UT-245 | DR-265
*/
describe("shouldApplyTimeUpdate", () => {
const base = { isPlaying: false, isSeeking: false, isDraggingSeekBar: false, readyState: 4 };
it("applies the update while the video is PLAYING", () => {
// THE REPORTED BUG. `timeupdate` was the only position source that still
// fires once requestAnimationFrame stops -- which is exactly what happens
// when the activity is paused behind a picture-in-picture window. Gating it
// on `!isPlaying` disabled it precisely when it was the only thing left,
// so the component's `currentTime` froze at the moment PiP was entered
// while the element played on. The background-audio handoff then resumed
// the audio-only stream at that frozen position.
expect(shouldApplyTimeUpdate({ ...base, isPlaying: true })).toBe(true);
});
it("still applies the update while paused", () => {
// The case it always handled: RAF is stopped, timeupdate carries the seek.
expect(shouldApplyTimeUpdate(base)).toBe(true);
});
it("yields to an in-flight seek", () => {
// A seek owns the position until it settles; a stale element read landing
// mid-seek is what makes a scrubbed video snap back.
expect(shouldApplyTimeUpdate({ ...base, isSeeking: true })).toBe(false);
expect(shouldApplyTimeUpdate({ ...base, isPlaying: true, isSeeking: true })).toBe(false);
});
it("yields while the user is dragging the seek bar", () => {
expect(shouldApplyTimeUpdate({ ...base, isDraggingSeekBar: true })).toBe(false);
expect(shouldApplyTimeUpdate({ ...base, isPlaying: true, isDraggingSeekBar: true })).toBe(
false,
);
});
it("ignores an element with no usable data yet", () => {
// readyState < HAVE_CURRENT_DATA reads 0, which would rewind the position.
expect(shouldApplyTimeUpdate({ ...base, readyState: 1 })).toBe(false);
expect(shouldApplyTimeUpdate({ ...base, isPlaying: true, readyState: 0 })).toBe(false);
});
});
-45
View File
@@ -1,45 +0,0 @@
/**
* Pure helpers for keeping the player's position variable honest.
*
* TRACES: UR-004, UR-041 | DR-265 | UT-245
*
* `VideoPlayer.svelte` tracks the absolute playback position in its own
* `currentTime` variable rather than reading `videoElement.currentTime` at the
* point of use — transcoded HLS resets the element to 0 on every segment
* rebuild, so only the component's running total is meaningful. Everything
* downstream reads that variable: the seek bar, the progress reports, the
* position mirrored into Rust, and the background-audio handoff.
*
* Which makes "who is allowed to write it" a correctness question, not a
* rendering detail — hence a pure module with tests rather than a condition
* buried in an event handler.
*/
export interface TimeUpdateGate {
/**
* Deliberately does NOT gate the update, and is accepted only to say so.
*
* `timeupdate` was written as a fallback "for when RAF isn't running" and so
* excluded itself whenever `isPlaying` was true. But RAF is driven by the
* document being rendered, and an Android activity behind a picture-in-picture
* window is paused: the loop stops while the element plays on, and the one
* remaining position source had switched itself off. Both writing the same
* derived value costs nothing — the element is the authority either way.
*/
isPlaying?: boolean;
isSeeking: boolean;
isDraggingSeekBar: boolean;
readyState: number;
}
/**
* Whether a `timeupdate` event may write the component's position.
*
* Kept free of Svelte/DOM so the rule is unit-testable without mounting the
* player.
*/
export function shouldApplyTimeUpdate(opts: TimeUpdateGate): boolean {
// An in-flight seek or a drag owns the position until it settles, and an
// element with no current data reads 0, which would rewind it.
return !opts.isSeeking && !opts.isDraggingSeekBar && opts.readyState >= 2;
}
@@ -1,21 +0,0 @@
import { describe, it, expect } from "vitest";
import { videoFitClass } from "./videoFit";
describe("videoFitClass", () => {
it("fills the container instead of capping at the source's intrinsic size", () => {
const cls = videoFitClass();
// max-w/max-h only shrink oversized media; a 480p source would stay a small
// box in the middle of a large window.
expect(cls).not.toContain("max-w-full");
expect(cls).not.toContain("max-h-full");
expect(cls).toContain("w-full");
expect(cls).toContain("h-full");
});
it("preserves aspect ratio while fitting (letterbox, never crop)", () => {
const cls = videoFitClass();
expect(cls).toContain("object-contain");
expect(cls).not.toContain("object-cover");
expect(cls).not.toContain("object-fill");
});
});
-17
View File
@@ -1,17 +0,0 @@
// Sizing rules for the HTML5 <video> element in the full-screen player.
// Extracted from VideoPlayer.svelte so the fit behaviour is unit-testable.
/**
* Classes applied to the <video> element so it fits the player viewport.
*
* TRACES: UR-005
*
* `max-w-full max-h-full` only ever *shrinks* oversized media, so a source
* smaller than the window (e.g. 480p on a 1080p display) rendered at its
* intrinsic size - a small box in the middle of a black screen. Filling the
* container and letting `object-contain` do the scaling fits the picture to
* whichever axis constrains it, in both directions, preserving aspect ratio.
*/
export function videoFitClass(): string {
return "w-full h-full object-contain";
}
@@ -1,95 +0,0 @@
/**
* Adapter-selection regression guards.
*
* TRACES: UR-003, UR-004 | DR-150 | UT-149
*
* The selection rule has two inputs and one hard safety property:
*
* - Rust says which backend the platform has (`backendKind`).
* - The user opts in with `experimentalNativeVideo`.
* - **The flag off must force HTML5 even when Rust says native.** That is the
* regression guard: a broken spike must not be able to ship as the default.
*
* These are pure functions, so the whole matrix is testable without a device.
*/
import { describe, expect, it } from "vitest";
import { createAdapter } from "./index";
import { Html5PlayerAdapter } from "./html5Adapter";
import { NativePlayerAdapter } from "./nativeAdapter";
import type { AdapterHost } from "./types";
const host: AdapterHost = {
reportState: () => {},
reportPosition: () => {},
reportEnded: () => {},
} as unknown as AdapterHost;
const bridge = {
getElement: () => null,
} as any;
describe("createAdapter", () => {
it("returns the native adapter when Rust says native and the flag is on", () => {
const adapter = createAdapter({
backendKind: "native",
host,
bridge,
experimentalNativeVideo: true,
});
expect(adapter).toBeInstanceOf(NativePlayerAdapter);
expect(adapter.kind).toBe("native");
});
// The regression guard: the flag is a suppressor, so off must beat Rust.
it("forces HTML5 when the flag is off even though Rust says native", () => {
const adapter = createAdapter({
backendKind: "native",
host,
bridge,
experimentalNativeVideo: false,
});
expect(adapter).toBeInstanceOf(Html5PlayerAdapter);
expect(adapter.kind).toBe("html5");
});
it("returns the HTML5 adapter when Rust says html5 and the flag is off", () => {
const adapter = createAdapter({
backendKind: "html5",
host,
bridge,
experimentalNativeVideo: false,
});
expect(adapter).toBeInstanceOf(Html5PlayerAdapter);
});
// The flag must never *promote* a platform Rust said has no native backend
// (e.g. Linux, where WebKitGTK cannot composite a surface behind the webview).
it("stays on HTML5 when Rust says html5 even with the flag on", () => {
const adapter = createAdapter({
backendKind: "html5",
host,
bridge,
experimentalNativeVideo: true,
});
expect(adapter).toBeInstanceOf(Html5PlayerAdapter);
});
it("defaults to HTML5 when the flag is omitted entirely", () => {
const adapter = createAdapter({ backendKind: "native", host, bridge });
expect(adapter).toBeInstanceOf(Html5PlayerAdapter);
});
it("requires a bridge for the HTML5 adapter", () => {
expect(() =>
createAdapter({ backendKind: "html5", host, experimentalNativeVideo: false }),
).toThrow(/bridge/i);
});
// The native adapter owns no DOM element, so it must not demand a bridge.
it("does not require a bridge for the native adapter", () => {
expect(() =>
createAdapter({ backendKind: "native", host, experimentalNativeVideo: true }),
).not.toThrow();
});
});
@@ -1,340 +0,0 @@
/**
* Unit tests for Html5PlayerAdapter.
*
* The Option-1 primitive design makes the adapter pure, decision-free mechanics
* — it takes a mock <video> element + bridge + host, so we can assert each
* primitive drives the element correctly without any real DOM or backend.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { Html5PlayerAdapter, type Html5ElementBridge } from "./html5Adapter";
import type { AdapterHost } from "./types";
/**
* A `StreamSelection` for tests that only care about the URL. Transcoded HLS is
* what these paths exercised before the contract carried a transport.
*/
function testSelection(url: string, transport: "hls" | "progressive" | "localFile" = "hls") {
return {
url,
transport: { type: transport },
playbackKind: { type: transport === "hls" ? "transcode" : "directPlay" },
rendition: null,
available: [],
mediaSourceId: null,
playSessionId: null,
needsTranscoding: transport === "hls",
} as import("$lib/api/bindings").StreamSelection;
}
/** A minimal fake <video> element that records mutations and fires events. */
function makeFakeVideo() {
const listeners: Record<string, Array<() => void>> = {};
const el: any = {
paused: true,
currentTime: 0,
volume: 1,
muted: false,
src: "blob:existing",
play: vi.fn(async () => {
el.paused = false;
}),
pause: vi.fn(() => {
el.paused = true;
}),
load: vi.fn(),
removeAttribute: vi.fn((attr: string) => {
if (attr === "src") el.src = "";
}),
addEventListener: (event: string, cb: () => void) => {
(listeners[event] ??= []).push(cb);
},
removeEventListener: (event: string, cb: () => void) => {
listeners[event] = (listeners[event] ?? []).filter((f) => f !== cb);
},
// Test helper: fire an event so waitForEvent resolves immediately.
_fire: (event: string) => {
(listeners[event] ?? []).slice().forEach((f) => f());
},
querySelectorAll: () => [] as any,
textTracks: [] as any,
};
return el;
}
type FakeVideo = ReturnType<typeof makeFakeVideo>;
function makeBridge(overrides: Partial<Html5ElementBridge> = {}): Html5ElementBridge {
let offset = 0;
return {
getElement: () => null,
getSeekOffset: () => offset,
setSeekOffset: vi.fn((o: number) => {
offset = o;
}),
setStreamSelection: vi.fn(),
destroyHls: vi.fn(),
getMediaSourceId: () => "msid-1",
...overrides,
};
}
function makeHost(): AdapterHost {
return {
onState: vi.fn(),
onPosition: vi.fn(),
onMediaLoaded: vi.fn(),
onEnded: vi.fn(),
onError: vi.fn(),
onStreamUrlChanged: vi.fn(),
onBuffering: vi.fn(),
onReady: vi.fn(),
};
}
describe("Html5PlayerAdapter", () => {
let host: AdapterHost;
let bridge: Html5ElementBridge;
let adapter: Html5PlayerAdapter;
let video: ReturnType<typeof makeFakeVideo>;
beforeEach(() => {
host = makeHost();
bridge = makeBridge();
adapter = new Html5PlayerAdapter(host, bridge);
video = makeFakeVideo();
adapter.attach(video);
});
it("is an html5-kind adapter", () => {
expect(adapter.kind).toBe("html5");
});
it("play() calls element.play()", async () => {
await adapter.play();
expect(video.play).toHaveBeenCalledTimes(1);
});
// A stalling HLS stream makes hls.js' gap-controller nudge the element, which
// aborts an in-flight play(). That AbortError is transient — the element is
// still trying to play — so it must not be surfaced as a player error, or the
// UI reports failure ~once a second for the whole stall.
it("play() does not report an interrupted-by-pause AbortError as an error", async () => {
const abort = new DOMException(
"The play() request was interrupted by a call to pause().",
"AbortError",
);
video.play = vi.fn(async () => {
throw abort;
});
await adapter.play();
expect(host.onError).not.toHaveBeenCalled();
});
it("play() still reports a genuine failure", async () => {
video.play = vi.fn(async () => {
throw new DOMException("no supported source", "NotSupportedError");
});
await adapter.play();
expect(host.onError).toHaveBeenCalledTimes(1);
expect(String((host.onError as any).mock.calls[0][0])).toContain("play() failed");
});
it("play() coalesces concurrent attempts into one element.play() call", async () => {
// During a stall the UI and recovery paths can both ask to play. Stacking
// element.play() calls is what generates the AbortError storm.
let resolvePlay: () => void = () => {};
video.play = vi.fn(
() =>
new Promise<void>((r) => {
resolvePlay = () => {
video.paused = false;
r();
};
}),
);
const first = adapter.play();
const second = adapter.play();
resolvePlay();
await Promise.all([first, second]);
expect(video.play).toHaveBeenCalledTimes(1);
});
it("play() works again after a previous attempt settled", async () => {
await adapter.play();
await adapter.play();
expect(video.play).toHaveBeenCalledTimes(2);
});
it("pause() calls element.pause()", async () => {
video.paused = false;
await adapter.pause();
expect(video.pause).toHaveBeenCalledTimes(1);
});
it("toggle() plays when paused and reports the resulting state", async () => {
video.paused = true;
const playing = await adapter.toggle();
expect(video.play).toHaveBeenCalled();
expect(playing).toBe(true);
});
it("toggle() pauses when playing", async () => {
video.paused = false;
const playing = await adapter.toggle();
expect(video.pause).toHaveBeenCalled();
expect(playing).toBe(false);
});
it("seekElement() sets currentTime, offset, and waits for 'seeked'", async () => {
const p = adapter.seekElement(42, 0);
expect(video.currentTime).toBe(42);
expect(bridge.setSeekOffset).toHaveBeenCalledWith(0);
video._fire("seeked"); // resolve the wait
await p;
});
it("reloadSource() runs the invariant teardown->swap->resume sequence", async () => {
video.paused = false; // was playing → should resume
const p = adapter.reloadSource(testSelection("http://new/master.m3u8"), 120);
// Teardown happened synchronously before the awaited canplay wait.
expect(video.pause).toHaveBeenCalled();
expect(bridge.destroyHls).toHaveBeenCalledTimes(1);
expect(video.removeAttribute).toHaveBeenCalledWith("src");
expect(video.load).toHaveBeenCalled();
// Allow the internal 100ms settle delay, then fire canplay to resume.
await new Promise((r) => setTimeout(r, 110));
expect(bridge.setStreamSelection).toHaveBeenCalledWith(
expect.objectContaining({ url: "http://new/master.m3u8", transport: { type: "hls" } }),
);
video._fire("canplay");
video._fire("seeked");
await p;
expect(video.play).toHaveBeenCalled(); // resumed because it was playing
});
/**
* The reload lands the viewer at the position they asked for — by *seeking*,
* with no transcode offset left over.
*
* This used to be inverted: the offset was set to the position and nothing
* seeked, which was right only while the reloaded URL itself began there via
* `StartTimeTicks`. DR-181 removes that parameter, because on an HLS playlist
* the server copies it onto every segment URI and then rejects each one with
* `400`. With the URL starting at the item's zero, the old arithmetic leaves
* `currentTime = offset + 0` — the scrubber reading 20:00 over the opening
* titles, and the seek silently never happening.
*
* TRACES: UR-004, UR-005 | DR-181 | UT-183
*/
it("reloadSource() seeks to the position and clears the transcode offset", async () => {
video.paused = false;
const p = adapter.reloadSource(testSelection("http://new/master.m3u8"), 1200);
await new Promise((r) => setTimeout(r, 110));
expect(bridge.setSeekOffset).toHaveBeenCalledWith(0);
expect(bridge.setSeekOffset).not.toHaveBeenCalledWith(1200);
// Nothing may seek before the new source is playable — the element drops it.
expect(video.currentTime).not.toBe(1200);
video._fire("canplay");
await new Promise((r) => setTimeout(r, 0));
expect(video.currentTime).toBe(1200);
video._fire("seeked");
await p;
expect(video.play).toHaveBeenCalled();
});
/** A reload to the very start has nothing to seek to; it must not stall. */
it("reloadSource() at position 0 does not wait for a seek", async () => {
video.paused = false;
const p = adapter.reloadSource(testSelection("http://new/master.m3u8"), 0);
await new Promise((r) => setTimeout(r, 110));
video._fire("canplay");
await p; // resolves without any "seeked" event
expect(video.play).toHaveBeenCalled();
});
/**
* A reload that never becomes playable must be reported as a failure. It used
* to resolve on the timeout, so a quality switch whose new stream the server
* refused to serve (Jellyfin 400s the first segment when two transcode jobs
* collide) looked like a success: the picker showed the new quality selected
* over a stream that never played, and the caller had nothing to revert to.
*
* TRACES: UR-074 | DR-177 | UT-175
*/
it("reloadSource() rejects when the new stream never becomes playable", async () => {
vi.useFakeTimers();
try {
video.paused = false;
const p = adapter.reloadSource(testSelection("http://new/master.m3u8"), 120);
const assertion = expect(p).rejects.toThrow(/canplay/i);
await vi.advanceTimersByTimeAsync(11_000); // past the 10s readiness budget
await assertion;
expect(video.play).not.toHaveBeenCalled(); // nothing to resume into
} finally {
vi.useRealTimers();
}
});
it("reloadSource() does not resume when it was paused", async () => {
video.paused = true;
const p = adapter.reloadSource(testSelection("http://new/master.m3u8"), 30);
await new Promise((r) => setTimeout(r, 110));
video._fire("canplay");
video._fire("seeked");
await p;
expect(video.play).not.toHaveBeenCalled();
});
it("setVolume() clamps to 0..1", () => {
adapter.setVolume(1.5);
expect(video.volume).toBe(1);
adapter.setVolume(-0.5);
expect(video.volume).toBe(0);
adapter.setVolume(0.4);
expect(video.volume).toBeCloseTo(0.4);
});
it("setMuted() sets the element muted flag", () => {
adapter.setMuted(true);
expect(video.muted).toBe(true);
});
it("getPosition() returns element time plus the transcode offset", () => {
video.currentTime = 10;
(bridge.getSeekOffset as any) = () => 100;
// Rebuild adapter with the offset-returning bridge.
const a = new Html5PlayerAdapter(host, bridge);
a.attach(video);
expect(a.getPosition()).toBe(110);
});
it("dispose() tears down hls and clears the element", async () => {
await adapter.dispose();
expect(bridge.destroyHls).toHaveBeenCalled();
expect(video.pause).toHaveBeenCalled();
// After dispose, primitives are no-ops (element detached).
await adapter.play();
// play was called once during dispose teardown? no — play only on reload/resume.
expect(video.play).not.toHaveBeenCalled();
});
it("primitives are safe no-ops before an element is attached", async () => {
const bare = new Html5PlayerAdapter(host, bridge);
await expect(bare.play()).resolves.toBeUndefined();
await expect(bare.pause()).resolves.toBeUndefined();
await expect(bare.seekElement(5, 0)).resolves.toBeUndefined();
expect(await bare.toggle()).toBe(false);
});
});
-317
View File
@@ -1,317 +0,0 @@
import type { StreamSelection } from "$lib/api/bindings";
/**
* Html5PlayerAdapter — the Linux/desktop (and interim Android) PlayerAdapter
* implementation. It owns the high-level control surface for an HTML5 `<video>`
* element and reports the element's lifecycle back into Rust via its
* {@link AdapterHost}.
*
* Design note on the split with VideoPlayer.svelte:
* The delicate, timing-sensitive parts (hls.js instance lifecycle, the transcode
* "reload stream" seek/audio-track dance with its dual-audio teardown and
* canplay waits) are inherently coupled to Svelte reactive state and the DOM
* element. Rather than relocate that reactive machinery wholesale (high
* regression risk), the adapter receives an {@link Html5ElementBridge} of narrow
* callbacks the owning component supplies. The adapter is the single OWNER of the
* control contract (play/pause/seek/track/volume) and of reporting; the bridge is
* the seam to the component's element/HLS/reactive state. This keeps all control
* intents flowing through the PlayerAdapter interface while preserving the
* hard-won element behavior verbatim.
*
* TRACES: UR-003, UR-005, UR-020, UR-021 | DR-001, DR-023, DR-024, DR-028, DR-096
*/
import type { AdapterHost, PlayerAdapter, PlayerLoadOptions } from "./types";
import { createLogger } from "$lib/utils/logger";
const log = createLogger("Html5PlayerAdapter");
/**
* The selection for a plain `load(url)` call.
*
* `PlayerLoadOptions` carries the backend's selection when the caller has one.
* When it does not — a local file, a live stream, a direct URL — the transport
* is inferred *once, here*, from what the caller already knows rather than from
* the URL text: a local path is a local file, and anything the backend flagged
* as transcoded is HLS, because every transcode this app requests is HLS.
*
* This is the one place a fallback is tolerable, and it is explicitly a
* fallback: the negotiated path never reaches it.
*
* TRACES: UR-079 | DR-225
*/
function selectionForLoad(streamUrl: string, options: PlayerLoadOptions): StreamSelection {
if (options.selection) return options.selection;
const transport: StreamSelection["transport"] = options.isLocalFile
? { type: "localFile" }
: options.needsTranscoding
? { type: "hls" }
: { type: "progressive" };
return {
url: streamUrl,
transport,
playbackKind: options.needsTranscoding ? { type: "transcode" } : { type: "directPlay" },
rendition: null,
available: [],
mediaSourceId: options.mediaSourceId ?? null,
playSessionId: null,
needsTranscoding: options.needsTranscoding,
};
}
/**
* Narrow seam the owning component provides so the adapter can execute the
* element/HLS-coupled parts of a control action without re-implementing the
* component's reactive HLS lifecycle. Every function here is a thin wrapper over
* work the component already does.
*/
export interface Html5ElementBridge {
/** The bound <video> element, or null before mount / after teardown. */
getElement(): HTMLVideoElement | null;
/** Current seek offset (seconds) for transcoded streams. */
getSeekOffset(): number;
setSeekOffset(offset: number): void;
/**
* Update the stream the component renders (triggers its HLS $effect).
*
* Carries the whole [`StreamSelection`], not just the URL: the component's
* effect has to know the transport to choose a loader, and deriving that from
* the URL is the substring check DR-225 removes.
*
* TRACES: UR-079 | DR-225
*/
setStreamSelection(selection: StreamSelection): void;
/** Tear down the component-owned hls.js instance (dual-audio prevention). */
destroyHls(): void;
/** Media source id for seek/audio-track URLs. */
getMediaSourceId(): string | null;
}
/**
* True for the `AbortError` the browser raises when a pending `play()` promise is
* cancelled by a `pause()` (or a source/seek change). It signals "that specific
* play attempt was superseded", not "playback failed" — hls.js' stall recovery
* produces it routinely, so it must not reach the player's error channel.
*/
function isPlayInterruptedError(err: unknown): boolean {
if (!err || typeof err !== "object") return false;
const { name, message } = err as { name?: string; message?: string };
return name === "AbortError" || (message ?? "").includes("interrupted");
}
export class Html5PlayerAdapter implements PlayerAdapter {
readonly kind = "html5" as const;
private attachedElement: HTMLVideoElement | null = null;
/** In-flight play() attempt, so concurrent callers share one element.play(). */
private pendingPlay: Promise<void> | null = null;
private host: AdapterHost;
private bridge: Html5ElementBridge;
constructor(host: AdapterHost, bridge: Html5ElementBridge) {
this.host = host;
this.bridge = bridge;
}
/**
* Resolve the LIVE <video> element. The bridge's `getElement()` returns the
* component's current reactive `videoElement`, which is authoritative: the
* element can be re-bound when the {#if} block re-renders, so a value captured
* once in `attach()` may go stale (this caused play/pause to silently no-op).
* Falls back to the attach()-captured element for unit tests whose bridge
* returns null.
*/
private get element(): HTMLVideoElement | null {
return this.bridge.getElement() ?? this.attachedElement;
}
attach(element: HTMLVideoElement | null): void {
this.attachedElement = element;
}
async load(streamUrl: string, options: PlayerLoadOptions): Promise<void> {
// The component's reactive HLS $effect performs the actual attach/load when
// the selection is set; loading is therefore driven by setStreamSelection.
// The component's canplay/frag-buffered path reports readiness through the
// host.
this.bridge.setSeekOffset(0);
this.bridge.setStreamSelection(selectionForLoad(streamUrl, options));
this.host.onState("loading");
}
async play(): Promise<void> {
const el = this.element;
if (!el) return;
// Coalesce concurrent attempts. While an HLS stream stalls, the UI and the
// gap-controller recovery path can both ask to play; stacking element.play()
// calls is what turns one stall into an AbortError storm.
if (this.pendingPlay) return this.pendingPlay;
this.pendingPlay = (async () => {
try {
await el.play();
// handlePlay on the element reports "playing"; no double-report here.
} catch (err) {
// A play() aborted by a pause() is transient, not a failure: hls.js
// nudges the element to recover from a stall, which cancels the pending
// play promise while the element keeps trying. Surfacing it would report
// an error roughly once a second for the duration of the stall.
if (isPlayInterruptedError(err)) {
log.debug("play() interrupted by pause (stall recovery)");
} else {
this.host.onError(`play() failed: ${err}`);
}
} finally {
this.pendingPlay = null;
}
})();
return this.pendingPlay;
}
async pause(): Promise<void> {
this.element?.pause();
}
async toggle(): Promise<boolean> {
const el = this.element;
if (!el) return false;
if (el.paused) {
await this.play();
return true;
}
await this.pause();
return false;
}
/**
* PRIMITIVE: in-place element seek (no reload). The backend already decided
* this seek does not need a transcode reload.
*/
async seekElement(positionSeconds: number, offset: number): Promise<void> {
const el = this.element;
if (!el) return;
el.currentTime = positionSeconds;
this.bridge.setSeekOffset(offset);
await this.waitForEvent(el, "seeked", 2000);
}
/**
* PRIMITIVE: compound reload — swap the source and resume at
* `positionSeconds`, an **absolute** position on the item's own timeline.
* Contains NO strategy decision; the backend already decided to reload and
* supplied the url/position. Preserves the hard-won dual-audio teardown and
* canplay wait.
*
* The position is reached by *seeking the element*, and the transcode offset
* is cleared to zero. It used to be the other way round — the offset was set
* to the position and nothing seeked — which was correct only while the
* reloaded URL itself began there, via `StartTimeTicks`. DR-181 removes that
* parameter (on an HLS playlist it makes the server reject every segment with
* `400`), so a reloaded stream now always starts at the beginning of the item.
* Leaving the old arithmetic in place would have left `currentTime` reading
* `offset + 0` — the scrubber showing 20:00 while the opening titles play, and
* no seek ever happening.
*
* TRACES: UR-004, UR-005 | DR-181 | UT-183
*/
async reloadSource(selection: StreamSelection, positionSeconds: number): Promise<void> {
const el = this.element;
if (!el) {
// Still update the selection so the component's HLS $effect can pick it up.
this.bridge.setSeekOffset(0);
this.bridge.setStreamSelection(selection);
return;
}
const wasPlaying = !el.paused;
el.pause();
this.bridge.destroyHls();
if (el.src) {
el.removeAttribute("src");
el.load();
}
await new Promise((r) => setTimeout(r, 100));
// The reloaded stream begins at the item's zero, so there is no base to add.
this.bridge.setSeekOffset(0);
this.bridge.setStreamSelection(selection);
// A source that never becomes playable is a failed reload, not a slow one:
// the caller (quality switch, transcoded seek) has to know so it can revert
// its selection and surface the error instead of leaving the UI claiming a
// stream that is not playing.
const ready = await this.waitForEvent(el, "canplay", 10000);
if (!ready) {
throw new Error(`Reloaded stream never fired "canplay" within 10000ms`);
}
// Now that the new source is playable, put it where the caller asked for.
// Seeking before `canplay` is dropped by the element, which is why this
// follows the wait rather than riding along with the URL swap.
if (positionSeconds > 0) {
el.currentTime = positionSeconds;
await this.waitForEvent(el, "seeked", 2000);
}
if (wasPlaying) await el.play();
}
setVolume(volume: number): void {
if (this.element) this.element.volume = Math.max(0, Math.min(1, volume));
}
setMuted(muted: boolean): void {
if (this.element) this.element.muted = muted;
}
/** Subtitle selection: HTML5 toggles textTracks on the element directly. */
async selectSubtitle(streamIndex: number | null, _arrayIndex?: number): Promise<void> {
const el = this.element;
if (!el || !el.textTracks) return;
for (let i = 0; i < el.textTracks.length; i++) {
el.textTracks[i].mode = "disabled";
}
if (streamIndex !== null) {
const tracks = el.querySelectorAll("track");
tracks.forEach((track) => {
const idx = parseInt(track.getAttribute("data-stream-index") || "-1");
if (idx === streamIndex && track.track) {
track.track.mode = "showing";
}
});
}
}
getPosition(): number {
const el = this.element;
if (!el) return 0;
return el.currentTime + this.bridge.getSeekOffset();
}
async dispose(): Promise<void> {
this.bridge.destroyHls();
const el = this.element;
if (el) {
el.pause();
el.removeAttribute("src");
el.load();
}
this.attachedElement = null;
}
/** Resolve when `event` fires on `el`, or after `timeoutMs` as a fallback. */
/**
* Resolves `true` when the event fires, `false` if the budget runs out. The
* distinction is the caller's to act on: a missing `seeked` is cosmetic, a
* missing `canplay` means the reload failed.
*/
private waitForEvent(el: HTMLVideoElement, event: string, timeoutMs: number): Promise<boolean> {
return new Promise<boolean>((resolve) => {
const done = (fired: boolean) => {
el.removeEventListener(event, listener);
clearTimeout(timer);
resolve(fired);
};
const listener = () => done(true);
el.addEventListener(event, listener);
// `done` closes over `timer`, but can only run once the listener fires or
// the timeout elapses — both strictly after this assignment.
const timer: ReturnType<typeof setTimeout> = setTimeout(() => done(false), timeoutMs);
});
}
}
+7 -66
View File
@@ -1,73 +1,14 @@
/**
* Player adapter factory + public exports.
* Player adapter public exports.
*
* `createAdapter` selects the concrete PlayerAdapter for the current platform.
* Rust decides *which backend this platform has* (`useHtml5Element` from
* `player_play_item`); this factory consumes that decision rather than
* re-deriving it.
* Video is always drawn by a native player — mpv on the desktop, ExoPlayer on
* Android — behind the transparent webview, so there is one video adapter,
* `NativePlayerAdapter`. The webview `<video>` adapter and the factory that
* chose between the two were deleted with that path (DR-235).
* `WebviewAudioAdapter` remains for audio on a desktop without mpv.
*
* The `experimentalNativeVideo` flag is a **suppressor, never a promoter**: it
* can force the HTML5 path when Rust says native (so an in-progress spike cannot
* ship as a regression), but it can never select native on a platform whose Rust
* backend reported HTML5 — Linux has no way to composite a surface behind a
* WebKitGTK webview, so promoting there would produce a black screen.
*
* The previous unconditional HTML5 override cited tauri#10152 as an upstream
* blocker. That was stale: #10152 is a dormant *feature request*, the capability
* shipped in tauri 27d01834, and the black-screen bug (tauri#8381, #9408) was a
* broken `setBackgroundColor` JNI signature fixed in wry 0.39.4 — we ship 0.53.x.
*
* TRACES: UR-003, UR-004 | DR-004, DR-150 | UT-149
* TRACES: UR-003, UR-004 | DR-004, DR-235
*/
import { Html5PlayerAdapter, type Html5ElementBridge } from "./html5Adapter";
import { NativePlayerAdapter } from "./nativeAdapter";
import type { AdapterHost, PlayerAdapter } from "./types";
export type { PlayerAdapter, AdapterHost, PlayerLoadOptions, SubtitleTrackInput } from "./types";
export type { Html5ElementBridge } from "./html5Adapter";
export { Html5PlayerAdapter } from "./html5Adapter";
export { NativePlayerAdapter } from "./nativeAdapter";
/** What the Rust `player_play_item` response says it chose. */
export type BackendKind = "html5" | "native";
export interface CreateAdapterArgs {
/** Backend kind reported by `player_play_item` (`useHtml5Element`). */
backendKind: BackendKind;
host: AdapterHost;
/** Required for the HTML5 adapter; ignored by the native adapter. */
bridge?: Html5ElementBridge;
/**
* User opt-in for the native video path. Defaults to **off**, so omitting it
* yields today's behaviour (HTML5 everywhere) rather than silently enabling
* the spike.
*/
experimentalNativeVideo?: boolean;
}
/**
* Build the adapter for this platform/stream.
*
* Native is chosen only when Rust reports a native backend AND the user has
* opted in. Every other combination is HTML5.
*/
export function createAdapter({
backendKind,
host,
bridge,
experimentalNativeVideo = false,
}: CreateAdapterArgs): PlayerAdapter {
const effectiveKind: BackendKind =
backendKind === "native" && experimentalNativeVideo ? "native" : "html5";
if (effectiveKind === "native") {
// The native surface is owned by the backend — no DOM element, no bridge.
return new NativePlayerAdapter(host);
}
if (!bridge) {
throw new Error("createAdapter: Html5ElementBridge is required for the HTML5 adapter");
}
return new Html5PlayerAdapter(host, bridge);
}
+11 -24
View File
@@ -1,29 +1,19 @@
import type { StreamSelection } from "$lib/api/bindings";
/**
* NativePlayerAdapter — the Android/ExoPlayer PlayerAdapter implementation.
* NativePlayerAdapter — the video PlayerAdapter, for every platform.
*
* ExoPlayer is driven entirely by the Rust backend (JNI), which already emits
* PlayerStatusEvents and handles seek/audio-track internally. So this adapter is
* a thin delegate to backend commands; there is no DOM element to touch and no
* hls.js. State reporting is unnecessary here because the native backend emits
* events directly — the adapter's job is only to forward control intents.
* The native player (mpv on the desktop, ExoPlayer on Android) is driven
* entirely by the Rust backend, which emits PlayerStatusEvents and handles
* seek/audio-track/quality internally. So this adapter is a thin delegate to
* backend commands; there is no DOM element to touch. State reporting is
* unnecessary because the backend emits events directly — the adapter's job is
* only to forward control intents.
*
* NOTE: This adapter is currently unreachable — `createAdapter()` hardcodes the
* HTML5 kind, so Android video runs through Html5PlayerAdapter.
* It used to be the opt-in alternative to an HTML5 `<video>` adapter; that path
* was deleted (DR-235), so this is the one video adapter. The compositing it
* relies on is described in docs/architecture/05-platform-backends.md.
*
* That override was introduced citing tauri#10152 as an upstream blocker. That
* is no longer accurate: #10152 is a stale *feature request* (dead since
* 2024-07-01) asking that `transparent` not be desktop-only, and the capability
* shipped in tauri commit 27d01834 (2024-09-02). The related black/white-screen
* bug (tauri#8381, #9408) was a broken JNI signature for setBackgroundColor,
* fixed in wry 0.39.4; we ship wry 0.55.x.
*
* What is genuinely unproven is SurfaceView-behind-WebView *compositing* on
* Tauri Android — nothing upstream blocks it, and nothing upstream demonstrates
* it either. docs/architecture/05-platform-backends.md ("Native Video
* Compositing") describes the path that shipped.
*
* TRACES: UR-003, UR-005 | DR-004, DR-028
* TRACES: UR-003, UR-005 | DR-004, DR-028, DR-235
*/
import { commands } from "$lib/api/bindings";
@@ -40,9 +30,6 @@ export class NativePlayerAdapter implements PlayerAdapter {
this.host = host;
}
// The native surface is owned by the backend; nothing to attach in the DOM.
attach(_element: HTMLVideoElement | null): void {}
async load(_streamUrl: string, options: PlayerLoadOptions): Promise<void> {
// player_play_item already initiated native playback before this adapter is
// created, so there is no stream to load here — but it carries no start
+8 -17
View File
@@ -1,10 +1,10 @@
import type { StreamSelection } from "$lib/api/bindings";
/**
* PlayerAdapter contract — the decoupled boundary between the UI/backend and a
* concrete video player implementation (Linux HTML5+hls.js, or Android native).
* concrete player implementation (the native video player, or webview audio).
*
* The whole point: UI components and the Rust backend interact with video ONLY
* through this interface. All element / hls.js / ExoPlayer / textTracks detail —
* through this interface. All player detail —
* and the backend seek/audio-track *strategy* round-trip — is internal to an
* implementation. A control intent (from UI or a backend lockscreen/remote/sleep
* event) reaches the element by the facade dispatching to the active adapter.
@@ -16,7 +16,7 @@ import type { StreamSelection } from "$lib/api/bindings";
* TRACES: UR-003, UR-005, UR-020, UR-021 | DR-001, DR-023, DR-024, DR-028
*/
/** A subtitle track handed to the adapter at load time (WebVTT for HTML5). */
/** A subtitle track handed to the adapter at load time (WebVTT). */
export interface SubtitleTrackInput {
index: number;
url: string;
@@ -90,14 +90,7 @@ export interface AdapterHost {
*/
export interface PlayerAdapter {
/** Which platform backend this adapter represents. */
readonly kind: "html5" | "native";
/**
* Bind the output target. For the HTML5 adapter this is the `<video>` element
* (pass null on teardown); the native adapter ignores it (ExoPlayer renders to
* its own surface).
*/
attach(element: HTMLVideoElement | null): void;
readonly kind: "native" | "webview-audio";
/** Load a stream and begin playback at `options.initialPosition`. */
load(streamUrl: string, options: PlayerLoadOptions): Promise<void>;
@@ -121,9 +114,7 @@ export interface PlayerAdapter {
/**
* Compound reload: swap to `selection` and resume at `offset` seconds. Runs
* the invariant mechanical sequence for this platform (html5: pause → hls
* teardown → clear src → set new selection → wait ready → resume; native:
* ExoPlayer setMediaItem + seekTo). No decision is made here — the backend
* the invariant mechanical sequence for this platform. No decision is made here — the backend
* already decided to reload, and `selection.transport` says how to open it, so
* no adapter has to infer that from the URL.
*
@@ -134,12 +125,12 @@ export interface PlayerAdapter {
setVolume(volume: number): void; // 0..1
setMuted(muted: boolean): void;
/** Enable a subtitle track (null disables) — DOM textTracks is a webview primitive. */
/** Enable a subtitle track (null disables). */
selectSubtitle(streamIndex: number | null, arrayIndex?: number): Promise<void>;
/** Current position in seconds (adapter's own truth, e.g. element.currentTime + offset). */
/** Current position in seconds (adapter's own truth). */
getPosition(): number;
/** Tear down: destroy hls, detach element, stop reporting. Idempotent. */
/** Tear down and stop reporting. Idempotent. */
dispose(): Promise<void>;
}
+4 -10
View File
@@ -1,11 +1,9 @@
import type { StreamSelection } from "$lib/api/bindings";
/**
* Webview audio adapter — plays audio-only media through a hidden `<audio>`
* element on platforms with no native audio backend (currently Windows).
*
* All *video* already renders through the webview `<video>` element on every
* platform; libmpv/ExoPlayer only drive audio-only playback. On Windows there is
* no native audio backend, so the Rust `WebviewAudioBackend` hands the stream URL
* element on a desktop with no native audio backend — none that ships: Linux
* and Windows play through mpv, Android through ExoPlayer. There the Rust
* `WebviewAudioBackend` hands the stream URL
* to the frontend via a `webview_audio_load` event and drives play/pause/seek
* through `control_command`. This adapter owns the `<audio>` element that plays
* it and reports state/position/duration/ended back to Rust through the same
@@ -22,7 +20,7 @@ import type { StreamSelection } from "$lib/api/bindings";
import type { AdapterHost, PlayerAdapter, PlayerLoadOptions } from "./types";
export class WebviewAudioAdapter implements PlayerAdapter {
readonly kind = "html5" as const;
readonly kind = "webview-audio" as const;
private audio: HTMLAudioElement;
private host: AdapterHost;
@@ -118,10 +116,6 @@ export class WebviewAudioAdapter implements PlayerAdapter {
});
}
attach(_element: HTMLVideoElement | null): void {
// The audio element is owned by the controller, not attached here.
}
setVolume(volume: number): void {
this.audio.volume = Math.max(0, Math.min(1, volume));
}
-19
View File
@@ -1,19 +0,0 @@
/**
* Compatibility shim.
*
* The HTML5 → Rust reporting functions moved to `adapters/rustReportHost.ts` as
* part of the PlayerAdapter refactor. Existing callers import the reporter as
* `import * as html5Adapter from "$lib/player/html5Adapter"`; this shim keeps
* that working while the migration proceeds. New adapter code should depend on
* the `AdapterHost` interface (see `adapters/types.ts`) instead.
*/
export {
reportState,
reportPosition,
reportMediaLoaded,
resetReporting,
} from "./adapters/rustReportHost";
/** @deprecated states are defined on the AdapterHost interface now. */
export type Html5PlayerState = "playing" | "paused" | "loading" | "stopped" | "idle";
+20 -43
View File
@@ -118,21 +118,21 @@ async function stop() {
}
async function seek(positionSeconds: number) {
// Audio path: backend seeks the native backend directly.
if (!activeAdapter) {
// Audio (and webview audio): the backend seeks its player directly.
if (activeAdapter?.kind !== "native") {
await commands.playerSeek(positionSeconds);
return;
}
// Video path: ask the backend to DECIDE the strategy (in-place vs reload), then
// execute the matching adapter primitive. The decision logic stays in Rust
// (player_seek_video); the adapter only runs the chosen mechanical primitive.
// Video: the backend decides the strategy (in place vs re-open) and carries
// it out (player_seek_video).
await seekVideo(positionSeconds, null, null);
}
/**
* Video seek: backend decides strategy, facade dispatches the chosen adapter
* primitive. `mediaSourceId`/`audioTrackIndex` come from the video view (they are
* needed for the transcode reload URL). Requires an active video adapter.
* Video seek. The backend decides whether the stream can be moved in place or
* has to be re-opened, and does either itself — every video renderer is a
* native player (DR-235). `mediaSourceId`/`audioTrackIndex` come from the video
* view (they are needed for the re-open URL).
*/
async function seekVideo(
positionSeconds: number,
@@ -144,27 +144,18 @@ async function seekVideo(
await commands.playerSeek(positionSeconds);
return;
}
const response = (await commands.playerSeekVideo(
const response = await commands.playerSeekVideo(
requireHandle(),
positionSeconds,
mediaSourceId,
audioTrackIndex,
adapter.kind === "html5",
)) as any;
// Serde keeps `seek_offset` snake_case (only the "strategy" tag is camelCase).
if (response.strategy === "reloadStream") {
// `seek_offset` is the ABSOLUTE position to resume at, not a base to add to
// the element's clock: the reloaded stream starts at the item's zero since
// DR-181, so reloadSource seeks there. (The name is the wire field's.)
await adapter.reloadSource(response.selection, response.seek_offset ?? positionSeconds);
} else {
);
await adapter.seekElement(response.position ?? positionSeconds, 0);
}
}
/**
* Switch audio track: backend decides (may reload the stream), facade dispatches
* the resulting primitive. Requires an active video adapter.
* Switch audio track. The backend selects in place or re-opens the stream
* itself. Requires an active video adapter.
*/
async function switchAudioTrack(
streamIndex: number,
@@ -172,26 +163,20 @@ async function switchAudioTrack(
currentPosition: number | null,
mediaSourceId: string | null,
): Promise<void> {
const adapter = activeAdapter;
if (!adapter) return;
const response = (await commands.playerSwitchAudioTrack(
if (!activeAdapter) return;
await commands.playerSwitchAudioTrack(
requireHandle(),
streamIndex,
arrayIndex,
adapter.kind === "html5",
currentPosition,
mediaSourceId,
)) as any;
if (response.strategy === "reloadStream") {
await adapter.reloadSource(response.selection, response.position!);
}
);
}
/**
* Change the bandwidth ceiling of the video playing now. The backend re-opens
* the stream at the new quality and decides who reloads: it handles a native
* backend itself, and hands HTML5 a selection for the same `reloadSource`
* primitive the audio-track switch uses. Requires an active video adapter.
* the stream at the new quality and resumes it. Requires an active video
* adapter.
*
* The change applies to **this playback only** — the backend sets a per-playback
* override that the next item clears, leaving the durable Settings default
@@ -207,22 +192,14 @@ async function setStreamQuality(
mediaSourceId: string | null,
audioTrackIndex: number | null,
): Promise<StreamSelection | null> {
const adapter = activeAdapter;
if (!adapter) return null;
const response = (await commands.playerSetStreamQuality(
if (!activeAdapter) return null;
const response = await commands.playerSetStreamQuality(
requireHandle(),
quality,
adapter.kind === "html5",
currentPosition,
mediaSourceId,
audioTrackIndex,
)) as any;
if (response.strategy === "reloadStream") {
await adapter.reloadSource(response.selection, response.position ?? currentPosition ?? 0);
return response.selection;
}
// The native backend reloaded itself, but still reports what it opened — the
// caller needs it to show the rung actually in force.
);
return response.selection ?? null;
}
-93
View File
@@ -1,93 +0,0 @@
/**
* The loader is chosen from the backend's `transport` tag, never from the URL.
*
* TRACES: UR-079 | DR-225 | UT-214
*/
import { describe, expect, it } from "vitest";
import { elementSrcFor, videoLoaderFor, type LoaderCapabilities } from "./streamTransport";
import type { StreamSelection, Transport } from "$lib/api/bindings";
const MODERN: LoaderCapabilities = { hlsJsSupported: true, nativeHlsSupported: false };
const SAFARI: LoaderCapabilities = { hlsJsSupported: false, nativeHlsSupported: true };
const NEITHER: LoaderCapabilities = { hlsJsSupported: false, nativeHlsSupported: false };
function selection(transport: Transport, url: string): Pick<StreamSelection, "url" | "transport"> {
return { url, transport };
}
describe("videoLoaderFor", () => {
it("attaches hls.js when the backend says HLS and hls.js is available", () => {
expect(videoLoaderFor(selection({ type: "hls" }, "https://s/master.m3u8"), MODERN)).toBe(
"hlsjs",
);
});
it("falls back to the element's own HLS loader when hls.js is unavailable", () => {
expect(videoLoaderFor(selection({ type: "hls" }, "https://s/master.m3u8"), SAFARI)).toBe(
"nativeHls",
);
});
it("loads a progressive stream directly", () => {
expect(
videoLoaderFor(
selection({ type: "progressive" }, "https://s/Videos/1/stream?static=true"),
MODERN,
),
).toBe("direct");
});
it("loads a local file directly", () => {
expect(
videoLoaderFor(selection({ type: "localFile" }, "http://127.0.0.1:9/media/x.mkv"), MODERN),
).toBe("direct");
});
// ---------------------------------------------------------------------
// The two cases the `.m3u8` substring check gets wrong. These are the
// reason the field exists; both fail against a URL-sniffing implementation.
// ---------------------------------------------------------------------
it("does NOT attach hls.js to a progressive stream whose URL happens to end .m3u8", () => {
// A direct play served from a path containing the substring — nothing stops
// a server, a proxy, or a local cache from producing this.
expect(
videoLoaderFor(selection({ type: "progressive" }, "https://s/files/movie.m3u8.mp4"), MODERN),
).toBe("direct");
expect(
videoLoaderFor(selection({ type: "progressive" }, "https://s/x?name=master.m3u8"), MODERN),
).toBe("direct");
});
it("DOES attach hls.js to an HLS stream whose URL does not contain .m3u8", () => {
// Jellyfin's own transcoding URLs are not required to end in `.m3u8`, and a
// DASH or query-routed playlist endpoint never would.
expect(videoLoaderFor(selection({ type: "hls" }, "https://s/Videos/1/hls"), MODERN)).toBe(
"hlsjs",
);
expect(
videoLoaderFor(selection({ type: "hls" }, "https://s/stream?format=playlist"), SAFARI),
).toBe("nativeHls");
});
it("falls back to direct when HLS is requested but nothing can play it", () => {
expect(videoLoaderFor(selection({ type: "hls" }, "https://s/master.m3u8"), NEITHER)).toBe(
"direct",
);
});
});
describe("elementSrcFor", () => {
it("empties the element's src only when hls.js drives it", () => {
expect(elementSrcFor(selection({ type: "hls" }, "https://s/master.m3u8"), MODERN)).toBe("");
expect(elementSrcFor(selection({ type: "hls" }, "https://s/master.m3u8"), SAFARI)).toBe(
"https://s/master.m3u8",
);
});
it("keeps the src for a progressive stream that looks like a playlist", () => {
const s = selection({ type: "progressive" }, "https://s/files/movie.m3u8.mp4");
expect(elementSrcFor(s, MODERN)).toBe("https://s/files/movie.m3u8.mp4");
});
});
-88
View File
@@ -1,88 +0,0 @@
/**
* Which loader opens a stream in the webview `<video>` element.
*
* Extracted from `VideoPlayer.svelte` so the decision can be unit-tested — the
* same pattern as `episodeStrip.ts` and `TrackList.logic.test.ts`.
*
* TRACES: UR-079 | DR-225 | UT-214
*/
import type { StreamSelection, Transport } from "$lib/api/bindings";
/** How the element should be fed. */
export type VideoLoader =
/** hls.js drives a MediaSource; the element's own `src` stays empty. */
| "hlsjs"
/** The element loads the playlist itself (Safari/WebKit native HLS). */
| "nativeHls"
/** The element loads the URL directly — a progressive file or a local one. */
| "direct";
/** What the running browser can do, passed in so the decision stays pure. */
export interface LoaderCapabilities {
/** `Hls.isSupported()` */
hlsJsSupported: boolean;
/** `video.canPlayType("application/vnd.apple.mpegurl")` was non-empty */
nativeHlsSupported: boolean;
}
/**
* Pick the loader from the backend's tagged `transport`.
*
* This used to read `url.includes(".m3u8")`, in two places in
* `VideoPlayer.svelte`. Rust *builds* that URL and knows exactly what it is;
* re-deriving the answer here by substring match is a domain fact reconstructed
* in the presentation layer — the same error as leaking item-type taxonomy, and
* one that fails silently in both directions: a progressive file served from a
* path containing `.m3u8` gets an HLS loader, and a playlist served from a path
* without it does not.
*
* The transport is the *stream's* property; whether a given loader exists is the
* *browser's*. Only the second is decided here.
*/
export function videoLoaderFor(
selection: Pick<StreamSelection, "url" | "transport">,
capabilities: LoaderCapabilities,
): VideoLoader {
return loaderForTransport(selection.transport.type, capabilities);
}
/**
* The same decision, taken from the transport *tag* alone.
*
* Exists because a Svelte `$effect` that reads the whole selection re-runs
* whenever the selection **object** is replaced — even with an identical URL and
* transport — and the HLS effect's teardown/rebuild is not idempotent: it
* destroys the hls.js instance and reattaches, which leaves the element with no
* video until something forces another cycle. The pre-DR-225 code read a plain
* URL *string*, so re-assigning the same value was a no-op and the effect stayed
* put. Passing primitives restores that.
*
* TRACES: UR-079 | DR-225 | UT-214
*/
export function loaderForTransport(
transport: Transport["type"],
capabilities: LoaderCapabilities,
): VideoLoader {
if (transport !== "hls") {
// Progressive and local files are what the element loads natively. No
// MediaSource, no playlist parsing.
return "direct";
}
if (capabilities.hlsJsSupported) return "hlsjs";
if (capabilities.nativeHlsSupported) return "nativeHls";
// Nothing here can parse a playlist. Handing the URL to the element is very
// likely to fail, but it is the only remaining move and it surfaces a real
// media error rather than silently doing nothing.
return "direct";
}
/** Convenience for the template: does the element's `src` stay empty? */
export function elementSrcFor(
selection: Pick<StreamSelection, "url" | "transport">,
capabilities: LoaderCapabilities,
): string {
return videoLoaderFor(selection, capabilities) === "hlsjs" ? "" : selection.url;
}
export type { Transport };
+2 -16
View File
@@ -20,26 +20,14 @@ const log = createLogger("capabilities");
export interface PlaybackCapabilities {
/** Audio renders through a webview `<audio>` element, not a native backend. */
usesWebviewAudio: boolean;
/** Video can render on a native surface behind a transparent webview. */
supportsNativeVideo: boolean;
/**
* The user may send video to the webview element instead of the native
* renderer. False on Android, where ExoPlayer is the only video renderer
* (DR-293). Rust decides; see `webview_video_fallback`.
*/
webviewVideoFallback: boolean;
}
/**
* Conservative defaults for when the backend cannot be reached (very early
* startup, or a command failure). Both false = "assume no special platform
* facilities": no stray `<audio>` element is mounted, and video stays on the
* HTML5 path, which is the safe behaviour everywhere.
* Conservative default for when the backend cannot be reached (very early
* startup, or a command failure): no stray `<audio>` element is mounted.
*/
const FALLBACK: PlaybackCapabilities = {
usesWebviewAudio: false,
supportsNativeVideo: false,
webviewVideoFallback: false,
};
let cached: PlaybackCapabilities | null = null;
@@ -58,8 +46,6 @@ export async function getPlaybackCapabilities(): Promise<PlaybackCapabilities> {
const caps = (await commands.playerGetCapabilities()) as PlaybackCapabilities;
cached = {
usesWebviewAudio: !!caps?.usesWebviewAudio,
supportsNativeVideo: !!caps?.supportsNativeVideo,
webviewVideoFallback: !!caps?.webviewVideoFallback,
};
return cached;
} catch (err) {
@@ -1,75 +0,0 @@
import { describe, it, expect, beforeEach, beforeAll, afterAll, vi } from "vitest";
import { get } from "svelte/store";
/**
* The stored value of the native-video preference, and what it means.
*
* The default has moved four times (see the history on `load()` in
* nativeVideo.ts), so the risk here is not "which way is it pointing" — it is
* that a flip silently overrides people who chose. The old reader was
* `getItem(KEY) === "true"`, which conflates "never chose" with "chose off";
* flipping the default under that reader re-enables the native path for
* everyone who deliberately turned it off. So the three cases are pinned
* separately rather than through the default alone.
*
* TRACES: UR-003, UR-004 | DR-188
*/
const STORAGE_KEY = "jellytau-experimental-native-video";
// jsdom here doesn't expose localStorage; stand in a minimal implementation,
// matching the viewMode/searchGroupOrder store tests.
const backing = new Map<string, string>();
const localStorageShim = {
getItem: (key: string) => backing.get(key) ?? null,
setItem: (key: string, value: string) => void backing.set(key, value),
removeItem: (key: string) => void backing.delete(key),
clear: () => backing.clear(),
};
beforeAll(() => {
vi.stubGlobal("localStorage", localStorageShim);
});
afterAll(() => {
vi.unstubAllGlobals();
});
async function freshStore() {
// The default is read at module init, so each case needs a fresh module.
vi.resetModules();
return await import("./nativeVideo");
}
describe("experimentalNativeVideo default", () => {
beforeEach(() => {
localStorage.clear();
});
it("defaults to ON when the user has never chosen", async () => {
const { experimentalNativeVideo } = await freshStore();
expect(get(experimentalNativeVideo)).toBe(true);
});
it("stays OFF for someone who deliberately turned it off", async () => {
// The regression the null check exists for: an explicit opt-out must
// survive the default flip, not be re-enabled by it.
localStorage.setItem(STORAGE_KEY, "false");
const { experimentalNativeVideo } = await freshStore();
expect(get(experimentalNativeVideo)).toBe(false);
});
it("stays ON for someone who deliberately turned it on", async () => {
localStorage.setItem(STORAGE_KEY, "true");
const { experimentalNativeVideo } = await freshStore();
expect(get(experimentalNativeVideo)).toBe(true);
});
it("persists an explicit choice in both directions", async () => {
const { experimentalNativeVideo } = await freshStore();
experimentalNativeVideo.set(false);
expect(localStorage.getItem(STORAGE_KEY)).toBe("false");
experimentalNativeVideo.set(true);
expect(localStorage.getItem(STORAGE_KEY)).toBe("true");
});
});
+8 -124
View File
@@ -1,138 +1,22 @@
// Native-video compositing state.
//
// TRACES: UR-003, UR-004 | DR-150, DR-152
// TRACES: UR-003, UR-004 | DR-150, DR-152, DR-235
//
// Two separate concerns live here, deliberately:
//
// 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.
// `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 SurfaceView
// behind the WebView is visible. It is deliberately NOT derived from the
// flag: the backgrounds must come back the moment the player unmounts.
// 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.
//
// Frontend-only preference, stored in localStorage per the `jellytau-view-mode`
// precedent in library.ts — no Rust settings command backs this.
// There used to be a second concern here: `experimentalNativeVideo`, a stored
// user switch that could force video back to the webview `<video>` element.
// That element is gone (DR-235), so there is nothing left to switch to.
import { writable } from "svelte/store";
const STORAGE_KEY = "jellytau-experimental-native-video";
/** The attribute app.css keys its transparency rules off. */
const NATIVE_VIDEO_ATTR = "data-native-video";
/**
* Whether the native path is on, defaulting to **on** when the user has never
* chosen.
*
* This default has moved three times, so the history is the documentation:
*
* - **off** while the path was a spike (DR-150).
* - **on** for picture-in-picture (DR-161), which shipped as *audio with no
* picture* — ExoPlayer decoded correctly into a live SurfaceView while the
* page stayed opaque over it.
* - **off** again (DR-172), which named the compositing as the suspect but did
* not find it.
* - **on** now, because the four defects behind that symptom were found and
* each is fixed and verified on a device: the app shell painted over the
* surface through a CSS rule targeting an attribute nothing set (DR-185); the
* poster card had no way to lift on a path with no `<video>` element
* (DR-182); the JS bridges raced the page load, so `setTransparent(true)`
* could never arrive (DR-183); and the SurfaceView was never detached
* (DR-184). Two further UI defects that only this path could show — the play
* overlay never clearing (DR-186) and the system bars staying over the player
* (DR-187) — are fixed with it.
*
* The picture is genuinely fixed and device-verified — `WebView transparent =
* true` and `Marking media ready` now appear in logcat with video on screen,
* the pair DR-172 went looking for and could not find. The default nonetheless
* stayed **off** for a further release, because turning it on surfaced a
* different gap: the background-audio handoff (UR-040) could only *return*
* through the HTML5 element, so coming back from background audio left playback
* dead. That was the same shape of mistake as DR-161 — a verified sub-path
* shipped as a default over an unverified one — so the flip waited (DR-190).
*
* - **on** now. The two defects that were holding it back are fixed and
* verified on a device: the handoff return restarts the renderer that is
* actually on screen rather than only ever reloading the `<video>` element
* (DR-196), and the letterbox bars are painted instead of retaining whatever
* was last in the framebuffer (DR-194). The evidence standard this default
* has been held to since DR-161 is met for both: audio handoff at 69:54
* returning to video playing at 70:18, and clean bars across playback, the
* control bar and a rotation round-trip.
*
* An explicit stored choice still wins in both directions, so anyone who turned
* it off keeps it off — hence the `null` check rather than a bare `=== "true"`,
* which would silently re-enable it for people who opted out.
*
* TRACES: UR-003, UR-004 | DR-188
*/
function load(): boolean {
if (typeof localStorage === "undefined") return true;
try {
const stored = localStorage.getItem(STORAGE_KEY);
// Never chosen → on. Chosen → honour it, in both directions.
return stored === null ? true : stored === "true";
} catch {
// Private-mode / disabled storage — same default as a fresh install.
return true;
}
}
function persist(enabled: boolean) {
if (typeof localStorage === "undefined") return;
try {
localStorage.setItem(STORAGE_KEY, String(enabled));
} catch {
// Quota or private-mode failure — keep the in-memory value.
}
}
function createExperimentalNativeVideoStore() {
const { subscribe, set } = writable<boolean>(load());
return {
subscribe,
set(enabled: boolean) {
persist(enabled);
set(enabled);
},
/** Read the current value without subscribing (init-time decisions). */
current: load,
};
}
/**
* User preference for the native Android video path. **Defaults to on** — see
* `load()`. The name still says "experimental" because the flag remains a
* suppressor of Rust's backend choice, not a promoter of it: turning it off
* forces the webview element, turning it on never produces a native backend
* where Rust says HTML5.
*/
export const experimentalNativeVideo = createExperimentalNativeVideoStore();
/**
* Whether video should take the native path, given the user's stored choice
* and whether this platform lets the user choose at all.
*
* On Android the answer is always native: ExoPlayer is the only video renderer
* there, and the webview element decodes none of the AC-3/E-AC-3/DTS/TrueHD
* that ExoPlayer plays through the FFmpeg extension — so a stored "off" would
* turn every original-file download into a silent film (DR-293). Rust reports
* whether a fallback exists (`webviewVideoFallback`); only then does the
* stored choice count.
*
* TRACES: UR-003, UR-071 | DR-293 | UT-262
*/
export function nativeVideoWanted(storedChoice: boolean, webviewVideoFallback: boolean): boolean {
return webviewVideoFallback ? storedChoice : true;
}
function createNativeVideoActiveStore() {
const { subscribe, set } = writable<boolean>(false);
-17
View File
@@ -1,17 +0,0 @@
import { describe, it, expect } from "vitest";
import { nativeVideoWanted } from "./nativeVideo";
// TRACES: UR-003, UR-071 | DR-293 | UT-262
describe("nativeVideoWanted", () => {
it("ignores a stored 'off' where there is no webview fallback (Android)", () => {
// Someone who once switched native video off on Android must not be
// routed to the webview, which plays original-file downloads silent.
expect(nativeVideoWanted(false, false)).toBe(true);
expect(nativeVideoWanted(true, false)).toBe(true);
});
it("honours the stored choice where a fallback exists (Linux beside mpv)", () => {
expect(nativeVideoWanted(false, true)).toBe(false);
expect(nativeVideoWanted(true, true)).toBe(true);
});
});
-34
View File
@@ -22,7 +22,6 @@ interface AndroidPictureInPictureBridge {
isSupported(): boolean;
canEnterPip(): boolean;
setAutoEnterEnabled(enabled: boolean): void;
setHtml5VideoState(active: boolean, width: number, height: number, playing: boolean): void;
}
declare global {
@@ -89,36 +88,3 @@ export function setAutoEnterEnabled(enabled: boolean): void {
log.warn("Failed to set auto-enter:", err);
}
}
/**
* Tell native that a WebView `<video>` is (or is no longer) the playback surface.
*
* This is what makes PiP work on the HTML5 path. The native side only ever knew
* about the ExoPlayer surface, and that path is behind `experimentalNativeVideo`,
* which defaulted to off when this was written — so `canEnterPip` was always
* false and pressing the button did nothing. Reporting the element's state gives
* native a surface it can legitimately shrink into, plus the intrinsic size it
* needs for the PiP window's aspect ratio and the play state for its play/pause
* action.
*
* The flag is back to defaulting **off** (DR-172, after native video shipped as
* audio with no picture), so this is once again the path Android normally takes —
* which is why PiP does not depend on that flag being on.
*
* Pass `active: false` when the element goes away, or PiP would be offered over a
* video that is no longer there.
*
* TRACES: UR-041 | DR-160
*/
export function setHtml5VideoState(
active: boolean,
width: number,
height: number,
playing: boolean,
): void {
try {
bridge()?.setHtml5VideoState(active, Math.round(width), Math.round(height), playing);
} catch (err) {
log.warn("Failed to report HTML5 video state:", err);
}
}
+18 -2
View File
@@ -24,9 +24,18 @@
import { nativeVideoActive } from "$lib/stores/nativeVideo";
import { createLogger } from "$lib/utils/logger";
import { platform } from "@tauri-apps/plugin-os";
const log = createLogger("videoSurface");
function isAndroid(): boolean {
try {
return platform() === "android";
} catch {
return false;
}
}
interface AndroidVideoSurfaceBridge {
setTransparent(transparent: boolean): void;
isSupported(): boolean;
@@ -46,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 {
@@ -69,6 +77,14 @@ export function enableNativeVideoCompositing(): void {
// would see through the app to the home screen.
nativeVideoActive.set(true);
const androidVideoSurface = bridge();
// Only Android has a webview widget to make transparent. On the desktop the
// window is created transparent and mpv draws beneath it, so the page layer
// above is all there is — an absent bridge there is correct, not a fault.
// TRACES: UR-080 | DR-237
if (!androidVideoSurface && !isAndroid()) {
nativeVideoActive.set(true);
return;
}
if (!androidVideoSurface) {
// Say so loudly. Every bridge call in this file is optional-chained, so a
// missing bridge is silent — and a silently-skipped setTransparent(true) is
+7 -2
View File
@@ -1,4 +1,4 @@
<!-- TRACES: UR-035, UR-038, UR-048, UR-058, UR-062 | DR-043, DR-062, DR-102, DR-103, DR-142 -->
<!-- TRACES: UR-035, UR-038, UR-048, UR-058, UR-062 | DR-043, DR-062, DR-102, DR-103, DR-142, DR-297 -->
<script lang="ts">
import { untrack } from "svelte";
import { formatDuration } from "$lib/utils/duration";
@@ -52,6 +52,7 @@
} from "$lib/components/library/seriesNavigation";
import { createLogger } from "$lib/utils/logger";
import { createCoalescedLoader } from "$lib/utils/coalescedLoader";
import { errorAfterLoad } from "$lib/components/library/detailLoadError";
const log = createLogger("LibraryDetail");
@@ -250,8 +251,12 @@
}
}
}
error = errorAfterLoad({ ok: true }, { refreshing: !isNewItem });
} catch (e) {
error = e instanceof Error ? e.message : "Failed to load item";
// A failed refresh keeps the page up (DR-297), so this log is the only
// trace it leaves — it must carry the value actually thrown.
log.error(`Failed to ${isNewItem ? "load" : "refresh"} item ${itemId}:`, e);
error = errorAfterLoad({ ok: false, error: e }, { refreshing: !isNewItem });
} finally {
loading = false;
}
-1
View File
@@ -37,7 +37,6 @@
} from "$lib/services/playbackReporting";
import { reportSkippedEpisode, shouldSuppressStopReport } from "$lib/services/skipReporting";
import { cleanup as cleanupNextEpisode } from "$lib/services/nextEpisodeService";
import * as html5Adapter from "$lib/player/html5Adapter";
import { createLogger } from "$lib/utils/logger";
const log = createLogger("PlayerPage");
+1 -60
View File
@@ -1,6 +1,6 @@
<!-- TRACES: UR-023, UR-025, UR-027, UR-029, UR-057, UR-076 | DR-030, DR-048, DR-077, DR-086, DR-132, DR-209 -->
<script lang="ts">
import { onDestroy, onMount } from "svelte";
import { onMount } from "svelte";
import { commands } from "$lib/api/bindings";
import { profiles } from "$lib/stores/profiles";
import ProfileSecuritySettings from "$lib/components/settings/ProfileSecuritySettings.svelte";
@@ -31,8 +31,6 @@
import { library, viewMode } from "$lib/stores/library";
import { auth } from "$lib/stores/auth";
import { isNetworkDetectionSupported, reportNetworkState } from "$lib/services/networkType";
import { experimentalNativeVideo } from "$lib/stores/nativeVideo";
import { getPlaybackCapabilities } from "$lib/services/playbackCapabilities";
import { createLogger } from "$lib/utils/logger";
import { openUrl, revealItemInDir } from "@tauri-apps/plugin-opener";
import {
@@ -131,26 +129,6 @@
{ label: "Unlimited", bytes: 0 },
];
// Native-video switch. Shown only where Rust reports a webview fallback —
// beside mpv native video on Linux. Never on Android: ExoPlayer is the only
// video renderer there, and the webview would play original-file downloads
// silent (DR-293). Rust owns the decision; the toggle is hidden where it
// cannot apply.
let offerNativeVideoSwitch = $state(false);
let nativeVideoEnabled = $state(false);
const unsubscribeNativeVideo = experimentalNativeVideo.subscribe((v) => {
nativeVideoEnabled = v;
});
function handleNativeVideoToggle() {
experimentalNativeVideo.set(!nativeVideoEnabled);
}
// Not returned from onMount: that callback is async, so its return value is a
// Promise and Svelte would never invoke it as a teardown.
onDestroy(unsubscribeNativeVideo);
// Mirrors the stored setting; the picker itself always appears for a
// PIN-protected profile regardless of this. (DR-274)
let askOnStart = $state(false);
@@ -158,7 +136,6 @@
onMount(async () => {
await loadSettings();
askOnStart = await commands.profilesGetAskOnStart();
offerNativeVideoSwitch = (await getPlaybackCapabilities()).webviewVideoFallback;
// Which update story this platform gets. Android cannot install its own
// APK, so it is offered the releases page instead of an install button.
@@ -956,42 +933,6 @@
<!-- Native video. Only rendered where Rust reports a webview fallback
(Linux beside mpv native video); never on Android (DR-293). -->
{#if offerNativeVideoSwitch}
<div class="bg-[var(--color-surface)] rounded-lg p-6 mt-4">
<div class="flex items-center justify-between">
<div class="pr-4">
<h3 class="text-xl font-semibold text-white">
Native Video
<span
class="ml-2 align-middle text-xs font-medium uppercase tracking-wide text-amber-400 border border-amber-400/40 rounded px-1.5 py-0.5"
>
Experimental
</span>
</h3>
<p class="text-sm text-gray-400 mt-1">
Decode video with the device's hardware decoder instead of the built-in web
player, for better performance and battery life, and so picture-in-picture shows
the video rather than the app. On by default. Turn it off to fall back to the
built-in web player if a video misbehaves.
</p>
</div>
<button
onclick={handleNativeVideoToggle}
class="relative inline-flex h-8 w-14 shrink-0 items-center rounded-full transition-colors {nativeVideoEnabled
? 'bg-[var(--color-jellyfin)]'
: 'bg-gray-600'}"
aria-label="Toggle native video"
>
<span
class="inline-block h-6 w-6 transform rounded-full bg-white transition-transform {nativeVideoEnabled
? 'translate-x-7'
: 'translate-x-1'}"
></span>
</button>
</div>
<p class="text-xs text-gray-500 mt-3">Takes effect the next time you start a video.</p>
</div>
{/if}
</div>
<!-- Profiles. Deliberately minimal here: adding, removing and PIN changes