Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a67452bc80 | ||
|
|
1677f5f299 | ||
|
|
bb3ab1edd7 | ||
|
|
4daf172834 | ||
|
|
9d9d81bef3 |
@@ -28,7 +28,7 @@ jobs:
|
|||||||
if: "!startsWith(github.event.head_commit.message, 'chore(release)')"
|
if: "!startsWith(github.event.head_commit.message, 'chore(release)')"
|
||||||
runs-on: linux/amd64
|
runs-on: linux/amd64
|
||||||
container:
|
container:
|
||||||
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.08.1
|
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.09.1
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
@@ -109,7 +109,7 @@ jobs:
|
|||||||
# at "warn" until its class is cleared and it can be promoted to "error".
|
# 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.
|
# Lower this as you clear them. Never raise it to make a build pass.
|
||||||
- name: Lint
|
- name: Lint
|
||||||
run: bun run lint -- --max-warnings=158
|
run: bun run lint -- --max-warnings=146
|
||||||
|
|
||||||
- name: Check TypeScript
|
- name: Check TypeScript
|
||||||
run: |
|
run: |
|
||||||
@@ -187,7 +187,7 @@ jobs:
|
|||||||
runs-on: linux/amd64
|
runs-on: linux/amd64
|
||||||
needs: test
|
needs: test
|
||||||
container:
|
container:
|
||||||
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.08.1
|
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.09.1
|
||||||
env:
|
env:
|
||||||
ANDROID_HOME: /opt/android-sdk
|
ANDROID_HOME: /opt/android-sdk
|
||||||
ANDROID_SDK_ROOT: /opt/android-sdk
|
ANDROID_SDK_ROOT: /opt/android-sdk
|
||||||
@@ -256,7 +256,7 @@ jobs:
|
|||||||
name: Supply Chain
|
name: Supply Chain
|
||||||
runs-on: linux/amd64
|
runs-on: linux/amd64
|
||||||
container:
|
container:
|
||||||
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.08.1
|
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.09.1
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ jobs:
|
|||||||
name: Run Tests
|
name: Run Tests
|
||||||
runs-on: linux/amd64
|
runs-on: linux/amd64
|
||||||
container:
|
container:
|
||||||
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.08.1
|
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.09.1
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -94,7 +94,7 @@ jobs:
|
|||||||
runs-on: linux/amd64
|
runs-on: linux/amd64
|
||||||
needs: test
|
needs: test
|
||||||
container:
|
container:
|
||||||
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.08.1
|
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.09.1
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -235,7 +235,7 @@ jobs:
|
|||||||
# baked into the builder image. No toolchain installs here — the image has
|
# baked into the builder image. No toolchain installs here — the image has
|
||||||
# cargo-xwin, clang/clang-cl, lld, llvm, nsis and the msvc target.
|
# cargo-xwin, clang/clang-cl, lld, llvm, nsis and the msvc target.
|
||||||
container:
|
container:
|
||||||
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.08.1
|
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.09.1
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -308,7 +308,7 @@ jobs:
|
|||||||
runs-on: linux/amd64
|
runs-on: linux/amd64
|
||||||
needs: test
|
needs: test
|
||||||
container:
|
container:
|
||||||
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.08.1
|
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.09.1
|
||||||
env:
|
env:
|
||||||
ANDROID_HOME: /opt/android-sdk
|
ANDROID_HOME: /opt/android-sdk
|
||||||
ANDROID_SDK_ROOT: /opt/android-sdk
|
ANDROID_SDK_ROOT: /opt/android-sdk
|
||||||
@@ -411,7 +411,7 @@ jobs:
|
|||||||
needs: [build-linux, build-windows, build-android]
|
needs: [build-linux, build-windows, build-android]
|
||||||
if: startsWith(github.ref, 'refs/tags/v')
|
if: startsWith(github.ref, 'refs/tags/v')
|
||||||
container:
|
container:
|
||||||
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.08.1
|
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.09.1
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ jobs:
|
|||||||
# The short-SHA output below avoids depending on it regardless.
|
# The short-SHA output below avoids depending on it regardless.
|
||||||
shell: bash
|
shell: bash
|
||||||
container:
|
container:
|
||||||
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.08.1
|
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.09.1
|
||||||
env:
|
env:
|
||||||
ANDROID_HOME: /opt/android-sdk
|
ANDROID_HOME: /opt/android-sdk
|
||||||
ANDROID_SDK_ROOT: /opt/android-sdk
|
ANDROID_SDK_ROOT: /opt/android-sdk
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ jobs:
|
|||||||
name: Build & publish docs to gitea-pages
|
name: Build & publish docs to gitea-pages
|
||||||
runs-on: linux/amd64
|
runs-on: linux/amd64
|
||||||
container:
|
container:
|
||||||
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.08.1
|
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.09.1
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ jobs:
|
|||||||
runs-on: linux/amd64
|
runs-on: linux/amd64
|
||||||
name: Check Requirement Traces
|
name: Check Requirement Traces
|
||||||
container:
|
container:
|
||||||
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.08.1
|
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.09.1
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
|
|||||||
@@ -9,6 +9,53 @@ 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
|
For how long each fixed defect had been shipping before it was found, see
|
||||||
[docs/defect-windows.md](docs/defect-windows.md).
|
[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
|
## v0.13.3
|
||||||
|
|
||||||
### 🐛 Fixes
|
### 🐛 Fixes
|
||||||
|
|||||||
@@ -2,8 +2,9 @@
|
|||||||
|
|
||||||
A cross-platform Jellyfin client. Business logic lives in a Rust backend
|
A cross-platform Jellyfin client. Business logic lives in a Rust backend
|
||||||
(`src-tauri/`); a SvelteKit + TypeScript frontend (`src/`) handles presentation
|
(`src-tauri/`); a SvelteKit + TypeScript frontend (`src/`) handles presentation
|
||||||
and talks to it over Tauri v2 IPC. Targets **Linux** (libmpv, WebKitGTK HTML5
|
and talks to it over Tauri v2 IPC. Targets **Linux** and **Windows** (libmpv for audio and video) and **Android**
|
||||||
`<video>` for transcoded playback) and **Android** (ExoPlayer).
|
(ExoPlayer). There is no webview `<video>`: every video renderer is native,
|
||||||
|
drawing behind the transparent webview.
|
||||||
|
|
||||||
Package manager is **bun**.
|
Package manager is **bun**.
|
||||||
|
|
||||||
@@ -151,9 +152,13 @@ output as a reviewed draft, not a final changelog.
|
|||||||
- **Svelte frontend** (`src/`) — presentation only. Stores in
|
- **Svelte frontend** (`src/`) — presentation only. Stores in
|
||||||
`src/lib/stores/`, API wrappers in `src/lib/api/`, components in
|
`src/lib/stores/`, API wrappers in `src/lib/api/`, components in
|
||||||
`src/lib/components/`.
|
`src/lib/components/`.
|
||||||
- **Playback layers** — Linux uses libmpv for direct playback and a WebKitGTK
|
- **Playback layers** — Linux and Windows use libmpv for audio and video (mpv
|
||||||
HTML5 `<video>` element for HLS-transcoded (h264) streams; Android uses
|
draws video beneath the transparent webview: the render API into a GTK surface
|
||||||
ExoPlayer with a foreground media service + `MediaSessionCompat`.
|
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
|
- **tauri-specta** generates TypeScript bindings and typed events from the Rust
|
||||||
command/event definitions (registered via the Builder in `src-tauri/src/lib.rs`).
|
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 |
|
| [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 |
|
| [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** |
|
| [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 |
|
| [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 |
|
| [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 |
|
| [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.
|
player reports and never determine it.
|
||||||
- **Unified player boundary.** UI controls playback *only* through the frontend
|
- **Unified player boundary.** UI controls playback *only* through the frontend
|
||||||
facade `src/lib/player/index.ts` (`playerController`) — never by calling
|
facade `src/lib/player/index.ts` (`playerController`) — never by calling
|
||||||
`commands.player*` directly. Webview HTML5 `<video>` reports its state back
|
`commands.player*` directly. Video has one adapter, `NativePlayerAdapter`,
|
||||||
into Rust via `src/lib/player/html5Adapter.ts` and the `player_report_*`
|
which only forwards intents; the backend performs every seek, track switch
|
||||||
commands, so the controller stays the single source of truth in both native
|
and quality change itself.
|
||||||
and HTML5 modes.
|
|
||||||
- **Reachability from real traffic.** Server online/offline is derived from the
|
- **Reachability from real traffic.** Server online/offline is derived from the
|
||||||
outcome of actual repository requests (reported to `ConnectivityMonitor`), not
|
outcome of actual repository requests (reported to `ConnectivityMonitor`), not
|
||||||
a side-channel poller. The `/System/Info/Public` probe runs *only while
|
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
|
player or hold a lock — it deadlocks. On Android, bind a locked
|
||||||
`AutoplayDecision` to a `let` *before* matching; a tokio `MutexGuard` held in
|
`AutoplayDecision` to a `let` *before* matching; a tokio `MutexGuard` held in
|
||||||
the `match` scrutinee deadlocks the `AdvanceToNext` arm.
|
the `match` scrutinee deadlocks the `AdvanceToNext` arm.
|
||||||
- **VideoPlayer native mode**: no lifecycle calls after an `await` in `onMount`
|
- **VideoPlayer `onMount`**: no lifecycle calls after an `await` — they throw
|
||||||
(it flips to HTML5 mode and breaks Android seek).
|
`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
|
- **Transcoded resume/seek**: `get_video_stream_url` must return the HLS
|
||||||
`master.m3u8`, not `stream.mp4`, or transcoded playback never starts.
|
`master.m3u8`, not `stream.mp4`, or transcoded playback never starts.
|
||||||
- **Downloads** cap at 3 concurrent; the backend pump auto-starts pending rows.
|
- **Downloads** cap at 3 concurrent; the backend pump auto-starts pending rows.
|
||||||
|
|||||||
+2
-2
@@ -136,8 +136,8 @@ WORKDIR /app
|
|||||||
CMD ["bash", "-c", "OUTPUT_DIR=/app/dist scripts/build-desktop-linux.sh"]
|
CMD ["bash", "-c", "OUTPUT_DIR=/app/dist scripts/build-desktop-linux.sh"]
|
||||||
|
|
||||||
# Windows cross-compile environment (MSVC target via cargo-xwin). Video works via
|
# Windows cross-compile environment (MSVC target via cargo-xwin). Video works via
|
||||||
# WebView2 and audio via the webview <audio> backend; NSIS installer is produced
|
# WebView2 and audio via mpv, whose DLL the builder image carries
|
||||||
# from Linux by cargo-xwin. Default bundles NSIS; override WIN_BUNDLES=none for
|
# (/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.
|
# exe-only. Build runs at container-run time like above.
|
||||||
FROM ${BUILDER_IMAGE} AS windows-cross
|
FROM ${BUILDER_IMAGE} AS windows-cross
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|||||||
@@ -186,6 +186,42 @@ RUN . $HOME/.cargo/env && \
|
|||||||
cargo cyclonedx --version && \
|
cargo cyclonedx --version && \
|
||||||
mdbook --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
|
WORKDIR /app
|
||||||
|
|
||||||
ENTRYPOINT ["/bin/bash"]
|
ENTRYPOINT ["/bin/bash"]
|
||||||
|
|||||||
@@ -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.
|
available in this repository; JellyTau's own code remains available under MIT.
|
||||||
|
|
||||||
Desktop builds do not include this component.
|
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.
|
||||||
|
|||||||
@@ -11,7 +11,6 @@
|
|||||||
"@tauri-apps/plugin-os": "^2.3.2",
|
"@tauri-apps/plugin-os": "^2.3.2",
|
||||||
"@tauri-apps/plugin-process": "^2.3.1",
|
"@tauri-apps/plugin-process": "^2.3.1",
|
||||||
"@tauri-apps/plugin-updater": "2.10.1",
|
"@tauri-apps/plugin-updater": "2.10.1",
|
||||||
"hls.js": "^1.6.15",
|
|
||||||
"svelte-dnd-action": "^0.9.69",
|
"svelte-dnd-action": "^0.9.69",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -485,8 +484,6 @@
|
|||||||
|
|
||||||
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
|
"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-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=="],
|
"html-escaper": ["html-escaper@2.0.2", "", {}, "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg=="],
|
||||||
|
|||||||
@@ -791,36 +791,15 @@ by exactly the inset.
|
|||||||
Unlike `addJavascriptInterface`, the inset push only writes CSS properties, so it
|
Unlike `addJavascriptInterface`, the inset push only writes CSS properties, so it
|
||||||
can safely be re-sent on resume.
|
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, DR-227
|
||||||
**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`.
|
|
||||||
|
|
||||||
`VideoPlayer` holds a `currentSelection`, not a URL string; `currentStreamUrl` is
|
`VideoPlayer` holds a `currentSelection`, not a URL string; `currentStreamUrl` is
|
||||||
derived from it. A reload replaces the selection **wholesale** (the adapter's
|
derived from it. A reload replaces the selection **wholesale**, so transport and
|
||||||
bridge takes a `StreamSelection`, not a URL), so transport and URL can never
|
URL can never drift apart — the transport is the stream's property and comes from
|
||||||
drift apart. The background-audio handoff states the transport it is moving to —
|
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
|
progressive mp3 out, HLS back — via `selectionAt()`, rather than leaving it to be
|
||||||
inferred.
|
inferred.
|
||||||
|
|
||||||
@@ -833,21 +812,16 @@ ceiling above the source bitrate *is* the source.
|
|||||||
## Native Video Store
|
## Native Video Store
|
||||||
|
|
||||||
**Location**: `src/lib/stores/nativeVideo.ts`
|
**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:
|
`nativeVideoActive` — whether a native surface is on screen *right now*.
|
||||||
|
Setting it toggles `data-native-video` on `<html>`, which is what the CSS in
|
||||||
|
`app.css` keys off to clear the app's opaque backgrounds so the picture behind
|
||||||
|
the webview shows. The backgrounds must come back the moment the player unmounts.
|
||||||
|
|
||||||
- `experimentalNativeVideo` — the user-facing opt-in flag, **defaulting to on**.
|
It used to hold `experimentalNativeVideo`, a stored switch that could force video
|
||||||
Rust already decides *which backend this platform has* (`useHtml5Element` from
|
back to the webview `<video>` element. That element is gone (DR-235), and the
|
||||||
`player_play_item`); this flag only *suppresses* that decision. It never turns
|
switch with it.
|
||||||
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*.
|
|
||||||
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.
|
|
||||||
|
|
||||||
See [05-platform-backends.md](05-platform-backends.md#native-video-compositing-android)
|
See [05-platform-backends.md](05-platform-backends.md#native-video-compositing-android)
|
||||||
for what is behind the WebView.
|
for what is behind the WebView.
|
||||||
|
|||||||
@@ -213,8 +213,8 @@ sequenceDiagram
|
|||||||
Online-->>Page: StreamSelection
|
Online-->>Page: StreamSelection
|
||||||
end
|
end
|
||||||
Page->>VP: selection
|
Page->>VP: selection
|
||||||
VP->>VP: videoLoaderFor(selection, caps)
|
VP->>VP: player_play_item(selection.url, transport)
|
||||||
Note over VP: hls.js / native HLS / direct —<br/>from the tag, never from the URL
|
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,
|
The selection travels with the stream from then on. A reload — a quality change,
|
||||||
|
|||||||
@@ -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.
|
**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
|
**TRACES**: UR-080 | DR-231, DR-235, DR-237
|
||||||
`src-tauri/src/commands/player/timers.rs`
|
|
||||||
|
|
||||||
Video on desktop (Linux WebKitGTK) is rendered by an HTML5 `<video>`/HLS element **inside the
|
Every video renderer is a native player drawing **behind** the transparent
|
||||||
webview**. Android no longer uses this path for video — see *The webview is not a video renderer on
|
webview, with the Svelte controls composited over it: ExoPlayer on Android, mpv on
|
||||||
Android* below. libmpv is initialized audio-only (`vo=null`,
|
Linux and Windows. There is no webview `<video>` element, no hls.js, and no
|
||||||
`video=false`), so the native backend cannot render or observe this element. The `<video>` is therefore
|
HTML5 adapter; the frontend has one video adapter, `NativePlayerAdapter`, and the
|
||||||
the real player, living outside Rust's reach.
|
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
|
Why the webview path was deleted rather than kept as a fallback:
|
||||||
is treated as **a dumb output device that reports back into Rust**, rather than an independent state
|
|
||||||
authority:
|
|
||||||
|
|
||||||
```mermaid
|
- **The transcode was a decoder constraint.** The `<video>` element decodes
|
||||||
flowchart LR
|
little beyond h264, so the desktop profile could only claim h264 and the server
|
||||||
subgraph Webview["Webview"]
|
re-encoded almost everything (7% direct play on a real library, against 85% for
|
||||||
Video["HTML5 <video> / HLS.js"]
|
the same library through ExoPlayer). The machine was never the limit — mpv was
|
||||||
Adapter["html5Adapter.ts<br/>(reports DOM events)"]
|
already running for audio. (The Linux/Windows profile still claims h264 until
|
||||||
end
|
DR-234 widens it; see [desktop-native-video.md](../specs/desktop-native-video.md).)
|
||||||
subgraph Backend["Rust"]
|
- **Each renderer is another place for every bug.** Three video renderers meant
|
||||||
Cmds["player_report_state<br/>player_report_position<br/>player_report_media_loaded"]
|
every seek strategy, track switch and lifecycle fix had three places to be got
|
||||||
Controller["PlayerController"]
|
right; the webview path was also where the renderer choice itself went wrong
|
||||||
Emitter["TauriEventEmitter"]
|
(silent Linux video, a Windows soundtrack decoded twice — DR-237).
|
||||||
end
|
- **A fallback that decodes less is not a fallback.** Android showed it first
|
||||||
subgraph Frontend["Frontend"]
|
(DR-293): an original-file download plays silent in the webview. With no
|
||||||
Events["playerEvents.ts"]
|
webview path there is no silent downgrade to fall into; a failed mpv init
|
||||||
Store["player store"]
|
emits `backend-init-failed` instead.
|
||||||
end
|
|
||||||
|
|
||||||
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:**
|
## Native video on the desktop (mpv)
|
||||||
- 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
|
**TRACES**: UR-080 | DR-231 … DR-237, DR-298, DR-299
|
||||||
another event source feeding the existing pipeline.
|
|
||||||
- Position reports are throttled (~250ms) to match the MPV cadence and avoid flooding IPC from the
|
The mpv half is shared; only the surface differs per platform
|
||||||
60fps RAF loop.
|
(`mpv_backend::video_output`):
|
||||||
- **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`);
|
| Platform | Output | Where the picture goes |
|
||||||
HTML5 *state reporting* goes through `html5Adapter.ts`. This restores the documented invariant
|
|---|---|---|
|
||||||
("frontend only displays state and invokes commands") for the video path.
|
| 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)
|
## MpvBackend (Linux)
|
||||||
|
|
||||||
**Location**: `src-tauri/src/player/mpv/`
|
**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
|
```mermaid
|
||||||
flowchart TB
|
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
|
state of its own, and does nothing for streaming. Decoding at playback fixes both
|
||||||
paths with no extra step.
|
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
|
The webview decodes none of the codecs above — so with the original file now
|
||||||
through the `experimentalNativeVideo` setting (a *suppressor* of Rust's native
|
downloaded as-is (DR-293), the old `experimentalNativeVideo` switch would have
|
||||||
choice), but the webview decodes none of the codecs above — so with the original
|
played every such download as a silent film. That is what first removed the
|
||||||
file now downloaded as-is (DR-293), turning native video off would play every such
|
webview video path on Android; DR-235 then removed it everywhere.
|
||||||
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 equalizer, and where its vocabulary lives
|
### 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
|
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:
|
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
|
1. Nothing in the WebView keeps playing once the app is backgrounded — the
|
||||||
backgrounded — the system throttles the WebView and media pauses.
|
system throttles it.
|
||||||
2. Keeping audio alive in the background requires a **native foreground media
|
2. Keeping audio alive in the background requires a **native foreground media
|
||||||
service**, which already exists for music (`JellyTauPlaybackService` +
|
service**, which already exists for music (`JellyTauPlaybackService` +
|
||||||
`JellyTauPlayer` + `MediaSessionCompat`).
|
`JellyTauPlayer` + `MediaSessionCompat`).
|
||||||
@@ -390,18 +403,14 @@ sequenceDiagram
|
|||||||
|
|
||||||
Details that were each a shipped defect:
|
Details that were each a shipped defect:
|
||||||
|
|
||||||
- **Position is absolute.** Transcoded HLS tracks time as
|
- **Position is absolute** — the position on the item's timeline the player
|
||||||
`videoElement.currentTime + seekOffset` (the element resets to 0 after each
|
reports, never an offset within a re-opened transcode (`computeHandoffPosition`).
|
||||||
transcode reload). `computeHandoffPosition` sums both terms; using the element
|
|
||||||
time alone rewinds by the offset.
|
|
||||||
- **A downloaded episode takes no base URL and an ordinary seek** (DR-180); a
|
- **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.
|
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 return must re-issue the load** (DR-196). The native player owns no
|
||||||
The two paths resume by different means — the webview `<video>` reloads off its
|
element and nothing watches the stream URL for it, so reassigning the URL — how
|
||||||
stream URL, watched by an `$effect`; ExoPlayer owns no element and nothing
|
the deleted webview path came back — restarted nothing and left a black screen
|
||||||
watches the URL for it, so it needs an explicit re-issue. Doing only the URL
|
with a play button that did nothing.
|
||||||
assignment restarted nothing on the native path 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
|
- **`wasPlaying` is captured on the way out** so play/pause survives the round
|
||||||
trip, and the handoff does not silently rewind (DR-203).
|
trip, and the handoff does not silently rewind (DR-203).
|
||||||
- **Mutually exclusive with PiP.** Toggle on → `setAutoEnterEnabled(false)`;
|
- **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
|
**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
|
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;
|
Tauri WebView**, with the Svelte controls drawn over it. It is the only video
|
||||||
the HTML5 `<video>` path remains the fallback and is not being removed. The
|
path (DR-235). Before that it was an opt-in whose default was flipped and
|
||||||
default has been flipped and reverted twice and each revert has a named cause —
|
reverted twice, each revert with a named cause — the per-defect record is in
|
||||||
the per-defect record is in `requirements.md` (DR-150 … DR-196).
|
`requirements.md` (DR-150 … DR-196).
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart TB
|
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
|
- **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
|
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.
|
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
|
native reveal fires on a `playing` state or a position tick carrying a position
|
||||||
or duration, and on nothing else.
|
or duration, and on nothing else.
|
||||||
- **Letterbox bars are painted**, not left holding whatever was last in the
|
- **Letterbox bars are painted**, not left holding whatever was last in the
|
||||||
|
|||||||
@@ -68,8 +68,8 @@ style-src 'self' 'unsafe-inline';
|
|||||||
font-src 'self' data:;
|
font-src 'self' data:;
|
||||||
img-src 'self' data: blob: asset: http://asset.localhost http: https:;
|
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:;
|
media-src 'self' blob: asset: http://asset.localhost http://127.0.0.1:* http: https:;
|
||||||
connect-src 'self' ipc: http://ipc.localhost http: https:;
|
connect-src 'self' ipc: http://ipc.localhost;
|
||||||
worker-src 'self' blob:;
|
worker-src 'self';
|
||||||
object-src 'none'; frame-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'
|
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. |
|
| `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. |
|
| `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. |
|
| `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. |
|
| `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. `http:`/`https:` is hls.js fetching manifests and segments; ordinary API traffic goes through Rust and is not subject to CSP. |
|
| `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' 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. |
|
| `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. |
|
| `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. |
|
| `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
|
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
|
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
|
*data* — but it still bars `file:`, `filesystem:` and scripting schemes, and it
|
||||||
|
|||||||
@@ -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 |
|
| [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 |
|
| [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 |
|
| [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 |
|
| [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 |
|
| [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 |
|
| [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)
|
│ └── sessions.ts # SessionsApi (remote session control)
|
||||||
├── player/ # Unified player boundary (frontend)
|
├── player/ # Unified player boundary (frontend)
|
||||||
│ ├── index.ts # playerController facade — the only write-side entry point for playback
|
│ ├── 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/
|
├── services/
|
||||||
│ ├── playerEvents.ts # Tauri event listener for player events
|
│ ├── playerEvents.ts # Tauri event listener for player events
|
||||||
│ └── playbackReporting.ts # Thin wrapper (~50 lines)
|
│ └── playbackReporting.ts # Thin wrapper (~50 lines)
|
||||||
|
|||||||
Vendored
+35
-17
@@ -7,17 +7,37 @@ job / SMTC lockscreen), but it runs and plays media.
|
|||||||
|
|
||||||
## How playback works on Windows
|
## How playback works on Windows
|
||||||
|
|
||||||
- **Video** — renders through the webview HTML5 `<video>` element (hls.js) on
|
- **Video** — **mpv**, drawing into the app's own window: `MpvBackend` is
|
||||||
*every* platform; on Windows that is WebView2 (Chromium/Edge), which plays HLS +
|
handed the main window's HWND as `wid` before mpv initialises and renders with
|
||||||
h264 fine. No Windows-specific code.
|
`vo=gpu-next,gpu` as a child of it, beneath the transparent WebView2 whose page
|
||||||
- **Audio-only (music)** — the native audio backends are libmpv (Linux) and
|
clears its background while a video is on screen (`data-native-video`). mpv's
|
||||||
ExoPlayer (Android); neither exists on Windows. Instead
|
own controller, key bindings and cursor handling are off — the Svelte controls
|
||||||
`create_player_backend()` in [../src-tauri/src/lib.rs](../../src-tauri/src/lib.rs)
|
drawn over it are the only ones. This is the arrangement tauri-plugin-libmpv
|
||||||
uses `WebviewAudioBackend` on non-Linux/non-Android targets: it hands the stream
|
ships on Windows (same zhongfly LGPL DLL). **Not yet seen on real Windows
|
||||||
URL to a webview `<audio>` element (see
|
hardware** — if the picture ends up *over* the controls, the z-order of mpv's
|
||||||
[../src/lib/services/webviewAudio.ts](../../src/lib/services/webviewAudio.ts)),
|
child window is the first thing to check.
|
||||||
which reports state back through the same `player_report_*` round-trip the video
|
- **Audio** — **libmpv**, the same `MpvBackend` Linux uses, with `ao=wasapi`.
|
||||||
path uses. Pure Rust + Tauri events.
|
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)
|
## 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
|
The builder image ([../Dockerfile.builder](../../Dockerfile.builder)) bakes in the
|
||||||
whole toolchain: the `x86_64-pc-windows-msvc` rust target, `cargo-xwin`, `lld`,
|
whole toolchain: the `x86_64-pc-windows-msvc` rust target, `cargo-xwin`, `lld`,
|
||||||
`llvm`, and `nsis`.
|
`llvm`, `nsis`, and the pinned Windows libmpv.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bun run docker:build:windows # NSIS installer + .exe -> ./dist
|
bun run docker:build:windows # NSIS installer + .exe -> ./dist
|
||||||
@@ -71,8 +91,6 @@ Outputs:
|
|||||||
|
|
||||||
## Outstanding for a first-class Windows release
|
## Outstanding for a first-class Windows release
|
||||||
|
|
||||||
1. Gapless/crossfade + SMTC (lockscreen) — currently no-ops in the webview audio
|
1. SMTC (lockscreen / media keys) — not wired on Windows.
|
||||||
path.
|
2. Code signing — the installer is unsigned.
|
||||||
2. Downloaded (`Local` source) file playback needs `convertFileSrc` on the
|
3. First run of mpv video on real Windows hardware (DR-237).
|
||||||
frontend; streaming works today.
|
|
||||||
3. Code signing + a Windows packaging CI job.
|
|
||||||
|
|||||||
@@ -80,10 +80,10 @@ introduced during this work passed conformance.
|
|||||||
|
|
||||||
## 3. Desktop (Linux)
|
## 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
|
```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.
|
- [ ] **Direct play** — a file the server does not transcode. Picture and sound.
|
||||||
|
|||||||
+23
-13
@@ -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-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-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-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-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-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 |
|
| 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-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-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-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-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-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-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 |
|
| 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 |
|
||||||
@@ -499,6 +499,8 @@ Internal architecture, components, and application logic.
|
|||||||
| 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-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-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-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 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -510,8 +512,8 @@ Internal architecture, components, and application logic.
|
|||||||
|----------|-------------------------|-------------------------|
|
|----------|-------------------------|-------------------------|
|
||||||
| UR-001 | IR-001, IR-002 | - |
|
| UR-001 | IR-001, IR-002 | - |
|
||||||
| UR-002 | IR-013 | DR-003, DR-012, DR-013, DR-014, DR-294 |
|
| 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-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 |
|
| 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-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-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 |
|
| UR-007 | IR-010 | DR-007, DR-008, DR-016, DR-257, DR-262, DR-277, DR-278 |
|
||||||
@@ -519,7 +521,7 @@ Internal architecture, components, and application logic.
|
|||||||
| UR-009 | IR-009, IR-010, IR-011 | - |
|
| UR-009 | IR-009, IR-010, IR-011 | - |
|
||||||
| UR-010 | IR-012, IR-021 | DR-037, DR-059 |
|
| UR-010 | IR-012, IR-021 | DR-037, DR-059 |
|
||||||
| UR-011 | IR-013 | DR-003, DR-015, DR-018 |
|
| 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-013 | IR-013 | DR-017 |
|
||||||
| UR-014 | IR-010 | DR-014, DR-019 |
|
| UR-014 | IR-010 | DR-014, DR-019 |
|
||||||
| UR-015 | - | DR-005, DR-020 |
|
| UR-015 | - | DR-005, DR-020 |
|
||||||
@@ -737,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-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-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-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-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-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 |
|
| 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 |
|
||||||
@@ -771,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-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-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-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-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-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 |
|
| 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 |
|
||||||
@@ -779,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-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-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-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-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-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 |
|
| 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 |
|
||||||
@@ -807,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-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-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-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-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-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-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 |
|
| 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 |
|
||||||
@@ -852,15 +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-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-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-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-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-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-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-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-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-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-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
|
### Integration Tests
|
||||||
|
|
||||||
| Test ID | Test Description | Traces To | Status |
|
| Test ID | Test Description | Traces To | Status |
|
||||||
|
|||||||
@@ -1,6 +1,17 @@
|
|||||||
# Spec: Desktop native video — mpv renders the picture, everywhere
|
# 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)
|
**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
|
**UX spec:** n/a — nothing about the player's appearance changes. What changes is
|
||||||
what is behind the controls.
|
what is behind the controls.
|
||||||
|
|||||||
@@ -1,8 +1,15 @@
|
|||||||
# Spec: Windows native audio backend
|
# Spec: Windows native audio backend
|
||||||
|
|
||||||
**Status:** Proposed — not started. Windows still runs on
|
**Status:** Partially implemented — code and packaging done, **unverified on
|
||||||
`WebviewAudioBackend`. Blocked on [libmpv2-migration.md](libmpv2-migration.md),
|
Windows hardware**. `MpvBackend` is the Windows audio backend (`ao=wasapi`);
|
||||||
whose crate swap has not landed either.
|
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;
|
**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
|
⚠️ the suggested id **IR-030 has since been allocated** to the scheduled catalog
|
||||||
crawl — allocate a fresh id (IR-033 or later) on implementation
|
crawl — allocate a fresh id (IR-033 or later) on implementation
|
||||||
|
|||||||
+3014
-2923
File diff suppressed because it is too large
Load Diff
+1
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "jellytau",
|
"name": "jellytau",
|
||||||
"version": "0.13.3",
|
"version": "0.14.0",
|
||||||
"description": "A cross-platform Jellyfin client built with Tauri, SvelteKit and Rust.",
|
"description": "A cross-platform Jellyfin client built with Tauri, SvelteKit and Rust.",
|
||||||
"author": "Duncan Tourolle <duncan@tourolle.paris>",
|
"author": "Duncan Tourolle <duncan@tourolle.paris>",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -64,7 +64,6 @@
|
|||||||
"@tauri-apps/plugin-os": "^2.3.2",
|
"@tauri-apps/plugin-os": "^2.3.2",
|
||||||
"@tauri-apps/plugin-process": "^2.3.1",
|
"@tauri-apps/plugin-process": "^2.3.1",
|
||||||
"@tauri-apps/plugin-updater": "2.10.1",
|
"@tauri-apps/plugin-updater": "2.10.1",
|
||||||
"hls.js": "^1.6.15",
|
|
||||||
"svelte-dnd-action": "^0.9.69"
|
"svelte-dnd-action": "^0.9.69"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -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>.
|
||||||
@@ -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.
|
||||||
@@ -7,14 +7,16 @@
|
|||||||
# officially supports for Windows (the mingw/GNU target is not), and unlike GNU
|
# officially supports for Windows (the mingw/GNU target is not), and unlike GNU
|
||||||
# it can bundle the NSIS installer from a Linux host.
|
# it can bundle the NSIS installer from a Linux host.
|
||||||
#
|
#
|
||||||
# Playback on Windows: video renders via WebView2 and audio via the webview
|
# Playback on Windows: video renders via WebView2; audio via mpv, whose
|
||||||
# <audio> backend (WebviewAudioBackend) — see docs/build/build-windows.md.
|
# libmpv-2.dll ships beside jellytau.exe — see docs/build/build-windows.md.
|
||||||
#
|
#
|
||||||
# Requirements (present in the Docker windows-cross target / unified builder):
|
# Requirements (present in the Docker windows-cross target / unified builder):
|
||||||
# - rustup target x86_64-pc-windows-msvc
|
# - rustup target x86_64-pc-windows-msvc
|
||||||
# - cargo-xwin (cargo install --locked cargo-xwin)
|
# - cargo-xwin (cargo install --locked cargo-xwin)
|
||||||
# - lld, llvm (linker + llvm-lib used by cargo-xwin)
|
# - lld, llvm (linker + llvm-lib used by cargo-xwin)
|
||||||
# - nsis (makensis) (installer generator)
|
# - nsis (makensis) (installer generator)
|
||||||
|
# - libmpv for Windows ($LIBMPV_WIN_DIR: libmpv-2.dll + mpv.lib, pinned and
|
||||||
|
# sha256-checked in Dockerfile.builder)
|
||||||
#
|
#
|
||||||
# Usage:
|
# Usage:
|
||||||
# scripts/build-windows-cross.sh # exe + NSIS installer
|
# 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 "🪟 Cross-compiling JellyTau for Windows ($TARGET, via cargo-xwin)"
|
||||||
echo "================================================================"
|
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 "Bundles: $WIN_BUNDLES"
|
||||||
echo ""
|
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 install --frozen-lockfile 2>/dev/null || bun install
|
||||||
bun run build
|
bun run build
|
||||||
|
|
||||||
|
|||||||
@@ -54,21 +54,29 @@ describe("tauri.conf.json CSP", () => {
|
|||||||
expect(csp["img-src"]).toContain("asset:");
|
expect(csp["img-src"]).toContain("asset:");
|
||||||
expect(csp["img-src"]).toContain("http://asset.localhost");
|
expect(csp["img-src"]).toContain("http://asset.localhost");
|
||||||
expect(csp["media-src"]).toContain("asset:");
|
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).
|
// The token-guarded loopback media server (DR-137).
|
||||||
expect(csp["media-src"]).toContain("http://127.0.0.1:*");
|
expect(csp["media-src"]).toContain("http://127.0.0.1:*");
|
||||||
// Tauri's invoke transport.
|
// Tauri's invoke transport.
|
||||||
expect(csp["connect-src"]).toContain("ipc:");
|
expect(csp["connect-src"]).toContain("ipc:");
|
||||||
expect(csp["connect-src"]).toContain("http://ipc.localhost");
|
expect(csp["connect-src"]).toContain("http://ipc.localhost");
|
||||||
// The user's Jellyfin server: an arbitrary run-time origin, http on a LAN.
|
// 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("http:");
|
||||||
expect(csp[directive]).toContain("https:");
|
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", () => {
|
it("never widens a data directive into script execution", () => {
|
||||||
for (const [name, sources] of Object.entries(csp)) {
|
for (const [name, sources] of Object.entries(csp)) {
|
||||||
if (name === "script-src" || name === "worker-src") {
|
if (name === "script-src" || name === "worker-src") {
|
||||||
|
|||||||
@@ -22,3 +22,7 @@ local.properties
|
|||||||
|
|
||||||
# macOS
|
# macOS
|
||||||
.DS_Store
|
.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/
|
||||||
|
|||||||
Generated
+1
-1
@@ -2275,7 +2275,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "jellytau"
|
name = "jellytau"
|
||||||
version = "0.13.3"
|
version = "0.14.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
"argon2",
|
"argon2",
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ name = "jellytau"
|
|||||||
# `player-conformance`, and a second binary makes a bare `cargo run` —
|
# `player-conformance`, and a second binary makes a bare `cargo run` —
|
||||||
# which `tauri dev` issues — ambiguous.
|
# which `tauri dev` issues — ambiguous.
|
||||||
default-run = "jellytau"
|
default-run = "jellytau"
|
||||||
version = "0.13.3"
|
version = "0.14.0"
|
||||||
description = "A cross-platform Jellyfin client"
|
description = "A cross-platform Jellyfin client"
|
||||||
authors = ["Duncan Tourolle <duncan@tourolle.paris>"]
|
authors = ["Duncan Tourolle <duncan@tourolle.paris>"]
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
@@ -108,9 +108,10 @@ tiny_http = { version = "0.12.0", default-features = false }
|
|||||||
tauri-plugin-updater = "2"
|
tauri-plugin-updater = "2"
|
||||||
tauri-plugin-process = "2"
|
tauri-plugin-process = "2"
|
||||||
|
|
||||||
# Linux-specific dependencies
|
# mpv platforms: Linux (system libmpv) and Windows (libmpv-2.dll shipped in the
|
||||||
[target.'cfg(target_os = "linux")'.dependencies]
|
# installer, import library generated in the builder image — see build.rs and
|
||||||
hostname = "0.4"
|
# scripts/build-windows-cross.sh). TRACES: UR-004 | DR-237
|
||||||
|
[target.'cfg(any(target_os = "linux", target_os = "windows"))'.dependencies]
|
||||||
libc = "0.2"
|
libc = "0.2"
|
||||||
# The crates.io release of libmpv predates the MPV versions we support, so this
|
# The crates.io release of libmpv predates the MPV versions we support, so this
|
||||||
# tracks the upstream git repo.
|
# 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
|
# TRACES: UR-080 | DR-230, IR-033
|
||||||
libmpv-sys = { git = "https://github.com/ParadoxSpiral/libmpv-rs.git", rev = "3e6c389b716f52a595cc5e8e3fa1f96cb76b3de7" }
|
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
|
# 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,
|
# `default_vbox()` hand back types this crate can name rather than a second,
|
||||||
# incompatible GTK.
|
# incompatible GTK.
|
||||||
|
|||||||
@@ -85,10 +85,9 @@ class MainActivity : TauriActivity() {
|
|||||||
super.onWebViewCreate(webView)
|
super.onWebViewCreate(webView)
|
||||||
android.util.Log.d("MainActivity", "onWebViewCreate - installing bridges before first page load")
|
android.util.Log.d("MainActivity", "onWebViewCreate - installing bridges before first page load")
|
||||||
mediaWebView = webView
|
mediaWebView = webView
|
||||||
// A new WebView means a new page, which reports no video yet. Anything the
|
// A new WebView means a new page, which plays no video yet. Anything held
|
||||||
// previous one left held would otherwise pin the screen on for the life of
|
// for the previous one would otherwise pin the screen on for the life of
|
||||||
// the process, since a page that goes away never sends its final
|
// the process. (DR-202)
|
||||||
// setHtml5VideoState(false, …). (DR-202)
|
|
||||||
ScreenWakeManager.releaseAll()
|
ScreenWakeManager.releaseAll()
|
||||||
installJavascriptBridges(webView)
|
installJavascriptBridges(webView)
|
||||||
configureWebViewSettings(webView)
|
configureWebViewSettings(webView)
|
||||||
@@ -327,22 +326,6 @@ class MainActivity : TauriActivity() {
|
|||||||
autoEnterPipEnabled = enabled
|
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")
|
}, "AndroidPictureInPicture")
|
||||||
android.util.Log.d("MainActivity", "JavaScript interface 'AndroidPictureInPicture' added")
|
android.util.Log.d("MainActivity", "JavaScript interface 'AndroidPictureInPicture' added")
|
||||||
|
|
||||||
|
|||||||
@@ -46,52 +46,7 @@ object PictureInPictureManager {
|
|||||||
private var receiver: BroadcastReceiver? = null
|
private var receiver: BroadcastReceiver? = null
|
||||||
private var hiddenWebView: WebView? = null
|
private var hiddenWebView: WebView? = null
|
||||||
|
|
||||||
/**
|
/** True when a native video surface is attached and playing. */
|
||||||
* 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. */
|
|
||||||
private fun isNativeVideoPath(): Boolean = try {
|
private fun isNativeVideoPath(): Boolean = try {
|
||||||
val player = JellyTauPlayer.getInstance()
|
val player = JellyTauPlayer.getInstance()
|
||||||
player.isPlayingVideo() &&
|
player.isPlayingVideo() &&
|
||||||
@@ -120,10 +75,9 @@ object PictureInPictureManager {
|
|||||||
*/
|
*/
|
||||||
fun canEnterPip(activity: Activity): Boolean {
|
fun canEnterPip(activity: Activity): Boolean {
|
||||||
if (!isPipSupported(activity)) return false
|
if (!isPipSupported(activity)) return false
|
||||||
// Either surface will do: the native one, or the WebView's `<video>`,
|
// Video only ever renders on the native surface: the WebView `<video>`
|
||||||
// which is what actually plays while experimentalNativeVideo is off.
|
// path is gone (DR-235). (DR-160)
|
||||||
// (DR-160)
|
return isNativeVideoPath()
|
||||||
return isNativeVideoPath() || html5VideoActive
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -186,9 +140,7 @@ object PictureInPictureManager {
|
|||||||
return clampedRatio(surface.width.toDouble() / surface.height.toDouble())
|
return clampedRatio(surface.width.toDouble() / surface.height.toDouble())
|
||||||
}
|
}
|
||||||
|
|
||||||
// No native surface: the WebView is the video, so use the intrinsic size
|
return null
|
||||||
// the frontend reported. (DR-160)
|
|
||||||
return html5AspectRatio
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -207,16 +159,10 @@ object PictureInPictureManager {
|
|||||||
|
|
||||||
@RequiresApi(Build.VERSION_CODES.O)
|
@RequiresApi(Build.VERSION_CODES.O)
|
||||||
private fun buildPlayPauseAction(activity: Activity): RemoteAction {
|
private fun buildPlayPauseAction(activity: Activity): RemoteAction {
|
||||||
// On the HTML5 path ExoPlayer is idle, so its `isPlaying` is always false
|
val isPlaying = try {
|
||||||
// and the button would be stuck showing "Play" mid-playback. (DR-160)
|
JellyTauPlayer.getInstance().getExoPlayer().isPlaying
|
||||||
val isPlaying = if (isNativeVideoPath()) {
|
} catch (e: Exception) {
|
||||||
try {
|
false
|
||||||
JellyTauPlayer.getInstance().getExoPlayer().isPlaying
|
|
||||||
} catch (e: Exception) {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
html5VideoPlaying
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val (iconRes, title, controlType, requestCode) = if (isPlaying) {
|
val (iconRes, title, controlType, requestCode) = if (isPlaying) {
|
||||||
@@ -288,11 +234,8 @@ object PictureInPictureManager {
|
|||||||
*/
|
*/
|
||||||
fun onPipModeChanged(activity: Activity, isInPipMode: Boolean) {
|
fun onPipModeChanged(activity: Activity, isInPipMode: Boolean) {
|
||||||
if (isInPipMode) {
|
if (isInPipMode) {
|
||||||
// Hiding the WebView is correct only when the video is *behind* it on
|
// The video is *behind* the WebView on the native surface, so hiding
|
||||||
// the native surface. On the HTML5 path the WebView is the video, so
|
// the WebView leaves only the picture. (DR-160)
|
||||||
// hiding it would leave an empty black PiP window — the frontend
|
|
||||||
// instead strips its own chrome when it hears the event below.
|
|
||||||
// (DR-160)
|
|
||||||
if (isNativeVideoPath()) {
|
if (isNativeVideoPath()) {
|
||||||
hideWebView(activity)
|
hideWebView(activity)
|
||||||
}
|
}
|
||||||
@@ -315,9 +258,8 @@ object PictureInPictureManager {
|
|||||||
/**
|
/**
|
||||||
* Fire a DOM event into the WebView.
|
* Fire a DOM event into the WebView.
|
||||||
*
|
*
|
||||||
* The HTML5 PiP path is a conversation with the frontend rather than
|
* Tells the frontend the window shrank or grew, so it can strip or restore
|
||||||
* something native can do alone: it has to be told to strip its chrome when
|
* its chrome. (DR-160)
|
||||||
* the window shrinks, and to play/pause the element. (DR-160)
|
|
||||||
*/
|
*/
|
||||||
private fun dispatchWebEvent(activity: Activity, name: String) {
|
private fun dispatchWebEvent(activity: Activity, name: String) {
|
||||||
val webView = findWebView(activity.window.decorView) ?: return
|
val webView = findWebView(activity.window.decorView) ?: return
|
||||||
@@ -358,28 +300,14 @@ object PictureInPictureManager {
|
|||||||
if (intent?.action != ACTION_MEDIA_CONTROL) return
|
if (intent?.action != ACTION_MEDIA_CONTROL) return
|
||||||
val control = intent.getIntExtra(EXTRA_CONTROL_TYPE, 0)
|
val control = intent.getIntExtra(EXTRA_CONTROL_TYPE, 0)
|
||||||
|
|
||||||
if (isNativeVideoPath()) {
|
val player = try {
|
||||||
val player = try {
|
JellyTauPlayer.getInstance()
|
||||||
JellyTauPlayer.getInstance()
|
} catch (e: Exception) {
|
||||||
} catch (e: Exception) {
|
return
|
||||||
return
|
}
|
||||||
}
|
when (control) {
|
||||||
when (control) {
|
CONTROL_PLAY -> player.play()
|
||||||
CONTROL_PLAY -> player.play()
|
CONTROL_PAUSE -> player.pause()
|
||||||
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.
|
// Swap the button to reflect the new state.
|
||||||
updatePipActions(activity)
|
updatePipActions(activity)
|
||||||
|
|||||||
@@ -7,14 +7,12 @@ import android.view.WindowManager
|
|||||||
import java.lang.ref.WeakReference
|
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 —
|
* Pure state, deliberately free of any Android type so it can be unit-tested —
|
||||||
* see ScreenWakeStateTest. Two independent holders, because video can be
|
* see ScreenWakeStateTest. The one holder is ExoPlayer drawing video into the
|
||||||
* rendered by either renderer and only one of them is active at a time:
|
* TextureView (DR-192); the WebView `<video>` that was a second holder is gone
|
||||||
*
|
* (DR-235).
|
||||||
* - **native** — ExoPlayer drawing into the TextureView (DR-192)
|
|
||||||
* - **html5** — a `<video>` inside the WebView, reported by the frontend
|
|
||||||
*
|
*
|
||||||
* Audio is deliberately *not* a holder. Playing music with the screen off is the
|
* 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.
|
* point of the audio path; only video needs the display alive.
|
||||||
@@ -23,11 +21,10 @@ import java.lang.ref.WeakReference
|
|||||||
*/
|
*/
|
||||||
class ScreenWakeState {
|
class ScreenWakeState {
|
||||||
private var nativeVideoPlaying = false
|
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
|
val keepScreenOn: Boolean
|
||||||
get() = nativeVideoPlaying || html5VideoPlaying
|
get() = nativeVideoPlaying
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param playing whether ExoPlayer is playing right now
|
* @param playing whether ExoPlayer is playing right now
|
||||||
@@ -37,18 +34,9 @@ class ScreenWakeState {
|
|||||||
nativeVideoPlaying = playing && isVideo
|
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). */
|
/** Drop every hold (teardown, or a page that can no longer be trusted). */
|
||||||
fun reset() {
|
fun reset() {
|
||||||
nativeVideoPlaying = false
|
nativeVideoPlaying = false
|
||||||
html5VideoPlaying = false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,9 +54,7 @@ class ScreenWakeState {
|
|||||||
* appeared nowhere, and neither renderer supplies one for free — ExoPlayer's
|
* appeared nowhere, and neither renderer supplies one for free — ExoPlayer's
|
||||||
* `setWakeMode` is a *CPU/wifi* wake lock and says nothing about the display,
|
* `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
|
* 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
|
* the media3 widget that would otherwise set `keepScreenOn` itself.
|
||||||
* `<video>` path does not either: the display wake lock Chrome takes for video
|
|
||||||
* lives in the browser layer, not in an embedded WebView.
|
|
||||||
*
|
*
|
||||||
* ## Approach
|
* ## Approach
|
||||||
*
|
*
|
||||||
@@ -78,15 +64,9 @@ class ScreenWakeState {
|
|||||||
* missed release the way an explicitly acquired wake lock can. It needs no
|
* 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.)
|
* permission. (The manifest's `WAKE_LOCK` is the media service's, unrelated.)
|
||||||
*
|
*
|
||||||
* The two renderers report independently and are OR-ed together in
|
* `JellyTauPlayer.onIsPlayingChanged` and its surface teardown drive
|
||||||
* [ScreenWakeState]:
|
* [ScreenWakeState] — ExoPlayer is the authoritative source of playback state,
|
||||||
*
|
* so the hold follows what it reports rather than what the UI intends.
|
||||||
* - `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.
|
|
||||||
*
|
*
|
||||||
* The Activity reference is weak and re-set on every `onCreate`, so a
|
* The Activity reference is weak and re-set on every `onCreate`, so a
|
||||||
* recreation (rotation) re-applies the current hold to the new window.
|
* 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
|
* Drop every hold. Used when a new WebView/page load starts from scratch, so
|
||||||
* binder thread, hence the synchronization and the post to the main thread.
|
* nothing held for the previous page can pin the screen on for the life of
|
||||||
*/
|
* the process.
|
||||||
@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.
|
|
||||||
*/
|
*/
|
||||||
@Synchronized
|
@Synchronized
|
||||||
fun releaseAll() {
|
fun releaseAll() {
|
||||||
|
|||||||
@@ -40,46 +40,9 @@ class ScreenWakeStateTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `webview video playing holds the screen on`() {
|
fun `teardown releases the hold`() {
|
||||||
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`() {
|
|
||||||
val state = ScreenWakeState()
|
val state = ScreenWakeState()
|
||||||
state.updateNative(playing = true, isVideo = true)
|
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()
|
state.reset()
|
||||||
assertFalse(state.keepScreenOn)
|
assertFalse(state.keepScreenOn)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,32 @@
|
|||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
|
link_windows_libmpv();
|
||||||
tauri_build::build()
|
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());
|
||||||
|
}
|
||||||
|
|||||||
@@ -185,18 +185,45 @@ fn confine_to_root(root: &Path, candidate: &Path) -> Result<PathBuf, String> {
|
|||||||
///
|
///
|
||||||
/// TRACES: DR-211 | UT-205
|
/// TRACES: DR-211 | UT-205
|
||||||
fn confine_queued_path(root: &Path, file_path: &str) -> Result<String, String> {
|
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 sanitized = PathBuf::new();
|
||||||
|
let mut spelled = String::new();
|
||||||
|
let mut need_sep = false;
|
||||||
for component in Path::new(file_path).components() {
|
for component in Path::new(file_path).components() {
|
||||||
match component {
|
let piece = match component {
|
||||||
Component::Normal(part) => sanitized.push(sanitize_filename(&part.to_string_lossy())),
|
Component::Normal(part) => sanitize_filename(&part.to_string_lossy()),
|
||||||
// Kept as they are, so `confine_to_root` is the single thing
|
// Kept as they are, so `confine_to_root` is the single thing
|
||||||
// deciding whether what they add up to is still inside the root.
|
// 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))?;
|
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
|
/// Request payload for download_item_and_start (bundled to stay within specta's
|
||||||
|
|||||||
@@ -69,10 +69,6 @@ pub struct PlayerStatus {
|
|||||||
pub muted: bool,
|
pub muted: bool,
|
||||||
pub shuffle: bool,
|
pub shuffle: bool,
|
||||||
pub repeat: RepeatMode,
|
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)
|
// Merged fields (prefer remote session when available)
|
||||||
/// Media item from either local queue or remote session
|
/// Media item from either local queue or remote session
|
||||||
@@ -156,16 +152,6 @@ pub struct QueueStatus {
|
|||||||
pub has_previous: bool,
|
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
|
/// Request to play a single video item
|
||||||
///
|
///
|
||||||
/// Simplified to video playback only. Audio playback uses player_play_tracks
|
/// Simplified to video playback only. Audio playback uses player_play_tracks
|
||||||
@@ -217,9 +203,8 @@ pub struct PlayItemRequest {
|
|||||||
pub series_id: Option<String>,
|
pub series_id: Option<String>,
|
||||||
/// Subtitle tracks to sideload, with URLs the frontend has already resolved.
|
/// Subtitle tracks to sideload, with URLs the frontend has already resolved.
|
||||||
///
|
///
|
||||||
/// Only the native backends use these: on Android they become the
|
/// On Android they become the `MediaItem.SubtitleConfiguration`s ExoPlayer
|
||||||
/// `MediaItem.SubtitleConfiguration`s ExoPlayer renders. The HTML5 path
|
/// renders; mpv loads them as external subtitle files (`mpv_tracks`).
|
||||||
/// builds its own `<track>` children instead and ignores this list.
|
|
||||||
///
|
///
|
||||||
/// **Order is the contract.** `player_set_subtitle_track(n)` reaches
|
/// **Order is the contract.** `player_set_subtitle_track(n)` reaches
|
||||||
/// `JellyTauPlayer.setSubtitleTrack(n)`, which indexes into ExoPlayer's
|
/// `JellyTauPlayer.setSubtitleTrack(n)`, which indexes into ExoPlayer's
|
||||||
@@ -328,19 +313,6 @@ pub enum VideoSeekResponse {
|
|||||||
/// Confirmed position after seek
|
/// Confirmed position after seek
|
||||||
position: f64,
|
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
|
/// Response for audio track switching operations
|
||||||
@@ -352,13 +324,6 @@ pub enum AudioTrackSwitchResponse {
|
|||||||
/// Confirmation message
|
/// Confirmation message
|
||||||
success: bool,
|
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.
|
/// Response for a mid-playback streaming-quality change.
|
||||||
@@ -388,16 +353,6 @@ pub enum StreamQualityResponse {
|
|||||||
/// Position playback resumed at.
|
/// Position playback resumed at.
|
||||||
position: f64,
|
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
|
/// Helper function to create MediaItem from video request
|
||||||
@@ -726,34 +681,18 @@ pub async fn player_play_item(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let controller = player.0.lock().await;
|
let controller = player.0.lock().await;
|
||||||
// Who gets the stream depends on who is going to *render* it, which is a
|
// The backend always gets the stream: every video renderer is native (mpv,
|
||||||
// runtime question, not a platform constant.
|
// 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`),
|
// TRACES: UR-080 | DR-231, DR-235, DR-237
|
||||||
// so handing the file to MPV as well would only have started a redundant
|
controller
|
||||||
// decode with no window to show it in — hence a `#[cfg(not(linux))]` guard
|
.play_item(media_item)
|
||||||
// and a queue-only path here. With mpv drawing the picture that inverts:
|
.map_err(|e| e.to_string())?;
|
||||||
// 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 {
|
|
||||||
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
|
// Emit queue changed event
|
||||||
controller.emit_queue_changed();
|
controller.emit_queue_changed();
|
||||||
@@ -776,8 +715,8 @@ pub async fn player_play_item(
|
|||||||
/// `stream_url` MUST be an audio-only URL (see
|
/// `stream_url` MUST be an audio-only URL (see
|
||||||
/// `get_audio_only_stream_url_for_video`). The item is created as
|
/// `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
|
/// `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
|
/// backend with `mediaType="audio"`, replacing the video, so exactly one audio
|
||||||
/// frontend side, so exactly one audio source is ever active.
|
/// source is ever active.
|
||||||
///
|
///
|
||||||
/// This deliberately goes through the queue-based `play_item` path (NOT a
|
/// 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
|
/// 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
|
/// 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
|
/// Returns the position in seconds. The sleep timer is intentionally left
|
||||||
/// untouched — if it fired while backgrounded, playback is already stopped and
|
/// 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();
|
let mode = playback_mode.0.get_mode();
|
||||||
|
|
||||||
// Stopping is a state transition worth seeing in a log. Native video is
|
// Stopping is a state transition worth seeing in a log. Native video is
|
||||||
// what made its absence matter: the webview <video> stopped implicitly when
|
// what made its absence matter: the (since deleted) webview <video> stopped
|
||||||
// the component unmounted, so nothing ever had to call this — and "never
|
// 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
|
// called" and "called but the backend kept playing" look identical from
|
||||||
// outside without it.
|
// outside without it.
|
||||||
info!("[player_stop] called (mode: {:?})", mode);
|
info!("[player_stop] called (mode: {:?})", mode);
|
||||||
@@ -1430,8 +1370,8 @@ pub async fn player_seek(
|
|||||||
/// - Direct play streams: Use native seeking
|
/// - Direct play streams: Use native seeking
|
||||||
/// - Transcoded non-HLS: Request new stream URL from server starting at seek position
|
/// - 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
|
/// The backend always handles the seek itself, including re-opening a stream,
|
||||||
/// internally. For HTML5 backends, it returns the new URL for the frontend to handle.
|
/// since every video renderer is native (DR-235).
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
pub async fn player_seek_video(
|
pub async fn player_seek_video(
|
||||||
@@ -1441,12 +1381,8 @@ pub async fn player_seek_video(
|
|||||||
position: f64,
|
position: f64,
|
||||||
media_source_id: Option<String>,
|
media_source_id: Option<String>,
|
||||||
audio_stream_index: Option<i32>,
|
audio_stream_index: Option<i32>,
|
||||||
use_html5: bool,
|
|
||||||
) -> Result<VideoSeekResponse, String> {
|
) -> Result<VideoSeekResponse, String> {
|
||||||
info!(
|
info!("[player_seek_video] Seeking to {} seconds", position);
|
||||||
"[player_seek_video] Seeking to {} seconds (use_html5: {})",
|
|
||||||
position, use_html5
|
|
||||||
);
|
|
||||||
|
|
||||||
// Get repository
|
// Get repository
|
||||||
let repository = repository_manager
|
let repository = repository_manager
|
||||||
@@ -1488,17 +1424,13 @@ pub async fn player_seek_video(
|
|||||||
let controller = player.0.lock().await;
|
let controller = player.0.lock().await;
|
||||||
controller.capabilities().seeks_transcoded_in_place
|
controller.capabilities().seeks_transcoded_in_place
|
||||||
};
|
};
|
||||||
let strategy = determine_video_seek_strategy(
|
let strategy =
|
||||||
is_local,
|
determine_video_seek_strategy(is_local, seeks_transcoded_in_place, needs_transcoding);
|
||||||
seeks_transcoded_in_place,
|
|
||||||
needs_transcoding,
|
|
||||||
use_html5,
|
|
||||||
);
|
|
||||||
|
|
||||||
info!(
|
info!(
|
||||||
"[player_seek_video] Stream analysis: is_local={}, seeks_transcoded_in_place={}, \
|
"[player_seek_video] Stream analysis: is_local={}, seeks_transcoded_in_place={}, \
|
||||||
needs_transcoding={}, use_html5={}, strategy={:?}",
|
needs_transcoding={}, strategy={:?}",
|
||||||
is_local, seeks_transcoded_in_place, needs_transcoding, use_html5, strategy
|
is_local, seeks_transcoded_in_place, needs_transcoding, strategy
|
||||||
);
|
);
|
||||||
|
|
||||||
match strategy {
|
match strategy {
|
||||||
@@ -1509,35 +1441,6 @@ pub async fn player_seek_video(
|
|||||||
controller.seek(position).map_err(|e| e.to_string())?;
|
controller.seek(position).map_err(|e| e.to_string())?;
|
||||||
Ok(VideoSeekResponse::Native { position })
|
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 => {
|
VideoSeekStrategy::BackendReloadStream => {
|
||||||
// Transcoded non-HLS with native backend - backend handles stream reload
|
// Transcoded non-HLS with native backend - backend handles stream reload
|
||||||
info!("[player_seek_video] Backend reload stream - requesting new stream URL");
|
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
|
/// carries the requested track at all** — see
|
||||||
/// [`determine_audio_track_switch_strategy`]:
|
/// [`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
|
/// - 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.
|
/// every track in it, so ExoPlayer selects in place by track-group index.
|
||||||
/// - A native backend playing a **transcode** does not. Jellyfin builds a
|
/// - 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
|
/// audio track index` and dropped the request — the default track just kept
|
||||||
/// playing, with nothing in the UI saying so.
|
/// playing, with nothing in the UI saying so.
|
||||||
///
|
///
|
||||||
/// libmpv implements neither selection nor reload here — it is the audio-only
|
/// mpv selects in place the same way (`mpv_tracks::select_audio`, by position in
|
||||||
/// backend and leaves `PlayerBackend::set_audio_track` at its
|
/// the file's audio tracks), and re-opens a transcode through the same path.
|
||||||
/// `not_implemented()` default, which is why IR-019 is met by these paths
|
|
||||||
/// rather than by MPV.
|
|
||||||
///
|
///
|
||||||
/// TRACES: UR-021, UR-005 | IR-019, DR-024, DR-258
|
/// TRACES: UR-021, UR-005 | IR-019, DR-024, DR-258
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -1644,12 +1542,13 @@ pub async fn player_switch_audio_track(
|
|||||||
repository_handle: String,
|
repository_handle: String,
|
||||||
stream_index: i32,
|
stream_index: i32,
|
||||||
array_index: i32,
|
array_index: i32,
|
||||||
use_html5: bool,
|
|
||||||
current_position: Option<f64>,
|
current_position: Option<f64>,
|
||||||
media_source_id: Option<String>,
|
media_source_id: Option<String>,
|
||||||
) -> Result<AudioTrackSwitchResponse, String> {
|
) -> Result<AudioTrackSwitchResponse, String> {
|
||||||
info!("[player_switch_audio_track] Switching to audio track - stream_index: {}, array_index: {}, use_html5: {}",
|
info!(
|
||||||
stream_index, array_index, use_html5);
|
"[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
|
// Read what the engine is playing before deciding anything — including
|
||||||
// where it is, which has to be captured before the stop below wipes it.
|
// 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!(
|
info!(
|
||||||
"[player_switch_audio_track] needs_transcoding={}, use_html5={}, strategy={:?}",
|
"[player_switch_audio_track] needs_transcoding={}, strategy={:?}",
|
||||||
needs_transcoding, use_html5, strategy
|
needs_transcoding, strategy
|
||||||
);
|
);
|
||||||
|
|
||||||
if strategy == AudioTrackSwitchStrategy::BackendSelectInPlace {
|
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 —
|
// 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
|
// 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:
|
// 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
|
// 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);
|
let position = crate::player::track_switch::resume_position(current_position, engine_position);
|
||||||
|
|
||||||
match strategy {
|
match strategy {
|
||||||
AudioTrackSwitchStrategy::Html5ReloadStream => Ok(AudioTrackSwitchResponse::ReloadStream {
|
|
||||||
selection,
|
|
||||||
position,
|
|
||||||
}),
|
|
||||||
AudioTrackSwitchStrategy::BackendReloadStream => {
|
AudioTrackSwitchStrategy::BackendReloadStream => {
|
||||||
// The native backend re-opens its own stream, the same sequence the
|
// The native backend re-opens its own stream, the same sequence the
|
||||||
// transcoded seek and quality change use: stop, repoint the queue
|
// 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
|
/// 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
|
/// 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
|
/// 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
|
/// the same reload the transcoded-seek and audio-track paths use, done here.
|
||||||
/// two-sided split: HTML5 gets the URL back and reloads its own element, while a
|
|
||||||
/// native backend is reloaded here.
|
|
||||||
///
|
///
|
||||||
/// The change applies to **this playback only**. The in-player picker is a
|
/// 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
|
/// "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_manager: State<'_, super::repository::RepositoryManagerWrapper>,
|
||||||
repository_handle: String,
|
repository_handle: String,
|
||||||
quality: crate::settings::StreamingQuality,
|
quality: crate::settings::StreamingQuality,
|
||||||
use_html5: bool,
|
|
||||||
current_position: Option<f64>,
|
current_position: Option<f64>,
|
||||||
media_source_id: Option<String>,
|
media_source_id: Option<String>,
|
||||||
audio_stream_index: Option<i32>,
|
audio_stream_index: Option<i32>,
|
||||||
) -> Result<StreamQualityResponse, String> {
|
) -> Result<StreamQualityResponse, String> {
|
||||||
info!(
|
info!(
|
||||||
"[player_set_stream_quality] Switching to {} (use_html5: {}, position: {:?})",
|
"[player_set_stream_quality] Switching to {} (position: {:?})",
|
||||||
quality.label(),
|
quality.label(),
|
||||||
use_html5,
|
|
||||||
current_position
|
current_position
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1880,14 +1771,7 @@ pub async fn player_set_stream_quality(
|
|||||||
.map_err(|e| format!("Failed to select a stream: {:?}", e))?;
|
.map_err(|e| format!("Failed to select a stream: {:?}", e))?;
|
||||||
let new_url = selection.url.clone();
|
let new_url = selection.url.clone();
|
||||||
|
|
||||||
if use_html5 {
|
// The native backend (mpv, ExoPlayer): stop, repoint the queue entry at the
|
||||||
return Ok(StreamQualityResponse::ReloadStream {
|
|
||||||
selection,
|
|
||||||
position,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Native backend (Android/ExoPlayer): stop, repoint the queue entry at the
|
|
||||||
// new URL, and reload — mirroring `VideoSeekStrategy::BackendReloadStream`.
|
// new URL, and reload — mirroring `VideoSeekStrategy::BackendReloadStream`.
|
||||||
// The re-opened stream begins at zero (an HLS playlist cannot carry a start
|
// 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
|
// 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
|
/// On Android this indexes ExoPlayer's *text track groups* — i.e. the position
|
||||||
/// of the sideloaded `MediaItem.SubtitleConfiguration`, not the Jellyfin stream
|
/// of the sideloaded `MediaItem.SubtitleConfiguration`, not the Jellyfin stream
|
||||||
/// index. The HTML5 path never reaches here; it toggles its own `<track>`
|
/// index. mpv gives it the same meaning: the position in the sideloaded WebVTT
|
||||||
/// children. libmpv implements neither, leaving the trait default in place.
|
/// list, loaded as external subtitle files (`mpv_tracks`).
|
||||||
///
|
///
|
||||||
/// TRACES: UR-020 | IR-018, DR-023
|
/// TRACES: UR-020 | IR-018, DR-023
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -2184,33 +2068,13 @@ pub async fn player_get_queue(
|
|||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct PlaybackCapabilities {
|
pub struct PlaybackCapabilities {
|
||||||
/// True when audio is rendered by a webview `<audio>` element rather than a
|
/// True when audio is rendered by a webview `<audio>` element rather than a
|
||||||
/// native backend. Native audio exists on Linux (mpv) and Android
|
/// native backend. Native audio exists on Linux and Windows (mpv) and
|
||||||
/// (ExoPlayer); everything else (Windows, future desktops) uses the webview.
|
/// 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,
|
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.
|
/// 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]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
pub async fn player_get_capabilities() -> Result<PlaybackCapabilities, String> {
|
pub async fn player_get_capabilities() -> Result<PlaybackCapabilities, String> {
|
||||||
// Mirrors the cfg gates the backends themselves are built under.
|
// Mirrors the cfg gates the backends themselves are built under: mpv on
|
||||||
let native_audio = cfg!(any(target_os = "android", target_os = "linux"));
|
// Linux and Windows (DR-237), ExoPlayer on Android.
|
||||||
|
let native_audio = cfg!(any(
|
||||||
|
target_os = "android",
|
||||||
|
target_os = "linux",
|
||||||
|
target_os = "windows"
|
||||||
|
));
|
||||||
|
|
||||||
Ok(PlaybackCapabilities {
|
Ok(PlaybackCapabilities {
|
||||||
uses_webview_audio: !native_audio,
|
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 {
|
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 {
|
PlayerStatus {
|
||||||
state: controller.state(),
|
state: controller.state(),
|
||||||
// The position on the item's timeline, whichever of the three paths is
|
// The position on the item's timeline, whichever path is rendering it —
|
||||||
// rendering it — the native backend answers for only one of them, and
|
// the native backend reads 0 for a handoff that has not ticked yet.
|
||||||
// reads 0 for webview video and for a handoff that has not ticked yet.
|
|
||||||
// TRACES: UR-005 | DR-178
|
// TRACES: UR-005 | DR-178
|
||||||
position: controller.absolute_position(),
|
position: controller.absolute_position(),
|
||||||
duration: controller.duration(),
|
duration: controller.duration(),
|
||||||
@@ -2262,8 +2108,6 @@ pub(super) fn get_player_status(controller: &PlayerController) -> PlayerStatus {
|
|||||||
muted: controller.muted(),
|
muted: controller.muted(),
|
||||||
shuffle: controller.is_shuffle(),
|
shuffle: controller.is_shuffle(),
|
||||||
repeat: controller.repeat_mode(),
|
repeat: controller.repeat_mode(),
|
||||||
backend,
|
|
||||||
use_html5_element,
|
|
||||||
|
|
||||||
// Merged fields initialized to defaults (will be set by player_get_status)
|
// Merged fields initialized to defaults (will be set by player_get_status)
|
||||||
merged_media: None,
|
merged_media: None,
|
||||||
@@ -3087,35 +2931,42 @@ pub async fn player_disable_jellyfin(player: State<'_, PlayerStateWrapper>) -> R
|
|||||||
mod tests {
|
mod tests {
|
||||||
use crate::utils::lock::MutexSafe;
|
use crate::utils::lock::MutexSafe;
|
||||||
|
|
||||||
/// Android has one video renderer, ExoPlayer. The webview element could only
|
/// Video always goes to the backend. `player_play_item` once decided per
|
||||||
/// be reached by the user switching native video off, and a file downloaded
|
/// platform whether the backend or the webview's `<video>` would render,
|
||||||
/// as the untouched original — AC-3 audio included — plays silent there,
|
/// and each wrong answer was silence (Linux, once mpv drew the picture) or a
|
||||||
/// so the switch is gone on Android (DR-293). Where mpv draws video on Linux
|
/// soundtrack decoded twice (Windows). With the webview video path deleted
|
||||||
/// the webview is still the tested fallback, so the switch stays there;
|
/// there is no second renderer to route to, and the queue-only branch is
|
||||||
/// everywhere else the webview is the only renderer and there is nothing to
|
/// gone with it.
|
||||||
/// switch.
|
|
||||||
///
|
///
|
||||||
/// TRACES: UR-003, UR-071 | DR-293 | UT-259
|
/// TRACES: UR-003, UR-080 | DR-235, DR-237 | UT-273
|
||||||
#[test]
|
#[test]
|
||||||
fn test_webview_video_fallback_is_offered_only_beside_mpv_native_video() {
|
fn test_video_always_goes_to_the_backend() {
|
||||||
use super::webview_video_fallback;
|
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!(
|
/// Only audio can still be the webview's, and only on a desktop with no mpv.
|
||||||
!webview_video_fallback(true, false),
|
///
|
||||||
"Android: ExoPlayer is the only video renderer"
|
/// TRACES: UR-003, UR-080 | DR-235, DR-237 | UT-272
|
||||||
);
|
#[tokio::test]
|
||||||
assert!(
|
async fn test_every_shipped_platform_plays_audio_natively() {
|
||||||
!webview_video_fallback(true, true),
|
let caps = super::player_get_capabilities().await.unwrap();
|
||||||
"Android never falls back, whatever else is switched on"
|
if cfg!(any(
|
||||||
);
|
target_os = "linux",
|
||||||
assert!(
|
target_os = "windows",
|
||||||
webview_video_fallback(false, true),
|
target_os = "android"
|
||||||
"Linux with mpv native video: the webview is the fallback"
|
)) {
|
||||||
);
|
assert!(!caps.uses_webview_audio);
|
||||||
assert!(
|
}
|
||||||
!webview_video_fallback(false, false),
|
|
||||||
"the webview is the only renderer; nothing to fall back from"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// UT-206 — the volume the command hands on is always a real number in
|
/// UT-206 — the volume the command hands on is always a real number in
|
||||||
|
|||||||
@@ -138,7 +138,7 @@ pub async fn player_play_next_episode(
|
|||||||
|
|
||||||
/// Handle playback ended event - triggers autoplay decision logic
|
/// Handle playback ended event - triggers autoplay decision logic
|
||||||
/// This is called from:
|
/// 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
|
/// - Frontend when audio track ends via backend event - no itemId/repositoryHandle needed
|
||||||
/// - Android JNI callback also triggers this logic directly
|
/// - 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();
|
let controller_arc = player.0.clone();
|
||||||
|
|
||||||
// Run autoplay decision logic
|
// 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
|
// that bypasses the backend queue and stale end_reason
|
||||||
let decision = {
|
let decision = {
|
||||||
let controller = controller_arc.lock().await;
|
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
|
// Where media renders in the webview — the `<audio>` element of the webview
|
||||||
// <video>), the real player lives outside the native backend, so the frontend
|
// audio backend, on a desktop with no mpv; video never does since DR-235 — the
|
||||||
// HTML5 adapter reports DOM events back through these commands. The controller
|
// 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
|
// re-emits them through the same PlayerStatusEvent pipeline the native backends
|
||||||
// use, keeping the Rust controller the single source of truth and the frontend
|
// use, keeping the Rust controller the single source of truth and the frontend
|
||||||
// player store fed from one place (playerEvents.ts) in both modes.
|
// 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]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
pub async fn player_report_state(
|
pub async fn player_report_state(
|
||||||
@@ -348,7 +349,7 @@ pub async fn player_report_state(
|
|||||||
Ok(())
|
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.
|
/// these to roughly match the native backends' ~250ms cadence.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
@@ -362,7 +363,7 @@ pub async fn player_report_position(
|
|||||||
Ok(())
|
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]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
pub async fn player_report_media_loaded(
|
pub async fn player_report_media_loaded(
|
||||||
|
|||||||
@@ -150,6 +150,7 @@ pub fn run_engine(url: &str, engine: Engine) -> u32 {
|
|||||||
None,
|
None,
|
||||||
std::sync::Arc::new(tokio::sync::Mutex::new(None)),
|
std::sync::Arc::new(tokio::sync::Mutex::new(None)),
|
||||||
std::sync::Arc::new(crate::playback_reporting::throttle::EventThrottler::new()),
|
std::sync::Arc::new(crate::playback_reporting::throttle::EventThrottler::new()),
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
.expect("could not create the legacy backend"),
|
.expect("could not create the legacy backend"),
|
||||||
crate::player::media_player::Capabilities::mpv(),
|
crate::player::media_player::Capabilities::mpv(),
|
||||||
|
|||||||
+48
-126
@@ -352,7 +352,7 @@ use player::{MediaSessionManager, PlayerBackend, PlayerController, TauriEventEmi
|
|||||||
// still launch (browse library, manage downloads, see an error) instead of crashing.
|
// still launch (browse library, manage downloads, see an error) instead of crashing.
|
||||||
use player::NullBackend;
|
use player::NullBackend;
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
||||||
use player::MpvBackend;
|
use player::MpvBackend;
|
||||||
use settings::VideoSettings;
|
use settings::VideoSettings;
|
||||||
use storage::Database;
|
use storage::Database;
|
||||||
@@ -694,15 +694,44 @@ fn create_player_backend(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// For Linux, use MPV backend for audio playback
|
// Linux and Windows: mpv. On Windows libmpv-2.dll ships beside the exe in
|
||||||
#[cfg(target_os = "linux")]
|
// 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");
|
info!("Initializing MPV backend");
|
||||||
match MpvBackend::new(Some(_event_emitter), playback_reporter, position_throttler) {
|
// 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) => {
|
Ok(backend) => {
|
||||||
info!("Successfully initialized MPV backend for Linux");
|
info!("Successfully initialized MPV backend");
|
||||||
Box::new(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) => {
|
Err(e) => {
|
||||||
error!("\n========================================");
|
error!("\n========================================");
|
||||||
error!("FATAL ERROR: Failed to initialize MPV backend");
|
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
|
// Platforms with no native audio backend (none that ships since Windows
|
||||||
// playback through a webview <audio> element (all video already renders in
|
// moved to mpv): render audio-only playback through a webview <audio>
|
||||||
// the webview). Falls back to NullBackend only if the backend can't init.
|
// element. Falls back to NullBackend only if the backend can't init.
|
||||||
#[cfg(not(any(target_os = "linux", target_os = "android")))]
|
#[cfg(not(any(target_os = "linux", target_os = "android", target_os = "windows")))]
|
||||||
{
|
{
|
||||||
info!("No native audio backend for this platform - using webview <audio> backend");
|
info!("No native audio backend for this platform - using webview <audio> backend");
|
||||||
match player::WebviewAudioBackend::new(_event_emitter) {
|
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
|
/// Cached thumbnails are handed to the webview as asset-protocol URLs by
|
||||||
/// `convertFileSrc` (`asset://localhost/…` on Linux/macOS,
|
/// `convertFileSrc` (`asset://localhost/…` on Linux/macOS,
|
||||||
/// `http://asset.localhost/…` on Windows/Android). Tauri only answers that
|
/// `http://asset.localhost/…` on Windows/Android). Tauri only answers that
|
||||||
@@ -1232,13 +1181,6 @@ pub fn run() {
|
|||||||
// TRACES: UR-078 | DR-218
|
// TRACES: UR-078 | DR-218
|
||||||
crate::utils::diagnostics::install_panic_hook();
|
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`
|
// NOTE: TypeScript bindings are generated by the `export_typescript_bindings`
|
||||||
// test (`cargo test export_typescript_bindings`), NOT at runtime. Calling
|
// test (`cargo test export_typescript_bindings`), NOT at runtime. Calling
|
||||||
// `.export()` here would try to write `../src/lib/api/bindings.ts` at app
|
// `.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
|
// during its construction, and doing this in the order the code
|
||||||
// used to read produced "no mpv handle" every time — the surface was
|
// used to read produced "no mpv handle" every time — the surface was
|
||||||
// built before there was anything to draw from.
|
// built before there was anything to draw from.
|
||||||
// Native video surface: put a GL area under Tauri's webview so mpv
|
// Native video surface: mpv draws into the main window's own vbox,
|
||||||
// can draw beneath the controls (UR-080 / DR-231).
|
// 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
|
// Unconditional on Linux since DR-235: mpv is the only Linux video
|
||||||
// first click. `tauri-runtime-wry`'s undecorated-resizing handler
|
// renderer, so there is no webview path to fall back to if this
|
||||||
// walks a hard-coded two-hop path on every button press in the
|
// fails — the warnings below are the whole diagnosis.
|
||||||
// webview:
|
|
||||||
//
|
//
|
||||||
// webview.parent() // "This one should be GtkBox"
|
// TRACES: UR-080 | DR-231, DR-235
|
||||||
// .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
|
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
if crate::player::native_video::enabled() {
|
if crate::player::native_video::enabled() {
|
||||||
use tauri::Manager;
|
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") {
|
if let Some(window) = app.get_webview_window("main") {
|
||||||
match window.default_vbox() {
|
match window.default_vbox() {
|
||||||
Ok(vbox) => {
|
Ok(vbox) => {
|
||||||
|
|||||||
@@ -98,10 +98,9 @@ pub trait PlayerBackend: Send + Sync {
|
|||||||
|
|
||||||
/// Set the active audio track by stream index
|
/// Set the active audio track by stream index
|
||||||
///
|
///
|
||||||
/// Overridden by the Android (ExoPlayer) backend. `MpvBackend` deliberately
|
/// Overridden by both video backends, ExoPlayer and `MpvBackend`; the
|
||||||
/// does **not** override it — MPV is the audio-only backend here, so it keeps
|
/// argument is a position among the file's audio tracks. A transcode is
|
||||||
/// this `not_implemented()` default and the Linux video path switches track by
|
/// re-opened instead (`player_switch_audio_track`).
|
||||||
/// re-opening the stream instead (`player_switch_audio_track`).
|
|
||||||
///
|
///
|
||||||
/// TRACES: UR-021 | IR-019, DR-024
|
/// TRACES: UR-021 | IR-019, DR-024
|
||||||
fn set_audio_track(&mut self, _stream_index: i32) -> Result<(), PlayerError> {
|
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)
|
/// Set the active subtitle track by stream index (None to disable subtitles)
|
||||||
///
|
///
|
||||||
/// Overridden by the Android (ExoPlayer) backend. `MpvBackend` deliberately
|
/// Overridden by both video backends, ExoPlayer and `MpvBackend`; the
|
||||||
/// does **not** override it, so it keeps this `not_implemented()` default;
|
/// argument is a position in the sideloaded subtitle list the play request
|
||||||
/// the Linux video path renders subtitles as `<track>` children of the
|
/// carried.
|
||||||
/// WebKitGTK HTML5 `<video>` element and never calls this.
|
|
||||||
///
|
///
|
||||||
/// TRACES: UR-020 | IR-018, DR-023
|
/// TRACES: UR-020 | IR-018, DR-023
|
||||||
fn set_subtitle_track(&mut self, _stream_index: Option<i32>) -> Result<(), PlayerError> {
|
fn set_subtitle_track(&mut self, _stream_index: Option<i32>) -> Result<(), PlayerError> {
|
||||||
|
|||||||
@@ -85,9 +85,8 @@ pub enum PlayerStatusEvent {
|
|||||||
remaining_seconds: u32,
|
remaining_seconds: u32,
|
||||||
},
|
},
|
||||||
/// Time-based sleep timer expired: playback must stop. The backend stops
|
/// Time-based sleep timer expired: playback must stop. The backend stops
|
||||||
/// its own (MPV/ExoPlayer) playback, but HTML5 video on Linux plays in the
|
/// its own (MPV/ExoPlayer) playback; the frontend pauses the active adapter
|
||||||
/// webview outside the backend's control — the frontend pauses it on this
|
/// on this event, which reaches a webview `<audio>` element where one plays.
|
||||||
/// event.
|
|
||||||
SleepTimerExpired,
|
SleepTimerExpired,
|
||||||
/// Show next episode popup with countdown
|
/// Show next episode popup with countdown
|
||||||
ShowNextEpisodePopup {
|
ShowNextEpisodePopup {
|
||||||
@@ -152,9 +151,9 @@ pub enum PlayerStatusEvent {
|
|||||||
/// media item locally), so the native side only signals intent here.
|
/// media item locally), so the native side only signals intent here.
|
||||||
RemoteDisconnectRequested,
|
RemoteDisconnectRequested,
|
||||||
/// Backend-originated control command targeting the active frontend player
|
/// Backend-originated control command targeting the active frontend player
|
||||||
/// adapter (the HTML5 <video> that lives in the webview, which Rust cannot
|
/// adapter — the webview `<audio>` element, which Rust cannot drive
|
||||||
/// drive directly). Emitted by control paths like the sleep timer, lockscreen,
|
/// directly. Emitted by control paths like the sleep timer, lockscreen, or
|
||||||
/// or remote so they can pause/play/seek/stop the webview element.
|
/// remote so they can pause/play/seek/stop it.
|
||||||
/// `playerEvents.ts` routes this to the active PlayerAdapter via the facade.
|
/// `playerEvents.ts` routes this to the active PlayerAdapter via the facade.
|
||||||
ControlCommand {
|
ControlCommand {
|
||||||
/// One of: "play", "pause", "stop", "seek".
|
/// 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.
|
/// Ask the frontend webview `<audio>` element to load and play a stream.
|
||||||
///
|
///
|
||||||
/// Emitted by `WebviewAudioBackend` on platforms with no native audio
|
/// Emitted by `WebviewAudioBackend` on a desktop with no native audio
|
||||||
/// backend (e.g. Windows): audio-only playback is rendered by an `<audio>`
|
/// backend (none that ships: Linux and Windows have mpv): audio-only
|
||||||
/// element in the webview, mirroring how all video already renders through
|
/// playback is rendered by an `<audio>` element in the webview. The element
|
||||||
/// the webview `<video>`. The element then reports its state/position back
|
/// then reports its state/position back
|
||||||
/// through the `player_report_*` commands, so the Rust controller stays the
|
/// through the `player_report_*` commands, so the Rust controller stays the
|
||||||
/// single source of truth. Subsequent play/pause/seek/stop reach the element
|
/// single source of truth. Subsequent play/pause/seek/stop reach the element
|
||||||
/// via `ControlCommand`.
|
/// via `ControlCommand`.
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
WEBVTT
|
||||||
|
|
||||||
|
00:00.000 --> 00:02.000
|
||||||
|
fixture subtitle
|
||||||
Binary file not shown.
@@ -140,7 +140,7 @@ pub struct Capabilities {
|
|||||||
pub audio_track_switching: bool,
|
pub audio_track_switching: bool,
|
||||||
/// A *server-side transcode* can be seeked without re-opening the stream.
|
/// 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
|
/// lets the server catch up. False for mpv, whose HLS demuxer cannot make
|
||||||
/// the server transcode from a new offset.
|
/// the server transcode from a new offset.
|
||||||
///
|
///
|
||||||
@@ -173,9 +173,8 @@ impl Capabilities {
|
|||||||
|
|
||||||
/// ExoPlayer.
|
/// ExoPlayer.
|
||||||
///
|
///
|
||||||
/// **Can** seek a transcode in place. It is a full HLS client, so like
|
/// **Can** seek a transcode in place. It is a full HLS client, so it seeks
|
||||||
/// hls.js it seeks within the VOD playlist it was handed and lets the
|
/// within the VOD playlist it was handed and lets the server catch up. Grouping it with mpv as "a native engine" gets this
|
||||||
/// server catch up. Grouping it with mpv as "a native engine" gets this
|
|
||||||
/// exactly backwards — being native is not the property that matters here,
|
/// exactly backwards — being native is not the property that matters here,
|
||||||
/// speaking HLS is, and that is the whole reason this is declared per
|
/// speaking HLS is, and that is the whole reason this is declared per
|
||||||
/// engine rather than inferred from a category.
|
/// engine rather than inferred from a category.
|
||||||
@@ -188,18 +187,6 @@ impl Capabilities {
|
|||||||
seeks_transcoded_in_place: true,
|
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.
|
/// A request to present an item.
|
||||||
@@ -242,7 +229,7 @@ impl OpenRequest {
|
|||||||
/// Anything that can present media.
|
/// Anything that can present media.
|
||||||
///
|
///
|
||||||
/// Implementations: `MpvPlayer` (Linux/Windows), `ExoPlayerPlayer` (Android),
|
/// 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`].
|
/// them must pass [`super::conformance`].
|
||||||
pub trait MediaPlayer: Send {
|
pub trait MediaPlayer: Send {
|
||||||
/// Present `req.selection`, beginning at `req.start`.
|
/// 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.
|
/// 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
|
/// 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
|
/// cannot make a server transcode from a new offset. Callers state the
|
||||||
/// destination and nothing else.
|
/// destination and nothing else.
|
||||||
fn seek(&mut self, to: Duration) -> Result<(), PlayerError>;
|
fn seek(&mut self, to: Duration) -> Result<(), PlayerError>;
|
||||||
|
|||||||
+21
-45
@@ -15,8 +15,12 @@ mod fake_player_conformance;
|
|||||||
pub mod legacy_player;
|
pub mod legacy_player;
|
||||||
pub mod media;
|
pub mod media;
|
||||||
pub mod media_player;
|
pub mod media_player;
|
||||||
|
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
||||||
|
pub mod mpv_command;
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
pub mod mpv_player;
|
pub mod mpv_player;
|
||||||
|
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
||||||
|
pub mod mpv_tracks;
|
||||||
pub mod queue;
|
pub mod queue;
|
||||||
pub mod seek;
|
pub mod seek;
|
||||||
pub mod session;
|
pub mod session;
|
||||||
@@ -40,7 +44,7 @@ pub mod jni_guard;
|
|||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
pub mod android;
|
pub mod android;
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
||||||
pub mod mpv_backend;
|
pub mod mpv_backend;
|
||||||
|
|
||||||
/// Whether this process renders video natively — one answer, three consumers
|
/// Whether this process renders video natively — one answer, three consumers
|
||||||
@@ -62,9 +66,10 @@ pub mod mpv_render;
|
|||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
pub mod video_surface;
|
pub mod video_surface;
|
||||||
|
|
||||||
// Platforms with no native audio backend (e.g. Windows) render audio-only
|
// Platforms with no native audio backend render audio-only playback through a
|
||||||
// playback through a webview <audio> element, mirroring how all video renders.
|
// webview <audio> element. None that ships: Windows moved to mpv (DR-237); this
|
||||||
#[cfg(not(any(target_os = "linux", target_os = "android")))]
|
// remains for an unported desktop (macOS).
|
||||||
|
#[cfg(not(any(target_os = "linux", target_os = "android", target_os = "windows")))]
|
||||||
pub mod webview_audio_backend;
|
pub mod webview_audio_backend;
|
||||||
|
|
||||||
// Re-export commonly used types
|
// Re-export commonly used types
|
||||||
@@ -86,10 +91,10 @@ pub use track_switch::{determine_audio_track_switch_strategy, AudioTrackSwitchSt
|
|||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
pub use android::ExoPlayerBackend;
|
pub use android::ExoPlayerBackend;
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
||||||
pub use mpv_backend::MpvBackend;
|
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;
|
pub use webview_audio_backend::WebviewAudioBackend;
|
||||||
|
|
||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
@@ -319,9 +324,10 @@ pub struct PlayerController {
|
|||||||
background_audio_base: Arc<Mutex<f64>>,
|
background_audio_base: Arc<Mutex<f64>>,
|
||||||
|
|
||||||
// True while a background-audio handoff owns playback: the native audio
|
// 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
|
// frontend reports like any other, which would otherwise leave the controller
|
||||||
// believing webview media is still active — aiming lockscreen transport at an
|
// believing webview media is still active — aiming lockscreen transport at an
|
||||||
// element that no longer exists (see `is_html5_active`).
|
// element that no longer exists (see `is_html5_active`).
|
||||||
@@ -338,7 +344,8 @@ pub struct PlayerController {
|
|||||||
// TRACES: UR-040 | DR-129
|
// TRACES: UR-040 | DR-129
|
||||||
stream_resume: Arc<Mutex<stream_end::ResumeTracker>>,
|
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
|
// 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
|
// 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.
|
/// Configure the media repository used for next-episode lookups.
|
||||||
///
|
///
|
||||||
/// The Android ExoPlayer ended-callback calls `on_playback_ended` with no
|
/// 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
|
/// call), so the controller needs a repository of its own or episode
|
||||||
/// autoplay silently decides Stop.
|
/// autoplay silently decides Stop.
|
||||||
pub fn set_repository(&self, repo: Arc<dyn MediaRepository>) {
|
pub fn set_repository(&self, repo: Arc<dyn MediaRepository>) {
|
||||||
@@ -538,37 +545,6 @@ impl PlayerController {
|
|||||||
Ok(())
|
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
|
/// 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
|
/// 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> {
|
pub fn load_and_play(&self, item: &MediaItem) -> Result<(), PlayerError> {
|
||||||
@@ -593,7 +569,7 @@ impl PlayerController {
|
|||||||
// intermittent.
|
// intermittent.
|
||||||
//
|
//
|
||||||
// The webview re-establishes its own authority the moment an element
|
// 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
|
// "element is gone" semantics as the "stopped"/"idle" report, applied at
|
||||||
// the point where we can know it directly.
|
// the point where we can know it directly.
|
||||||
//
|
//
|
||||||
@@ -717,7 +693,7 @@ impl PlayerController {
|
|||||||
Ok(())
|
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.
|
/// player, so transport must be routed to it rather than the native backend.
|
||||||
///
|
///
|
||||||
/// TRACES: UR-005 | DR-097
|
/// TRACES: UR-005 | DR-097
|
||||||
@@ -769,7 +745,7 @@ impl PlayerController {
|
|||||||
/// Toggle play/pause.
|
/// Toggle play/pause.
|
||||||
///
|
///
|
||||||
/// The decision is made HERE, from authoritative state — the reported webview
|
/// 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).
|
/// The frontend must never decide this from the DOM (see DR-097).
|
||||||
///
|
///
|
||||||
/// TRACES: UR-005 | DR-097
|
/// TRACES: UR-005 | DR-097
|
||||||
@@ -1046,7 +1022,7 @@ impl PlayerController {
|
|||||||
/// truncation comparison. `position()` alone answers for exactly one of the
|
/// truncation comparison. `position()` alone answers for exactly one of the
|
||||||
/// three ways this app plays media, and reads 0 for the other two:
|
/// 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
|
/// backend, so its position is a permanent 0. The element's own reports are
|
||||||
/// the only reading there is.
|
/// the only reading there is.
|
||||||
/// - **Background-audio handoff**: the audio-only stream's zero is the
|
/// - **Background-audio handoff**: the audio-only stream's zero is the
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ use crate::utils::conversions::{seconds_to_ticks, volume_to_percent};
|
|||||||
use crate::utils::lock::MutexSafe;
|
use crate::utils::lock::MutexSafe;
|
||||||
use libmpv::Mpv;
|
use libmpv::Mpv;
|
||||||
use log::{debug, error, info, warn};
|
use log::{debug, error, info, warn};
|
||||||
use std::process::Command;
|
|
||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||||
@@ -54,8 +53,21 @@ struct InternalState {
|
|||||||
volume: f32,
|
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 {
|
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...");
|
info!("[MpvBackend] Detecting audio system...");
|
||||||
|
|
||||||
// Try PulseAudio/PipeWire first (most common on modern Linux)
|
// 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 {
|
fn get_stream_url(media: &MediaItem) -> String {
|
||||||
match &media.source {
|
match &media.source {
|
||||||
MediaSource::Remote { stream_url, .. } => stream_url.clone(),
|
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, .. } => {
|
MediaSource::Local { file_path, .. } => {
|
||||||
format!("file://{}", file_path.to_string_lossy())
|
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).
|
/// can fail, and the app falls back to a no-op backend rather than dying).
|
||||||
///
|
///
|
||||||
/// TRACES: UR-080 | DR-231
|
/// 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 {
|
pub fn registered_handle() -> *mut libmpv_sys::mpv_handle {
|
||||||
MPV_HANDLE
|
MPV_HANDLE
|
||||||
.get()
|
.get()
|
||||||
@@ -128,12 +149,73 @@ pub fn registered_handle() -> *mut libmpv_sys::mpv_handle {
|
|||||||
.unwrap_or(std::ptr::null_mut())
|
.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 {
|
impl MpvBackend {
|
||||||
/// Create a new MPV backend
|
/// 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(
|
pub fn new(
|
||||||
event_emitter: Option<Arc<dyn PlayerEventEmitter>>,
|
event_emitter: Option<Arc<dyn PlayerEventEmitter>>,
|
||||||
playback_reporter: Arc<TokioMutex<Option<PlaybackReporter>>>,
|
playback_reporter: Arc<TokioMutex<Option<PlaybackReporter>>>,
|
||||||
position_throttler: Arc<EventThrottler>,
|
position_throttler: Arc<EventThrottler>,
|
||||||
|
video_window: Option<i64>,
|
||||||
) -> Result<Self, PlayerError> {
|
) -> Result<Self, PlayerError> {
|
||||||
info!("[MpvBackend] Initializing MPV backend...");
|
info!("[MpvBackend] Initializing MPV backend...");
|
||||||
|
|
||||||
@@ -145,9 +227,26 @@ impl MpvBackend {
|
|||||||
libc::setlocale(libc::LC_NUMERIC, c_locale.as_ptr());
|
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),
|
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
|
// Detect and configure audio output
|
||||||
let audio_driver = detect_audio_system();
|
let audio_driver = detect_audio_system();
|
||||||
@@ -178,26 +277,23 @@ impl MpvBackend {
|
|||||||
|
|
||||||
// Video is disabled unless this process is drawing it.
|
// Video is disabled unless this process is drawing it.
|
||||||
//
|
//
|
||||||
// `video: no` is why mpv has never decoded a frame here: Linux video has
|
// Linux video went through the webview until DR-235, and decoding it
|
||||||
// always gone through the webview, and decoding it twice would burn a
|
// here too would have burned a core for a picture nobody saw — hence
|
||||||
// core for a picture nobody sees. With native video on, mpv needs both
|
// `video: no`. With native video, mpv needs the decoder *and* an output
|
||||||
// the decoder *and* `vo=libmpv` — the render API only works through that
|
// that draws where the app wants it: the render API on Linux (the default
|
||||||
// output, and the default would try to open a window of its own.
|
// would open a window of its own), the app's window on Windows.
|
||||||
//
|
//
|
||||||
// Set at construction because mpv resolves the video output when it
|
// Set at construction because mpv resolves the video output when it
|
||||||
// initialises; flipping it later does not re-open one.
|
// initialises; flipping it later does not re-open one.
|
||||||
//
|
//
|
||||||
// TRACES: UR-080 | DR-231, DR-235
|
// TRACES: UR-080 | DR-231, DR-235, DR-237
|
||||||
if super::native_video::enabled() {
|
for (name, value) in output.options() {
|
||||||
mpv.set_property("vo", "libmpv").map_err(|e| PlayerError {
|
mpv.set_property(name, value.as_str())
|
||||||
message: format!("Failed to select the libmpv video output: {:?}", e),
|
.map_err(|e| PlayerError {
|
||||||
})?;
|
message: format!("Failed to set {name}={value}: {:?}", 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),
|
|
||||||
})?;
|
|
||||||
}
|
}
|
||||||
|
info!("[MpvBackend] video output: {:?}", output);
|
||||||
|
|
||||||
// Set volume to 100% (we'll control via MPV's volume property)
|
// Set volume to 100% (we'll control via MPV's volume property)
|
||||||
mpv.set_property("volume", 100i64)
|
mpv.set_property("volume", 100i64)
|
||||||
@@ -288,8 +384,8 @@ impl MpvBackend {
|
|||||||
// StateChanged rather than tracking playback itself, per the
|
// StateChanged rather than tracking playback itself, per the
|
||||||
// one-directional state rule. Unobserved, the event never came and
|
// one-directional state rule. Unobserved, the event never came and
|
||||||
// the button never moved. Invisible until native video shipped,
|
// the button never moved. Invisible until native video shipped,
|
||||||
// because the webview <video> element's own DOM events drove that
|
// because the (since deleted) webview <video> element's own DOM
|
||||||
// control on Linux.
|
// events drove that control on Linux.
|
||||||
//
|
//
|
||||||
// TRACES: UR-005 | DR-239
|
// TRACES: UR-005 | DR-239
|
||||||
ev_ctx
|
ev_ctx
|
||||||
@@ -600,12 +696,23 @@ impl PlayerBackend for MpvBackend {
|
|||||||
// TRACES: UR-040, UR-005 | DR-253
|
// TRACES: UR-040, UR-005 | DR-253
|
||||||
*self.pending_seek.lock_safe() = None;
|
*self.pending_seek.lock_safe() = None;
|
||||||
|
|
||||||
// Load the media file
|
// The item's own sideloaded subtitles, none shown, and its default audio
|
||||||
self.mpv
|
// track — whatever the previous item had chosen. Only video carries
|
||||||
.command("loadfile", &[&stream_url])
|
// subtitles; for audio this just clears the last item's.
|
||||||
.map_err(|e| PlayerError {
|
// TRACES: UR-020, UR-021 | DR-023, DR-024, DR-235
|
||||||
message: format!("Failed to load file: {:?}", e),
|
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");
|
debug!("[MpvBackend] Load command sent successfully");
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -638,8 +745,8 @@ impl PlayerBackend for MpvBackend {
|
|||||||
fn stop(&mut self) -> Result<(), PlayerError> {
|
fn stop(&mut self) -> Result<(), PlayerError> {
|
||||||
debug!("[MpvBackend] Stop command");
|
debug!("[MpvBackend] Stop command");
|
||||||
|
|
||||||
self.mpv.command("stop", &[]).map_err(|e| PlayerError {
|
super::mpv_command::command(&self.mpv, &["stop"]).map_err(|e| PlayerError {
|
||||||
message: format!("Failed to stop: {:?}", e),
|
message: format!("Failed to stop: {e}"),
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
// Stopping ends the seek's subject along with the playback.
|
// Stopping ends the seek's subject along with the playback.
|
||||||
@@ -760,6 +867,36 @@ impl PlayerBackend for MpvBackend {
|
|||||||
state.volume
|
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> {
|
fn set_audio_settings(&mut self, settings: &AudioSettings) -> Result<(), PlayerError> {
|
||||||
info!("[MpvBackend] Applying audio settings");
|
info!("[MpvBackend] Applying audio settings");
|
||||||
self.audio_settings = settings.clone();
|
self.audio_settings = settings.clone();
|
||||||
@@ -998,3 +1135,97 @@ mod af_filter_tests {
|
|||||||
assert!(eq_pos < norm_pos, "normalizer runs after EQ: {af}");
|
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:?}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -85,6 +85,8 @@ impl MpvPlayer {
|
|||||||
let mpv = Mpv::new().map_err(|e| PlayerError {
|
let mpv = Mpv::new().map_err(|e| PlayerError {
|
||||||
message: format!("mpv_create failed: {e:?}"),
|
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| {
|
let set = |k: &str, v: &str| {
|
||||||
if let Err(e) = mpv.set_property(k, v) {
|
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);
|
info!("[MpvPlayer] open {} at {:?}", req.selection.url, req.start);
|
||||||
self.mpv
|
// TRACES: UR-003, UR-004 | DR-298
|
||||||
.command("loadfile", &[&req.selection.url, "replace"])
|
super::mpv_command::command(&self.mpv, &["loadfile", &req.selection.url, "replace"])
|
||||||
.map_err(|e| PlayerError {
|
.map_err(|e| PlayerError {
|
||||||
message: format!("loadfile failed: {e:?}"),
|
message: format!("loadfile failed: {e}"),
|
||||||
})?;
|
})?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -275,8 +277,8 @@ impl MediaPlayer for MpvPlayer {
|
|||||||
}
|
}
|
||||||
// Idempotent: stopping an already-stopped mpv is not an error worth
|
// Idempotent: stopping an already-stopped mpv is not an error worth
|
||||||
// propagating, and callers legitimately close twice on teardown.
|
// propagating, and callers legitimately close twice on teardown.
|
||||||
if let Err(e) = self.mpv.command("stop", &[]) {
|
if let Err(e) = super::mpv_command::command(&self.mpv, &["stop"]) {
|
||||||
debug!("[MpvPlayer] stop on an idle player: {e:?}");
|
debug!("[MpvPlayer] stop on an idle player: {e}");
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,8 +2,7 @@
|
|||||||
//!
|
//!
|
||||||
//! Three things need this and must agree: the mpv backend (which has to be
|
//! Three things need this and must agree: the mpv backend (which has to be
|
||||||
//! configured for video *at construction*, before anything plays), the video
|
//! configured for video *at construction*, before anything plays), the video
|
||||||
//! surface (which has nothing to draw otherwise), and `get_player_status`
|
//! surface (which has nothing to draw otherwise), and the device profile.
|
||||||
//! (which tells the frontend whether to use a webview `<video>` element).
|
|
||||||
//!
|
//!
|
||||||
//! It is a function rather than three `env::var` checks for the reason this
|
//! 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
|
//! codebase keeps rediscovering: a capability answered in several places is a
|
||||||
@@ -14,65 +13,49 @@
|
|||||||
//!
|
//!
|
||||||
//! TRACES: UR-080 | DR-231, DR-235
|
//! 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.
|
/// Whether mpv should decode and draw video in this process.
|
||||||
///
|
///
|
||||||
/// Read fresh rather than cached: it is consulted a handful of times at startup,
|
/// True wherever mpv is the desktop video renderer: Linux since DR-235 phase 1,
|
||||||
/// and a `OnceLock` here would only make it harder to test.
|
/// 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
|
/// TRACES: UR-080 | DR-231, DR-235
|
||||||
pub fn enabled() -> bool {
|
pub fn enabled() -> bool {
|
||||||
// Only where a native renderer exists. On Android ExoPlayer already does
|
// On Android ExoPlayer draws video and `use_html5_element` is false for
|
||||||
// this and `use_html5_element` is false for entirely separate reasons.
|
// entirely separate reasons.
|
||||||
if !cfg!(all(target_os = "linux", not(target_os = "android"))) {
|
cfg!(any(target_os = "linux", target_os = "windows"))
|
||||||
return false;
|
|
||||||
}
|
|
||||||
matches!(
|
|
||||||
std::env::var(ENV_FLAG).as_deref(),
|
|
||||||
Ok("1") | Ok("true") | Ok("yes")
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
/// Absent, empty, or anything unrecognised means off. A half-set variable
|
/// mpv draws video on Linux and Windows with nothing to opt into, and the retired
|
||||||
/// must not half-enable a renderer — the failure mode would be mpv
|
/// variable cannot opt back out: with the webview path gone, "off"
|
||||||
/// configured for video with nothing drawing it, i.e. audio playing over a
|
/// would configure mpv for audio only with nothing else to draw the picture.
|
||||||
/// black rectangle.
|
|
||||||
///
|
///
|
||||||
/// TRACES: UR-080 | DR-231 | UT-216
|
/// TRACES: UR-080 | DR-235, DR-237 | UT-271
|
||||||
#[test]
|
#[test]
|
||||||
fn test_only_explicit_truthy_values_enable_it() {
|
fn desktop_always_renders_video_natively() {
|
||||||
let restore = std::env::var(ENV_FLAG).ok();
|
let restore = std::env::var("JELLYTAU_NATIVE_VIDEO").ok();
|
||||||
|
|
||||||
for value in ["", "0", "no", "false", "maybe", "2"] {
|
for value in [None, Some("0"), Some("false"), Some("1")] {
|
||||||
std::env::set_var(ENV_FLAG, value);
|
match value {
|
||||||
assert!(!enabled(), "{value:?} must not enable native video");
|
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!(
|
assert_eq!(
|
||||||
enabled(),
|
enabled(),
|
||||||
cfg!(all(target_os = "linux", not(target_os = "android"))),
|
cfg!(any(target_os = "linux", target_os = "windows")),
|
||||||
"{value:?} enables it exactly where a native renderer exists"
|
"JELLYTAU_NATIVE_VIDEO={value:?} must not decide the renderer"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
std::env::remove_var(ENV_FLAG);
|
|
||||||
assert!(!enabled(), "absent means off");
|
|
||||||
|
|
||||||
match restore {
|
match restore {
|
||||||
Some(v) => std::env::set_var(ENV_FLAG, v),
|
Some(v) => std::env::set_var("JELLYTAU_NATIVE_VIDEO", v),
|
||||||
None => std::env::remove_var(ENV_FLAG),
|
None => std::env::remove_var("JELLYTAU_NATIVE_VIDEO"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+40
-129
@@ -5,17 +5,18 @@
|
|||||||
//! [`VideoSeekStrategy`] into a concrete backend/frontend action.
|
//! [`VideoSeekStrategy`] into a concrete backend/frontend action.
|
||||||
|
|
||||||
/// Seek strategy for video playback, derived from a stream's characteristics.
|
/// 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)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum VideoSeekStrategy {
|
pub enum VideoSeekStrategy {
|
||||||
/// Local file - always use native seek on backend
|
/// Local file - always use native seek on backend
|
||||||
LocalNativeSeek,
|
LocalNativeSeek,
|
||||||
/// HLS or direct stream with HTML5 - frontend handles seek, skip backend
|
/// Seekable where it sits (direct play/stream, or a transcode the engine
|
||||||
Html5NativeSeek,
|
/// can move) - backend seeks
|
||||||
/// HLS or direct stream with native backend - backend handles seek
|
|
||||||
BackendNativeSeek,
|
BackendNativeSeek,
|
||||||
/// Transcoded non-HLS with HTML5 - reload stream, frontend handles
|
/// A transcode the engine cannot move - re-open the stream at the target
|
||||||
Html5ReloadStream,
|
|
||||||
/// Transcoded non-HLS with native backend - reload stream, backend handles
|
|
||||||
BackendReloadStream,
|
BackendReloadStream,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,12 +30,10 @@ pub enum VideoSeekStrategy {
|
|||||||
/// can seek a server-side transcode without re-opening it. Declared by the
|
/// can seek a server-side transcode without re-opening it. Declared by the
|
||||||
/// engine via `Capabilities`, never inferred from the URL or the renderer.
|
/// engine via `Capabilities`, never inferred from the URL or the renderer.
|
||||||
/// * `needs_transcoding` - Whether the content needs transcoding
|
/// * `needs_transcoding` - Whether the content needs transcoding
|
||||||
/// * `use_html5` - Whether frontend is using HTML5 video element
|
|
||||||
pub fn determine_video_seek_strategy(
|
pub fn determine_video_seek_strategy(
|
||||||
is_local: bool,
|
is_local: bool,
|
||||||
seeks_transcoded_in_place: bool,
|
seeks_transcoded_in_place: bool,
|
||||||
needs_transcoding: bool,
|
needs_transcoding: bool,
|
||||||
use_html5: bool,
|
|
||||||
) -> VideoSeekStrategy {
|
) -> VideoSeekStrategy {
|
||||||
// Local files always support native seeking via backend
|
// Local files always support native seeking via backend
|
||||||
if is_local {
|
if is_local {
|
||||||
@@ -42,39 +41,20 @@ pub fn determine_video_seek_strategy(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// A server-side transcode is produced *from* `StartTimeTicks`, so where the
|
// 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.
|
// 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
|
||||||
// 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
|
|
||||||
// transcoded seek must re-negotiate the stream regardless of container.
|
// transcoded seek must re-negotiate the stream regardless of container.
|
||||||
//
|
//
|
||||||
// Before native video shipped, `use_html5` was always true for HLS and the
|
// Whether a transcode can be seeked in place is a property of the engine,
|
||||||
// native+HLS+transcode cell was unreachable, which is why `is_hls` alone
|
// and the engine states it. This used to be inferred from `is_hls`, which
|
||||||
// used to be a safe proxy for "seekable in place". It no longer is: turning
|
// held only while hls.js was the sole HLS renderer — and stopped holding the
|
||||||
// native video on routed every transcoded seek into a backend seek that
|
// moment mpv became one (DR-238).
|
||||||
// silently does nothing, and presents as "resume does not work".
|
if needs_transcoding && !seeks_transcoded_in_place {
|
||||||
if needs_transcoding {
|
return 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).
|
|
||||||
return match (seeks_transcoded_in_place, use_html5) {
|
|
||||||
(true, true) => VideoSeekStrategy::Html5NativeSeek,
|
|
||||||
(true, false) => VideoSeekStrategy::BackendNativeSeek,
|
|
||||||
(false, true) => VideoSeekStrategy::Html5ReloadStream,
|
|
||||||
(false, false) => VideoSeekStrategy::BackendReloadStream,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Direct play and direct stream are seekable where they sit.
|
// Direct play, direct stream, or a transcode the engine can move.
|
||||||
if use_html5 {
|
VideoSeekStrategy::BackendNativeSeek
|
||||||
// The frontend seeks via videoElement.currentTime; calling backend.seek()
|
|
||||||
// would move a player that is not the one rendering.
|
|
||||||
VideoSeekStrategy::Html5NativeSeek
|
|
||||||
} else {
|
|
||||||
VideoSeekStrategy::BackendNativeSeek
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// The four items below are consumed by the Android MediaSessionHandler; on other
|
// The four items below are consumed by the Android MediaSessionHandler; on other
|
||||||
@@ -224,114 +204,45 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_seek_strategy_local_file() {
|
fn test_seek_strategy_local_file() {
|
||||||
// Local files always use native backend seek regardless of other flags
|
// Local files always use native backend seek regardless of other flags
|
||||||
assert_eq!(
|
for (in_place, transcode) in [(false, false), (true, true), (false, true)] {
|
||||||
determine_video_seek_strategy(true, false, false, false),
|
assert_eq!(
|
||||||
VideoSeekStrategy::LocalNativeSeek
|
determine_video_seek_strategy(true, in_place, transcode),
|
||||||
);
|
VideoSeekStrategy::LocalNativeSeek
|
||||||
assert_eq!(
|
);
|
||||||
determine_video_seek_strategy(true, false, false, true),
|
}
|
||||||
VideoSeekStrategy::LocalNativeSeek
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
determine_video_seek_strategy(true, true, true, true),
|
|
||||||
VideoSeekStrategy::LocalNativeSeek
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Non-transcoded streams seek in place regardless of the engine's
|
/// Direct play and direct stream seek in place, whatever the engine's
|
||||||
/// transcode ability, which only applies to transcodes.
|
/// transcode ability — that only applies to transcodes.
|
||||||
#[test]
|
#[test]
|
||||||
fn test_seek_strategy_direct_stream() {
|
fn test_seek_strategy_direct_stream() {
|
||||||
// HTML5 renders, so the frontend seeks the element
|
for in_place in [false, true] {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
determine_video_seek_strategy(false, true, false, true),
|
determine_video_seek_strategy(false, in_place, false),
|
||||||
VideoSeekStrategy::Html5NativeSeek
|
VideoSeekStrategy::BackendNativeSeek
|
||||||
);
|
);
|
||||||
// The native engine renders, so it seeks
|
}
|
||||||
assert_eq!(
|
|
||||||
determine_video_seek_strategy(false, true, false, 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
|
/// Jellyfin produces a transcode from `StartTimeTicks`; mpv's HLS demuxer
|
||||||
/// within the VOD playlist it is handed, but mpv's HLS demuxer cannot make
|
/// cannot make the server transcode from a new offset, so the stream has to
|
||||||
/// the server transcode from a new offset, so the stream has to be
|
/// be re-negotiated. Inferring this from the container once routed every
|
||||||
/// re-negotiated. Before native video existed, `use_html5` was always true
|
/// transcoded seek into a native seek that silently does nothing, which
|
||||||
/// for HLS and this case was unreachable — turning native video on routed
|
/// presents as "resume does not work".
|
||||||
/// 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
|
/// TRACES: UR-040 | DR-238, DR-246 | UT-217
|
||||||
#[test]
|
#[test]
|
||||||
fn test_transcoded_seek_follows_the_engines_declared_ability() {
|
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!(
|
assert_eq!(
|
||||||
determine_video_seek_strategy(false, false, true, false),
|
determine_video_seek_strategy(false, false, true),
|
||||||
VideoSeekStrategy::BackendReloadStream
|
VideoSeekStrategy::BackendReloadStream
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
determine_video_seek_strategy(false, false, true, true),
|
determine_video_seek_strategy(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),
|
|
||||||
VideoSeekStrategy::BackendNativeSeek
|
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,10 +13,6 @@
|
|||||||
/// How a request to change audio track has to be carried out.
|
/// How a request to change audio track has to be carried out.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum AudioTrackSwitchStrategy {
|
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
|
/// Re-open the stream pinned to the chosen track; the backend reloads
|
||||||
/// itself and restores the position.
|
/// itself and restores the position.
|
||||||
BackendReloadStream,
|
BackendReloadStream,
|
||||||
@@ -29,17 +25,9 @@ pub enum AudioTrackSwitchStrategy {
|
|||||||
/// # Arguments
|
/// # Arguments
|
||||||
/// * `needs_transcoding` - Whether the stream now playing is a server-side
|
/// * `needs_transcoding` - Whether the stream now playing is a server-side
|
||||||
/// transcode, which carries exactly the one audio track it was built around.
|
/// 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
|
/// TRACES: UR-021 | IR-019, DR-024, DR-258 | UT-232
|
||||||
pub fn determine_audio_track_switch_strategy(
|
pub fn determine_audio_track_switch_strategy(needs_transcoding: bool) -> AudioTrackSwitchStrategy {
|
||||||
needs_transcoding: bool,
|
|
||||||
use_html5: bool,
|
|
||||||
) -> AudioTrackSwitchStrategy {
|
|
||||||
if use_html5 {
|
|
||||||
return AudioTrackSwitchStrategy::Html5ReloadStream;
|
|
||||||
}
|
|
||||||
|
|
||||||
if needs_transcoding {
|
if needs_transcoding {
|
||||||
AudioTrackSwitchStrategy::BackendReloadStream
|
AudioTrackSwitchStrategy::BackendReloadStream
|
||||||
} else {
|
} else {
|
||||||
@@ -86,8 +74,7 @@ mod tests {
|
|||||||
assert_eq!(resume_position(None, 1337.5), 1337.5);
|
assert_eq!(resume_position(None, 1337.5), 1337.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The HTML5 path does have an element and its clock is the honest answer
|
/// A caller that does know the position is believed.
|
||||||
/// there, so what the caller supplies wins.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_caller_that_knows_its_position_is_believed() {
|
fn a_caller_that_knows_its_position_is_believed() {
|
||||||
assert_eq!(resume_position(Some(42.0), 1337.5), 42.0);
|
assert_eq!(resume_position(Some(42.0), 1337.5), 42.0);
|
||||||
@@ -123,7 +110,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn a_transcode_is_re_opened_because_it_carries_only_one_track() {
|
fn a_transcode_is_re_opened_because_it_carries_only_one_track() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
determine_audio_track_switch_strategy(true, false),
|
determine_audio_track_switch_strategy(true),
|
||||||
AudioTrackSwitchStrategy::BackendReloadStream
|
AudioTrackSwitchStrategy::BackendReloadStream
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -133,23 +120,8 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn a_direct_play_switches_in_place() {
|
fn a_direct_play_switches_in_place() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
determine_audio_track_switch_strategy(false, false),
|
determine_audio_track_switch_strategy(false),
|
||||||
AudioTrackSwitchStrategy::BackendSelectInPlace
|
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
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +1,18 @@
|
|||||||
//! Webview audio backend — audio-only playback for platforms without a native
|
//! Webview audio backend — audio-only playback for a desktop without a native
|
||||||
//! audio backend (currently Windows).
|
//! audio backend. No shipped platform uses it: Linux and Windows play through
|
||||||
|
//! mpv, Android through ExoPlayer.
|
||||||
//!
|
//!
|
||||||
//! ## Why this exists
|
//! Instead of decoding audio itself, it hands the stream URL to a frontend
|
||||||
//! All *video* already renders through the webview HTML5 `<video>` element on
|
//! `<audio>` element via a `WebviewAudioLoad` event and then drives
|
||||||
//! every platform (see `VideoPlayer.svelte`); libmpv/ExoPlayer only ever drive
|
//! play/pause/seek/stop through `ControlCommand` events. The `<audio>` element
|
||||||
//! *audio-only* (music) playback. On Windows there is no native audio backend,
|
//! reports its real state/position back through the `player_report_*` commands,
|
||||||
//! so `create_player_backend()` used to fall back to `NullBackend` and music was
|
//! so the Rust `PlayerController` remains the single source of truth (the
|
||||||
//! silent.
|
//! controller's `report_html5_*` methods fold those reports into the normal
|
||||||
//!
|
//! event pipeline).
|
||||||
//! 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).
|
|
||||||
//!
|
//!
|
||||||
//! Because the reported state flows through the event pipeline (not through this
|
//! 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'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.
|
//! best-effort local mirror for direct `player_get_state` queries.
|
||||||
//!
|
//!
|
||||||
//! TRACES: UR-003, UR-004, UR-005 | DR-004
|
//! TRACES: UR-003, UR-004, UR-005 | DR-004
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "JellyTau",
|
"productName": "JellyTau",
|
||||||
"version": "0.13.3",
|
"version": "0.14.0",
|
||||||
"identifier": "com.dtourolle.jellytau",
|
"identifier": "com.dtourolle.jellytau",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "bun run dev",
|
"beforeDevCommand": "bun run dev",
|
||||||
@@ -22,8 +22,8 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"security": {
|
"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'",
|
"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 http: https: ws: wss:; worker-src 'self' blob:; object-src 'none'; frame-src 'none'; base-uri 'self'; form-action 'self'",
|
"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": {
|
"assetProtocol": {
|
||||||
"enable": true,
|
"enable": true,
|
||||||
"scope": [
|
"scope": [
|
||||||
|
|||||||
@@ -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
@@ -21,7 +21,7 @@ async playerPlayItem(item: PlayItemRequest) : Promise<PlayerStatus> {
|
|||||||
},
|
},
|
||||||
/**
|
/**
|
||||||
* Exit background-audio mode: stop the native audio player and return its final
|
* 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
|
* Returns the position in seconds. The sleep timer is intentionally left
|
||||||
* untouched — if it fired while backgrounded, playback is already stopped and
|
* 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
|
* `stream_url` MUST be an audio-only URL (see
|
||||||
* `get_audio_only_stream_url_for_video`). The item is created as
|
* `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
|
* `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
|
* backend with `mediaType="audio"`, replacing the video, so exactly one audio
|
||||||
* frontend side, so exactly one audio source is ever active.
|
* source is ever active.
|
||||||
*
|
*
|
||||||
* This deliberately goes through the queue-based `play_item` path (NOT a
|
* 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
|
* 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
|
* - Direct play streams: Use native seeking
|
||||||
* - Transcoded non-HLS: Request new stream URL from server starting at seek position
|
* - 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
|
* The backend always handles the seek itself, including re-opening a stream,
|
||||||
* internally. For HTML5 backends, it returns the new URL for the frontend to handle.
|
* since every video renderer is native (DR-235).
|
||||||
*/
|
*/
|
||||||
async playerSeekVideo(repositoryHandle: string, position: number, mediaSourceId: string | null, audioStreamIndex: number | null, useHtml5: boolean) : Promise<VideoSeekResponse> {
|
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, useHtml5 });
|
return await TAURI_INVOKE("player_seek_video", { repositoryHandle, position, mediaSourceId, audioStreamIndex });
|
||||||
},
|
},
|
||||||
async playerSetVolume(volume: number) : Promise<PlayerStatus> {
|
async playerSetVolume(volume: number) : Promise<PlayerStatus> {
|
||||||
return await TAURI_INVOKE("player_set_volume", { volume });
|
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
|
* carries the requested track at all** — see
|
||||||
* [`determine_audio_track_switch_strategy`]:
|
* [`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
|
* - 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.
|
* every track in it, so ExoPlayer selects in place by track-group index.
|
||||||
* - A native backend playing a **transcode** does not. Jellyfin builds a
|
* - 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
|
* audio track index` and dropped the request — the default track just kept
|
||||||
* playing, with nothing in the UI saying so.
|
* playing, with nothing in the UI saying so.
|
||||||
*
|
*
|
||||||
* libmpv implements neither selection nor reload here — it is the audio-only
|
* mpv selects in place the same way (`mpv_tracks::select_audio`, by position in
|
||||||
* backend and leaves `PlayerBackend::set_audio_track` at its
|
* the file's audio tracks), and re-opens a transcode through the same path.
|
||||||
* `not_implemented()` default, which is why IR-019 is met by these paths
|
|
||||||
* rather than by MPV.
|
|
||||||
*
|
*
|
||||||
* TRACES: UR-021, UR-005 | IR-019, DR-024, DR-258
|
* 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> {
|
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, useHtml5, currentPosition, mediaSourceId });
|
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.
|
* 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
|
* On Android this indexes ExoPlayer's *text track groups* — i.e. the position
|
||||||
* of the sideloaded `MediaItem.SubtitleConfiguration`, not the Jellyfin stream
|
* of the sideloaded `MediaItem.SubtitleConfiguration`, not the Jellyfin stream
|
||||||
* index. The HTML5 path never reaches here; it toggles its own `<track>`
|
* index. mpv gives it the same meaning: the position in the sideloaded WebVTT
|
||||||
* children. libmpv implements neither, leaving the trait default in place.
|
* list, loaded as external subtitle files (`mpv_tracks`).
|
||||||
*
|
*
|
||||||
* TRACES: UR-020 | IR-018, DR-023
|
* 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
|
* 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
|
* 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
|
* 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
|
* the same reload the transcoded-seek and audio-track paths use, done here.
|
||||||
* two-sided split: HTML5 gets the URL back and reloads its own element, while a
|
|
||||||
* native backend is reloaded here.
|
|
||||||
*
|
*
|
||||||
* The change applies to **this playback only**. The in-player picker is a
|
* 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
|
* "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
|
* 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> {
|
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, useHtml5, currentPosition, mediaSourceId, audioStreamIndex });
|
return await TAURI_INVOKE("player_set_stream_quality", { repositoryHandle, quality, currentPosition, mediaSourceId, audioStreamIndex });
|
||||||
},
|
},
|
||||||
/**
|
/**
|
||||||
* Set sleep timer mode
|
* Set sleep timer mode
|
||||||
@@ -347,7 +340,7 @@ async playerPlayNextEpisode(item: PlayItemRequest) : Promise<PlayerStatus> {
|
|||||||
/**
|
/**
|
||||||
* Handle playback ended event - triggers autoplay decision logic
|
* Handle playback ended event - triggers autoplay decision logic
|
||||||
* This is called from:
|
* 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
|
* - Frontend when audio track ends via backend event - no itemId/repositoryHandle needed
|
||||||
* - Android JNI callback also triggers this logic directly
|
* - Android JNI callback also triggers this logic directly
|
||||||
*
|
*
|
||||||
@@ -380,20 +373,20 @@ async playerRecoverStream() : Promise<boolean> {
|
|||||||
return await TAURI_INVOKE("player_recover_stream");
|
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> {
|
async playerReportState(state: string, mediaId: string | null) : Promise<null> {
|
||||||
return await TAURI_INVOKE("player_report_state", { state, mediaId });
|
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.
|
* these to roughly match the native backends' ~250ms cadence.
|
||||||
*/
|
*/
|
||||||
async playerReportPosition(position: number, duration: number) : Promise<null> {
|
async playerReportPosition(position: number, duration: number) : Promise<null> {
|
||||||
return await TAURI_INVOKE("player_report_position", { position, duration });
|
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> {
|
async playerReportMediaLoaded(duration: number) : Promise<null> {
|
||||||
return await TAURI_INVOKE("player_report_media_loaded", { duration });
|
return await TAURI_INVOKE("player_report_media_loaded", { duration });
|
||||||
@@ -2134,11 +2127,7 @@ export type AudioTrackSwitchResponse =
|
|||||||
/**
|
/**
|
||||||
* Native backend handled it (Android ExoPlayer)
|
* Native backend handled it (Android ExoPlayer)
|
||||||
*/
|
*/
|
||||||
{ strategy: "native"; success: boolean } |
|
{ strategy: "native"; success: boolean }
|
||||||
/**
|
|
||||||
* HTML5 needs to reload stream with new audio track
|
|
||||||
*/
|
|
||||||
{ strategy: "reloadStream"; selection: StreamSelection; position: number }
|
|
||||||
/**
|
/**
|
||||||
* Authentication result
|
* Authentication result
|
||||||
*/
|
*/
|
||||||
@@ -2836,9 +2825,8 @@ seriesId?: string | null;
|
|||||||
/**
|
/**
|
||||||
* Subtitle tracks to sideload, with URLs the frontend has already resolved.
|
* Subtitle tracks to sideload, with URLs the frontend has already resolved.
|
||||||
*
|
*
|
||||||
* Only the native backends use these: on Android they become the
|
* On Android they become the `MediaItem.SubtitleConfiguration`s ExoPlayer
|
||||||
* `MediaItem.SubtitleConfiguration`s ExoPlayer renders. The HTML5 path
|
* renders; mpv loads them as external subtitle files (`mpv_tracks`).
|
||||||
* builds its own `<track>` children instead and ignores this list.
|
|
||||||
*
|
*
|
||||||
* **Order is the contract.** `player_set_subtitle_track(n)` reaches
|
* **Order is the contract.** `player_set_subtitle_track(n)` reaches
|
||||||
* `JellyTauPlayer.setSubtitleTrack(n)`, which indexes into ExoPlayer's
|
* `JellyTauPlayer.setSubtitleTrack(n)`, which indexes into ExoPlayer's
|
||||||
@@ -2907,23 +2895,14 @@ startPosition?: number | null }
|
|||||||
export type PlaybackCapabilities = {
|
export type PlaybackCapabilities = {
|
||||||
/**
|
/**
|
||||||
* True when audio is rendered by a webview `<audio>` element rather than a
|
* True when audio is rendered by a webview `<audio>` element rather than a
|
||||||
* native backend. Native audio exists on Linux (mpv) and Android
|
* native backend. Native audio exists on Linux and Windows (mpv) and
|
||||||
* (ExoPlayer); everything else (Windows, future desktops) uses the webview.
|
* 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;
|
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 }
|
|
||||||
/**
|
/**
|
||||||
* Playback information
|
* Playback information
|
||||||
*/
|
*/
|
||||||
@@ -3135,14 +3114,6 @@ export type PlayerState =
|
|||||||
* Response for player state queries
|
* Response for player state queries
|
||||||
*/
|
*/
|
||||||
export type PlayerStatus = { state: PlayerState; position: number; duration: number | null; volume: number; muted: boolean; shuffle: boolean; repeat: RepeatMode;
|
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
|
* 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 } |
|
{ type: "sleep_timer_changed"; mode: SleepTimerMode; remaining_seconds: number } |
|
||||||
/**
|
/**
|
||||||
* Time-based sleep timer expired: playback must stop. The backend stops
|
* Time-based sleep timer expired: playback must stop. The backend stops
|
||||||
* its own (MPV/ExoPlayer) playback, but HTML5 video on Linux plays in the
|
* its own (MPV/ExoPlayer) playback; the frontend pauses the active adapter
|
||||||
* webview outside the backend's control — the frontend pauses it on this
|
* on this event, which reaches a webview `<audio>` element where one plays.
|
||||||
* event.
|
|
||||||
*/
|
*/
|
||||||
{ type: "sleep_timer_expired" } |
|
{ type: "sleep_timer_expired" } |
|
||||||
/**
|
/**
|
||||||
@@ -3245,19 +3215,19 @@ export type PlayerStatusEvent =
|
|||||||
{ type: "remote_disconnect_requested" } |
|
{ type: "remote_disconnect_requested" } |
|
||||||
/**
|
/**
|
||||||
* Backend-originated control command targeting the active frontend player
|
* Backend-originated control command targeting the active frontend player
|
||||||
* adapter (the HTML5 <video> that lives in the webview, which Rust cannot
|
* adapter — the webview `<audio>` element, which Rust cannot drive
|
||||||
* drive directly). Emitted by control paths like the sleep timer, lockscreen,
|
* directly. Emitted by control paths like the sleep timer, lockscreen, or
|
||||||
* or remote so they can pause/play/seek/stop the webview element.
|
* remote so they can pause/play/seek/stop it.
|
||||||
* `playerEvents.ts` routes this to the active PlayerAdapter via the facade.
|
* `playerEvents.ts` routes this to the active PlayerAdapter via the facade.
|
||||||
*/
|
*/
|
||||||
{ type: "control_command"; action: string; position: number | null } |
|
{ type: "control_command"; action: string; position: number | null } |
|
||||||
/**
|
/**
|
||||||
* Ask the frontend webview `<audio>` element to load and play a stream.
|
* Ask the frontend webview `<audio>` element to load and play a stream.
|
||||||
*
|
*
|
||||||
* Emitted by `WebviewAudioBackend` on platforms with no native audio
|
* Emitted by `WebviewAudioBackend` on a desktop with no native audio
|
||||||
* backend (e.g. Windows): audio-only playback is rendered by an `<audio>`
|
* backend (none that ships: Linux and Windows have mpv): audio-only
|
||||||
* element in the webview, mirroring how all video already renders through
|
* playback is rendered by an `<audio>` element in the webview. The element
|
||||||
* the webview `<video>`. The element then reports its state/position back
|
* then reports its state/position back
|
||||||
* through the `player_report_*` commands, so the Rust controller stays the
|
* through the `player_report_*` commands, so the Rust controller stays the
|
||||||
* single source of truth. Subsequent play/pause/seek/stop reach the element
|
* single source of truth. Subsequent play/pause/seek/stop reach the element
|
||||||
* via `ControlCommand`.
|
* via `ControlCommand`.
|
||||||
@@ -3612,11 +3582,7 @@ export type StreamQualityResponse =
|
|||||||
*
|
*
|
||||||
* TRACES: UR-074, UR-079 | DR-226, DR-227
|
* TRACES: UR-074, UR-079 | DR-226, DR-227
|
||||||
*/
|
*/
|
||||||
{ strategy: "native"; selection: StreamSelection; position: number } |
|
{ strategy: "native"; selection: StreamSelection; position: number }
|
||||||
/**
|
|
||||||
* HTML5 must reload its element with this selection.
|
|
||||||
*/
|
|
||||||
{ strategy: "reloadStream"; selection: StreamSelection; position: number }
|
|
||||||
/**
|
/**
|
||||||
* Everything a player backend needs to open a stream, and everything the UI
|
* Everything a player backend needs to open a stream, and everything the UI
|
||||||
* needs to describe it.
|
* needs to describe it.
|
||||||
@@ -3845,18 +3811,6 @@ playbackPositionMs?: number | null; isPlayed?: boolean | null; isFavorite?: bool
|
|||||||
* User info returned to frontend
|
* User info returned to frontend
|
||||||
*/
|
*/
|
||||||
export type UserInfo = { id: string; serverId: string; username: string; isActive: boolean }
|
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
|
* Response for video seek operations
|
||||||
*/
|
*/
|
||||||
@@ -3864,11 +3818,7 @@ export type VideoSeekResponse =
|
|||||||
/**
|
/**
|
||||||
* Use native seeking (HLS or direct stream)
|
* Use native seeking (HLS or direct stream)
|
||||||
*/
|
*/
|
||||||
{ strategy: "native"; position: number } |
|
{ strategy: "native"; position: number }
|
||||||
/**
|
|
||||||
* Reload stream from new position (transcoded non-HLS)
|
|
||||||
*/
|
|
||||||
{ strategy: "reloadStream"; selection: StreamSelection; seek_offset: number }
|
|
||||||
/**
|
/**
|
||||||
* Video playback settings
|
* Video playback settings
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,78 +1,27 @@
|
|||||||
/**
|
/**
|
||||||
* VideoPlayer scrub regression tests (Android backend path)
|
* VideoPlayer scrub regression tests.
|
||||||
*
|
*
|
||||||
* Reproduces the reported bug: with a sleep timer active, scrubbing the
|
* Reproduces the reported bug: with a sleep timer active, scrubbing the
|
||||||
* video seek bar "seeks, then jumps back to the old position".
|
* video seek bar "seeks, then jumps back to the old position".
|
||||||
*
|
*
|
||||||
* Root cause history:
|
* Root cause history: native init called onDestroy() after an await ->
|
||||||
* - Native init called onDestroy() after an await -> lifecycle_outside_component
|
* lifecycle_outside_component -> the catch treated init as failed and silently
|
||||||
* -> the catch treated init as failed and silently flipped useHtml5Element to
|
* switched seeks to the (since deleted) webview `<video>` path while the native
|
||||||
* true, so seeks went down the HTML5 path while ExoPlayer kept playing.
|
* player kept playing.
|
||||||
* - The native SurfaceView has never been visible through the webview, so the
|
|
||||||
* INTERIM behavior (until the video-player API refactor) is: when the backend
|
|
||||||
* reports native mode, VideoPlayer deliberately overrides to HTML5 rendering
|
|
||||||
* and stops the native backend (single audio source, webview owns playback).
|
|
||||||
*
|
*
|
||||||
* These tests pin the interim behavior: Android's native response is
|
* These tests pin that scrubbing reaches the backend and holds its position
|
||||||
* overridden, the backend is stopped exactly once, and scrubbing keeps
|
* with a sleep timer active. Every video renderer is native now (DR-235).
|
||||||
* working (and holds its position) with a sleep timer active.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
|
||||||
// ---- Mocks (must precede component import) --------------------------------
|
// ---- 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", () => ({
|
vi.mock("@tauri-apps/api/core", () => ({
|
||||||
invoke: vi.fn(),
|
invoke: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const playerPlayItem = vi.fn(async () => ({
|
const playerPlayItem = vi.fn(async () => ({
|
||||||
// What Android reports: native ExoPlayer backend
|
|
||||||
useHtml5Element: false,
|
|
||||||
backend: "exoplayer",
|
|
||||||
state: { kind: "playing" },
|
state: { kind: "playing" },
|
||||||
}));
|
}));
|
||||||
const playerSeekVideo = vi.fn(async (_h: string, position: number) => ({
|
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, {
|
const utils = render(VideoPlayer, {
|
||||||
props: {
|
props: {
|
||||||
media: makeEpisode(),
|
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(playerPlayItem).toHaveBeenCalled());
|
||||||
await waitFor(() => expect(playerStop).toHaveBeenCalled());
|
|
||||||
|
|
||||||
const slider = utils.container.querySelector('input[type="range"]') as HTMLInputElement;
|
const slider = utils.container.querySelector('input[type="range"]') as HTMLInputElement;
|
||||||
const video = utils.container.querySelector("video") as HTMLVideoElement;
|
|
||||||
expect(slider).not.toBeNull();
|
expect(slider).not.toBeNull();
|
||||||
expect(video).not.toBeNull();
|
return { ...utils, slider };
|
||||||
return { ...utils, slider, video };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Scrub the seek bar to `target` seconds like a user drag. */
|
/** 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);
|
await fireEvent.mouseDown(slider);
|
||||||
slider.value = String(target);
|
slider.value = String(target);
|
||||||
await fireEvent.input(slider);
|
await fireEvent.input(slider);
|
||||||
await fireEvent.change(slider);
|
await fireEvent.change(slider);
|
||||||
await fireEvent.mouseUp(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();
|
await tick();
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("VideoPlayer scrubbing with active sleep timer (Android)", () => {
|
describe("VideoPlayer scrubbing with active sleep timer", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
for (const key of Object.keys(channelHandlers)) delete channelHandlers[key];
|
|
||||||
sleepTimer.set({ mode: { kind: "off" }, remainingSeconds: 0 });
|
sleepTimer.set({ mode: { kind: "off" }, remainingSeconds: 0 });
|
||||||
sleepTimerExpiredSignal.set(0);
|
sleepTimerExpiredSignal.set(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("overrides the native backend response to HTML5 rendering and stops the backend once", async () => {
|
it("scrubbing without a timer seeks through the backend and keeps the new position", async () => {
|
||||||
await mountAndroidPlayer();
|
const { slider } = await mountPlayer();
|
||||||
// The native backend must be stopped so it doesn't play audio behind the
|
|
||||||
// webview (frozen picture + double audio source).
|
|
||||||
expect(playerStop).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("scrubbing without a timer seeks via the HTML5 path and keeps the new position", async () => {
|
await scrubTo(slider, 600);
|
||||||
const { slider, video } = await mountAndroidPlayer();
|
|
||||||
|
|
||||||
await scrubTo(slider, video, 600);
|
await waitFor(() => expect(playerSeekVideo).toHaveBeenCalledWith("repo-1", 600, "src-1", null));
|
||||||
|
|
||||||
await waitFor(() =>
|
|
||||||
expect(playerSeekVideo).toHaveBeenCalledWith(
|
|
||||||
"repo-1",
|
|
||||||
600,
|
|
||||||
"src-1",
|
|
||||||
null,
|
|
||||||
true, // HTML5 path: the webview owns playback after the override
|
|
||||||
),
|
|
||||||
);
|
|
||||||
expect(parseFloat(slider.value)).toBeCloseTo(600);
|
expect(parseFloat(slider.value)).toBeCloseTo(600);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("scrubbing still works (and holds position) after enabling an episodes sleep timer", async () => {
|
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.
|
// Enable "2 more episodes" timer; backend then ticks every second.
|
||||||
sleepTimerTick(2);
|
sleepTimerTick(2);
|
||||||
@@ -239,7 +166,7 @@ describe("VideoPlayer scrubbing with active sleep timer (Android)", () => {
|
|||||||
sleepTimerTick(2);
|
sleepTimerTick(2);
|
||||||
await tick();
|
await tick();
|
||||||
|
|
||||||
await scrubTo(slider, video, 600);
|
await scrubTo(slider, 600);
|
||||||
await waitFor(() => expect(playerSeekVideo).toHaveBeenCalledTimes(1));
|
await waitFor(() => expect(playerSeekVideo).toHaveBeenCalledTimes(1));
|
||||||
expect(parseFloat(slider.value)).toBeCloseTo(600);
|
expect(parseFloat(slider.value)).toBeCloseTo(600);
|
||||||
|
|
||||||
@@ -249,13 +176,13 @@ describe("VideoPlayer scrubbing with active sleep timer (Android)", () => {
|
|||||||
expect(parseFloat(slider.value)).toBeCloseTo(600);
|
expect(parseFloat(slider.value)).toBeCloseTo(600);
|
||||||
|
|
||||||
// A second scrub must also work.
|
// A second scrub must also work.
|
||||||
await scrubTo(slider, video, 900);
|
await scrubTo(slider, 900);
|
||||||
await waitFor(() => expect(playerSeekVideo).toHaveBeenCalledTimes(2));
|
await waitFor(() => expect(playerSeekVideo).toHaveBeenCalledTimes(2));
|
||||||
expect(parseFloat(slider.value)).toBeCloseTo(900);
|
expect(parseFloat(slider.value)).toBeCloseTo(900);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("sleep-timer ticks alone never move the seek bar", async () => {
|
it("sleep-timer ticks alone never move the seek bar", async () => {
|
||||||
const { slider } = await mountAndroidPlayer();
|
const { slider } = await mountPlayer();
|
||||||
|
|
||||||
const before = slider.value;
|
const before = slider.value;
|
||||||
for (let i = 0; i < 5; i++) {
|
for (let i = 0; i < 5; i++) {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -21,8 +21,9 @@
|
|||||||
* rather than internals.
|
* rather than internals.
|
||||||
*
|
*
|
||||||
* The specific traps encoded here, each a bug that shipped:
|
* The specific traps encoded here, each a bug that shipped:
|
||||||
* - pausing renders a full-screen <button> play overlay OVER the video, so the
|
* - pausing renders a full-screen <button> play overlay OVER the video
|
||||||
* second tap of a double tap lands on a button, not 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
|
* - the browser synthesizes a `click` after a touch tap, which must not toggle
|
||||||
* a second time, on ANY layered target;
|
* a second time, on ANY layered target;
|
||||||
* - the bottom controls bar must drive its own buttons and NOT the container's
|
* - 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 { invoke } from "@tauri-apps/api/core";
|
||||||
import VideoPlayer from "./VideoPlayer.svelte";
|
import VideoPlayer from "./VideoPlayer.svelte";
|
||||||
import { SEEK_FORWARD_SECONDS } from "./tapGestures";
|
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
|
* 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", () => ({
|
vi.mock("$lib/utils/pictureInPicture", () => ({
|
||||||
isPipSupported: () => false,
|
isPipSupported: () => false,
|
||||||
enterPip: vi.fn(),
|
enterPip: vi.fn(),
|
||||||
setAutoEnterEnabled: vi.fn(),
|
setAutoEnterEnabled: vi.fn(),
|
||||||
setHtml5VideoState: vi.fn(),
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("$lib/stores/auth", () => ({
|
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)", () => {
|
describe("VideoPlayer tap surface (real component)", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
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
|
// This file deliberately does NOT mock `$lib/api/bindings` — it renders the
|
||||||
// real component against the real bindings, which bottom out in the globally
|
// real component against the real bindings, which bottom out in the globally
|
||||||
// mocked `invoke`. That mock resolves `undefined` for every command, so the
|
// 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 () => {
|
it("a single tap on the video toggles play/pause exactly once", async () => {
|
||||||
const { container } = renderPlayer();
|
const { container } = renderPlayer();
|
||||||
const video = container.querySelector("video");
|
await tick();
|
||||||
expect(video).toBeTruthy();
|
|
||||||
|
|
||||||
touchAt(video!, 900);
|
touchAt(videoSurface(container), 900);
|
||||||
|
|
||||||
expect(toggleSpy).toHaveBeenCalledTimes(1);
|
expect(toggleSpy).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("the synthesized click after a tap does not toggle a second time", async () => {
|
it("the synthesized click after a tap does not toggle a second time", async () => {
|
||||||
const { container } = renderPlayer();
|
const { container } = renderPlayer();
|
||||||
const video = container.querySelector("video")!;
|
await tick();
|
||||||
|
const video = videoSurface(container);
|
||||||
|
|
||||||
touchAt(video, 900);
|
touchAt(video, 900);
|
||||||
// The compatibility click the browser fires after a touch tap. detail=0 is
|
// 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.
|
// guard that does not know about that overlay discards it and seeking dies.
|
||||||
//
|
//
|
||||||
// Reproducing it requires the overlay to actually render, which means
|
// 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();
|
vi.useFakeTimers();
|
||||||
try {
|
try {
|
||||||
const { container } = renderPlayer();
|
const { container } = renderPlayer();
|
||||||
const video = container.querySelector("video")!;
|
await tick();
|
||||||
|
|
||||||
// Tap 1 on the video.
|
// Tap 1 on the video surface.
|
||||||
touchAt(video, 900);
|
touchAt(videoSurface(container), 900);
|
||||||
|
|
||||||
// The element reports it paused → isPlaying=false → overlay renders.
|
// The player reports it paused → isPlaying=false → overlay renders.
|
||||||
video.dispatchEvent(new Event("pause"));
|
player.setPaused(MEDIA, 0, 600);
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
await tick();
|
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();
|
expect(overlay, "the play overlay should be covering the video").toBeTruthy();
|
||||||
|
|
||||||
vi.advanceTimersByTime(120); // inside DOUBLE_TAP_WINDOW_MS
|
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) --------------------------------
|
// ---- 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", () => ({
|
vi.mock("@tauri-apps/api/core", () => ({
|
||||||
invoke: vi.fn(),
|
invoke: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const playerPlayItem = vi.fn(async () => ({
|
const playerPlayItem = vi.fn(async () => ({
|
||||||
useHtml5Element: false,
|
|
||||||
backend: "exoplayer",
|
|
||||||
state: { kind: "playing" },
|
state: { kind: "playing" },
|
||||||
}));
|
}));
|
||||||
const playerSeekVideo = vi.fn(async (_h: string, position: number) => ({
|
const playerSeekVideo = vi.fn(async (_h: string, position: number) => ({
|
||||||
@@ -166,12 +121,10 @@ async function mountAndroidPlayer() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await waitFor(() => expect(playerPlayItem).toHaveBeenCalled());
|
await waitFor(() => expect(playerPlayItem).toHaveBeenCalled());
|
||||||
await waitFor(() => expect(playerStop).toHaveBeenCalled());
|
|
||||||
|
|
||||||
const slider = utils.container.querySelector('input[type="range"]') as HTMLInputElement;
|
const slider = utils.container.querySelector('input[type="range"]') as HTMLInputElement;
|
||||||
const video = utils.container.querySelector("video") as HTMLVideoElement;
|
|
||||||
expect(slider).not.toBeNull();
|
expect(slider).not.toBeNull();
|
||||||
return { ...utils, slider, video };
|
return { ...utils, slider };
|
||||||
}
|
}
|
||||||
|
|
||||||
function touch(x: number, y: number) {
|
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
|
* 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.
|
* 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)] });
|
await fireEvent.touchStart(slider, { touches: [touch(100, 700)] });
|
||||||
// Finger travels across the bar. Small vertical wander is normal for a thumb
|
// Finger travels across the bar. Small vertical wander is normal for a thumb
|
||||||
// drag; the horizontal travel is what matters.
|
// 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.touchMove(slider, { touches: [touch(700, 705)] });
|
||||||
await fireEvent.change(slider);
|
await fireEvent.change(slider);
|
||||||
await fireEvent.touchEnd(slider, { touches: [] });
|
await fireEvent.touchEnd(slider, { touches: [] });
|
||||||
if (video) await fireEvent(video, new Event("seeked"));
|
|
||||||
await tick();
|
await tick();
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("VideoPlayer seek bar — touch drag (Android)", () => {
|
describe("VideoPlayer seek bar — touch drag (Android)", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
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 () => {
|
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(() =>
|
await waitFor(() => expect(playerSeekVideo).toHaveBeenCalledWith("repo-1", 600, "src-1", null));
|
||||||
expect(playerSeekVideo).toHaveBeenCalledWith("repo-1", 600, "src-1", null, true),
|
|
||||||
);
|
|
||||||
expect(parseFloat(slider.value)).toBeCloseTo(600);
|
expect(parseFloat(slider.value)).toBeCloseTo(600);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("a touch drag on the seek bar never toggles play/pause", async () => {
|
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:
|
// The container gesture layer must stay out of a control drag entirely:
|
||||||
// no swipe mis-read, so no play/pause correction.
|
// 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 () => {
|
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
|
// 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
|
// 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";
|
slider.value = "600";
|
||||||
await fireEvent.input(slider);
|
await fireEvent.input(slider);
|
||||||
await fireEvent.touchEnd(slider, { touches: [] });
|
await fireEvent.touchEnd(slider, { touches: [] });
|
||||||
if (video) await fireEvent(video, new Event("seeked"));
|
|
||||||
await tick();
|
await tick();
|
||||||
|
|
||||||
await waitFor(() =>
|
await waitFor(() => expect(playerSeekVideo).toHaveBeenCalledWith("repo-1", 600, "src-1", null));
|
||||||
expect(playerSeekVideo).toHaveBeenCalledWith("repo-1", 600, "src-1", null, true),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("commits the seek exactly once when both touchend and change fire", async () => {
|
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);
|
expect(playerSeekVideo).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("a touch drag on the seek bar does not hijack into brightness control", async () => {
|
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);
|
// Mid-drag, with the finger still down: a mis-read swipe raises the
|
||||||
|
// brightness indicator for as long as the swipe lasts.
|
||||||
// Brightness is applied as a CSS filter on the <video>; a control drag must
|
await fireEvent.touchStart(slider, { touches: [touch(100, 700)] });
|
||||||
// leave it untouched.
|
await fireEvent.touchMove(slider, { touches: [touch(400, 600)] });
|
||||||
const el = container.querySelector("video") as HTMLVideoElement | null;
|
await fireEvent.touchMove(slider, { touches: [touch(700, 500)] });
|
||||||
if (el) {
|
await tick();
|
||||||
expect(el.style.filter).toBe("brightness(1)");
|
expect(container.textContent).not.toContain("Brightness");
|
||||||
}
|
await fireEvent.touchEnd(slider, { touches: [] });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -97,9 +97,8 @@ describe("backgroundAudioHandoff", () => {
|
|||||||
//
|
//
|
||||||
// TRACES: UR-040, UR-003 | DR-196
|
// TRACES: UR-040, UR-003 | DR-196
|
||||||
describe("planHandoffReturn", () => {
|
describe("planHandoffReturn", () => {
|
||||||
it("restarts the native backend when the native path is rendering", () => {
|
it("restarts the native backend", () => {
|
||||||
const plan = planHandoffReturn({
|
const plan = planHandoffReturn({
|
||||||
useHtml5Element: false,
|
|
||||||
position: 4214,
|
position: 4214,
|
||||||
wasPlaying: true,
|
wasPlaying: true,
|
||||||
nativeStateKind: "playing",
|
nativeStateKind: "playing",
|
||||||
@@ -109,19 +108,8 @@ describe("backgroundAudioHandoff", () => {
|
|||||||
expect(plan.shouldPlay).toBe(true);
|
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", () => {
|
it("honours a lockscreen pause over the handoff snapshot", () => {
|
||||||
const plan = planHandoffReturn({
|
const plan = planHandoffReturn({
|
||||||
useHtml5Element: false,
|
|
||||||
position: 300,
|
position: 300,
|
||||||
wasPlaying: true,
|
wasPlaying: true,
|
||||||
nativeStateKind: "paused",
|
nativeStateKind: "paused",
|
||||||
@@ -131,7 +119,6 @@ describe("backgroundAudioHandoff", () => {
|
|||||||
|
|
||||||
it("never returns a negative resume position", () => {
|
it("never returns a negative resume position", () => {
|
||||||
const plan = planHandoffReturn({
|
const plan = planHandoffReturn({
|
||||||
useHtml5Element: false,
|
|
||||||
position: -3,
|
position: -3,
|
||||||
wasPlaying: false,
|
wasPlaying: false,
|
||||||
nativeStateKind: undefined,
|
nativeStateKind: undefined,
|
||||||
@@ -146,7 +133,6 @@ describe("backgroundAudioHandoff", () => {
|
|||||||
// TRACES: UR-040, UR-023 | DR-296 | UT-265
|
// TRACES: UR-040, UR-023 | DR-296 | UT-265
|
||||||
it("switches to the item the backend advanced to", () => {
|
it("switches to the item the backend advanced to", () => {
|
||||||
const plan = planHandoffReturn({
|
const plan = planHandoffReturn({
|
||||||
useHtml5Element: false,
|
|
||||||
position: 95,
|
position: 95,
|
||||||
wasPlaying: true,
|
wasPlaying: true,
|
||||||
nativeStateKind: "playing",
|
nativeStateKind: "playing",
|
||||||
@@ -160,21 +146,19 @@ describe("backgroundAudioHandoff", () => {
|
|||||||
|
|
||||||
it("reloads in place when the backend is still on the mounted item", () => {
|
it("reloads in place when the backend is still on the mounted item", () => {
|
||||||
const plan = planHandoffReturn({
|
const plan = planHandoffReturn({
|
||||||
useHtml5Element: true,
|
|
||||||
position: 95,
|
position: 95,
|
||||||
wasPlaying: true,
|
wasPlaying: true,
|
||||||
nativeStateKind: "playing",
|
nativeStateKind: "playing",
|
||||||
mountedItemId: "ep1",
|
mountedItemId: "ep1",
|
||||||
resumeItemId: "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", () => {
|
it("reloads in place when the backend reports no item", () => {
|
||||||
// Queue emptied while backgrounded (e.g. the sleep timer): there is no
|
// 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.
|
// other item to go to, so the mounted one is the best we have.
|
||||||
const plan = planHandoffReturn({
|
const plan = planHandoffReturn({
|
||||||
useHtml5Element: false,
|
|
||||||
position: 95,
|
position: 95,
|
||||||
wasPlaying: false,
|
wasPlaying: false,
|
||||||
nativeStateKind: undefined,
|
nativeStateKind: undefined,
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ export interface HandoffReturn {
|
|||||||
* no longer on the item this player was mounted with, so the player must
|
* no longer on the item this player was mounted with, so the player must
|
||||||
* switch to `itemId` instead of reloading itself.
|
* 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`. */
|
/** The item to switch to; set only for `other-item`. */
|
||||||
itemId?: string;
|
itemId?: string;
|
||||||
/** Absolute position the background audio reached. */
|
/** Absolute position the background audio reached. */
|
||||||
@@ -94,19 +94,14 @@ export interface HandoffReturn {
|
|||||||
/**
|
/**
|
||||||
* How to come back when the app returns to the foreground.
|
* How to come back when the app returns to the foreground.
|
||||||
*
|
*
|
||||||
* The two render paths resume by completely different means, and conflating
|
* The native backend owns no element, and nothing reacts to the stream URL on
|
||||||
* them is what broke the native one:
|
* 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
|
* The component once did only the URL assignment — which is how the deleted
|
||||||
* component watches it, (re)initialises HLS or sets `videoElement.src`, and
|
* webview `<video>` path came back. On the native path that left the backend
|
||||||
* `canplay` then drives the seek and play.
|
* holding no item at all: a black screen with
|
||||||
* - **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
|
|
||||||
* a play overlay, a play button that did nothing, and the position pinned at
|
* 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.
|
* 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
|
* TRACES: UR-040, UR-003, UR-023 | DR-196, DR-296 | UT-060, UT-265
|
||||||
*/
|
*/
|
||||||
export function planHandoffReturn(opts: {
|
export function planHandoffReturn(opts: {
|
||||||
useHtml5Element: boolean;
|
|
||||||
position: number;
|
position: number;
|
||||||
wasPlaying: boolean;
|
wasPlaying: boolean;
|
||||||
nativeStateKind: string | undefined;
|
nativeStateKind: string | undefined;
|
||||||
@@ -134,11 +128,7 @@ export function planHandoffReturn(opts: {
|
|||||||
if (opts.resumeItemId && opts.resumeItemId !== opts.mountedItemId) {
|
if (opts.resumeItemId && opts.resumeItemId !== opts.mountedItemId) {
|
||||||
return { target: "other-item", itemId: opts.resumeItemId, position, shouldPlay };
|
return { target: "other-item", itemId: opts.resumeItemId, position, shouldPlay };
|
||||||
}
|
}
|
||||||
return {
|
return { target: "native-backend", position, shouldPlay };
|
||||||
target: opts.useHtml5Element ? "html5-element" : "native-backend",
|
|
||||||
position,
|
|
||||||
shouldPlay,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -2,14 +2,9 @@ import { describe, it, expect } from "vitest";
|
|||||||
import { planFullscreen } from "./fullscreenTarget";
|
import { planFullscreen } from "./fullscreenTarget";
|
||||||
|
|
||||||
describe("planFullscreen", () => {
|
describe("planFullscreen", () => {
|
||||||
it("fullscreens only the document when an in-document <video> renders", () => {
|
it("fullscreens the OS window as well as the document", () => {
|
||||||
// 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", () => {
|
|
||||||
// The picture is drawn behind the webview at window size, so a
|
// The picture is drawn behind the webview at window size, so a
|
||||||
// document-only fullscreen leaves it at the old size.
|
// 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
|
* Every video renderer is a native surface since the webview `<video>` path was
|
||||||
* picture rather than an in-document `<video>` element.
|
* deleted (DR-235), so the OS window always has to move with the document.
|
||||||
*/
|
*/
|
||||||
export function planFullscreen(rendersNatively: boolean): FullscreenPlan {
|
export function planFullscreen(): FullscreenPlan {
|
||||||
return { document: true, osWindow: rendersNatively };
|
return { document: true, osWindow: true };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,64 +0,0 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
|
||||||
import { fatalNetworkErrorAction } from "./hlsRecovery";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A fatal hls.js network error mid-film must be retried, not reported as the
|
|
||||||
* end of the stream — reporting "ended" hands control to autoplay and skips to
|
|
||||||
* the next item while the user is still watching this one.
|
|
||||||
*
|
|
||||||
* The position the player displays is *already absolute*: the RAF loop sets
|
|
||||||
* `currentTime = seekOffset + element.currentTime`. Anything that adds the
|
|
||||||
* offset a second time doubles the apparent position, and after a quality
|
|
||||||
* switch or a transcoded seek the offset is the whole resume position — so past
|
|
||||||
* roughly the halfway mark the doubled value clears the near-end threshold and
|
|
||||||
* every transient error is misread as the end.
|
|
||||||
*
|
|
||||||
* TRACES: UR-004, UR-074 | DR-177 | UT-174
|
|
||||||
*/
|
|
||||||
describe("fatalNetworkErrorAction", () => {
|
|
||||||
it("retries a mid-film failure after a quality switch instead of ending playback", () => {
|
|
||||||
// 90-minute film, quality switched at the 50-minute mark: the reloaded
|
|
||||||
// stream's timeline starts at 0, so seekOffset carries the 50 minutes and
|
|
||||||
// the displayed position — already absolute — is 3000s of 5400s, 56%
|
|
||||||
// through and nowhere near the end.
|
|
||||||
const action = fatalNetworkErrorAction({
|
|
||||||
positionSeconds: 3000,
|
|
||||||
knownDurationSeconds: 5400,
|
|
||||||
attempts: 1,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(action).toBe("retry");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("treats a failure in the last tenth of the stream as the end", () => {
|
|
||||||
// Jellyfin's transcoded HLS does not always emit #EXT-X-ENDLIST, so a
|
|
||||||
// genuine end-of-stream arrives as a fatal network error.
|
|
||||||
const action = fatalNetworkErrorAction({
|
|
||||||
positionSeconds: 5300,
|
|
||||||
knownDurationSeconds: 5400,
|
|
||||||
attempts: 1,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(action).toBe("ended");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("stops retrying once the recovery budget is spent", () => {
|
|
||||||
const action = fatalNetworkErrorAction({
|
|
||||||
positionSeconds: 60,
|
|
||||||
knownDurationSeconds: 5400,
|
|
||||||
attempts: 4,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(action).toBe("giveUp");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("retries when the runtime is not known yet", () => {
|
|
||||||
const action = fatalNetworkErrorAction({
|
|
||||||
positionSeconds: 120,
|
|
||||||
knownDurationSeconds: 0,
|
|
||||||
attempts: 1,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(action).toBe("retry");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
/**
|
|
||||||
* What to do about a *fatal* hls.js network error.
|
|
||||||
*
|
|
||||||
* Jellyfin's transcoded HLS streams do not always terminate with an
|
|
||||||
* `#EXT-X-ENDLIST`, so a stream that has simply run out looks identical to one
|
|
||||||
* that broke: both arrive as a fatal network error. The only thing separating
|
|
||||||
* them is how far playback had got, which is why this decision is worth
|
|
||||||
* isolating from the player component — read the position wrong and a
|
|
||||||
* recoverable stall turns into a skip to the next item.
|
|
||||||
*
|
|
||||||
* TRACES: UR-004, UR-074 | DR-177 | UT-174
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** Fraction of the runtime past which a fatal error reads as "the stream ended". */
|
|
||||||
const NEAR_END_FRACTION = 0.9;
|
|
||||||
|
|
||||||
/** How many times to ask hls.js to resume before giving up on the stream. */
|
|
||||||
export const MAX_FATAL_NETWORK_RECOVERIES = 3;
|
|
||||||
|
|
||||||
export type FatalNetworkErrorAction = "ended" | "retry" | "giveUp";
|
|
||||||
|
|
||||||
export interface FatalNetworkErrorInput {
|
|
||||||
/**
|
|
||||||
* Absolute position in the media, in seconds — the value the player displays.
|
|
||||||
*
|
|
||||||
* It is already absolute (`seekOffset + element.currentTime`): do NOT add the
|
|
||||||
* transcode seek offset again. After a quality switch or a transcoded seek the
|
|
||||||
* offset *is* the resume position, so double-counting it puts an apparent
|
|
||||||
* position past the near-end threshold from roughly halfway through, and every
|
|
||||||
* transient error then ends playback.
|
|
||||||
*/
|
|
||||||
positionSeconds: number;
|
|
||||||
/** Known runtime in seconds; 0 or negative when the runtime isn't known yet. */
|
|
||||||
knownDurationSeconds: number;
|
|
||||||
/** Recovery attempts already made against this hls.js instance. */
|
|
||||||
attempts: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Whether a failure at this position should be read as the stream ending. */
|
|
||||||
export function isNearEndOfStream(positionSeconds: number, knownDurationSeconds: number): boolean {
|
|
||||||
if (knownDurationSeconds <= 0 || positionSeconds <= 0) return false;
|
|
||||||
return positionSeconds / knownDurationSeconds > NEAR_END_FRACTION;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function fatalNetworkErrorAction({
|
|
||||||
positionSeconds,
|
|
||||||
knownDurationSeconds,
|
|
||||||
attempts,
|
|
||||||
}: FatalNetworkErrorInput): FatalNetworkErrorAction {
|
|
||||||
if (isNearEndOfStream(positionSeconds, knownDurationSeconds)) return "ended";
|
|
||||||
return attempts <= MAX_FATAL_NETWORK_RECOVERIES ? "retry" : "giveUp";
|
|
||||||
}
|
|
||||||
@@ -5,30 +5,19 @@ import {
|
|||||||
subtitleStreamsOf,
|
subtitleStreamsOf,
|
||||||
subtitleTrackLabel,
|
subtitleTrackLabel,
|
||||||
resolveSubtitleTracks,
|
resolveSubtitleTracks,
|
||||||
reconcileSelectedSubtitle,
|
|
||||||
videoCrossOriginMode,
|
|
||||||
nativeSubtitleTracks,
|
nativeSubtitleTracks,
|
||||||
nativeSubtitleArrayIndex,
|
nativeSubtitleArrayIndex,
|
||||||
type SubtitleStreamLike,
|
type SubtitleStreamLike,
|
||||||
} from "./subtitleTracks";
|
} 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
|
* TRACES: UR-020 | DR-023 | UT-143, UT-144
|
||||||
*
|
*
|
||||||
* The bug this guards: VideoPlayer rendered no `<track>` children at all (the
|
* URLs are resolved into plain strings before they reach the player: the
|
||||||
* block was commented out "to debug playback issues"), so
|
* original markup bound the *Promise* returned by an async function to a
|
||||||
* `Html5PlayerAdapter.selectSubtitle()` walked an empty `textTracks` list and
|
* `<track src>`, so every track's src stringified to "[object Promise]".
|
||||||
* 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).
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const SUBS: SubtitleStreamLike[] = [
|
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.
|
* 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
|
* 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
|
* to actually be handed to `playerPlayItem`, and the index sent to the backend
|
||||||
|
|||||||
@@ -125,72 +125,13 @@ export async function resolveSubtitleTracks(
|
|||||||
return resolved.filter((t): t is RenderableSubtitleTrack => t !== null);
|
return resolved.filter((t): t is RenderableSubtitleTrack => t !== null);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// ===== The native player =====================================================
|
||||||
* 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 HTML5 element gets `<track>` children; the native backend instead gets the
|
// The native backend gets the list *up front*, as part of the play request:
|
||||||
// list *up front*, as part of the play request, because ExoPlayer sideloads
|
// ExoPlayer sideloads subtitles as `MediaItem.SubtitleConfiguration`s that must
|
||||||
// subtitles as `MediaItem.SubtitleConfiguration`s that must exist before
|
// exist before `prepare()`, and mpv queues them as external files for the load.
|
||||||
// `prepare()`. There is no "add a subtitle later" — a track absent from the
|
// There is no "add a subtitle later" — a track absent from the request simply
|
||||||
// MediaItem simply does not exist as far as the player is concerned.
|
// does not exist as far as the player is concerned.
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Map resolved tracks onto the wire shape `PlayItemRequest.subtitles` carries.
|
* Map resolved tracks onto the wire shape `PlayItemRequest.subtitles` carries.
|
||||||
|
|||||||
@@ -1,45 +0,0 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
|
||||||
import { shouldApplyTimeUpdate } from "./timeTracking";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* TRACES: UT-245 | DR-265
|
|
||||||
*/
|
|
||||||
describe("shouldApplyTimeUpdate", () => {
|
|
||||||
const base = { isPlaying: false, isSeeking: false, isDraggingSeekBar: false, readyState: 4 };
|
|
||||||
|
|
||||||
it("applies the update while the video is PLAYING", () => {
|
|
||||||
// THE REPORTED BUG. `timeupdate` was the only position source that still
|
|
||||||
// fires once requestAnimationFrame stops -- which is exactly what happens
|
|
||||||
// when the activity is paused behind a picture-in-picture window. Gating it
|
|
||||||
// on `!isPlaying` disabled it precisely when it was the only thing left,
|
|
||||||
// so the component's `currentTime` froze at the moment PiP was entered
|
|
||||||
// while the element played on. The background-audio handoff then resumed
|
|
||||||
// the audio-only stream at that frozen position.
|
|
||||||
expect(shouldApplyTimeUpdate({ ...base, isPlaying: true })).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("still applies the update while paused", () => {
|
|
||||||
// The case it always handled: RAF is stopped, timeupdate carries the seek.
|
|
||||||
expect(shouldApplyTimeUpdate(base)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("yields to an in-flight seek", () => {
|
|
||||||
// A seek owns the position until it settles; a stale element read landing
|
|
||||||
// mid-seek is what makes a scrubbed video snap back.
|
|
||||||
expect(shouldApplyTimeUpdate({ ...base, isSeeking: true })).toBe(false);
|
|
||||||
expect(shouldApplyTimeUpdate({ ...base, isPlaying: true, isSeeking: true })).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("yields while the user is dragging the seek bar", () => {
|
|
||||||
expect(shouldApplyTimeUpdate({ ...base, isDraggingSeekBar: true })).toBe(false);
|
|
||||||
expect(shouldApplyTimeUpdate({ ...base, isPlaying: true, isDraggingSeekBar: true })).toBe(
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("ignores an element with no usable data yet", () => {
|
|
||||||
// readyState < HAVE_CURRENT_DATA reads 0, which would rewind the position.
|
|
||||||
expect(shouldApplyTimeUpdate({ ...base, readyState: 1 })).toBe(false);
|
|
||||||
expect(shouldApplyTimeUpdate({ ...base, isPlaying: true, readyState: 0 })).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
/**
|
|
||||||
* Pure helpers for keeping the player's position variable honest.
|
|
||||||
*
|
|
||||||
* TRACES: UR-004, UR-041 | DR-265 | UT-245
|
|
||||||
*
|
|
||||||
* `VideoPlayer.svelte` tracks the absolute playback position in its own
|
|
||||||
* `currentTime` variable rather than reading `videoElement.currentTime` at the
|
|
||||||
* point of use — transcoded HLS resets the element to 0 on every segment
|
|
||||||
* rebuild, so only the component's running total is meaningful. Everything
|
|
||||||
* downstream reads that variable: the seek bar, the progress reports, the
|
|
||||||
* position mirrored into Rust, and the background-audio handoff.
|
|
||||||
*
|
|
||||||
* Which makes "who is allowed to write it" a correctness question, not a
|
|
||||||
* rendering detail — hence a pure module with tests rather than a condition
|
|
||||||
* buried in an event handler.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface TimeUpdateGate {
|
|
||||||
/**
|
|
||||||
* Deliberately does NOT gate the update, and is accepted only to say so.
|
|
||||||
*
|
|
||||||
* `timeupdate` was written as a fallback "for when RAF isn't running" and so
|
|
||||||
* excluded itself whenever `isPlaying` was true. But RAF is driven by the
|
|
||||||
* document being rendered, and an Android activity behind a picture-in-picture
|
|
||||||
* window is paused: the loop stops while the element plays on, and the one
|
|
||||||
* remaining position source had switched itself off. Both writing the same
|
|
||||||
* derived value costs nothing — the element is the authority either way.
|
|
||||||
*/
|
|
||||||
isPlaying?: boolean;
|
|
||||||
isSeeking: boolean;
|
|
||||||
isDraggingSeekBar: boolean;
|
|
||||||
readyState: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether a `timeupdate` event may write the component's position.
|
|
||||||
*
|
|
||||||
* Kept free of Svelte/DOM so the rule is unit-testable without mounting the
|
|
||||||
* player.
|
|
||||||
*/
|
|
||||||
export function shouldApplyTimeUpdate(opts: TimeUpdateGate): boolean {
|
|
||||||
// An in-flight seek or a drag owns the position until it settles, and an
|
|
||||||
// element with no current data reads 0, which would rewind it.
|
|
||||||
return !opts.isSeeking && !opts.isDraggingSeekBar && opts.readyState >= 2;
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
|
||||||
import { videoFitClass } from "./videoFit";
|
|
||||||
|
|
||||||
describe("videoFitClass", () => {
|
|
||||||
it("fills the container instead of capping at the source's intrinsic size", () => {
|
|
||||||
const cls = videoFitClass();
|
|
||||||
// max-w/max-h only shrink oversized media; a 480p source would stay a small
|
|
||||||
// box in the middle of a large window.
|
|
||||||
expect(cls).not.toContain("max-w-full");
|
|
||||||
expect(cls).not.toContain("max-h-full");
|
|
||||||
expect(cls).toContain("w-full");
|
|
||||||
expect(cls).toContain("h-full");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("preserves aspect ratio while fitting (letterbox, never crop)", () => {
|
|
||||||
const cls = videoFitClass();
|
|
||||||
expect(cls).toContain("object-contain");
|
|
||||||
expect(cls).not.toContain("object-cover");
|
|
||||||
expect(cls).not.toContain("object-fill");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
// Sizing rules for the HTML5 <video> element in the full-screen player.
|
|
||||||
// Extracted from VideoPlayer.svelte so the fit behaviour is unit-testable.
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Classes applied to the <video> element so it fits the player viewport.
|
|
||||||
*
|
|
||||||
* TRACES: UR-005
|
|
||||||
*
|
|
||||||
* `max-w-full max-h-full` only ever *shrinks* oversized media, so a source
|
|
||||||
* smaller than the window (e.g. 480p on a 1080p display) rendered at its
|
|
||||||
* intrinsic size - a small box in the middle of a black screen. Filling the
|
|
||||||
* container and letting `object-contain` do the scaling fits the picture to
|
|
||||||
* whichever axis constrains it, in both directions, preserving aspect ratio.
|
|
||||||
*/
|
|
||||||
export function videoFitClass(): string {
|
|
||||||
return "w-full h-full object-contain";
|
|
||||||
}
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
/**
|
|
||||||
* Adapter-selection regression guards.
|
|
||||||
*
|
|
||||||
* TRACES: UR-003, UR-004 | DR-150 | UT-149
|
|
||||||
*
|
|
||||||
* The selection rule has two inputs and one hard safety property:
|
|
||||||
*
|
|
||||||
* - Rust says which backend the platform has (`backendKind`).
|
|
||||||
* - The user opts in with `experimentalNativeVideo`.
|
|
||||||
* - **The flag off must force HTML5 even when Rust says native.** That is the
|
|
||||||
* regression guard: a broken spike must not be able to ship as the default.
|
|
||||||
*
|
|
||||||
* These are pure functions, so the whole matrix is testable without a device.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { createAdapter } from "./index";
|
|
||||||
import { Html5PlayerAdapter } from "./html5Adapter";
|
|
||||||
import { NativePlayerAdapter } from "./nativeAdapter";
|
|
||||||
import type { AdapterHost } from "./types";
|
|
||||||
|
|
||||||
const host: AdapterHost = {
|
|
||||||
reportState: () => {},
|
|
||||||
reportPosition: () => {},
|
|
||||||
reportEnded: () => {},
|
|
||||||
} as unknown as AdapterHost;
|
|
||||||
|
|
||||||
const bridge = {
|
|
||||||
getElement: () => null,
|
|
||||||
} as any;
|
|
||||||
|
|
||||||
describe("createAdapter", () => {
|
|
||||||
it("returns the native adapter when Rust says native and the flag is on", () => {
|
|
||||||
const adapter = createAdapter({
|
|
||||||
backendKind: "native",
|
|
||||||
host,
|
|
||||||
bridge,
|
|
||||||
experimentalNativeVideo: true,
|
|
||||||
});
|
|
||||||
expect(adapter).toBeInstanceOf(NativePlayerAdapter);
|
|
||||||
expect(adapter.kind).toBe("native");
|
|
||||||
});
|
|
||||||
|
|
||||||
// The regression guard: the flag is a suppressor, so off must beat Rust.
|
|
||||||
it("forces HTML5 when the flag is off even though Rust says native", () => {
|
|
||||||
const adapter = createAdapter({
|
|
||||||
backendKind: "native",
|
|
||||||
host,
|
|
||||||
bridge,
|
|
||||||
experimentalNativeVideo: false,
|
|
||||||
});
|
|
||||||
expect(adapter).toBeInstanceOf(Html5PlayerAdapter);
|
|
||||||
expect(adapter.kind).toBe("html5");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns the HTML5 adapter when Rust says html5 and the flag is off", () => {
|
|
||||||
const adapter = createAdapter({
|
|
||||||
backendKind: "html5",
|
|
||||||
host,
|
|
||||||
bridge,
|
|
||||||
experimentalNativeVideo: false,
|
|
||||||
});
|
|
||||||
expect(adapter).toBeInstanceOf(Html5PlayerAdapter);
|
|
||||||
});
|
|
||||||
|
|
||||||
// The flag must never *promote* a platform Rust said has no native backend
|
|
||||||
// (e.g. Linux, where WebKitGTK cannot composite a surface behind the webview).
|
|
||||||
it("stays on HTML5 when Rust says html5 even with the flag on", () => {
|
|
||||||
const adapter = createAdapter({
|
|
||||||
backendKind: "html5",
|
|
||||||
host,
|
|
||||||
bridge,
|
|
||||||
experimentalNativeVideo: true,
|
|
||||||
});
|
|
||||||
expect(adapter).toBeInstanceOf(Html5PlayerAdapter);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("defaults to HTML5 when the flag is omitted entirely", () => {
|
|
||||||
const adapter = createAdapter({ backendKind: "native", host, bridge });
|
|
||||||
expect(adapter).toBeInstanceOf(Html5PlayerAdapter);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("requires a bridge for the HTML5 adapter", () => {
|
|
||||||
expect(() =>
|
|
||||||
createAdapter({ backendKind: "html5", host, experimentalNativeVideo: false }),
|
|
||||||
).toThrow(/bridge/i);
|
|
||||||
});
|
|
||||||
|
|
||||||
// The native adapter owns no DOM element, so it must not demand a bridge.
|
|
||||||
it("does not require a bridge for the native adapter", () => {
|
|
||||||
expect(() =>
|
|
||||||
createAdapter({ backendKind: "native", host, experimentalNativeVideo: true }),
|
|
||||||
).not.toThrow();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,340 +0,0 @@
|
|||||||
/**
|
|
||||||
* Unit tests for Html5PlayerAdapter.
|
|
||||||
*
|
|
||||||
* The Option-1 primitive design makes the adapter pure, decision-free mechanics
|
|
||||||
* — it takes a mock <video> element + bridge + host, so we can assert each
|
|
||||||
* primitive drives the element correctly without any real DOM or backend.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
||||||
import { Html5PlayerAdapter, type Html5ElementBridge } from "./html5Adapter";
|
|
||||||
import type { AdapterHost } from "./types";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A `StreamSelection` for tests that only care about the URL. Transcoded HLS is
|
|
||||||
* what these paths exercised before the contract carried a transport.
|
|
||||||
*/
|
|
||||||
function testSelection(url: string, transport: "hls" | "progressive" | "localFile" = "hls") {
|
|
||||||
return {
|
|
||||||
url,
|
|
||||||
transport: { type: transport },
|
|
||||||
playbackKind: { type: transport === "hls" ? "transcode" : "directPlay" },
|
|
||||||
rendition: null,
|
|
||||||
available: [],
|
|
||||||
mediaSourceId: null,
|
|
||||||
playSessionId: null,
|
|
||||||
needsTranscoding: transport === "hls",
|
|
||||||
} as import("$lib/api/bindings").StreamSelection;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A minimal fake <video> element that records mutations and fires events. */
|
|
||||||
function makeFakeVideo() {
|
|
||||||
const listeners: Record<string, Array<() => void>> = {};
|
|
||||||
const el: any = {
|
|
||||||
paused: true,
|
|
||||||
currentTime: 0,
|
|
||||||
volume: 1,
|
|
||||||
muted: false,
|
|
||||||
src: "blob:existing",
|
|
||||||
play: vi.fn(async () => {
|
|
||||||
el.paused = false;
|
|
||||||
}),
|
|
||||||
pause: vi.fn(() => {
|
|
||||||
el.paused = true;
|
|
||||||
}),
|
|
||||||
load: vi.fn(),
|
|
||||||
removeAttribute: vi.fn((attr: string) => {
|
|
||||||
if (attr === "src") el.src = "";
|
|
||||||
}),
|
|
||||||
addEventListener: (event: string, cb: () => void) => {
|
|
||||||
(listeners[event] ??= []).push(cb);
|
|
||||||
},
|
|
||||||
removeEventListener: (event: string, cb: () => void) => {
|
|
||||||
listeners[event] = (listeners[event] ?? []).filter((f) => f !== cb);
|
|
||||||
},
|
|
||||||
// Test helper: fire an event so waitForEvent resolves immediately.
|
|
||||||
_fire: (event: string) => {
|
|
||||||
(listeners[event] ?? []).slice().forEach((f) => f());
|
|
||||||
},
|
|
||||||
querySelectorAll: () => [] as any,
|
|
||||||
textTracks: [] as any,
|
|
||||||
};
|
|
||||||
return el;
|
|
||||||
}
|
|
||||||
type FakeVideo = ReturnType<typeof makeFakeVideo>;
|
|
||||||
|
|
||||||
function makeBridge(overrides: Partial<Html5ElementBridge> = {}): Html5ElementBridge {
|
|
||||||
let offset = 0;
|
|
||||||
return {
|
|
||||||
getElement: () => null,
|
|
||||||
getSeekOffset: () => offset,
|
|
||||||
setSeekOffset: vi.fn((o: number) => {
|
|
||||||
offset = o;
|
|
||||||
}),
|
|
||||||
setStreamSelection: vi.fn(),
|
|
||||||
destroyHls: vi.fn(),
|
|
||||||
getMediaSourceId: () => "msid-1",
|
|
||||||
...overrides,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeHost(): AdapterHost {
|
|
||||||
return {
|
|
||||||
onState: vi.fn(),
|
|
||||||
onPosition: vi.fn(),
|
|
||||||
onMediaLoaded: vi.fn(),
|
|
||||||
onEnded: vi.fn(),
|
|
||||||
onError: vi.fn(),
|
|
||||||
onStreamUrlChanged: vi.fn(),
|
|
||||||
onBuffering: vi.fn(),
|
|
||||||
onReady: vi.fn(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("Html5PlayerAdapter", () => {
|
|
||||||
let host: AdapterHost;
|
|
||||||
let bridge: Html5ElementBridge;
|
|
||||||
let adapter: Html5PlayerAdapter;
|
|
||||||
let video: ReturnType<typeof makeFakeVideo>;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
host = makeHost();
|
|
||||||
bridge = makeBridge();
|
|
||||||
adapter = new Html5PlayerAdapter(host, bridge);
|
|
||||||
video = makeFakeVideo();
|
|
||||||
adapter.attach(video);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("is an html5-kind adapter", () => {
|
|
||||||
expect(adapter.kind).toBe("html5");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("play() calls element.play()", async () => {
|
|
||||||
await adapter.play();
|
|
||||||
expect(video.play).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
// A stalling HLS stream makes hls.js' gap-controller nudge the element, which
|
|
||||||
// aborts an in-flight play(). That AbortError is transient — the element is
|
|
||||||
// still trying to play — so it must not be surfaced as a player error, or the
|
|
||||||
// UI reports failure ~once a second for the whole stall.
|
|
||||||
it("play() does not report an interrupted-by-pause AbortError as an error", async () => {
|
|
||||||
const abort = new DOMException(
|
|
||||||
"The play() request was interrupted by a call to pause().",
|
|
||||||
"AbortError",
|
|
||||||
);
|
|
||||||
video.play = vi.fn(async () => {
|
|
||||||
throw abort;
|
|
||||||
});
|
|
||||||
|
|
||||||
await adapter.play();
|
|
||||||
|
|
||||||
expect(host.onError).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("play() still reports a genuine failure", async () => {
|
|
||||||
video.play = vi.fn(async () => {
|
|
||||||
throw new DOMException("no supported source", "NotSupportedError");
|
|
||||||
});
|
|
||||||
|
|
||||||
await adapter.play();
|
|
||||||
|
|
||||||
expect(host.onError).toHaveBeenCalledTimes(1);
|
|
||||||
expect(String((host.onError as any).mock.calls[0][0])).toContain("play() failed");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("play() coalesces concurrent attempts into one element.play() call", async () => {
|
|
||||||
// During a stall the UI and recovery paths can both ask to play. Stacking
|
|
||||||
// element.play() calls is what generates the AbortError storm.
|
|
||||||
let resolvePlay: () => void = () => {};
|
|
||||||
video.play = vi.fn(
|
|
||||||
() =>
|
|
||||||
new Promise<void>((r) => {
|
|
||||||
resolvePlay = () => {
|
|
||||||
video.paused = false;
|
|
||||||
r();
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const first = adapter.play();
|
|
||||||
const second = adapter.play();
|
|
||||||
resolvePlay();
|
|
||||||
await Promise.all([first, second]);
|
|
||||||
|
|
||||||
expect(video.play).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("play() works again after a previous attempt settled", async () => {
|
|
||||||
await adapter.play();
|
|
||||||
await adapter.play();
|
|
||||||
expect(video.play).toHaveBeenCalledTimes(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("pause() calls element.pause()", async () => {
|
|
||||||
video.paused = false;
|
|
||||||
await adapter.pause();
|
|
||||||
expect(video.pause).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("toggle() plays when paused and reports the resulting state", async () => {
|
|
||||||
video.paused = true;
|
|
||||||
const playing = await adapter.toggle();
|
|
||||||
expect(video.play).toHaveBeenCalled();
|
|
||||||
expect(playing).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("toggle() pauses when playing", async () => {
|
|
||||||
video.paused = false;
|
|
||||||
const playing = await adapter.toggle();
|
|
||||||
expect(video.pause).toHaveBeenCalled();
|
|
||||||
expect(playing).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("seekElement() sets currentTime, offset, and waits for 'seeked'", async () => {
|
|
||||||
const p = adapter.seekElement(42, 0);
|
|
||||||
expect(video.currentTime).toBe(42);
|
|
||||||
expect(bridge.setSeekOffset).toHaveBeenCalledWith(0);
|
|
||||||
video._fire("seeked"); // resolve the wait
|
|
||||||
await p;
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reloadSource() runs the invariant teardown->swap->resume sequence", async () => {
|
|
||||||
video.paused = false; // was playing → should resume
|
|
||||||
const p = adapter.reloadSource(testSelection("http://new/master.m3u8"), 120);
|
|
||||||
|
|
||||||
// Teardown happened synchronously before the awaited canplay wait.
|
|
||||||
expect(video.pause).toHaveBeenCalled();
|
|
||||||
expect(bridge.destroyHls).toHaveBeenCalledTimes(1);
|
|
||||||
expect(video.removeAttribute).toHaveBeenCalledWith("src");
|
|
||||||
expect(video.load).toHaveBeenCalled();
|
|
||||||
|
|
||||||
// Allow the internal 100ms settle delay, then fire canplay to resume.
|
|
||||||
await new Promise((r) => setTimeout(r, 110));
|
|
||||||
expect(bridge.setStreamSelection).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({ url: "http://new/master.m3u8", transport: { type: "hls" } }),
|
|
||||||
);
|
|
||||||
video._fire("canplay");
|
|
||||||
video._fire("seeked");
|
|
||||||
await p;
|
|
||||||
expect(video.play).toHaveBeenCalled(); // resumed because it was playing
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The reload lands the viewer at the position they asked for — by *seeking*,
|
|
||||||
* with no transcode offset left over.
|
|
||||||
*
|
|
||||||
* This used to be inverted: the offset was set to the position and nothing
|
|
||||||
* seeked, which was right only while the reloaded URL itself began there via
|
|
||||||
* `StartTimeTicks`. DR-181 removes that parameter, because on an HLS playlist
|
|
||||||
* the server copies it onto every segment URI and then rejects each one with
|
|
||||||
* `400`. With the URL starting at the item's zero, the old arithmetic leaves
|
|
||||||
* `currentTime = offset + 0` — the scrubber reading 20:00 over the opening
|
|
||||||
* titles, and the seek silently never happening.
|
|
||||||
*
|
|
||||||
* TRACES: UR-004, UR-005 | DR-181 | UT-183
|
|
||||||
*/
|
|
||||||
it("reloadSource() seeks to the position and clears the transcode offset", async () => {
|
|
||||||
video.paused = false;
|
|
||||||
const p = adapter.reloadSource(testSelection("http://new/master.m3u8"), 1200);
|
|
||||||
|
|
||||||
await new Promise((r) => setTimeout(r, 110));
|
|
||||||
expect(bridge.setSeekOffset).toHaveBeenCalledWith(0);
|
|
||||||
expect(bridge.setSeekOffset).not.toHaveBeenCalledWith(1200);
|
|
||||||
|
|
||||||
// Nothing may seek before the new source is playable — the element drops it.
|
|
||||||
expect(video.currentTime).not.toBe(1200);
|
|
||||||
|
|
||||||
video._fire("canplay");
|
|
||||||
await new Promise((r) => setTimeout(r, 0));
|
|
||||||
expect(video.currentTime).toBe(1200);
|
|
||||||
|
|
||||||
video._fire("seeked");
|
|
||||||
await p;
|
|
||||||
expect(video.play).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
/** A reload to the very start has nothing to seek to; it must not stall. */
|
|
||||||
it("reloadSource() at position 0 does not wait for a seek", async () => {
|
|
||||||
video.paused = false;
|
|
||||||
const p = adapter.reloadSource(testSelection("http://new/master.m3u8"), 0);
|
|
||||||
await new Promise((r) => setTimeout(r, 110));
|
|
||||||
video._fire("canplay");
|
|
||||||
await p; // resolves without any "seeked" event
|
|
||||||
expect(video.play).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A reload that never becomes playable must be reported as a failure. It used
|
|
||||||
* to resolve on the timeout, so a quality switch whose new stream the server
|
|
||||||
* refused to serve (Jellyfin 400s the first segment when two transcode jobs
|
|
||||||
* collide) looked like a success: the picker showed the new quality selected
|
|
||||||
* over a stream that never played, and the caller had nothing to revert to.
|
|
||||||
*
|
|
||||||
* TRACES: UR-074 | DR-177 | UT-175
|
|
||||||
*/
|
|
||||||
it("reloadSource() rejects when the new stream never becomes playable", async () => {
|
|
||||||
vi.useFakeTimers();
|
|
||||||
try {
|
|
||||||
video.paused = false;
|
|
||||||
const p = adapter.reloadSource(testSelection("http://new/master.m3u8"), 120);
|
|
||||||
const assertion = expect(p).rejects.toThrow(/canplay/i);
|
|
||||||
await vi.advanceTimersByTimeAsync(11_000); // past the 10s readiness budget
|
|
||||||
await assertion;
|
|
||||||
expect(video.play).not.toHaveBeenCalled(); // nothing to resume into
|
|
||||||
} finally {
|
|
||||||
vi.useRealTimers();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reloadSource() does not resume when it was paused", async () => {
|
|
||||||
video.paused = true;
|
|
||||||
const p = adapter.reloadSource(testSelection("http://new/master.m3u8"), 30);
|
|
||||||
await new Promise((r) => setTimeout(r, 110));
|
|
||||||
video._fire("canplay");
|
|
||||||
video._fire("seeked");
|
|
||||||
await p;
|
|
||||||
expect(video.play).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("setVolume() clamps to 0..1", () => {
|
|
||||||
adapter.setVolume(1.5);
|
|
||||||
expect(video.volume).toBe(1);
|
|
||||||
adapter.setVolume(-0.5);
|
|
||||||
expect(video.volume).toBe(0);
|
|
||||||
adapter.setVolume(0.4);
|
|
||||||
expect(video.volume).toBeCloseTo(0.4);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("setMuted() sets the element muted flag", () => {
|
|
||||||
adapter.setMuted(true);
|
|
||||||
expect(video.muted).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("getPosition() returns element time plus the transcode offset", () => {
|
|
||||||
video.currentTime = 10;
|
|
||||||
(bridge.getSeekOffset as any) = () => 100;
|
|
||||||
// Rebuild adapter with the offset-returning bridge.
|
|
||||||
const a = new Html5PlayerAdapter(host, bridge);
|
|
||||||
a.attach(video);
|
|
||||||
expect(a.getPosition()).toBe(110);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("dispose() tears down hls and clears the element", async () => {
|
|
||||||
await adapter.dispose();
|
|
||||||
expect(bridge.destroyHls).toHaveBeenCalled();
|
|
||||||
expect(video.pause).toHaveBeenCalled();
|
|
||||||
// After dispose, primitives are no-ops (element detached).
|
|
||||||
await adapter.play();
|
|
||||||
// play was called once during dispose teardown? no — play only on reload/resume.
|
|
||||||
expect(video.play).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("primitives are safe no-ops before an element is attached", async () => {
|
|
||||||
const bare = new Html5PlayerAdapter(host, bridge);
|
|
||||||
await expect(bare.play()).resolves.toBeUndefined();
|
|
||||||
await expect(bare.pause()).resolves.toBeUndefined();
|
|
||||||
await expect(bare.seekElement(5, 0)).resolves.toBeUndefined();
|
|
||||||
expect(await bare.toggle()).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,317 +0,0 @@
|
|||||||
import type { StreamSelection } from "$lib/api/bindings";
|
|
||||||
/**
|
|
||||||
* Html5PlayerAdapter — the Linux/desktop (and interim Android) PlayerAdapter
|
|
||||||
* implementation. It owns the high-level control surface for an HTML5 `<video>`
|
|
||||||
* element and reports the element's lifecycle back into Rust via its
|
|
||||||
* {@link AdapterHost}.
|
|
||||||
*
|
|
||||||
* Design note on the split with VideoPlayer.svelte:
|
|
||||||
* The delicate, timing-sensitive parts (hls.js instance lifecycle, the transcode
|
|
||||||
* "reload stream" seek/audio-track dance with its dual-audio teardown and
|
|
||||||
* canplay waits) are inherently coupled to Svelte reactive state and the DOM
|
|
||||||
* element. Rather than relocate that reactive machinery wholesale (high
|
|
||||||
* regression risk), the adapter receives an {@link Html5ElementBridge} of narrow
|
|
||||||
* callbacks the owning component supplies. The adapter is the single OWNER of the
|
|
||||||
* control contract (play/pause/seek/track/volume) and of reporting; the bridge is
|
|
||||||
* the seam to the component's element/HLS/reactive state. This keeps all control
|
|
||||||
* intents flowing through the PlayerAdapter interface while preserving the
|
|
||||||
* hard-won element behavior verbatim.
|
|
||||||
*
|
|
||||||
* TRACES: UR-003, UR-005, UR-020, UR-021 | DR-001, DR-023, DR-024, DR-028, DR-096
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { AdapterHost, PlayerAdapter, PlayerLoadOptions } from "./types";
|
|
||||||
import { createLogger } from "$lib/utils/logger";
|
|
||||||
|
|
||||||
const log = createLogger("Html5PlayerAdapter");
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The selection for a plain `load(url)` call.
|
|
||||||
*
|
|
||||||
* `PlayerLoadOptions` carries the backend's selection when the caller has one.
|
|
||||||
* When it does not — a local file, a live stream, a direct URL — the transport
|
|
||||||
* is inferred *once, here*, from what the caller already knows rather than from
|
|
||||||
* the URL text: a local path is a local file, and anything the backend flagged
|
|
||||||
* as transcoded is HLS, because every transcode this app requests is HLS.
|
|
||||||
*
|
|
||||||
* This is the one place a fallback is tolerable, and it is explicitly a
|
|
||||||
* fallback: the negotiated path never reaches it.
|
|
||||||
*
|
|
||||||
* TRACES: UR-079 | DR-225
|
|
||||||
*/
|
|
||||||
function selectionForLoad(streamUrl: string, options: PlayerLoadOptions): StreamSelection {
|
|
||||||
if (options.selection) return options.selection;
|
|
||||||
const transport: StreamSelection["transport"] = options.isLocalFile
|
|
||||||
? { type: "localFile" }
|
|
||||||
: options.needsTranscoding
|
|
||||||
? { type: "hls" }
|
|
||||||
: { type: "progressive" };
|
|
||||||
return {
|
|
||||||
url: streamUrl,
|
|
||||||
transport,
|
|
||||||
playbackKind: options.needsTranscoding ? { type: "transcode" } : { type: "directPlay" },
|
|
||||||
rendition: null,
|
|
||||||
available: [],
|
|
||||||
mediaSourceId: options.mediaSourceId ?? null,
|
|
||||||
playSessionId: null,
|
|
||||||
needsTranscoding: options.needsTranscoding,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Narrow seam the owning component provides so the adapter can execute the
|
|
||||||
* element/HLS-coupled parts of a control action without re-implementing the
|
|
||||||
* component's reactive HLS lifecycle. Every function here is a thin wrapper over
|
|
||||||
* work the component already does.
|
|
||||||
*/
|
|
||||||
export interface Html5ElementBridge {
|
|
||||||
/** The bound <video> element, or null before mount / after teardown. */
|
|
||||||
getElement(): HTMLVideoElement | null;
|
|
||||||
/** Current seek offset (seconds) for transcoded streams. */
|
|
||||||
getSeekOffset(): number;
|
|
||||||
setSeekOffset(offset: number): void;
|
|
||||||
/**
|
|
||||||
* Update the stream the component renders (triggers its HLS $effect).
|
|
||||||
*
|
|
||||||
* Carries the whole [`StreamSelection`], not just the URL: the component's
|
|
||||||
* effect has to know the transport to choose a loader, and deriving that from
|
|
||||||
* the URL is the substring check DR-225 removes.
|
|
||||||
*
|
|
||||||
* TRACES: UR-079 | DR-225
|
|
||||||
*/
|
|
||||||
setStreamSelection(selection: StreamSelection): void;
|
|
||||||
/** Tear down the component-owned hls.js instance (dual-audio prevention). */
|
|
||||||
destroyHls(): void;
|
|
||||||
/** Media source id for seek/audio-track URLs. */
|
|
||||||
getMediaSourceId(): string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* True for the `AbortError` the browser raises when a pending `play()` promise is
|
|
||||||
* cancelled by a `pause()` (or a source/seek change). It signals "that specific
|
|
||||||
* play attempt was superseded", not "playback failed" — hls.js' stall recovery
|
|
||||||
* produces it routinely, so it must not reach the player's error channel.
|
|
||||||
*/
|
|
||||||
function isPlayInterruptedError(err: unknown): boolean {
|
|
||||||
if (!err || typeof err !== "object") return false;
|
|
||||||
const { name, message } = err as { name?: string; message?: string };
|
|
||||||
return name === "AbortError" || (message ?? "").includes("interrupted");
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Html5PlayerAdapter implements PlayerAdapter {
|
|
||||||
readonly kind = "html5" as const;
|
|
||||||
|
|
||||||
private attachedElement: HTMLVideoElement | null = null;
|
|
||||||
/** In-flight play() attempt, so concurrent callers share one element.play(). */
|
|
||||||
private pendingPlay: Promise<void> | null = null;
|
|
||||||
private host: AdapterHost;
|
|
||||||
private bridge: Html5ElementBridge;
|
|
||||||
|
|
||||||
constructor(host: AdapterHost, bridge: Html5ElementBridge) {
|
|
||||||
this.host = host;
|
|
||||||
this.bridge = bridge;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve the LIVE <video> element. The bridge's `getElement()` returns the
|
|
||||||
* component's current reactive `videoElement`, which is authoritative: the
|
|
||||||
* element can be re-bound when the {#if} block re-renders, so a value captured
|
|
||||||
* once in `attach()` may go stale (this caused play/pause to silently no-op).
|
|
||||||
* Falls back to the attach()-captured element for unit tests whose bridge
|
|
||||||
* returns null.
|
|
||||||
*/
|
|
||||||
private get element(): HTMLVideoElement | null {
|
|
||||||
return this.bridge.getElement() ?? this.attachedElement;
|
|
||||||
}
|
|
||||||
|
|
||||||
attach(element: HTMLVideoElement | null): void {
|
|
||||||
this.attachedElement = element;
|
|
||||||
}
|
|
||||||
|
|
||||||
async load(streamUrl: string, options: PlayerLoadOptions): Promise<void> {
|
|
||||||
// The component's reactive HLS $effect performs the actual attach/load when
|
|
||||||
// the selection is set; loading is therefore driven by setStreamSelection.
|
|
||||||
// The component's canplay/frag-buffered path reports readiness through the
|
|
||||||
// host.
|
|
||||||
this.bridge.setSeekOffset(0);
|
|
||||||
this.bridge.setStreamSelection(selectionForLoad(streamUrl, options));
|
|
||||||
this.host.onState("loading");
|
|
||||||
}
|
|
||||||
|
|
||||||
async play(): Promise<void> {
|
|
||||||
const el = this.element;
|
|
||||||
if (!el) return;
|
|
||||||
// Coalesce concurrent attempts. While an HLS stream stalls, the UI and the
|
|
||||||
// gap-controller recovery path can both ask to play; stacking element.play()
|
|
||||||
// calls is what turns one stall into an AbortError storm.
|
|
||||||
if (this.pendingPlay) return this.pendingPlay;
|
|
||||||
|
|
||||||
this.pendingPlay = (async () => {
|
|
||||||
try {
|
|
||||||
await el.play();
|
|
||||||
// handlePlay on the element reports "playing"; no double-report here.
|
|
||||||
} catch (err) {
|
|
||||||
// A play() aborted by a pause() is transient, not a failure: hls.js
|
|
||||||
// nudges the element to recover from a stall, which cancels the pending
|
|
||||||
// play promise while the element keeps trying. Surfacing it would report
|
|
||||||
// an error roughly once a second for the duration of the stall.
|
|
||||||
if (isPlayInterruptedError(err)) {
|
|
||||||
log.debug("play() interrupted by pause (stall recovery)");
|
|
||||||
} else {
|
|
||||||
this.host.onError(`play() failed: ${err}`);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
this.pendingPlay = null;
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
return this.pendingPlay;
|
|
||||||
}
|
|
||||||
|
|
||||||
async pause(): Promise<void> {
|
|
||||||
this.element?.pause();
|
|
||||||
}
|
|
||||||
|
|
||||||
async toggle(): Promise<boolean> {
|
|
||||||
const el = this.element;
|
|
||||||
if (!el) return false;
|
|
||||||
if (el.paused) {
|
|
||||||
await this.play();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
await this.pause();
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* PRIMITIVE: in-place element seek (no reload). The backend already decided
|
|
||||||
* this seek does not need a transcode reload.
|
|
||||||
*/
|
|
||||||
async seekElement(positionSeconds: number, offset: number): Promise<void> {
|
|
||||||
const el = this.element;
|
|
||||||
if (!el) return;
|
|
||||||
el.currentTime = positionSeconds;
|
|
||||||
this.bridge.setSeekOffset(offset);
|
|
||||||
await this.waitForEvent(el, "seeked", 2000);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* PRIMITIVE: compound reload — swap the source and resume at
|
|
||||||
* `positionSeconds`, an **absolute** position on the item's own timeline.
|
|
||||||
* Contains NO strategy decision; the backend already decided to reload and
|
|
||||||
* supplied the url/position. Preserves the hard-won dual-audio teardown and
|
|
||||||
* canplay wait.
|
|
||||||
*
|
|
||||||
* The position is reached by *seeking the element*, and the transcode offset
|
|
||||||
* is cleared to zero. It used to be the other way round — the offset was set
|
|
||||||
* to the position and nothing seeked — which was correct only while the
|
|
||||||
* reloaded URL itself began there, via `StartTimeTicks`. DR-181 removes that
|
|
||||||
* parameter (on an HLS playlist it makes the server reject every segment with
|
|
||||||
* `400`), so a reloaded stream now always starts at the beginning of the item.
|
|
||||||
* Leaving the old arithmetic in place would have left `currentTime` reading
|
|
||||||
* `offset + 0` — the scrubber showing 20:00 while the opening titles play, and
|
|
||||||
* no seek ever happening.
|
|
||||||
*
|
|
||||||
* TRACES: UR-004, UR-005 | DR-181 | UT-183
|
|
||||||
*/
|
|
||||||
async reloadSource(selection: StreamSelection, positionSeconds: number): Promise<void> {
|
|
||||||
const el = this.element;
|
|
||||||
if (!el) {
|
|
||||||
// Still update the selection so the component's HLS $effect can pick it up.
|
|
||||||
this.bridge.setSeekOffset(0);
|
|
||||||
this.bridge.setStreamSelection(selection);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const wasPlaying = !el.paused;
|
|
||||||
el.pause();
|
|
||||||
this.bridge.destroyHls();
|
|
||||||
if (el.src) {
|
|
||||||
el.removeAttribute("src");
|
|
||||||
el.load();
|
|
||||||
}
|
|
||||||
await new Promise((r) => setTimeout(r, 100));
|
|
||||||
// The reloaded stream begins at the item's zero, so there is no base to add.
|
|
||||||
this.bridge.setSeekOffset(0);
|
|
||||||
this.bridge.setStreamSelection(selection);
|
|
||||||
// A source that never becomes playable is a failed reload, not a slow one:
|
|
||||||
// the caller (quality switch, transcoded seek) has to know so it can revert
|
|
||||||
// its selection and surface the error instead of leaving the UI claiming a
|
|
||||||
// stream that is not playing.
|
|
||||||
const ready = await this.waitForEvent(el, "canplay", 10000);
|
|
||||||
if (!ready) {
|
|
||||||
throw new Error(`Reloaded stream never fired "canplay" within 10000ms`);
|
|
||||||
}
|
|
||||||
// Now that the new source is playable, put it where the caller asked for.
|
|
||||||
// Seeking before `canplay` is dropped by the element, which is why this
|
|
||||||
// follows the wait rather than riding along with the URL swap.
|
|
||||||
if (positionSeconds > 0) {
|
|
||||||
el.currentTime = positionSeconds;
|
|
||||||
await this.waitForEvent(el, "seeked", 2000);
|
|
||||||
}
|
|
||||||
if (wasPlaying) await el.play();
|
|
||||||
}
|
|
||||||
|
|
||||||
setVolume(volume: number): void {
|
|
||||||
if (this.element) this.element.volume = Math.max(0, Math.min(1, volume));
|
|
||||||
}
|
|
||||||
|
|
||||||
setMuted(muted: boolean): void {
|
|
||||||
if (this.element) this.element.muted = muted;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Subtitle selection: HTML5 toggles textTracks on the element directly. */
|
|
||||||
async selectSubtitle(streamIndex: number | null, _arrayIndex?: number): Promise<void> {
|
|
||||||
const el = this.element;
|
|
||||||
if (!el || !el.textTracks) return;
|
|
||||||
for (let i = 0; i < el.textTracks.length; i++) {
|
|
||||||
el.textTracks[i].mode = "disabled";
|
|
||||||
}
|
|
||||||
if (streamIndex !== null) {
|
|
||||||
const tracks = el.querySelectorAll("track");
|
|
||||||
tracks.forEach((track) => {
|
|
||||||
const idx = parseInt(track.getAttribute("data-stream-index") || "-1");
|
|
||||||
if (idx === streamIndex && track.track) {
|
|
||||||
track.track.mode = "showing";
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
getPosition(): number {
|
|
||||||
const el = this.element;
|
|
||||||
if (!el) return 0;
|
|
||||||
return el.currentTime + this.bridge.getSeekOffset();
|
|
||||||
}
|
|
||||||
|
|
||||||
async dispose(): Promise<void> {
|
|
||||||
this.bridge.destroyHls();
|
|
||||||
const el = this.element;
|
|
||||||
if (el) {
|
|
||||||
el.pause();
|
|
||||||
el.removeAttribute("src");
|
|
||||||
el.load();
|
|
||||||
}
|
|
||||||
this.attachedElement = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Resolve when `event` fires on `el`, or after `timeoutMs` as a fallback. */
|
|
||||||
/**
|
|
||||||
* Resolves `true` when the event fires, `false` if the budget runs out. The
|
|
||||||
* distinction is the caller's to act on: a missing `seeked` is cosmetic, a
|
|
||||||
* missing `canplay` means the reload failed.
|
|
||||||
*/
|
|
||||||
private waitForEvent(el: HTMLVideoElement, event: string, timeoutMs: number): Promise<boolean> {
|
|
||||||
return new Promise<boolean>((resolve) => {
|
|
||||||
const done = (fired: boolean) => {
|
|
||||||
el.removeEventListener(event, listener);
|
|
||||||
clearTimeout(timer);
|
|
||||||
resolve(fired);
|
|
||||||
};
|
|
||||||
const listener = () => done(true);
|
|
||||||
el.addEventListener(event, listener);
|
|
||||||
// `done` closes over `timer`, but can only run once the listener fires or
|
|
||||||
// the timeout elapses — both strictly after this assignment.
|
|
||||||
const timer: ReturnType<typeof setTimeout> = setTimeout(() => done(false), timeoutMs);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,73 +1,14 @@
|
|||||||
/**
|
/**
|
||||||
* Player adapter factory + public exports.
|
* Player adapter public exports.
|
||||||
*
|
*
|
||||||
* `createAdapter` selects the concrete PlayerAdapter for the current platform.
|
* Video is always drawn by a native player — mpv on the desktop, ExoPlayer on
|
||||||
* Rust decides *which backend this platform has* (`useHtml5Element` from
|
* Android — behind the transparent webview, so there is one video adapter,
|
||||||
* `player_play_item`); this factory consumes that decision rather than
|
* `NativePlayerAdapter`. The webview `<video>` adapter and the factory that
|
||||||
* re-deriving it.
|
* 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
|
* TRACES: UR-003, UR-004 | DR-004, DR-235
|
||||||
* 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
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
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 { PlayerAdapter, AdapterHost, PlayerLoadOptions, SubtitleTrackInput } from "./types";
|
||||||
export type { Html5ElementBridge } from "./html5Adapter";
|
|
||||||
export { Html5PlayerAdapter } from "./html5Adapter";
|
|
||||||
export { NativePlayerAdapter } from "./nativeAdapter";
|
export { NativePlayerAdapter } from "./nativeAdapter";
|
||||||
|
|
||||||
/** What the Rust `player_play_item` response says it chose. */
|
|
||||||
export type BackendKind = "html5" | "native";
|
|
||||||
|
|
||||||
export interface CreateAdapterArgs {
|
|
||||||
/** Backend kind reported by `player_play_item` (`useHtml5Element`). */
|
|
||||||
backendKind: BackendKind;
|
|
||||||
host: AdapterHost;
|
|
||||||
/** Required for the HTML5 adapter; ignored by the native adapter. */
|
|
||||||
bridge?: Html5ElementBridge;
|
|
||||||
/**
|
|
||||||
* User opt-in for the native video path. Defaults to **off**, so omitting it
|
|
||||||
* yields today's behaviour (HTML5 everywhere) rather than silently enabling
|
|
||||||
* the spike.
|
|
||||||
*/
|
|
||||||
experimentalNativeVideo?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build the adapter for this platform/stream.
|
|
||||||
*
|
|
||||||
* Native is chosen only when Rust reports a native backend AND the user has
|
|
||||||
* opted in. Every other combination is HTML5.
|
|
||||||
*/
|
|
||||||
export function createAdapter({
|
|
||||||
backendKind,
|
|
||||||
host,
|
|
||||||
bridge,
|
|
||||||
experimentalNativeVideo = false,
|
|
||||||
}: CreateAdapterArgs): PlayerAdapter {
|
|
||||||
const effectiveKind: BackendKind =
|
|
||||||
backendKind === "native" && experimentalNativeVideo ? "native" : "html5";
|
|
||||||
|
|
||||||
if (effectiveKind === "native") {
|
|
||||||
// The native surface is owned by the backend — no DOM element, no bridge.
|
|
||||||
return new NativePlayerAdapter(host);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!bridge) {
|
|
||||||
throw new Error("createAdapter: Html5ElementBridge is required for the HTML5 adapter");
|
|
||||||
}
|
|
||||||
return new Html5PlayerAdapter(host, bridge);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,29 +1,19 @@
|
|||||||
import type { StreamSelection } from "$lib/api/bindings";
|
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
|
* The native player (mpv on the desktop, ExoPlayer on Android) is driven
|
||||||
* PlayerStatusEvents and handles seek/audio-track internally. So this adapter is
|
* entirely by the Rust backend, which emits PlayerStatusEvents and handles
|
||||||
* a thin delegate to backend commands; there is no DOM element to touch and no
|
* seek/audio-track/quality internally. So this adapter is a thin delegate to
|
||||||
* hls.js. State reporting is unnecessary here because the native backend emits
|
* backend commands; there is no DOM element to touch. State reporting is
|
||||||
* events directly — the adapter's job is only to forward control intents.
|
* 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
|
* It used to be the opt-in alternative to an HTML5 `<video>` adapter; that path
|
||||||
* HTML5 kind, so Android video runs through Html5PlayerAdapter.
|
* 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
|
* TRACES: UR-003, UR-005 | DR-004, DR-028, DR-235
|
||||||
* 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
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { commands } from "$lib/api/bindings";
|
import { commands } from "$lib/api/bindings";
|
||||||
@@ -40,9 +30,6 @@ export class NativePlayerAdapter implements PlayerAdapter {
|
|||||||
this.host = host;
|
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> {
|
async load(_streamUrl: string, options: PlayerLoadOptions): Promise<void> {
|
||||||
// player_play_item already initiated native playback before this adapter is
|
// 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
|
// created, so there is no stream to load here — but it carries no start
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import type { StreamSelection } from "$lib/api/bindings";
|
import type { StreamSelection } from "$lib/api/bindings";
|
||||||
/**
|
/**
|
||||||
* PlayerAdapter contract — the decoupled boundary between the UI/backend and a
|
* 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
|
* 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
|
* 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
|
* implementation. A control intent (from UI or a backend lockscreen/remote/sleep
|
||||||
* event) reaches the element by the facade dispatching to the active adapter.
|
* 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
|
* 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 {
|
export interface SubtitleTrackInput {
|
||||||
index: number;
|
index: number;
|
||||||
url: string;
|
url: string;
|
||||||
@@ -90,14 +90,7 @@ export interface AdapterHost {
|
|||||||
*/
|
*/
|
||||||
export interface PlayerAdapter {
|
export interface PlayerAdapter {
|
||||||
/** Which platform backend this adapter represents. */
|
/** Which platform backend this adapter represents. */
|
||||||
readonly kind: "html5" | "native";
|
readonly kind: "native" | "webview-audio";
|
||||||
|
|
||||||
/**
|
|
||||||
* 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;
|
|
||||||
|
|
||||||
/** Load a stream and begin playback at `options.initialPosition`. */
|
/** Load a stream and begin playback at `options.initialPosition`. */
|
||||||
load(streamUrl: string, options: PlayerLoadOptions): Promise<void>;
|
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
|
* Compound reload: swap to `selection` and resume at `offset` seconds. Runs
|
||||||
* the invariant mechanical sequence for this platform (html5: pause → hls
|
* the invariant mechanical sequence for this platform. No decision is made here — the backend
|
||||||
* teardown → clear src → set new selection → wait ready → resume; native:
|
|
||||||
* ExoPlayer setMediaItem + seekTo). No decision is made here — the backend
|
|
||||||
* already decided to reload, and `selection.transport` says how to open it, so
|
* already decided to reload, and `selection.transport` says how to open it, so
|
||||||
* no adapter has to infer that from the URL.
|
* no adapter has to infer that from the URL.
|
||||||
*
|
*
|
||||||
@@ -134,12 +125,12 @@ export interface PlayerAdapter {
|
|||||||
setVolume(volume: number): void; // 0..1
|
setVolume(volume: number): void; // 0..1
|
||||||
setMuted(muted: boolean): void;
|
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>;
|
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;
|
getPosition(): number;
|
||||||
|
|
||||||
/** Tear down: destroy hls, detach element, stop reporting. Idempotent. */
|
/** Tear down and stop reporting. Idempotent. */
|
||||||
dispose(): Promise<void>;
|
dispose(): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
import type { StreamSelection } from "$lib/api/bindings";
|
import type { StreamSelection } from "$lib/api/bindings";
|
||||||
/**
|
/**
|
||||||
* Webview audio adapter — plays audio-only media through a hidden `<audio>`
|
* Webview audio adapter — plays audio-only media through a hidden `<audio>`
|
||||||
* element on platforms with no native audio backend (currently Windows).
|
* element on a desktop with no native audio backend — none that ships: Linux
|
||||||
*
|
* and Windows play through mpv, Android through ExoPlayer. There the Rust
|
||||||
* All *video* already renders through the webview `<video>` element on every
|
* `WebviewAudioBackend` hands the stream URL
|
||||||
* platform; libmpv/ExoPlayer only drive audio-only playback. On Windows there is
|
|
||||||
* no native audio backend, so the Rust `WebviewAudioBackend` hands the stream URL
|
|
||||||
* to the frontend via a `webview_audio_load` event and drives play/pause/seek
|
* 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
|
* through `control_command`. This adapter owns the `<audio>` element that plays
|
||||||
* it and reports state/position/duration/ended back to Rust through the same
|
* 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";
|
import type { AdapterHost, PlayerAdapter, PlayerLoadOptions } from "./types";
|
||||||
|
|
||||||
export class WebviewAudioAdapter implements PlayerAdapter {
|
export class WebviewAudioAdapter implements PlayerAdapter {
|
||||||
readonly kind = "html5" as const;
|
readonly kind = "webview-audio" as const;
|
||||||
|
|
||||||
private audio: HTMLAudioElement;
|
private audio: HTMLAudioElement;
|
||||||
private host: AdapterHost;
|
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 {
|
setVolume(volume: number): void {
|
||||||
this.audio.volume = Math.max(0, Math.min(1, volume));
|
this.audio.volume = Math.max(0, Math.min(1, volume));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
/**
|
|
||||||
* Compatibility shim.
|
|
||||||
*
|
|
||||||
* The HTML5 → Rust reporting functions moved to `adapters/rustReportHost.ts` as
|
|
||||||
* part of the PlayerAdapter refactor. Existing callers import the reporter as
|
|
||||||
* `import * as html5Adapter from "$lib/player/html5Adapter"`; this shim keeps
|
|
||||||
* that working while the migration proceeds. New adapter code should depend on
|
|
||||||
* the `AdapterHost` interface (see `adapters/types.ts`) instead.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export {
|
|
||||||
reportState,
|
|
||||||
reportPosition,
|
|
||||||
reportMediaLoaded,
|
|
||||||
resetReporting,
|
|
||||||
} from "./adapters/rustReportHost";
|
|
||||||
|
|
||||||
/** @deprecated states are defined on the AdapterHost interface now. */
|
|
||||||
export type Html5PlayerState = "playing" | "paused" | "loading" | "stopped" | "idle";
|
|
||||||
+21
-44
@@ -118,21 +118,21 @@ async function stop() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function seek(positionSeconds: number) {
|
async function seek(positionSeconds: number) {
|
||||||
// Audio path: backend seeks the native backend directly.
|
// Audio (and webview audio): the backend seeks its player directly.
|
||||||
if (!activeAdapter) {
|
if (activeAdapter?.kind !== "native") {
|
||||||
await commands.playerSeek(positionSeconds);
|
await commands.playerSeek(positionSeconds);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Video path: ask the backend to DECIDE the strategy (in-place vs reload), then
|
// Video: the backend decides the strategy (in place vs re-open) and carries
|
||||||
// execute the matching adapter primitive. The decision logic stays in Rust
|
// it out (player_seek_video).
|
||||||
// (player_seek_video); the adapter only runs the chosen mechanical primitive.
|
|
||||||
await seekVideo(positionSeconds, null, null);
|
await seekVideo(positionSeconds, null, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Video seek: backend decides strategy, facade dispatches the chosen adapter
|
* Video seek. The backend decides whether the stream can be moved in place or
|
||||||
* primitive. `mediaSourceId`/`audioTrackIndex` come from the video view (they are
|
* has to be re-opened, and does either itself — every video renderer is a
|
||||||
* needed for the transcode reload URL). Requires an active video adapter.
|
* native player (DR-235). `mediaSourceId`/`audioTrackIndex` come from the video
|
||||||
|
* view (they are needed for the re-open URL).
|
||||||
*/
|
*/
|
||||||
async function seekVideo(
|
async function seekVideo(
|
||||||
positionSeconds: number,
|
positionSeconds: number,
|
||||||
@@ -144,27 +144,18 @@ async function seekVideo(
|
|||||||
await commands.playerSeek(positionSeconds);
|
await commands.playerSeek(positionSeconds);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const response = (await commands.playerSeekVideo(
|
const response = await commands.playerSeekVideo(
|
||||||
requireHandle(),
|
requireHandle(),
|
||||||
positionSeconds,
|
positionSeconds,
|
||||||
mediaSourceId,
|
mediaSourceId,
|
||||||
audioTrackIndex,
|
audioTrackIndex,
|
||||||
adapter.kind === "html5",
|
);
|
||||||
)) as any;
|
await adapter.seekElement(response.position ?? positionSeconds, 0);
|
||||||
// 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
|
* Switch audio track. The backend selects in place or re-opens the stream
|
||||||
* the resulting primitive. Requires an active video adapter.
|
* itself. Requires an active video adapter.
|
||||||
*/
|
*/
|
||||||
async function switchAudioTrack(
|
async function switchAudioTrack(
|
||||||
streamIndex: number,
|
streamIndex: number,
|
||||||
@@ -172,26 +163,20 @@ async function switchAudioTrack(
|
|||||||
currentPosition: number | null,
|
currentPosition: number | null,
|
||||||
mediaSourceId: string | null,
|
mediaSourceId: string | null,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const adapter = activeAdapter;
|
if (!activeAdapter) return;
|
||||||
if (!adapter) return;
|
await commands.playerSwitchAudioTrack(
|
||||||
const response = (await commands.playerSwitchAudioTrack(
|
|
||||||
requireHandle(),
|
requireHandle(),
|
||||||
streamIndex,
|
streamIndex,
|
||||||
arrayIndex,
|
arrayIndex,
|
||||||
adapter.kind === "html5",
|
|
||||||
currentPosition,
|
currentPosition,
|
||||||
mediaSourceId,
|
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
|
* 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
|
* the stream at the new quality and resumes it. Requires an active video
|
||||||
* backend itself, and hands HTML5 a selection for the same `reloadSource`
|
* adapter.
|
||||||
* primitive the audio-track switch uses. Requires an active video adapter.
|
|
||||||
*
|
*
|
||||||
* The change applies to **this playback only** — the backend sets a per-playback
|
* The change applies to **this playback only** — the backend sets a per-playback
|
||||||
* override that the next item clears, leaving the durable Settings default
|
* override that the next item clears, leaving the durable Settings default
|
||||||
@@ -207,22 +192,14 @@ async function setStreamQuality(
|
|||||||
mediaSourceId: string | null,
|
mediaSourceId: string | null,
|
||||||
audioTrackIndex: number | null,
|
audioTrackIndex: number | null,
|
||||||
): Promise<StreamSelection | null> {
|
): Promise<StreamSelection | null> {
|
||||||
const adapter = activeAdapter;
|
if (!activeAdapter) return null;
|
||||||
if (!adapter) return null;
|
const response = await commands.playerSetStreamQuality(
|
||||||
const response = (await commands.playerSetStreamQuality(
|
|
||||||
requireHandle(),
|
requireHandle(),
|
||||||
quality,
|
quality,
|
||||||
adapter.kind === "html5",
|
|
||||||
currentPosition,
|
currentPosition,
|
||||||
mediaSourceId,
|
mediaSourceId,
|
||||||
audioTrackIndex,
|
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;
|
return response.selection ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,93 +0,0 @@
|
|||||||
/**
|
|
||||||
* The loader is chosen from the backend's `transport` tag, never from the URL.
|
|
||||||
*
|
|
||||||
* TRACES: UR-079 | DR-225 | UT-214
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { elementSrcFor, videoLoaderFor, type LoaderCapabilities } from "./streamTransport";
|
|
||||||
import type { StreamSelection, Transport } from "$lib/api/bindings";
|
|
||||||
|
|
||||||
const MODERN: LoaderCapabilities = { hlsJsSupported: true, nativeHlsSupported: false };
|
|
||||||
const SAFARI: LoaderCapabilities = { hlsJsSupported: false, nativeHlsSupported: true };
|
|
||||||
const NEITHER: LoaderCapabilities = { hlsJsSupported: false, nativeHlsSupported: false };
|
|
||||||
|
|
||||||
function selection(transport: Transport, url: string): Pick<StreamSelection, "url" | "transport"> {
|
|
||||||
return { url, transport };
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("videoLoaderFor", () => {
|
|
||||||
it("attaches hls.js when the backend says HLS and hls.js is available", () => {
|
|
||||||
expect(videoLoaderFor(selection({ type: "hls" }, "https://s/master.m3u8"), MODERN)).toBe(
|
|
||||||
"hlsjs",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("falls back to the element's own HLS loader when hls.js is unavailable", () => {
|
|
||||||
expect(videoLoaderFor(selection({ type: "hls" }, "https://s/master.m3u8"), SAFARI)).toBe(
|
|
||||||
"nativeHls",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("loads a progressive stream directly", () => {
|
|
||||||
expect(
|
|
||||||
videoLoaderFor(
|
|
||||||
selection({ type: "progressive" }, "https://s/Videos/1/stream?static=true"),
|
|
||||||
MODERN,
|
|
||||||
),
|
|
||||||
).toBe("direct");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("loads a local file directly", () => {
|
|
||||||
expect(
|
|
||||||
videoLoaderFor(selection({ type: "localFile" }, "http://127.0.0.1:9/media/x.mkv"), MODERN),
|
|
||||||
).toBe("direct");
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------
|
|
||||||
// The two cases the `.m3u8` substring check gets wrong. These are the
|
|
||||||
// reason the field exists; both fail against a URL-sniffing implementation.
|
|
||||||
// ---------------------------------------------------------------------
|
|
||||||
|
|
||||||
it("does NOT attach hls.js to a progressive stream whose URL happens to end .m3u8", () => {
|
|
||||||
// A direct play served from a path containing the substring — nothing stops
|
|
||||||
// a server, a proxy, or a local cache from producing this.
|
|
||||||
expect(
|
|
||||||
videoLoaderFor(selection({ type: "progressive" }, "https://s/files/movie.m3u8.mp4"), MODERN),
|
|
||||||
).toBe("direct");
|
|
||||||
expect(
|
|
||||||
videoLoaderFor(selection({ type: "progressive" }, "https://s/x?name=master.m3u8"), MODERN),
|
|
||||||
).toBe("direct");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("DOES attach hls.js to an HLS stream whose URL does not contain .m3u8", () => {
|
|
||||||
// Jellyfin's own transcoding URLs are not required to end in `.m3u8`, and a
|
|
||||||
// DASH or query-routed playlist endpoint never would.
|
|
||||||
expect(videoLoaderFor(selection({ type: "hls" }, "https://s/Videos/1/hls"), MODERN)).toBe(
|
|
||||||
"hlsjs",
|
|
||||||
);
|
|
||||||
expect(
|
|
||||||
videoLoaderFor(selection({ type: "hls" }, "https://s/stream?format=playlist"), SAFARI),
|
|
||||||
).toBe("nativeHls");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("falls back to direct when HLS is requested but nothing can play it", () => {
|
|
||||||
expect(videoLoaderFor(selection({ type: "hls" }, "https://s/master.m3u8"), NEITHER)).toBe(
|
|
||||||
"direct",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("elementSrcFor", () => {
|
|
||||||
it("empties the element's src only when hls.js drives it", () => {
|
|
||||||
expect(elementSrcFor(selection({ type: "hls" }, "https://s/master.m3u8"), MODERN)).toBe("");
|
|
||||||
expect(elementSrcFor(selection({ type: "hls" }, "https://s/master.m3u8"), SAFARI)).toBe(
|
|
||||||
"https://s/master.m3u8",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps the src for a progressive stream that looks like a playlist", () => {
|
|
||||||
const s = selection({ type: "progressive" }, "https://s/files/movie.m3u8.mp4");
|
|
||||||
expect(elementSrcFor(s, MODERN)).toBe("https://s/files/movie.m3u8.mp4");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
/**
|
|
||||||
* Which loader opens a stream in the webview `<video>` element.
|
|
||||||
*
|
|
||||||
* Extracted from `VideoPlayer.svelte` so the decision can be unit-tested — the
|
|
||||||
* same pattern as `episodeStrip.ts` and `TrackList.logic.test.ts`.
|
|
||||||
*
|
|
||||||
* TRACES: UR-079 | DR-225 | UT-214
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { StreamSelection, Transport } from "$lib/api/bindings";
|
|
||||||
|
|
||||||
/** How the element should be fed. */
|
|
||||||
export type VideoLoader =
|
|
||||||
/** hls.js drives a MediaSource; the element's own `src` stays empty. */
|
|
||||||
| "hlsjs"
|
|
||||||
/** The element loads the playlist itself (Safari/WebKit native HLS). */
|
|
||||||
| "nativeHls"
|
|
||||||
/** The element loads the URL directly — a progressive file or a local one. */
|
|
||||||
| "direct";
|
|
||||||
|
|
||||||
/** What the running browser can do, passed in so the decision stays pure. */
|
|
||||||
export interface LoaderCapabilities {
|
|
||||||
/** `Hls.isSupported()` */
|
|
||||||
hlsJsSupported: boolean;
|
|
||||||
/** `video.canPlayType("application/vnd.apple.mpegurl")` was non-empty */
|
|
||||||
nativeHlsSupported: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pick the loader from the backend's tagged `transport`.
|
|
||||||
*
|
|
||||||
* This used to read `url.includes(".m3u8")`, in two places in
|
|
||||||
* `VideoPlayer.svelte`. Rust *builds* that URL and knows exactly what it is;
|
|
||||||
* re-deriving the answer here by substring match is a domain fact reconstructed
|
|
||||||
* in the presentation layer — the same error as leaking item-type taxonomy, and
|
|
||||||
* one that fails silently in both directions: a progressive file served from a
|
|
||||||
* path containing `.m3u8` gets an HLS loader, and a playlist served from a path
|
|
||||||
* without it does not.
|
|
||||||
*
|
|
||||||
* The transport is the *stream's* property; whether a given loader exists is the
|
|
||||||
* *browser's*. Only the second is decided here.
|
|
||||||
*/
|
|
||||||
export function videoLoaderFor(
|
|
||||||
selection: Pick<StreamSelection, "url" | "transport">,
|
|
||||||
capabilities: LoaderCapabilities,
|
|
||||||
): VideoLoader {
|
|
||||||
return loaderForTransport(selection.transport.type, capabilities);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The same decision, taken from the transport *tag* alone.
|
|
||||||
*
|
|
||||||
* Exists because a Svelte `$effect` that reads the whole selection re-runs
|
|
||||||
* whenever the selection **object** is replaced — even with an identical URL and
|
|
||||||
* transport — and the HLS effect's teardown/rebuild is not idempotent: it
|
|
||||||
* destroys the hls.js instance and reattaches, which leaves the element with no
|
|
||||||
* video until something forces another cycle. The pre-DR-225 code read a plain
|
|
||||||
* URL *string*, so re-assigning the same value was a no-op and the effect stayed
|
|
||||||
* put. Passing primitives restores that.
|
|
||||||
*
|
|
||||||
* TRACES: UR-079 | DR-225 | UT-214
|
|
||||||
*/
|
|
||||||
export function loaderForTransport(
|
|
||||||
transport: Transport["type"],
|
|
||||||
capabilities: LoaderCapabilities,
|
|
||||||
): VideoLoader {
|
|
||||||
if (transport !== "hls") {
|
|
||||||
// Progressive and local files are what the element loads natively. No
|
|
||||||
// MediaSource, no playlist parsing.
|
|
||||||
return "direct";
|
|
||||||
}
|
|
||||||
if (capabilities.hlsJsSupported) return "hlsjs";
|
|
||||||
if (capabilities.nativeHlsSupported) return "nativeHls";
|
|
||||||
// Nothing here can parse a playlist. Handing the URL to the element is very
|
|
||||||
// likely to fail, but it is the only remaining move and it surfaces a real
|
|
||||||
// media error rather than silently doing nothing.
|
|
||||||
return "direct";
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Convenience for the template: does the element's `src` stay empty? */
|
|
||||||
export function elementSrcFor(
|
|
||||||
selection: Pick<StreamSelection, "url" | "transport">,
|
|
||||||
capabilities: LoaderCapabilities,
|
|
||||||
): string {
|
|
||||||
return videoLoaderFor(selection, capabilities) === "hlsjs" ? "" : selection.url;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type { Transport };
|
|
||||||
@@ -20,26 +20,14 @@ const log = createLogger("capabilities");
|
|||||||
export interface PlaybackCapabilities {
|
export interface PlaybackCapabilities {
|
||||||
/** Audio renders through a webview `<audio>` element, not a native backend. */
|
/** Audio renders through a webview `<audio>` element, not a native backend. */
|
||||||
usesWebviewAudio: boolean;
|
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
|
* Conservative default for when the backend cannot be reached (very early
|
||||||
* startup, or a command failure). Both false = "assume no special platform
|
* startup, or a command failure): no stray `<audio>` element is mounted.
|
||||||
* facilities": no stray `<audio>` element is mounted, and video stays on the
|
|
||||||
* HTML5 path, which is the safe behaviour everywhere.
|
|
||||||
*/
|
*/
|
||||||
const FALLBACK: PlaybackCapabilities = {
|
const FALLBACK: PlaybackCapabilities = {
|
||||||
usesWebviewAudio: false,
|
usesWebviewAudio: false,
|
||||||
supportsNativeVideo: false,
|
|
||||||
webviewVideoFallback: false,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let cached: PlaybackCapabilities | null = null;
|
let cached: PlaybackCapabilities | null = null;
|
||||||
@@ -58,8 +46,6 @@ export async function getPlaybackCapabilities(): Promise<PlaybackCapabilities> {
|
|||||||
const caps = (await commands.playerGetCapabilities()) as PlaybackCapabilities;
|
const caps = (await commands.playerGetCapabilities()) as PlaybackCapabilities;
|
||||||
cached = {
|
cached = {
|
||||||
usesWebviewAudio: !!caps?.usesWebviewAudio,
|
usesWebviewAudio: !!caps?.usesWebviewAudio,
|
||||||
supportsNativeVideo: !!caps?.supportsNativeVideo,
|
|
||||||
webviewVideoFallback: !!caps?.webviewVideoFallback,
|
|
||||||
};
|
};
|
||||||
return cached;
|
return cached;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -1,75 +0,0 @@
|
|||||||
import { describe, it, expect, beforeEach, beforeAll, afterAll, vi } from "vitest";
|
|
||||||
import { get } from "svelte/store";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The stored value of the native-video preference, and what it means.
|
|
||||||
*
|
|
||||||
* The default has moved four times (see the history on `load()` in
|
|
||||||
* nativeVideo.ts), so the risk here is not "which way is it pointing" — it is
|
|
||||||
* that a flip silently overrides people who chose. The old reader was
|
|
||||||
* `getItem(KEY) === "true"`, which conflates "never chose" with "chose off";
|
|
||||||
* flipping the default under that reader re-enables the native path for
|
|
||||||
* everyone who deliberately turned it off. So the three cases are pinned
|
|
||||||
* separately rather than through the default alone.
|
|
||||||
*
|
|
||||||
* TRACES: UR-003, UR-004 | DR-188
|
|
||||||
*/
|
|
||||||
|
|
||||||
const STORAGE_KEY = "jellytau-experimental-native-video";
|
|
||||||
|
|
||||||
// jsdom here doesn't expose localStorage; stand in a minimal implementation,
|
|
||||||
// matching the viewMode/searchGroupOrder store tests.
|
|
||||||
const backing = new Map<string, string>();
|
|
||||||
const localStorageShim = {
|
|
||||||
getItem: (key: string) => backing.get(key) ?? null,
|
|
||||||
setItem: (key: string, value: string) => void backing.set(key, value),
|
|
||||||
removeItem: (key: string) => void backing.delete(key),
|
|
||||||
clear: () => backing.clear(),
|
|
||||||
};
|
|
||||||
|
|
||||||
beforeAll(() => {
|
|
||||||
vi.stubGlobal("localStorage", localStorageShim);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(() => {
|
|
||||||
vi.unstubAllGlobals();
|
|
||||||
});
|
|
||||||
|
|
||||||
async function freshStore() {
|
|
||||||
// The default is read at module init, so each case needs a fresh module.
|
|
||||||
vi.resetModules();
|
|
||||||
return await import("./nativeVideo");
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("experimentalNativeVideo default", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
localStorage.clear();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("defaults to ON when the user has never chosen", async () => {
|
|
||||||
const { experimentalNativeVideo } = await freshStore();
|
|
||||||
expect(get(experimentalNativeVideo)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("stays OFF for someone who deliberately turned it off", async () => {
|
|
||||||
// The regression the null check exists for: an explicit opt-out must
|
|
||||||
// survive the default flip, not be re-enabled by it.
|
|
||||||
localStorage.setItem(STORAGE_KEY, "false");
|
|
||||||
const { experimentalNativeVideo } = await freshStore();
|
|
||||||
expect(get(experimentalNativeVideo)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("stays ON for someone who deliberately turned it on", async () => {
|
|
||||||
localStorage.setItem(STORAGE_KEY, "true");
|
|
||||||
const { experimentalNativeVideo } = await freshStore();
|
|
||||||
expect(get(experimentalNativeVideo)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("persists an explicit choice in both directions", async () => {
|
|
||||||
const { experimentalNativeVideo } = await freshStore();
|
|
||||||
experimentalNativeVideo.set(false);
|
|
||||||
expect(localStorage.getItem(STORAGE_KEY)).toBe("false");
|
|
||||||
experimentalNativeVideo.set(true);
|
|
||||||
expect(localStorage.getItem(STORAGE_KEY)).toBe("true");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,138 +1,22 @@
|
|||||||
// Native-video compositing state.
|
// Native-video compositing state.
|
||||||
//
|
//
|
||||||
// TRACES: UR-003, UR-004 | DR-150, DR-152
|
// TRACES: UR-003, UR-004 | DR-150, DR-152, DR-235
|
||||||
//
|
//
|
||||||
// Two separate concerns live here, deliberately:
|
// `nativeVideoActive` — whether a native video surface is on screen right now.
|
||||||
|
// Setting it toggles `data-native-video` on <html>, which is what the CSS in
|
||||||
|
// app.css keys off to clear the app's opaque backgrounds so the video surface
|
||||||
|
// behind the webview is visible. The backgrounds must come back the moment the
|
||||||
|
// player unmounts.
|
||||||
//
|
//
|
||||||
// 1. `experimentalNativeVideo` — the user-facing opt-in flag. Rust already
|
// There used to be a second concern here: `experimentalNativeVideo`, a stored
|
||||||
// decides *which backend this platform has* (`useHtml5Element` from
|
// user switch that could force video back to the webview `<video>` element.
|
||||||
// `player_play_item`); this flag only *suppresses* that decision so a
|
// That element is gone (DR-235), so there is nothing left to switch to.
|
||||||
// half-working spike cannot ship as a regression. It never turns native on
|
|
||||||
// where Rust says HTML5.
|
|
||||||
//
|
|
||||||
// 2. `nativeVideoActive` — whether a native surface is on screen right now.
|
|
||||||
// Setting it toggles `data-native-video` on <html>, which is what the CSS in
|
|
||||||
// app.css keys off to clear the app's opaque backgrounds so the SurfaceView
|
|
||||||
// behind the WebView is visible. It is deliberately NOT derived from the
|
|
||||||
// flag: the backgrounds must come back the moment the player unmounts.
|
|
||||||
//
|
|
||||||
// Frontend-only preference, stored in localStorage per the `jellytau-view-mode`
|
|
||||||
// precedent in library.ts — no Rust settings command backs this.
|
|
||||||
|
|
||||||
import { writable } from "svelte/store";
|
import { writable } from "svelte/store";
|
||||||
|
|
||||||
const STORAGE_KEY = "jellytau-experimental-native-video";
|
|
||||||
|
|
||||||
/** The attribute app.css keys its transparency rules off. */
|
/** The attribute app.css keys its transparency rules off. */
|
||||||
const NATIVE_VIDEO_ATTR = "data-native-video";
|
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() {
|
function createNativeVideoActiveStore() {
|
||||||
const { subscribe, set } = writable<boolean>(false);
|
const { subscribe, set } = writable<boolean>(false);
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
|
||||||
import { nativeVideoWanted } from "./nativeVideo";
|
|
||||||
|
|
||||||
// TRACES: UR-003, UR-071 | DR-293 | UT-262
|
|
||||||
describe("nativeVideoWanted", () => {
|
|
||||||
it("ignores a stored 'off' where there is no webview fallback (Android)", () => {
|
|
||||||
// Someone who once switched native video off on Android must not be
|
|
||||||
// routed to the webview, which plays original-file downloads silent.
|
|
||||||
expect(nativeVideoWanted(false, false)).toBe(true);
|
|
||||||
expect(nativeVideoWanted(true, false)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("honours the stored choice where a fallback exists (Linux beside mpv)", () => {
|
|
||||||
expect(nativeVideoWanted(false, true)).toBe(false);
|
|
||||||
expect(nativeVideoWanted(true, true)).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -22,7 +22,6 @@ interface AndroidPictureInPictureBridge {
|
|||||||
isSupported(): boolean;
|
isSupported(): boolean;
|
||||||
canEnterPip(): boolean;
|
canEnterPip(): boolean;
|
||||||
setAutoEnterEnabled(enabled: boolean): void;
|
setAutoEnterEnabled(enabled: boolean): void;
|
||||||
setHtml5VideoState(active: boolean, width: number, height: number, playing: boolean): void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
@@ -89,36 +88,3 @@ export function setAutoEnterEnabled(enabled: boolean): void {
|
|||||||
log.warn("Failed to set auto-enter:", err);
|
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -24,9 +24,18 @@
|
|||||||
|
|
||||||
import { nativeVideoActive } from "$lib/stores/nativeVideo";
|
import { nativeVideoActive } from "$lib/stores/nativeVideo";
|
||||||
import { createLogger } from "$lib/utils/logger";
|
import { createLogger } from "$lib/utils/logger";
|
||||||
|
import { platform } from "@tauri-apps/plugin-os";
|
||||||
|
|
||||||
const log = createLogger("videoSurface");
|
const log = createLogger("videoSurface");
|
||||||
|
|
||||||
|
function isAndroid(): boolean {
|
||||||
|
try {
|
||||||
|
return platform() === "android";
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
interface AndroidVideoSurfaceBridge {
|
interface AndroidVideoSurfaceBridge {
|
||||||
setTransparent(transparent: boolean): void;
|
setTransparent(transparent: boolean): void;
|
||||||
isSupported(): boolean;
|
isSupported(): boolean;
|
||||||
@@ -46,8 +55,7 @@ function bridge(): AndroidVideoSurfaceBridge | undefined {
|
|||||||
/**
|
/**
|
||||||
* Whether the native-surface bridge exists on this platform. This reports only
|
* Whether the native-surface bridge exists on this platform. This reports only
|
||||||
* that the *plumbing* is present; whether native video should actually be used
|
* that the *plumbing* is present; whether native video should actually be used
|
||||||
* is Rust's decision (`player_get_capabilities`) gated by the user's
|
* is Rust's decision.
|
||||||
* `experimentalNativeVideo` flag.
|
|
||||||
*/
|
*/
|
||||||
export function isNativeSurfaceBridgeAvailable(): boolean {
|
export function isNativeSurfaceBridgeAvailable(): boolean {
|
||||||
try {
|
try {
|
||||||
@@ -69,6 +77,14 @@ export function enableNativeVideoCompositing(): void {
|
|||||||
// would see through the app to the home screen.
|
// would see through the app to the home screen.
|
||||||
nativeVideoActive.set(true);
|
nativeVideoActive.set(true);
|
||||||
const androidVideoSurface = bridge();
|
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) {
|
if (!androidVideoSurface) {
|
||||||
// Say so loudly. Every bridge call in this file is optional-chained, so a
|
// 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
|
// missing bridge is silent — and a silently-skipped setTransparent(true) is
|
||||||
|
|||||||
@@ -37,7 +37,6 @@
|
|||||||
} from "$lib/services/playbackReporting";
|
} from "$lib/services/playbackReporting";
|
||||||
import { reportSkippedEpisode, shouldSuppressStopReport } from "$lib/services/skipReporting";
|
import { reportSkippedEpisode, shouldSuppressStopReport } from "$lib/services/skipReporting";
|
||||||
import { cleanup as cleanupNextEpisode } from "$lib/services/nextEpisodeService";
|
import { cleanup as cleanupNextEpisode } from "$lib/services/nextEpisodeService";
|
||||||
import * as html5Adapter from "$lib/player/html5Adapter";
|
|
||||||
import { createLogger } from "$lib/utils/logger";
|
import { createLogger } from "$lib/utils/logger";
|
||||||
|
|
||||||
const log = createLogger("PlayerPage");
|
const log = createLogger("PlayerPage");
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<!-- TRACES: UR-023, UR-025, UR-027, UR-029, UR-057, UR-076 | DR-030, DR-048, DR-077, DR-086, DR-132, DR-209 -->
|
<!-- 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">
|
<script lang="ts">
|
||||||
import { onDestroy, onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import { commands } from "$lib/api/bindings";
|
import { commands } from "$lib/api/bindings";
|
||||||
import { profiles } from "$lib/stores/profiles";
|
import { profiles } from "$lib/stores/profiles";
|
||||||
import ProfileSecuritySettings from "$lib/components/settings/ProfileSecuritySettings.svelte";
|
import ProfileSecuritySettings from "$lib/components/settings/ProfileSecuritySettings.svelte";
|
||||||
@@ -31,8 +31,6 @@
|
|||||||
import { library, viewMode } from "$lib/stores/library";
|
import { library, viewMode } from "$lib/stores/library";
|
||||||
import { auth } from "$lib/stores/auth";
|
import { auth } from "$lib/stores/auth";
|
||||||
import { isNetworkDetectionSupported, reportNetworkState } from "$lib/services/networkType";
|
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 { createLogger } from "$lib/utils/logger";
|
||||||
import { openUrl, revealItemInDir } from "@tauri-apps/plugin-opener";
|
import { openUrl, revealItemInDir } from "@tauri-apps/plugin-opener";
|
||||||
import {
|
import {
|
||||||
@@ -131,26 +129,6 @@
|
|||||||
{ label: "Unlimited", bytes: 0 },
|
{ 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
|
// Mirrors the stored setting; the picker itself always appears for a
|
||||||
// PIN-protected profile regardless of this. (DR-274)
|
// PIN-protected profile regardless of this. (DR-274)
|
||||||
let askOnStart = $state(false);
|
let askOnStart = $state(false);
|
||||||
@@ -158,7 +136,6 @@
|
|||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
await loadSettings();
|
await loadSettings();
|
||||||
askOnStart = await commands.profilesGetAskOnStart();
|
askOnStart = await commands.profilesGetAskOnStart();
|
||||||
offerNativeVideoSwitch = (await getPlaybackCapabilities()).webviewVideoFallback;
|
|
||||||
|
|
||||||
// Which update story this platform gets. Android cannot install its own
|
// Which update story this platform gets. Android cannot install its own
|
||||||
// APK, so it is offered the releases page instead of an install button.
|
// 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
|
<!-- Native video. Only rendered where Rust reports a webview fallback
|
||||||
(Linux beside mpv native video); never on Android (DR-293). -->
|
(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>
|
</div>
|
||||||
|
|
||||||
<!-- Profiles. Deliberately minimal here: adding, removing and PIN changes
|
<!-- Profiles. Deliberately minimal here: adding, removing and PIN changes
|
||||||
|
|||||||
Reference in New Issue
Block a user