Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a67452bc80 | ||
|
|
1677f5f299 | ||
|
|
bb3ab1edd7 | ||
|
|
4daf172834 | ||
|
|
9d9d81bef3 | ||
|
|
fd1277746d | ||
|
|
d7136aef48 | ||
|
|
73fd8a1dfe | ||
|
|
a676f4aba8 | ||
|
|
c0545a245f | ||
|
|
e21ddae737 | ||
|
|
21f24dd998 | ||
|
|
1fb5f070c8 | ||
|
|
f6efa7208a | ||
|
|
9dd44eeada | ||
|
|
5259b47cf3 | ||
|
|
bed1030443 | ||
|
|
bb7d5dc01a | ||
|
|
a90de67c54 | ||
|
|
b98cbfe28a | ||
|
|
0f928798d7 |
@@ -28,7 +28,7 @@ jobs:
|
||||
if: "!startsWith(github.event.head_commit.message, 'chore(release)')"
|
||||
runs-on: linux/amd64
|
||||
container:
|
||||
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.08.1
|
||||
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.09.1
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
@@ -109,7 +109,7 @@ jobs:
|
||||
# at "warn" until its class is cleared and it can be promoted to "error".
|
||||
# Lower this as you clear them. Never raise it to make a build pass.
|
||||
- name: Lint
|
||||
run: bun run lint -- --max-warnings=158
|
||||
run: bun run lint -- --max-warnings=146
|
||||
|
||||
- name: Check TypeScript
|
||||
run: |
|
||||
@@ -187,7 +187,7 @@ jobs:
|
||||
runs-on: linux/amd64
|
||||
needs: test
|
||||
container:
|
||||
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.08.1
|
||||
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.09.1
|
||||
env:
|
||||
ANDROID_HOME: /opt/android-sdk
|
||||
ANDROID_SDK_ROOT: /opt/android-sdk
|
||||
@@ -256,7 +256,7 @@ jobs:
|
||||
name: Supply Chain
|
||||
runs-on: linux/amd64
|
||||
container:
|
||||
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.08.1
|
||||
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.09.1
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
|
||||
@@ -21,7 +21,7 @@ jobs:
|
||||
name: Run Tests
|
||||
runs-on: linux/amd64
|
||||
container:
|
||||
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.08.1
|
||||
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.09.1
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
@@ -94,7 +94,7 @@ jobs:
|
||||
runs-on: linux/amd64
|
||||
needs: test
|
||||
container:
|
||||
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.08.1
|
||||
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.09.1
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
@@ -235,7 +235,7 @@ jobs:
|
||||
# baked into the builder image. No toolchain installs here — the image has
|
||||
# cargo-xwin, clang/clang-cl, lld, llvm, nsis and the msvc target.
|
||||
container:
|
||||
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.08.1
|
||||
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.09.1
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
@@ -308,7 +308,7 @@ jobs:
|
||||
runs-on: linux/amd64
|
||||
needs: test
|
||||
container:
|
||||
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.08.1
|
||||
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.09.1
|
||||
env:
|
||||
ANDROID_HOME: /opt/android-sdk
|
||||
ANDROID_SDK_ROOT: /opt/android-sdk
|
||||
@@ -411,7 +411,7 @@ jobs:
|
||||
needs: [build-linux, build-windows, build-android]
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
container:
|
||||
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.08.1
|
||||
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.09.1
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
@@ -85,7 +85,7 @@ jobs:
|
||||
# The short-SHA output below avoids depending on it regardless.
|
||||
shell: bash
|
||||
container:
|
||||
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.08.1
|
||||
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.09.1
|
||||
env:
|
||||
ANDROID_HOME: /opt/android-sdk
|
||||
ANDROID_SDK_ROOT: /opt/android-sdk
|
||||
|
||||
@@ -21,7 +21,7 @@ jobs:
|
||||
name: Build & publish docs to gitea-pages
|
||||
runs-on: linux/amd64
|
||||
container:
|
||||
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.08.1
|
||||
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.09.1
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
runs-on: linux/amd64
|
||||
name: Check Requirement Traces
|
||||
container:
|
||||
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.08.1
|
||||
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.09.1
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
|
||||
+158
@@ -9,6 +9,164 @@ generated trace matrix lives in [docs/traceability.md](docs/traceability.md).
|
||||
For how long each fixed defect had been shipping before it was found, see
|
||||
[docs/defect-windows.md](docs/defect-windows.md).
|
||||
|
||||
## v0.14.0
|
||||
|
||||
Video on the desktop is played by mpv, on Linux and now on Windows, and the
|
||||
built-in web player is gone from every platform. Windows gets real audio
|
||||
playback too.
|
||||
|
||||
### 🔒 Security
|
||||
|
||||
- **A track title can no longer run a command on Linux.** mpv was handed stream
|
||||
URLs and downloaded-file paths as a single command string, in which `;` starts
|
||||
a new command — so a file whose title tag carried one could run a program
|
||||
when it played. Every mpv command now passes its arguments separately.
|
||||
(DR-298)
|
||||
- **mpv checks the server's certificate.** It did not by default, and the
|
||||
addresses it opens carry your login token, so anyone able to intercept the
|
||||
connection could read it. It also no longer hands a failed address to
|
||||
youtube-dl. (DR-299)
|
||||
- **The app's web page can no longer reach the network.** It needed that only
|
||||
for the web video player; everything now goes through the backend, so the
|
||||
permission was just a way out for anything injected into the page.
|
||||
|
||||
### ✨ Features
|
||||
|
||||
- **mpv plays all video on Linux and Windows.** On Windows it draws into the app
|
||||
window under the controls; on Linux it no longer needs the experimental
|
||||
switch. Video still arrives transcoded to h264 for now — asking the server for
|
||||
the original file is the next step. (DR-235, DR-237)
|
||||
- **Windows plays audio through mpv**, so volume, the equalizer, volume
|
||||
normalization and gapless playback now work there. The installer ships
|
||||
mpv's LGPL-licensed library and its licence text. (DR-237)
|
||||
|
||||
### 🐛 Fixes
|
||||
|
||||
- **Subtitles and audio-track switching work in desktop video.** mpv now loads
|
||||
the subtitle list and switches subtitles and audio tracks itself; before this
|
||||
they did nothing once mpv drew the picture. (DR-023, DR-024)
|
||||
- **Downloaded songs with a space in their title play offline on Linux.**
|
||||
(DR-298)
|
||||
- **A queued download on Windows keeps the path it was saved under**, rather
|
||||
than having its separators rewritten. (DR-211)
|
||||
|
||||
### 🧹 Removed
|
||||
|
||||
- **The built-in web video player and the "Native Video" setting.** There is
|
||||
nothing left to switch between: every platform plays video natively.
|
||||
(DR-235)
|
||||
|
||||
## v0.13.3
|
||||
|
||||
### 🐛 Fixes
|
||||
|
||||
- **Coming back to the app no longer replaces the page with "Failed to load
|
||||
item".** After a few minutes in the background, resuming the app on a series
|
||||
page could swap the whole page for that error, and it stayed until you
|
||||
navigated away. The page refreshes itself on resume; a refresh that fails now
|
||||
leaves what you were looking at on screen, and the error clears as soon as a
|
||||
load succeeds. When a page genuinely cannot open, it now says why instead of
|
||||
the generic message. (DR-297)
|
||||
|
||||
## v0.13.2
|
||||
|
||||
A series opens with its episodes in under a second. v0.13.1 fixed the season
|
||||
list; the episode list below it still took about five seconds on a Fairphone.
|
||||
|
||||
### ⚡ Performance
|
||||
|
||||
- **The episode list no longer waits for the server.** It used to wait for
|
||||
"Next Up" and your resume point, fetched from the server first, although every
|
||||
episode was already on the device. The list now shows as soon as it is
|
||||
loaded, and Next Up answers from the device first like everything else.
|
||||
(DR-101, DR-295)
|
||||
- **A page loads once.** Opening a series loaded it six times over, putting
|
||||
about seventy requests in flight at once. It now loads once, and re-loads only
|
||||
when something actually changed (reconnecting, marking watched). (DR-295)
|
||||
- **The local database finds things by index instead of reading everything.**
|
||||
Each item now records the one container it is listed under, so a series,
|
||||
season or album page is a single index lookup — under a millisecond on a
|
||||
100,000-item library, where it used to scan the whole catalogue. The update
|
||||
converts an existing library in well under a second, once. (DR-012, DR-013)
|
||||
|
||||
### 🐛 Fixes
|
||||
|
||||
- **A series lists its seasons, not every episode as well.** Both the library
|
||||
view and the Downloads view mixed a series' episodes in with its seasons.
|
||||
(DR-013)
|
||||
|
||||
## v0.13.1
|
||||
|
||||
Pages answer from the cache again. Found on a Fairphone, where opening a
|
||||
series took one to ten seconds even though everything on it was already cached.
|
||||
|
||||
### ⚡ Performance
|
||||
|
||||
- **Series and library pages load from the cache.** Frasier's season and
|
||||
episode lists now appear in 34–133 ms on a Fairphone 5; they took 600–1030 ms
|
||||
each, waiting on the server. Three causes stacked up. Every database read
|
||||
waited behind every write, because one connection served the whole app. The
|
||||
listing query scanned the entire cached catalogue whatever page it was for.
|
||||
And a cache answer that took longer than 100 ms was ignored until the server
|
||||
replied. Reads now have their own connections, the query uses its indexes
|
||||
(about 50× faster on a 100k-item cache), and the cache and the server race:
|
||||
whichever answers first with something to show wins. (DR-012, DR-013)
|
||||
- **A series page walks its seasons once, in parallel.** It used to walk all of
|
||||
them twice, one after another. (DR-295)
|
||||
|
||||
### 🐛 Fixes
|
||||
|
||||
- **Coming back from background audio opens the right episode.** If an episode
|
||||
ended while the app was in the background, returning to it reloaded the
|
||||
*previous* episode at the new one's position. (DR-296)
|
||||
- **Offline changes keep their own identity.** Two favourites or progress
|
||||
updates queued at the same moment could be handed each other's queue entry,
|
||||
so syncing one marked the other as done. (DR-014)
|
||||
- **Catalog caching no longer switches off data-integrity checks for the rest
|
||||
of the app** while it saves a page. (DR-012)
|
||||
|
||||
## v0.13.0
|
||||
|
||||
Android plays and downloads the original file, and offline mode works without
|
||||
a network. Found on a tablet with no Dolby decoder, where nearly every film was
|
||||
being transcoded by the server — slowly, and unresumably — just for its audio.
|
||||
|
||||
### ✨ Features
|
||||
|
||||
- **Dolby and DTS audio play on every Android device.** Android ships no AC-3,
|
||||
E-AC-3, DTS or TrueHD decoders — they exist only where a manufacturer paid for
|
||||
them. The player now decodes them itself (FFmpeg), so these films stream and
|
||||
download as the original file instead of a server transcode: direct play when
|
||||
streaming, and a download that runs at full speed, shows a real percentage,
|
||||
and resumes after a dropped connection. Measured: a 910 MB E-AC-3 5.1 episode
|
||||
in 94 seconds, where a transcode managed about 1 MB/s. (DR-293)
|
||||
|
||||
### 🐛 Fixes
|
||||
|
||||
- **Downloaded films play offline.** Playing a download asked the server for
|
||||
details it did not need, so with no network it waited seven seconds, failed,
|
||||
and never opened the file on disk. It now answers from the download itself.
|
||||
(DR-294)
|
||||
- **"Failed to load item" offline.** Opening a downloaded show's details could
|
||||
fail while the app was writing to its database (the catalog sync at every
|
||||
launch, a download finishing): the cached answer was thrown away for being
|
||||
slow, and the server — unreachable — was reported instead. The library list,
|
||||
genres, playlists, search and favourites had the same flaw. They now wait for
|
||||
the cache. (DR-294)
|
||||
- **The TV page no longer blanks offline.** Its "Next Up" row was server-only,
|
||||
and its failure took the whole page with it. It now falls back to the cache.
|
||||
(DR-294)
|
||||
|
||||
### 🔧 Changes
|
||||
|
||||
- **Native video is no longer optional on Android.** The built-in web player
|
||||
cannot decode Dolby or DTS audio, so with original files downloaded it would
|
||||
play them silent. The "Native Video" switch is gone from Android settings (it
|
||||
remains on Linux, beside mpv native video).
|
||||
- **Licence.** The Android app now bundles a GPL-3.0 component (the FFmpeg audio
|
||||
decoder), so the distributed APK carries GPL-3.0 terms; JellyTau's source stays
|
||||
MIT. See `THIRD_PARTY_NOTICES.md`.
|
||||
|
||||
## v0.12.2
|
||||
|
||||
Two download fixes, found together on a tablet whose films all needed their
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
|
||||
A cross-platform Jellyfin client. Business logic lives in a Rust backend
|
||||
(`src-tauri/`); a SvelteKit + TypeScript frontend (`src/`) handles presentation
|
||||
and talks to it over Tauri v2 IPC. Targets **Linux** (libmpv, WebKitGTK HTML5
|
||||
`<video>` for transcoded playback) and **Android** (ExoPlayer).
|
||||
and talks to it over Tauri v2 IPC. Targets **Linux** and **Windows** (libmpv for audio and video) and **Android**
|
||||
(ExoPlayer). There is no webview `<video>`: every video renderer is native,
|
||||
drawing behind the transparent webview.
|
||||
|
||||
Package manager is **bun**.
|
||||
|
||||
@@ -151,9 +152,13 @@ output as a reviewed draft, not a final changelog.
|
||||
- **Svelte frontend** (`src/`) — presentation only. Stores in
|
||||
`src/lib/stores/`, API wrappers in `src/lib/api/`, components in
|
||||
`src/lib/components/`.
|
||||
- **Playback layers** — Linux uses libmpv for direct playback and a WebKitGTK
|
||||
HTML5 `<video>` element for HLS-transcoded (h264) streams; Android uses
|
||||
ExoPlayer with a foreground media service + `MediaSessionCompat`.
|
||||
- **Playback layers** — Linux and Windows use libmpv for audio and video (mpv
|
||||
draws video beneath the transparent webview: the render API into a GTK surface
|
||||
on Linux, `wid` into the app window on Windows); Android uses ExoPlayer with a
|
||||
foreground media service + `MediaSessionCompat`. The webview `<video>`/hls.js
|
||||
path was deleted (DR-235). Every mpv command goes through
|
||||
`player/mpv_command.rs` (argv, never a command string) and every handle is
|
||||
hardened (`tls-verify=yes`, `ytdl=no`) — see DR-298/DR-299.
|
||||
- **tauri-specta** generates TypeScript bindings and typed events from the Rust
|
||||
command/event definitions (registered via the Builder in `src-tauri/src/lib.rs`).
|
||||
|
||||
@@ -167,7 +172,7 @@ canonical, maintained source; this file only summarizes. See
|
||||
| [02-svelte-frontend.md](docs/architecture/02-svelte-frontend.md) | Stores, repository architecture, MiniPlayer, autoplay, nav guard |
|
||||
| [03-data-flow.md](docs/architecture/03-data-flow.md) | Cache-first query flow, playback initiation, mode transfer |
|
||||
| [04-type-sync-and-threading.md](docs/architecture/04-type-sync-and-threading.md) | **Rust↔TS type sync, the IPC camelCase convention + param table, locking** |
|
||||
| [05-platform-backends.md](docs/architecture/05-platform-backends.md) | MpvBackend (Linux), ExoPlayerBackend (Android), MediaSession, HTML5 adapter |
|
||||
| [05-platform-backends.md](docs/architecture/05-platform-backends.md) | MpvBackend (Linux/Windows) incl. desktop native video, ExoPlayerBackend (Android), MediaSession |
|
||||
| [06-downloads-and-offline.md](docs/architecture/06-downloads-and-offline.md) | Download manager/worker, smart cache, offline commands |
|
||||
| [07-connectivity.md](docs/architecture/07-connectivity.md) | HTTP retry, ConnectivityMonitor, reachability model |
|
||||
| [08-database-design.md](docs/architecture/08-database-design.md) | Tables, relationships, key queries |
|
||||
@@ -185,10 +190,9 @@ and [docs/build/build-release.md](docs/build/build-release.md).
|
||||
player reports and never determine it.
|
||||
- **Unified player boundary.** UI controls playback *only* through the frontend
|
||||
facade `src/lib/player/index.ts` (`playerController`) — never by calling
|
||||
`commands.player*` directly. Webview HTML5 `<video>` reports its state back
|
||||
into Rust via `src/lib/player/html5Adapter.ts` and the `player_report_*`
|
||||
commands, so the controller stays the single source of truth in both native
|
||||
and HTML5 modes.
|
||||
`commands.player*` directly. Video has one adapter, `NativePlayerAdapter`,
|
||||
which only forwards intents; the backend performs every seek, track switch
|
||||
and quality change itself.
|
||||
- **Reachability from real traffic.** Server online/offline is derived from the
|
||||
outcome of actual repository requests (reported to `ConnectivityMonitor`), not
|
||||
a side-channel poller. The `/System/Info/Public` probe runs *only while
|
||||
@@ -313,8 +317,9 @@ tagged responses keep the Rust field names as-is (e.g. `new_url`, not `newUrl`).
|
||||
player or hold a lock — it deadlocks. On Android, bind a locked
|
||||
`AutoplayDecision` to a `let` *before* matching; a tokio `MutexGuard` held in
|
||||
the `match` scrutinee deadlocks the `AdvanceToNext` arm.
|
||||
- **VideoPlayer native mode**: no lifecycle calls after an `await` in `onMount`
|
||||
(it flips to HTML5 mode and breaks Android seek).
|
||||
- **VideoPlayer `onMount`**: no lifecycle calls after an `await` — they throw
|
||||
`lifecycle_outside_component` (this once silently switched seeks to a renderer
|
||||
that was not playing).
|
||||
- **Transcoded resume/seek**: `get_video_stream_url` must return the HLS
|
||||
`master.m3u8`, not `stream.mp4`, or transcoded playback never starts.
|
||||
- **Downloads** cap at 3 concurrent; the backend pump auto-starts pending rows.
|
||||
|
||||
+2
-2
@@ -136,8 +136,8 @@ WORKDIR /app
|
||||
CMD ["bash", "-c", "OUTPUT_DIR=/app/dist scripts/build-desktop-linux.sh"]
|
||||
|
||||
# Windows cross-compile environment (MSVC target via cargo-xwin). Video works via
|
||||
# WebView2 and audio via the webview <audio> backend; NSIS installer is produced
|
||||
# from Linux by cargo-xwin. Default bundles NSIS; override WIN_BUNDLES=none for
|
||||
# WebView2 and audio via mpv, whose DLL the builder image carries
|
||||
# (/opt/libmpv-win64); NSIS installer is produced from Linux by cargo-xwin. Default bundles NSIS; override WIN_BUNDLES=none for
|
||||
# exe-only. Build runs at container-run time like above.
|
||||
FROM ${BUILDER_IMAGE} AS windows-cross
|
||||
WORKDIR /app
|
||||
|
||||
@@ -186,6 +186,42 @@ RUN . $HOME/.cargo/env && \
|
||||
cargo cyclonedx --version && \
|
||||
mdbook --version
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# libmpv for Windows (DR-237) — the Windows build links it and ships the DLL.
|
||||
#
|
||||
# zhongfly/mpv-winbuild's **LGPL** dev build: libmpv-2.dll with FFmpeg compiled
|
||||
# in, built `-Dgpl=false` with no x264/x265 and no --enable-gpl (its
|
||||
# compile-lgpl-libmpv.patch). FFmpeg keeps --enable-version3, so the DLL is
|
||||
# LGPL-3.0: the installer ships the licence texts and keeps the DLL a separate,
|
||||
# replaceable file — THIRD_PARTY_NOTICES.md. Not the default (GPL) asset from
|
||||
# the same release, and not shinchiro's, which is GPL-3.0 only.
|
||||
#
|
||||
# Pinned by asset name *and* sha256: GitHub prunes old releases from that repo,
|
||||
# so a rebuild after pruning fails loudly here rather than quietly taking a
|
||||
# newer build. To bump, change all three values.
|
||||
#
|
||||
# The archive ships a MinGW import library (libmpv.dll.a); the MSVC target needs
|
||||
# `mpv.lib`, generated from the DLL's own mpv_* exports so it cannot name a
|
||||
# symbol the DLL lacks. Output: $LIBMPV_WIN_DIR/{libmpv-2.dll,mpv.lib,include}.
|
||||
ENV LIBMPV_WIN_RELEASE=2026-09-24-2a4eb8067c \
|
||||
LIBMPV_WIN_ASSET=mpv-dev-lgpl-x86_64-20260924-git-2a4eb8067c.7z \
|
||||
LIBMPV_WIN_SHA256=f89c6195e13bfce3e8c0cc5d7f5d889ebdcf12184a41c8288360f5acd1e7306c \
|
||||
LIBMPV_WIN_DIR=/opt/libmpv-win64
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends libarchive-tools \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& wget -q "https://github.com/zhongfly/mpv-winbuild/releases/download/${LIBMPV_WIN_RELEASE}/${LIBMPV_WIN_ASSET}" \
|
||||
-O /tmp/mpv-dev.7z \
|
||||
&& echo "${LIBMPV_WIN_SHA256} /tmp/mpv-dev.7z" | sha256sum -c - \
|
||||
&& mkdir -p "$LIBMPV_WIN_DIR" \
|
||||
&& bsdtar -xf /tmp/mpv-dev.7z -C "$LIBMPV_WIN_DIR" libmpv-2.dll include \
|
||||
&& rm /tmp/mpv-dev.7z \
|
||||
&& { echo "LIBRARY libmpv-2.dll"; echo "EXPORTS"; \
|
||||
llvm-readobj --coff-exports "$LIBMPV_WIN_DIR/libmpv-2.dll" \
|
||||
| awk '/Name: mpv_/ { print " " $2 }'; } > "$LIBMPV_WIN_DIR/mpv.def" \
|
||||
&& llvm-lib /def:"$LIBMPV_WIN_DIR/mpv.def" /out:"$LIBMPV_WIN_DIR/mpv.lib" /machine:x64 \
|
||||
&& test "$(grep -c '^ mpv_' "$LIBMPV_WIN_DIR/mpv.def")" -ge 50 \
|
||||
&& ls -la "$LIBMPV_WIN_DIR"
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENTRYPOINT ["/bin/bash"]
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
# Third-party notices
|
||||
|
||||
JellyTau's own source code is licensed under the MIT License (see `LICENSE`).
|
||||
Some builds bundle third-party components under other licences, listed here.
|
||||
|
||||
## Android: FFmpeg audio decoder (GPL-3.0)
|
||||
|
||||
The Android app bundles **`org.jellyfin.media3:media3-ffmpeg-decoder`**, the
|
||||
Jellyfin project's build of the media3 FFmpeg extension, which contains FFmpeg.
|
||||
It lets the player decode AC-3, E-AC-3, DTS and TrueHD audio, which Android does
|
||||
not ship.
|
||||
|
||||
- Licence: **GNU General Public License v3.0**
|
||||
- Source: <https://github.com/jellyfin/jellyfin-androidx-media> (build of
|
||||
<https://github.com/androidx/media>), with FFmpeg from <https://ffmpeg.org>
|
||||
|
||||
Because this component is GPL-3.0, **the Android APK as distributed is subject
|
||||
to the terms of the GPL-3.0**. The complete corresponding source for JellyTau is
|
||||
available in this repository; JellyTau's own code remains available under MIT.
|
||||
|
||||
Desktop builds do not include this component.
|
||||
|
||||
## Windows: libmpv (LGPL-3.0)
|
||||
|
||||
The Windows installer bundles **`libmpv-2.dll`**, the mpv media player library
|
||||
with FFmpeg compiled in, which plays audio on Windows. It is the LGPL build from
|
||||
<https://github.com/zhongfly/mpv-winbuild> (mpv built `-Dgpl=false`, FFmpeg
|
||||
without `--enable-gpl`), pinned by version and checksum in `Dockerfile.builder`.
|
||||
|
||||
- Licence: **GNU Lesser General Public License v3.0** (FFmpeg is configured
|
||||
`--enable-version3`; mpv itself is LGPL-2.1-or-later in this configuration).
|
||||
The installer carries the texts as `licenses/LGPL-3.0.txt` and
|
||||
`licenses/GPL-3.0.txt`, which the LGPL-3.0 incorporates.
|
||||
- Source: mpv <https://github.com/mpv-player/mpv> and FFmpeg
|
||||
<https://ffmpeg.org>, built by the scripts at
|
||||
<https://github.com/zhongfly/mpv-winbuild>; the exact mpv commit is in the
|
||||
asset name in `Dockerfile.builder`.
|
||||
|
||||
JellyTau links it dynamically, and the DLL sits beside `jellytau.exe` as a
|
||||
separate file, so it can be replaced with any compatible libmpv build. JellyTau's
|
||||
own code remains under MIT. The Linux builds use the system's libmpv and do not
|
||||
bundle it.
|
||||
@@ -11,7 +11,6 @@
|
||||
"@tauri-apps/plugin-os": "^2.3.2",
|
||||
"@tauri-apps/plugin-process": "^2.3.1",
|
||||
"@tauri-apps/plugin-updater": "2.10.1",
|
||||
"hls.js": "^1.6.15",
|
||||
"svelte-dnd-action": "^0.9.69",
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -485,8 +484,6 @@
|
||||
|
||||
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
|
||||
|
||||
"hls.js": ["hls.js@1.6.15", "", {}, "sha512-E3a5VwgXimGHwpRGV+WxRTKeSp2DW5DI5MWv34ulL3t5UNmyJWCQ1KmLEHbYzcfThfXG8amBL+fCYPneGHC4VA=="],
|
||||
|
||||
"html-encoding-sniffer": ["html-encoding-sniffer@6.0.0", "", { "dependencies": { "@exodus/bytes": "^1.6.0" } }, "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg=="],
|
||||
|
||||
"html-escaper": ["html-escaper@2.0.2", "", {}, "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg=="],
|
||||
|
||||
@@ -314,57 +314,22 @@ pub struct PlaybackModeManager {
|
||||
|
||||
**Location**: `src-tauri/src/storage/db_service.rs`
|
||||
|
||||
Async database interface wrapping synchronous `rusqlite` to prevent blocking the Tokio runtime:
|
||||
Async database interface over `rusqlite`. `RusqliteService` owns the
|
||||
database: writes run as jobs on one writer thread, reads on a pool of read-only
|
||||
WAL connections, so a read never waits for a write. Callers only see the
|
||||
trait (`execute`, `insert`, `execute_detached`, `query_one` / `query_optional` /
|
||||
`query_many`, `transaction`, `transaction_without_foreign_keys`) and build
|
||||
queries with `Query` + `QueryParam`, which keeps values out of the SQL string.
|
||||
|
||||
```rust
|
||||
#[async_trait]
|
||||
pub trait DatabaseService: Send + Sync {
|
||||
async fn execute(&self, query: Query) -> Result<usize, DatabaseError>;
|
||||
async fn execute_batch(&self, queries: Vec<Query>) -> Result<(), DatabaseError>;
|
||||
async fn query_one<T, F>(&self, query: Query, mapper: F) -> Result<T, DatabaseError>
|
||||
where F: FnOnce(&Row) -> Result<T> + Send + 'static;
|
||||
async fn query_optional<T, F>(&self, query: Query, mapper: F) -> Result<Option<T>, DatabaseError>
|
||||
where F: FnOnce(&Row) -> Result<T> + Send + 'static;
|
||||
async fn query_many<T, F>(&self, query: Query, mapper: F) -> Result<Vec<T>, DatabaseError>
|
||||
where F: Fn(&Row) -> Result<T> + Send + 'static;
|
||||
async fn transaction<F, T>(&self, f: F) -> Result<T, DatabaseError>
|
||||
where F: FnOnce(Transaction) -> Result<T> + Send + 'static;
|
||||
}
|
||||
|
||||
pub struct RusqliteService {
|
||||
connection: Arc<Mutex<Connection>>,
|
||||
}
|
||||
|
||||
impl DatabaseService for RusqliteService {
|
||||
async fn execute(&self, query: Query) -> Result<usize, DatabaseError> {
|
||||
let conn = self.connection.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
// Execute query on blocking thread pool
|
||||
}).await?
|
||||
}
|
||||
// ... other methods use spawn_blocking
|
||||
}
|
||||
```
|
||||
|
||||
**Key Benefits:**
|
||||
- **No Freezing**: All blocking DB ops run in thread pool via `spawn_blocking`
|
||||
- **Type Safety**: `QueryParam` enum prevents SQL injection
|
||||
- **Future Proof**: Easy to swap to native async DB (tokio-rusqlite)
|
||||
- **Testable**: Can mock DatabaseService for tests
|
||||
|
||||
**Usage Pattern:**
|
||||
```rust
|
||||
// Before (blocking - causes UI freeze)
|
||||
let conn = database.connection();
|
||||
let conn = conn.lock().unwrap(); // BLOCKS
|
||||
conn.query_row(...) // BLOCKS
|
||||
|
||||
// After (async - no freezing)
|
||||
let db_service = database.service();
|
||||
let db_service = database.service(); // cheap clone of the shared owner
|
||||
let query = Query::with_params("SELECT ...", vec![...]);
|
||||
db_service.query_one(query, |row| {...}).await // spawn_blocking internally
|
||||
db_service.query_one(query, |row| {...}).await // runs on a reader
|
||||
```
|
||||
|
||||
Ownership model, invariants and the reasons for them:
|
||||
[08-database-design.md → Connection ownership](08-database-design.md#connection-ownership).
|
||||
|
||||
## Component Hierarchy
|
||||
|
||||
```mermaid
|
||||
@@ -701,6 +666,14 @@ render behind it. There is no measurement and no reserved padding. If you
|
||||
restructure the shell, preserve the scroll containment — reintroducing padding
|
||||
math reintroduces the bug.
|
||||
|
||||
**The route renders in exactly one element.** `+layout.svelte` switches the
|
||||
wrapper's *classes* between the shell scroller and the plain clipped box that
|
||||
layout-owning routes (library, settings, player) get — it must not switch
|
||||
between two branches that each render `children`. The page store that decides
|
||||
the mode can update a flush after the new route renders, so two branches
|
||||
mounted a page under one and then remounted it under the other: every
|
||||
navigation between the two kinds of route loaded the page twice (DR-295).
|
||||
|
||||
### AccountMenu
|
||||
|
||||
One component for both breakpoints, anchored to the username/avatar (a real
|
||||
@@ -754,6 +727,22 @@ hero button labelled `Resume S2E4` / `Play S1E1`.
|
||||
A season is not a destination: `/library/<seasonId>` redirects to its series
|
||||
(DR-103). Video library routes collapse to one per library (DR-105).
|
||||
|
||||
**The episode list never waits for Next Up or resume.** `resolve_series_view`
|
||||
(`series_progress.rs`, `with_hints`) returns as soon as the episodes are in;
|
||||
Next Up and resume are used if they have answered by then and dropped if not,
|
||||
and `pick_current_episode` falls back to the episodes' own watch state. They
|
||||
only refine which episode is current, and waiting for them held the list for
|
||||
the server's 2–3 s although every episode was cached. Next Up is cache-first
|
||||
like every other query (03-data-flow).
|
||||
|
||||
**One load per item, however many triggers.** The detail page loads through
|
||||
`createCoalescedLoader` (`utils/coalescedLoader.ts`): calls for the item
|
||||
already loading share that load, and callers that know the data changed
|
||||
(`fresh`: reconnect, filter change, mark watched, clear history) get exactly
|
||||
one re-run after it. `onMount`, a mount-time `$effect`, the reachability
|
||||
effect's first run and the double mount above used to each start a full load —
|
||||
six per open, about seventy requests in flight.
|
||||
|
||||
`episodeStrip.ts` holds the pure logic for the "More Episodes" strip, extracted
|
||||
from the component because it had three distinct bugs that markup made
|
||||
untestable: the strip collapsing to just the current episode while real siblings
|
||||
@@ -802,36 +791,15 @@ by exactly the inset.
|
||||
Unlike `addJavascriptInterface`, the inset push only writes CSS properties, so it
|
||||
can safely be re-sent on resume.
|
||||
|
||||
## Stream Transport
|
||||
## Stream Selection in the Player
|
||||
|
||||
**Location**: `src/lib/player/streamTransport.ts`
|
||||
**TRACES**: UR-079 | DR-225 | UT-214
|
||||
|
||||
`videoLoaderFor(selection, capabilities)` picks the loader for the webview
|
||||
`<video>` element — `hlsjs`, `nativeHls`, or `direct` — from the backend's tagged
|
||||
`selection.transport`. `elementSrcFor` is its template companion: the element's
|
||||
`src` is emptied only when hls.js is driving it.
|
||||
|
||||
The split is the point. **The transport is the stream's property and comes from
|
||||
Rust; whether a given loader exists is the browser's, and is the only thing
|
||||
decided here.**
|
||||
|
||||
> This replaced `currentStreamUrl.includes(".m3u8")`, which appeared twice in
|
||||
> `VideoPlayer.svelte` — once in the HLS `$effect` and once inline in the
|
||||
> template's `src`. Rust builds that URL and knows what it is; re-deriving it
|
||||
> here by substring match was a domain fact reconstructed in the presentation
|
||||
> layer, and it fails silently in both directions. The two tests that pin it are
|
||||
> the ones that failed against the old implementation: a `progressive` stream
|
||||
> whose URL contains `.m3u8` must **not** get an HLS loader, and an `hls` stream
|
||||
> whose URL contains no `.m3u8` must.
|
||||
>
|
||||
> Logic lives in a plain `.ts` module rather than in the component for the usual
|
||||
> reason — it is testable there. Same pattern as `episodeStrip.ts`.
|
||||
**TRACES**: UR-079 | DR-225, DR-227
|
||||
|
||||
`VideoPlayer` holds a `currentSelection`, not a URL string; `currentStreamUrl` is
|
||||
derived from it. A reload replaces the selection **wholesale** (the adapter's
|
||||
bridge takes a `StreamSelection`, not a URL), so transport and URL can never
|
||||
drift apart. The background-audio handoff states the transport it is moving to —
|
||||
derived from it. A reload replaces the selection **wholesale**, so transport and
|
||||
URL can never drift apart — the transport is the stream's property and comes from
|
||||
Rust, never from a substring match on the URL (which is what `.m3u8` sniffing in
|
||||
the component once did). The background-audio handoff states the transport it is moving to —
|
||||
progressive mp3 out, HLS back — via `selectionAt()`, rather than leaving it to be
|
||||
inferred.
|
||||
|
||||
@@ -844,21 +812,16 @@ ceiling above the source bitrate *is* the source.
|
||||
## Native Video Store
|
||||
|
||||
**Location**: `src/lib/stores/nativeVideo.ts`
|
||||
**TRACES**: UR-003, UR-004 | DR-188
|
||||
**TRACES**: UR-003, UR-004 | DR-188, DR-235
|
||||
|
||||
Two separate concerns live here, deliberately:
|
||||
`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**.
|
||||
Rust already decides *which backend this platform has* (`useHtml5Element` from
|
||||
`player_play_item`); this flag only *suppresses* that decision. It never turns
|
||||
native on where Rust says HTML5. An explicit stored choice wins in both
|
||||
directions, so someone who opted out is not re-enabled by a default flip —
|
||||
hence the `null` check rather than a bare `=== "true"`.
|
||||
- `nativeVideoActive` — whether a native surface is on screen *right now*.
|
||||
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.
|
||||
It used to hold `experimentalNativeVideo`, a stored switch that could force video
|
||||
back to the webview `<video>` element. That element is gone (DR-235), and the
|
||||
switch with it.
|
||||
|
||||
See [05-platform-backends.md](05-platform-backends.md#native-video-compositing-android)
|
||||
for what is behind the WebView.
|
||||
|
||||
@@ -28,12 +28,17 @@ sequenceDiagram
|
||||
Server->>Conn: mark_unreachable() (debounced)
|
||||
end
|
||||
|
||||
alt Cache returns with content
|
||||
alt Cache answers first with content (inside 100ms, or later but before the server)
|
||||
Cache-->>Hybrid: Result with items
|
||||
Hybrid-->>Rust: Return cache result
|
||||
else Cache timeout or empty
|
||||
Server-->>Hybrid: Fresh result (later)
|
||||
Hybrid->>Cache: save_to_cache() in background
|
||||
else Server answers first, or cache is empty
|
||||
Server-->>Hybrid: Fresh result
|
||||
Hybrid-->>Rust: Return server result
|
||||
else Server fails
|
||||
Cache-->>Hybrid: Whatever the cache has (waited for)
|
||||
Hybrid-->>Rust: Return cache result, else the server error
|
||||
end
|
||||
|
||||
Rust-->>Client: SearchResult
|
||||
@@ -42,11 +47,21 @@ sequenceDiagram
|
||||
```
|
||||
|
||||
**Key Points:**
|
||||
- Cache queries have 100ms timeout for responsiveness
|
||||
- Server queries always run for fresh data
|
||||
- Cache wins if it has meaningful content
|
||||
- Automatic fallback to server if cache is empty/stale
|
||||
- Background cache updates (planned)
|
||||
- Both legs start together. A cache answer with content inside 100 ms
|
||||
(`CACHE_FAST_PATH`) returns at once.
|
||||
- **The deadline does not decide the race.** A cache read still running at
|
||||
100 ms is raced against the server (`HybridRepository::race_slow_cache`), and
|
||||
whichever answers first *with content* wins. It used to be that a read past
|
||||
the deadline was only consulted if the server failed, so a page whose cache
|
||||
read took 150 ms always paid the full server round trip — about a second on a
|
||||
phone, on every visit.
|
||||
- An empty or failed cache answer is not a win; the server decides. A failed
|
||||
server falls back to whatever the cache said, waiting for it if necessary.
|
||||
- On a cache win the server's page is still cached in the background when it
|
||||
arrives, so per-user state (positions, favourites) keeps up.
|
||||
- A `get_items` leg that takes 250 ms or more is logged at INFO with its row
|
||||
count, so a slow page can be attributed to the cache or the server from a
|
||||
device log alone.
|
||||
- **Connectivity side-effect**: each server request feeds the `ConnectivityMonitor`, which is the source of truth for the offline/online banner (see [07-connectivity.md](07-connectivity.md)). A server-answered error (401/404/5xx) still counts as *reachable* — only network failures, sustained past a debounce window, flip the app to offline.
|
||||
|
||||
### Listing order is decided in Rust
|
||||
@@ -198,8 +213,8 @@ sequenceDiagram
|
||||
Online-->>Page: StreamSelection
|
||||
end
|
||||
Page->>VP: selection
|
||||
VP->>VP: videoLoaderFor(selection, caps)
|
||||
Note over VP: hls.js / native HLS / direct —<br/>from the tag, never from the URL
|
||||
VP->>VP: player_play_item(selection.url, transport)
|
||||
Note over VP: the native player opens it —<br/>transport from the tag, never from the URL
|
||||
```
|
||||
|
||||
The selection travels with the stream from then on. A reload — a quality change,
|
||||
|
||||
@@ -90,55 +90,75 @@ flowchart LR
|
||||
|
||||
**Important**: The command is `player_get_queue` (returns `QueueStatus` with `hasNext`/`hasPrevious`). There is no `player_get_queue_status` command.
|
||||
|
||||
## HTML5 Video Adapter (webview-rendered video)
|
||||
## Video is always native (no webview `<video>`)
|
||||
|
||||
**Location**: `src/lib/player/html5Adapter.ts`, `src/lib/player/index.ts`, report commands in
|
||||
`src-tauri/src/commands/player/timers.rs`
|
||||
**TRACES**: UR-080 | DR-231, DR-235, DR-237
|
||||
|
||||
Video on desktop (Linux WebKitGTK) — and, per current interim behavior, Android — is rendered by an
|
||||
HTML5 `<video>`/HLS element **inside the webview**. libmpv is initialized audio-only (`vo=null`,
|
||||
`video=false`), so the native backend cannot render or observe this element. The `<video>` is therefore
|
||||
the real player, living outside Rust's reach.
|
||||
Every video renderer is a native player drawing **behind** the transparent
|
||||
webview, with the Svelte controls composited over it: ExoPlayer on Android, mpv on
|
||||
Linux and Windows. There is no webview `<video>` element, no hls.js, and no
|
||||
HTML5 adapter; the frontend has one video adapter, `NativePlayerAdapter`, and the
|
||||
backend performs every seek, track switch and quality change itself.
|
||||
|
||||
To keep the `PlayerController` the single source of truth (matching the audio path), the HTML5 element
|
||||
is treated as **a dumb output device that reports back into Rust**, rather than an independent state
|
||||
authority:
|
||||
Why the webview path was deleted rather than kept as a fallback:
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph Webview["Webview"]
|
||||
Video["HTML5 <video> / HLS.js"]
|
||||
Adapter["html5Adapter.ts<br/>(reports DOM events)"]
|
||||
end
|
||||
subgraph Backend["Rust"]
|
||||
Cmds["player_report_state<br/>player_report_position<br/>player_report_media_loaded"]
|
||||
Controller["PlayerController"]
|
||||
Emitter["TauriEventEmitter"]
|
||||
end
|
||||
subgraph Frontend["Frontend"]
|
||||
Events["playerEvents.ts"]
|
||||
Store["player store"]
|
||||
end
|
||||
- **The transcode was a decoder constraint.** The `<video>` element decodes
|
||||
little beyond h264, so the desktop profile could only claim h264 and the server
|
||||
re-encoded almost everything (7% direct play on a real library, against 85% for
|
||||
the same library through ExoPlayer). The machine was never the limit — mpv was
|
||||
already running for audio. (The Linux/Windows profile still claims h264 until
|
||||
DR-234 widens it; see [desktop-native-video.md](../specs/desktop-native-video.md).)
|
||||
- **Each renderer is another place for every bug.** Three video renderers meant
|
||||
every seek strategy, track switch and lifecycle fix had three places to be got
|
||||
right; the webview path was also where the renderer choice itself went wrong
|
||||
(silent Linux video, a Windows soundtrack decoded twice — DR-237).
|
||||
- **A fallback that decodes less is not a fallback.** Android showed it first
|
||||
(DR-293): an original-file download plays silent in the webview. With no
|
||||
webview path there is no silent downgrade to fall into; a failed mpv init
|
||||
emits `backend-init-failed` instead.
|
||||
|
||||
Video -->|DOM events| Adapter --> Cmds --> Controller --> Emitter --> Events --> Store
|
||||
```
|
||||
What survives of the webview reporting pipeline is audio-only:
|
||||
`WebviewAudioBackend` plays through a hidden `<audio>` element and reports back
|
||||
through the `player_report_*` commands (`rustReportHost.ts`), for a desktop with
|
||||
no mpv. No shipped platform uses it.
|
||||
|
||||
**Key points:**
|
||||
- The adapter re-emits the *same* `PlayerStatusEvent`s (`StateChanged`, `PositionUpdate`, `MediaLoaded`)
|
||||
the native backends emit, so `playerEvents.ts` needs **no** HTML5-specific branch — HTML5 is just
|
||||
another event source feeding the existing pipeline.
|
||||
- Position reports are throttled (~250ms) to match the MPV cadence and avoid flooding IPC from the
|
||||
60fps RAF loop.
|
||||
- **Boundary rule**: UI components never touch the report commands or `videoElement` state directly.
|
||||
Playback *control* goes through the unified facade `src/lib/player/index.ts` (`playerController`);
|
||||
HTML5 *state reporting* goes through `html5Adapter.ts`. This restores the documented invariant
|
||||
("frontend only displays state and invokes commands") for the video path.
|
||||
## Native video on the desktop (mpv)
|
||||
|
||||
**TRACES**: UR-080 | DR-231 … DR-237, DR-298, DR-299
|
||||
|
||||
The mpv half is shared; only the surface differs per platform
|
||||
(`mpv_backend::video_output`):
|
||||
|
||||
| Platform | Output | Where the picture goes |
|
||||
|---|---|---|
|
||||
| Linux | `vo=libmpv` (render API) | An FBO drawn in the main window's own `GtkBox` `draw` handler, which GTK paints *before* its children — so beneath the webview, with no widget reparenting (`video_surface.rs`; a `GtkOverlay` aborts the process on the first click, see that module) |
|
||||
| Windows | `vo=gpu-next,gpu`, `wid=<HWND>` | mpv renders as a child of the app window, beneath the transparent WebView2 — the arrangement tauri-plugin-libmpv ships. `wid` only takes effect before initialisation, so `MpvBackend::new` takes the handle; with no handle it draws nothing rather than open a window of its own. mpv's controller, bindings and cursor handling are off |
|
||||
|
||||
In both, the page clears its opaque backgrounds while a video is on screen
|
||||
(`data-native-video`, the same CSS Android uses).
|
||||
|
||||
**Tracks** (`mpv_tracks.rs`): subtitles are the WebVTT list the play request
|
||||
carries, queued on `sub-files` before the load and selected **by position in
|
||||
that list** — the same meaning ExoPlayer gives `player_set_subtitle_track`.
|
||||
Selection starts off (the menu opens on "Off") and `sid`/`aid` are reset before
|
||||
every load, so a choice made for one item cannot leak into the next. Audio tracks
|
||||
are selected by position in the file; a transcode carries one track and is
|
||||
re-opened instead.
|
||||
|
||||
**Two rules for every libmpv handle** (`mpv_command.rs`):
|
||||
|
||||
- Commands go through `mpv_command::command`, an argv built for `mpv_command`,
|
||||
never the pinned crate's `Mpv::command`, which joins its arguments into a
|
||||
string that mpv parses — `;` chains a second command, so a track title in a
|
||||
downloaded file's path could run `run …` (DR-298).
|
||||
- Every handle is hardened before its first load: `tls-verify=yes` (mpv's
|
||||
default is *no*, and its URLs carry the `ApiKey`) and `ytdl=no` (DR-299).
|
||||
|
||||
## MpvBackend (Linux)
|
||||
|
||||
**Location**: `src-tauri/src/player/mpv/`
|
||||
|
||||
The MPV backend uses libmpv for audio playback on Linux. Since MPV handles are not `Send`, all operations occur on a dedicated thread.
|
||||
The MPV backend uses libmpv for audio **and video** playback on Linux and Windows (video: see *Native video on the desktop* above). Since MPV handles are not `Send`, all operations occur on a dedicated thread.
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
@@ -287,6 +307,44 @@ and the trait default is still a silent `Ok(())` rather than an error, so a back
|
||||
that omits the method still reports success. Flipping that default waits on the
|
||||
device verification.
|
||||
|
||||
### Licensed audio codecs: the FFmpeg extension
|
||||
|
||||
**TRACES**: UR-004, UR-071 | DR-293
|
||||
|
||||
Android does not ship AC-3, E-AC-3, DTS or TrueHD decoders — they are licensed
|
||||
codecs, present only where a vendor paid for them. The ROD2-W09 test tablet has a
|
||||
vendor DTS decoder and no AC-3/E-AC-3 at all. ExoPlayer has no decoders of its
|
||||
own, so on such a device those tracks are undecodable, and before this every film
|
||||
with Dolby audio was re-encoded by the server — for streaming *and* for download.
|
||||
|
||||
`JellyTauPlayer` builds ExoPlayer with `DefaultRenderersFactory` in
|
||||
`EXTENSION_RENDERER_MODE_ON`: the platform's decoders are tried first (a vendor DTS
|
||||
decoder stays in charge where there is one) and the FFmpeg audio renderer takes
|
||||
what they cannot decode. `CodecDetector` reports the extension's codecs beside the
|
||||
`MediaCodecList` ones, asking `FfmpegLibrary.supportsFormat` per MIME type rather
|
||||
than assuming, so a build whose native library failed to load reports only what
|
||||
the platform decodes. Rust's device profile and download policy read that list,
|
||||
which is what keeps "what we tell the server" and "what actually decodes" in step.
|
||||
|
||||
The decoder is `org.jellyfin.media3:media3-ffmpeg-decoder` — Jellyfin's build of
|
||||
media3's FFmpeg extension, versioned `<media3 version>+N`. **Bump it in the same
|
||||
commit as media3.** It is GPL-3.0: the distributed APK carries those terms, the
|
||||
source stays MIT (see `THIRD_PARTY_NOTICES.md`). Its JNI methods are covered by the
|
||||
AAR's own consumer rules and by `-keep class androidx.media3.** { *; }` in
|
||||
`proguard-jellytau.pro`, which also keeps the renderer ExoPlayer loads reflectively.
|
||||
|
||||
**Rejected:** re-encoding a download's audio on the device after it lands (a
|
||||
remux). It costs minutes of CPU and twice the disk per film, needs a pipeline
|
||||
state of its own, and does nothing for streaming. Decoding at playback fixes both
|
||||
paths with no extra step.
|
||||
|
||||
### Why the webview could not stay a fallback on Android
|
||||
|
||||
The webview decodes none of the codecs above — so with the original file now
|
||||
downloaded as-is (DR-293), the old `experimentalNativeVideo` switch would have
|
||||
played every such download as a silent film. That is what first removed the
|
||||
webview video path on Android; DR-235 then removed it everywhere.
|
||||
|
||||
### The equalizer, and where its vocabulary lives
|
||||
|
||||
**TRACES**: UR-027 | DR-030, IR-020
|
||||
@@ -314,8 +372,8 @@ chain of unbounded length.
|
||||
Keeping a video's **audio** alive when the app is backgrounded or the screen
|
||||
locks, while video decode stops. Two verified facts drive the whole design:
|
||||
|
||||
1. An Android WebView `<video>` **does not** keep playing audio once the app is
|
||||
backgrounded — the system throttles the WebView and media pauses.
|
||||
1. Nothing in the WebView keeps playing once the app is backgrounded — the
|
||||
system throttles it.
|
||||
2. Keeping audio alive in the background requires a **native foreground media
|
||||
service**, which already exists for music (`JellyTauPlaybackService` +
|
||||
`JellyTauPlayer` + `MediaSessionCompat`).
|
||||
@@ -345,18 +403,14 @@ sequenceDiagram
|
||||
|
||||
Details that were each a shipped defect:
|
||||
|
||||
- **Position is absolute.** Transcoded HLS tracks time as
|
||||
`videoElement.currentTime + seekOffset` (the element resets to 0 after each
|
||||
transcode reload). `computeHandoffPosition` sums both terms; using the element
|
||||
time alone rewinds by the offset.
|
||||
- **Position is absolute** — the position on the item's timeline the player
|
||||
reports, never an offset within a re-opened transcode (`computeHandoffPosition`).
|
||||
- **A downloaded episode takes no base URL and an ordinary seek** (DR-180); a
|
||||
stream takes the base and no seek; a handoff at 0:00 takes neither.
|
||||
- **The return must restart the renderer that is actually on screen** (DR-196).
|
||||
The two paths resume by different means — the webview `<video>` reloads off its
|
||||
stream URL, watched by an `$effect`; ExoPlayer owns no element and nothing
|
||||
watches the URL for it, so it needs an explicit re-issue. Doing only the URL
|
||||
assignment restarted nothing on the native path and left a black screen with a
|
||||
play button that did nothing.
|
||||
- **The return must re-issue the load** (DR-196). The native player owns no
|
||||
element and nothing watches the stream URL for it, so reassigning the URL — how
|
||||
the deleted webview path came back — restarted nothing and left a black screen
|
||||
with a play button that did nothing.
|
||||
- **`wasPlaying` is captured on the way out** so play/pause survives the round
|
||||
trip, and the handoff does not silently rewind (DR-203).
|
||||
- **Mutually exclusive with PiP.** Toggle on → `setAutoEnterEnabled(false)`;
|
||||
@@ -377,10 +431,10 @@ non-Android platform.
|
||||
**TRACES**: UR-003, UR-004 | DR-150 … DR-152, DR-182 … DR-196
|
||||
|
||||
Android can render video on the **native ExoPlayer surface behind a transparent
|
||||
Tauri WebView**, with the Svelte controls drawn over it. This is on by default;
|
||||
the HTML5 `<video>` path remains the fallback and is not being removed. The
|
||||
default has been flipped and reverted twice and each revert has a named cause —
|
||||
the per-defect record is in `requirements.md` (DR-150 … DR-196).
|
||||
Tauri WebView**, with the Svelte controls drawn over it. It is the only video
|
||||
path (DR-235). Before that it was an opt-in whose default was flipped and
|
||||
reverted twice, each revert with a named cause — the per-defect record is in
|
||||
`requirements.md` (DR-150 … DR-196).
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
@@ -410,7 +464,7 @@ Load-bearing details, each of which was a shipped defect:
|
||||
- **The app shell stops painting over the surface** (DR-185). `app.css` clears
|
||||
its opaque backgrounds off `[data-native-video]`; before that, a CSS rule
|
||||
targeted an attribute nothing ever set, so the fix looked applied and was not.
|
||||
- **The poster card can lift on a path with no `<video>` element** (DR-182) — the
|
||||
- **The poster card lifts without a `<video>` element** (DR-182) — the
|
||||
native reveal fires on a `playing` state or a position tick carrying a position
|
||||
or duration, and on nothing else.
|
||||
- **Letterbox bars are painted**, not left holding whatever was last in the
|
||||
|
||||
@@ -186,6 +186,63 @@ Per-item disk usage comes from `repository_get_download_disk_usage`
|
||||
Downloaded browse cards, detail pages, the device total and the remove
|
||||
confirmation (DR-085).
|
||||
|
||||
## What a Video Download Fetches
|
||||
|
||||
**TRACES**: UR-071, UR-004 | DR-171, DR-293
|
||||
|
||||
An `original`-quality download is the server's untouched file (`Static=true`)
|
||||
unless its audio cannot be decoded by **the renderer that will play it** —
|
||||
`renderer_can_decode_audio`, DR-234's per-platform answer. Only then is the
|
||||
server asked to re-encode the audio on the way down (`allowVideoStreamCopy`
|
||||
keeps the picture byte-for-byte).
|
||||
|
||||
The distinction matters because a transcode is generated as it is sent: no
|
||||
`Content-Length`, `Range` ignored. It measured ~1 MB/s and restarted from byte
|
||||
zero on every network blip, against a direct copy that moved a 910 MB episode in
|
||||
94 s with no retries. On Android the renderer is ExoPlayer with the FFmpeg
|
||||
extension ([05-platform-backends.md](05-platform-backends.md)), which decodes
|
||||
AC-3/E-AC-3/DTS/TrueHD, so Android downloads are always the direct copy. On
|
||||
Linux the webview still renders video and the transcode still applies.
|
||||
|
||||
The policy used to judge against the *webview's* codec list on every platform
|
||||
(DR-171), because a download outlives the native-video setting that was active
|
||||
when it arrived. That reasoning is why Android's webview video path was removed
|
||||
rather than merely defaulted off: a file downloaded as the original must never
|
||||
meet a renderer that cannot decode it.
|
||||
|
||||
## Offline Means No Network
|
||||
|
||||
**TRACES**: UR-002, UR-071 | DR-294
|
||||
|
||||
Three defects made "offline" depend on the network; the invariants that replace
|
||||
them:
|
||||
|
||||
- **A download plays without the server.** Playing a downloaded item asked the
|
||||
server for its `PlaybackInfo` only to read the media-source id; offline that
|
||||
retried for seven seconds, failed, and the file was never opened.
|
||||
`OfflineRepository::local_playback_info` answers for any completed download of
|
||||
the current user — local path, direct play, item id as media source (a download
|
||||
names no source, so the server served its default, which carries the item's id)
|
||||
— and `HybridRepository::get_playback_info` consults it **first**.
|
||||
- **A slow cache read is waited for, never discarded.** Reads no longer queue
|
||||
behind writes (see [Connection ownership](08-database-design.md#connection-ownership)),
|
||||
but a big query, a cold page cache or a busy reader pool can still push a read
|
||||
past the 100 ms fast path. Such a read is raced against the server rather
|
||||
than set aside (see [03-data-flow.md](03-data-flow.md)). `get_items`, the library list, genres and playlist items used to discard
|
||||
such a read, wait for the server, and — offline — return its error over data on
|
||||
disk; "More info" on a downloaded show failed that way. They now start the read
|
||||
with `cache_try` (which keeps it running) and `settle` on it when the server
|
||||
fails. Cache-only reads (search, favourites) have no server to fall back from,
|
||||
so they simply await the cache.
|
||||
- **Server-only sections degrade, they do not fail a page.** Next Up went only to
|
||||
the server, and the TV landing page loads it in one `Promise.all`, so offline it
|
||||
blanked the whole page. It now falls back to the cache when the server cannot
|
||||
answer.
|
||||
|
||||
What still needs the server, deliberately: streaming anything not downloaded,
|
||||
live TV and channels, reporting playback, and edits (favourites, playlists,
|
||||
played state).
|
||||
|
||||
## Download Commands
|
||||
|
||||
**Location**: `src-tauri/src/commands/download/` — `mod.rs` (the commands below), `pinning.rs`, `smart_cache.rs`
|
||||
|
||||
@@ -243,9 +243,14 @@ CREATE TABLE items (
|
||||
last_sync DATETIME,
|
||||
|
||||
UNIQUE(jellyfin_id, server_id)
|
||||
|
||||
-- Logical container (migration 027): episode → season/series, season →
|
||||
-- series, track → album, else parent. See "Listing query shape".
|
||||
-- container_id TEXT GENERATED ALWAYS AS (CASE item_type … END) VIRTUAL
|
||||
);
|
||||
|
||||
-- Performance indexes
|
||||
CREATE INDEX idx_items_container ON items(container_id, sort_name, name);
|
||||
CREATE INDEX idx_items_server ON items(server_id);
|
||||
CREATE INDEX idx_items_library ON items(library_id);
|
||||
CREATE INDEX idx_items_parent ON items(parent_id);
|
||||
@@ -594,6 +599,125 @@ flowchart LR
|
||||
| Episode | ~3 KB | ~100 KB | 300 MB - 2 GB |
|
||||
| Full music library (5000 songs) | ~10 MB | ~250 MB | 25-75 GB |
|
||||
|
||||
## Connection ownership
|
||||
|
||||
`storage::Database` is opened once at startup and owns the database for the
|
||||
life of the app; nothing else opens the file. Everything goes through
|
||||
`Database::service()`, which returns a clone of one `RusqliteService`
|
||||
(`storage/db_service.rs`) — callers never hold a `Connection`.
|
||||
|
||||
```
|
||||
┌─────────────────────────────┐
|
||||
execute / insert / │ writer thread ("db-writer") │ one read-write connection,
|
||||
transaction / ────►│ jobs run in arrival order │ foreign_keys = ON
|
||||
execute_detached └─────────────────────────────┘
|
||||
┌─────────────────────────────┐
|
||||
query_one / │ reader pool (3 connections) │ query_only = ON; WAL gives
|
||||
query_optional / ─►│ via spawn_blocking │ each the last committed
|
||||
query_many └─────────────────────────────┘ snapshot
|
||||
```
|
||||
|
||||
Pragmas: `journal_mode = WAL`, `synchronous = NORMAL`, `busy_timeout = 5 s` on
|
||||
every connection. `PRAGMA optimize` runs once at open, after migrations, so the planner has
|
||||
statistics (see "Listing query shape").
|
||||
|
||||
**Why.** It used to be one connection behind one `std::sync::Mutex`. WAL was on,
|
||||
but with a single connection its one benefit — readers running beside a writer —
|
||||
never applied: every read queued behind every write. A series page's background
|
||||
refresh cached hundreds of episodes one autocommit (and one fsync) at a time,
|
||||
and library pages, thumbnail lookups and settings reads all waited behind the
|
||||
pile; the same page measured 1.7 s or 3.95 s depending on what was queued.
|
||||
|
||||
**Invariants a change must keep:**
|
||||
|
||||
- **Writes go to the writer, reads to the pool.** A reader is `query_only`, so a
|
||||
write sent through `query_*` fails loudly rather than racing the writer. A
|
||||
read that must see a write *in the same unit of work* belongs inside the
|
||||
`transaction` closure, which runs on the writer.
|
||||
- **Connection-wide state is set inside one writer job.** `PRAGMA foreign_keys`
|
||||
is per connection and ignored inside a transaction. `save_to_cache` used to
|
||||
switch it off with one `execute` and back on with another, so it stayed off
|
||||
for every other write that ran across the save's awaits (they did — nearly
|
||||
all of 1,600 FK-violating writes got through in the regression test).
|
||||
`transaction_without_foreign_keys` flips it around `BEGIN`/`COMMIT` in a single
|
||||
job. Any future pragma toggle must work the same way.
|
||||
- **An insert's rowid comes from the same job.** `insert()` returns it; there is
|
||||
deliberately no standalone `last_insert_rowid()`, which returned whichever row
|
||||
the last *anyone* inserted.
|
||||
- **Batch writes into one transaction.** A commit is a queue slot on the
|
||||
writer; a page of items is one `transaction`, not one `execute` per row.
|
||||
- **Best-effort bookkeeping does not wait.** `execute_detached` queues a write
|
||||
(the thumbnail LRU access time) in order with the rest and returns
|
||||
immediately; failures are only logged.
|
||||
- **The writer survives a panicking job.** The job's caller gets an error; the
|
||||
loop rolls back any transaction the job left open and restores
|
||||
`foreign_keys = ON`, then carries on.
|
||||
|
||||
**`synchronous = NORMAL`** is corruption-safe in WAL mode and survives an app
|
||||
crash; only a power cut can lose the last few commits. Everything stored is
|
||||
either re-fetchable from the server or (the sync queue, local positions)
|
||||
recoverable at that granularity.
|
||||
|
||||
**In-memory databases** (tests) cannot be shared between connections, so
|
||||
`RusqliteService::new` has no pool and routes reads through the writer — the
|
||||
old serialized behaviour.
|
||||
|
||||
**Not done, and why:** grouping consecutive small `execute` jobs into one
|
||||
commit on the writer. With `synchronous = NORMAL` a commit no longer fsyncs, so
|
||||
the gain is small, and it would change a failed statement's error semantics
|
||||
for the jobs grouped with it.
|
||||
|
||||
### Listing query shape
|
||||
|
||||
`OfflineRepository::get_items` (`items_listing_sql`) is the hot read: every
|
||||
library, series, season and album page goes through it, and it must fit the
|
||||
100 ms cache fast path on a phone. Every other cached read follows the same
|
||||
rules.
|
||||
|
||||
- **Children are matched on `items.container_id`** (migration 027), a VIRTUAL
|
||||
generated column holding the item's *logical* container: an episode's season
|
||||
(else series, else parent), a season's series, a track's album, otherwise the
|
||||
parent. Jellyfin's `ParentId` is the storage parent, not the logical one — in
|
||||
a series without season folders an episode's `ParentId` is the series while
|
||||
its `SeasonId` names a virtual season — so listings used to match on four
|
||||
columns at once. That `OR` defeated the planner into walking the whole table,
|
||||
and it was wrong: every episode carries its series id, so a series listed all
|
||||
its episodes beside its seasons. Being generated, the column covers every
|
||||
write path (cache, downloads, catalog crawl) without any of them knowing, and
|
||||
cannot drift from the columns it is computed from.
|
||||
- **`idx_items_container (container_id, sort_name, name)`** serves a listing as
|
||||
one index range already in display order — no sort step. `sort_name` is
|
||||
usually NULL in the cache (the cache never writes it), hence `name` in the
|
||||
index too.
|
||||
- **Containers exist even when never browsed.** A series lists its seasons, so
|
||||
an episode whose season row was never cached (it arrived through Next Up or
|
||||
Latest) would be unreachable from its series offline. `save_to_cache` — and
|
||||
migration 027 for rows already on disk — inserts placeholders named from the
|
||||
child's own fields (`season_name`, `series_name`, `album_name`) with
|
||||
`synced_at` NULL, so they show only when a download makes them available; the
|
||||
server's real row replaces them wholesale on the next browse.
|
||||
- **Availability is a per-row `EXISTS`** (`downloaded_sql` / `available_sql`):
|
||||
cached for browsing (only with the catalog-browse flag), downloaded, or a
|
||||
container with a downloaded *descendant* — which is why that one check still
|
||||
looks at all four link columns (a series is available through an episode two
|
||||
levels down). It used to be a CTE that built the id of every available item
|
||||
in the database before filtering, paying for the whole table on every call.
|
||||
- **The library clause is added only for a library parent** (`is_library`),
|
||||
never `OR`ed into an ordinary listing, where it forces a full scan.
|
||||
- **`+i.server_id`** keeps the planner off the server index, which every row
|
||||
shares. `PRAGMA optimize` at open (`analysis_limit = 400`) gives the planner
|
||||
statistics, but even with them it chose that index for the old `OR`; the `+`
|
||||
is the guarantee.
|
||||
- **User data is fetched in batches** (`with_user_data`, one `IN (…)` query per
|
||||
500 rows), not once per row.
|
||||
|
||||
Measured on a ~110k-item benchmark catalogue (desktop): a series listing went
|
||||
from ~80 ms to under 1 ms; migration 027 upgrades an existing database of that
|
||||
size in ~0.1 s. `listing_a_non_library_parent_uses_the_container_index` and
|
||||
migration 027's tests assert the plans; `storage::tests::write_bench_database`
|
||||
(ignored; set `JELLYTAU_BENCH_DB`) writes the benchmark catalogue for the
|
||||
`sqlite3` CLI.
|
||||
|
||||
## Rust Module Structure
|
||||
|
||||
```
|
||||
|
||||
@@ -68,8 +68,8 @@ style-src 'self' 'unsafe-inline';
|
||||
font-src 'self' data:;
|
||||
img-src 'self' data: blob: asset: http://asset.localhost http: https:;
|
||||
media-src 'self' blob: asset: http://asset.localhost http://127.0.0.1:* http: https:;
|
||||
connect-src 'self' ipc: http://ipc.localhost http: https:;
|
||||
worker-src 'self' blob:;
|
||||
connect-src 'self' ipc: http://ipc.localhost;
|
||||
worker-src 'self';
|
||||
object-src 'none'; frame-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'
|
||||
```
|
||||
|
||||
@@ -79,13 +79,13 @@ object-src 'none'; frame-src 'none'; base-uri 'self'; form-action 'self'; frame-
|
||||
| `script-src 'self'` | The genuinely restrictive half. Bundled JS only; Tauri's build-time nonce covers the one inline `<script>` in `index.html`. Adding `'unsafe-inline'` here would silently do nothing anyway — a nonce in a directive voids it. |
|
||||
| `style-src 'self' 'unsafe-inline'` | Svelte compiles `style="…"` attributes into markup, including `app.html`'s `display: contents` wrapper, and CSP treats a style *attribute* as inline. Safe only while no `<style>` **element** survives into `index.html`: Tauri would nonce it, and the nonce would then void `'unsafe-inline'`. The production build extracts all CSS to files, so it currently has none. |
|
||||
| `img-src` | Thumbnails come from two places: the asset protocol (`asset://localhost/…` on Linux/macOS, `http://asset.localhost/…` on Windows/Android — the same protocol, named differently by `convertFileSrc`) and, on a cache miss, straight from the Jellyfin server. `data:`/`blob:` cover inline and generated images. |
|
||||
| `media-src` | `<video>`/`<audio>` sources: HLS transcodes and progressive streams from the server, the token-guarded loopback media server on `http://127.0.0.1:<random port>` (DR-137), and `blob:` for the MSE object URL hls.js attaches. |
|
||||
| `connect-src` | `ipc:` / `http://ipc.localhost` is Tauri's `invoke` transport (custom scheme on Linux/macOS, `http` host on Windows/Android) — without it every command is blocked. `http:`/`https:` is hls.js fetching manifests and segments; ordinary API traffic goes through Rust and is not subject to CSP. |
|
||||
| `worker-src 'self' blob:` | hls.js runs its demuxer in a worker built from a blob (`enableWorker: true`). Without `blob:` it falls back to main-thread demuxing — playback survives but costs more CPU. |
|
||||
| `media-src` | `<audio>` sources for the webview audio backend (a desktop without mpv; no shipped platform): streams from the server and the token-guarded loopback media server on `http://127.0.0.1:<random port>` (DR-137). Video never plays in the webview (DR-235). |
|
||||
| `connect-src` | `ipc:` / `http://ipc.localhost` is Tauri's `invoke` transport (custom scheme on Linux/macOS, `http` host on Windows/Android) — without it every command is blocked. Nothing else: all network traffic goes through Rust. It allowed any `http:`/`https:` host while hls.js fetched manifests and segments in the page; with hls.js gone (DR-235) that grant was only an exfiltration channel for injected script, so it went too. |
|
||||
| `worker-src 'self'` | No blob workers since hls.js (whose demuxer ran in one) was removed (DR-235). |
|
||||
| `object-src`, `frame-src` = `'none'` | No plugins, no iframes; both are classic injection sinks. |
|
||||
| `base-uri 'self'`, `form-action 'self'`, `frame-ancestors 'none'` | Block `<base>` hijacking, form exfiltration and framing. `frame-ancestors` is only honoured when the policy is delivered as a header, which is platform-dependent; it is harmless where it is not. |
|
||||
|
||||
**`img-src`/`media-src`/`connect-src` are deliberately permissive.** The Jellyfin
|
||||
**`img-src`/`media-src` are deliberately permissive.** The Jellyfin
|
||||
origin is typed in by the user at run time and is routinely plain `http` on a
|
||||
LAN, so it cannot be enumerated at build time. `http: https:` is a wide grant for
|
||||
*data* — but it still bars `file:`, `filesystem:` and scripting schemes, and it
|
||||
|
||||
@@ -99,7 +99,7 @@ Each major subsystem is documented in its own file in this directory:
|
||||
| [02 - Svelte Frontend](02-svelte-frontend.md) | Store structure, music library navigation, playback reporting, repository architecture, playback mode system, database service abstraction, component hierarchy, MiniPlayer, sleep timer, auto-play, navigation guard, playlist management UI, library mosaic, series/episode navigation, downloaded browse, safe-area insets, native-video store, logging |
|
||||
| [03 - Data Flow](03-data-flow.md) | Repository query flow (cache-first), locally-indexed search, playback initiation, playback mode transfer, queue navigation, volume control |
|
||||
| [04 - Type Sync & Threading](04-type-sync-and-threading.md) | Rust/TypeScript type synchronization, Tauri v2 IPC parameter naming convention, thread safety patterns |
|
||||
| [05 - Platform Backends](05-platform-backends.md) | Player events system, HTML5 video adapter, MpvBackend (Linux), ExoPlayerBackend (Android) incl. audio settings parity, **native video compositing**, MediaSession & remote volume, album art caching, backend initialization |
|
||||
| [05 - Platform Backends](05-platform-backends.md) | Player events system, MpvBackend (Linux), ExoPlayerBackend (Android) incl. audio settings parity, **native video compositing**, MediaSession & remote volume, album art caching, backend initialization |
|
||||
| [06 - Downloads & Offline](06-downloads-and-offline.md) | Download manager, download worker, smart caching engine, **one storage model (cache entries are downloads)**, offline catalog visibility, download/offline commands, player integration, frontend store, UI components |
|
||||
| [07 - Connectivity](07-connectivity.md) | HTTP client with retry logic, connectivity monitor, network resilience architecture |
|
||||
| [08 - Database Design](08-database-design.md) | Entity relationships, all table definitions (servers, users, libraries, items, user_data, downloads, media_streams, sync_queue, thumbnails, playlists), key queries, data flow diagrams, storage estimates |
|
||||
@@ -176,7 +176,6 @@ src/lib/
|
||||
│ └── sessions.ts # SessionsApi (remote session control)
|
||||
├── player/ # Unified player boundary (frontend)
|
||||
│ ├── index.ts # playerController facade — the only write-side entry point for playback
|
||||
│ └── html5Adapter.ts # Reports webview <video> DOM events back into Rust (player_report_*)
|
||||
├── services/
|
||||
│ ├── playerEvents.ts # Tauri event listener for player events
|
||||
│ └── playbackReporting.ts # Thin wrapper (~50 lines)
|
||||
|
||||
Vendored
+35
-17
@@ -7,17 +7,37 @@ job / SMTC lockscreen), but it runs and plays media.
|
||||
|
||||
## How playback works on Windows
|
||||
|
||||
- **Video** — renders through the webview HTML5 `<video>` element (hls.js) on
|
||||
*every* platform; on Windows that is WebView2 (Chromium/Edge), which plays HLS +
|
||||
h264 fine. No Windows-specific code.
|
||||
- **Audio-only (music)** — the native audio backends are libmpv (Linux) and
|
||||
ExoPlayer (Android); neither exists on Windows. Instead
|
||||
`create_player_backend()` in [../src-tauri/src/lib.rs](../../src-tauri/src/lib.rs)
|
||||
uses `WebviewAudioBackend` on non-Linux/non-Android targets: it hands the stream
|
||||
URL to a webview `<audio>` element (see
|
||||
[../src/lib/services/webviewAudio.ts](../../src/lib/services/webviewAudio.ts)),
|
||||
which reports state back through the same `player_report_*` round-trip the video
|
||||
path uses. Pure Rust + Tauri events.
|
||||
- **Video** — **mpv**, drawing into the app's own window: `MpvBackend` is
|
||||
handed the main window's HWND as `wid` before mpv initialises and renders with
|
||||
`vo=gpu-next,gpu` as a child of it, beneath the transparent WebView2 whose page
|
||||
clears its background while a video is on screen (`data-native-video`). mpv's
|
||||
own controller, key bindings and cursor handling are off — the Svelte controls
|
||||
drawn over it are the only ones. This is the arrangement tauri-plugin-libmpv
|
||||
ships on Windows (same zhongfly LGPL DLL). **Not yet seen on real Windows
|
||||
hardware** — if the picture ends up *over* the controls, the z-order of mpv's
|
||||
child window is the first thing to check.
|
||||
- **Audio** — **libmpv**, the same `MpvBackend` Linux uses, with `ao=wasapi`.
|
||||
Volume, EQ, normalization and gapless all go through mpv's filter graph as on
|
||||
Linux. `libmpv-2.dll` ships beside `jellytau.exe` in the installer.
|
||||
|
||||
### libmpv on Windows
|
||||
|
||||
- **Which build:** zhongfly/mpv-winbuild's *LGPL* dev asset, pinned by name and
|
||||
sha256 in [Dockerfile.builder](../../Dockerfile.builder), which unpacks it to
|
||||
`/opt/libmpv-win64`. Licence terms: [THIRD_PARTY_NOTICES.md](../../THIRD_PARTY_NOTICES.md).
|
||||
The same release also carries a GPL asset (x264/x265) — do not switch to it
|
||||
casually, it moves the installer onto GPL-3.0 terms.
|
||||
- **Import library:** the archive only ships a MinGW `libmpv.dll.a`. The image
|
||||
generates an MSVC `mpv.lib` from the DLL's own `mpv_*` exports
|
||||
(`llvm-readobj --coff-exports` → `.def` → `llvm-lib /def:`), so it cannot name
|
||||
a symbol the DLL lacks.
|
||||
- **One directory:** `scripts/build-windows-cross.sh` copies both files into
|
||||
`src-tauri/windows-libs/` (gitignored) before building. `build.rs` links
|
||||
`mpv.lib` from there and `tauri.windows.conf.json` bundles the DLL from there,
|
||||
so the DLL shipped is the one linked against. Outside the image, point
|
||||
`LIBMPV_WIN_DIR` at a directory holding `libmpv-2.dll` and `mpv.lib`.
|
||||
- **Bumping it:** change the three `LIBMPV_WIN_*` values together, rebuild and
|
||||
push the image, bump the image tag the workflows pin.
|
||||
|
||||
## Cross-compiling from Linux (MSVC + cargo-xwin)
|
||||
|
||||
@@ -35,7 +55,7 @@ Tauri CLI bundle the **NSIS installer from a Linux host**.
|
||||
|
||||
The builder image ([../Dockerfile.builder](../../Dockerfile.builder)) bakes in the
|
||||
whole toolchain: the `x86_64-pc-windows-msvc` rust target, `cargo-xwin`, `lld`,
|
||||
`llvm`, and `nsis`.
|
||||
`llvm`, `nsis`, and the pinned Windows libmpv.
|
||||
|
||||
```bash
|
||||
bun run docker:build:windows # NSIS installer + .exe -> ./dist
|
||||
@@ -71,8 +91,6 @@ Outputs:
|
||||
|
||||
## Outstanding for a first-class Windows release
|
||||
|
||||
1. Gapless/crossfade + SMTC (lockscreen) — currently no-ops in the webview audio
|
||||
path.
|
||||
2. Downloaded (`Local` source) file playback needs `convertFileSrc` on the
|
||||
frontend; streaming works today.
|
||||
3. Code signing + a Windows packaging CI job.
|
||||
1. SMTC (lockscreen / media keys) — not wired on Windows.
|
||||
2. Code signing — the installer is unsigned.
|
||||
3. First run of mpv video on real Windows hardware (DR-237).
|
||||
|
||||
@@ -80,10 +80,10 @@ introduced during this work passed conformance.
|
||||
|
||||
## 3. Desktop (Linux)
|
||||
|
||||
Run with native video on, since that is what is new:
|
||||
mpv draws all Linux video (DR-235) — there is no switch:
|
||||
|
||||
```bash
|
||||
JELLYTAU_NATIVE_VIDEO=1 bun run tauri dev
|
||||
bun run tauri dev
|
||||
```
|
||||
|
||||
- [ ] **Direct play** — a file the server does not transcode. Picture and sound.
|
||||
|
||||
+48
-19
@@ -384,7 +384,7 @@ Internal architecture, components, and application logic.
|
||||
| DR-188 | Native Android video is **ready to be the default except for the background-audio handoff**, and the flip therefore waits. The picture defects behind DR-172 are all found, fixed and device-verified — DR-185 (the app shell painted over the surface through a CSS rule targeting an attribute nothing set), DR-182 (nothing could lift the poster card on a path with no `<video>` element), DR-183 (the JS bridges raced the page load, so `setTransparent(true)` could never arrive), DR-184 (the SurfaceView was never detached), plus DR-186 and DR-187, the two UI defects only this path could reveal. On a device logcat now carries `WebView transparent = true` and `Marking media ready` with video on screen, which is the pair DR-172 went looking for and could not find, and skip, seek and rotation were exercised by hand. Turning the default on then surfaced a *different* unverified sub-path: the background-audio handoff could only *return* through the HTML5 element, so coming back from the lockscreen left playback dead, and the flip waited for that rather than shipping a verified sub-path over an unverified one as DR-161 had. **The default is now on.** The two defects holding it back are fixed and device-verified — DR-196 (the handoff return restarts the renderer that is actually on screen) and DR-194 (the letterbox bars are painted rather than retaining stale framebuffer content) — with the evidence this default has been held to since DR-161: an audio handoff at 69:54 returning to video playing at 70:18, and clean bars across playback, the control bar and a rotation round-trip. An explicit stored choice still wins in both directions, so an opt-out survives the flip (the stored value is null-checked rather than compared to "true", which would have silently re-enabled it for everyone who turned it off) | Android | UR-003, UR-004 | Done |
|
||||
| DR-189 | The control bar comes down on a touchscreen. Its hide timer was armed from exactly one place — the player container's `onmousemove` — and a touchscreen never fires `mousemove`, so on Android the bar was never scheduled to hide and sat over the video for the whole film. It went unnoticed for as long as the native video surface was itself invisible (DR-172/DR-185): with nothing behind it to obscure, a permanent control bar reads as the UI rather than as a defect. Two changes, because there were two faults. `revealControls()` replaces `handleMouseMove` and is called on entry and on every touch interaction as well as on mouse movement, so touch arms the countdown. And the countdown became an `$effect` over the state rather than a one-shot timer armed by the input event: the first attempt armed a timer on entry, three seconds later playback had not started, `shouldHideControls` correctly declined, and nothing ever re-armed it — the timer has to follow the conditions that *permit* hiding, which arrive on their own schedule. The decision itself is `shouldHideControls` in `controlsVisibility.ts`, pure and separated from the clock and the DOM, because what was wrong here was the conditions and not the `setTimeout`: the bar stays up while paused (a user who paused by tapping the surface has no other way back), mid-seek (the position readout is the point of the bar then), and while any track/subtitle/quality menu is open (the menus are anchored to the bar, so hiding it would take the open menu with it) | UI | UR-003, UR-066 | Done |
|
||||
| DR-191 | Forcing the WebView overlay to redraw from the Activity, because with the ExoPlayer **SurfaceView** beneath it the overlay's ordinary damage stopped reaching the screen: the page kept mutating — the clock text every second, the control bar's opacity going to 0 — while the display held whatever frame it last presented, over video that animated perfectly. Not a state defect; the live DOM showed the slider advancing 476 → 479 across three seconds behind a screen showing neither. Only **structural** changes got through, which is why the play overlay always appeared to work (an `{#if}` block, added and removed) while the progress bar never did, and why rotation lost the transport UI. A CSS animation cannot help, since opacity animates on the compositor without repainting the layer. **Superseded by DR-192**: this drove `postInvalidateOnAnimation` in a loop, which treats the symptom — the cause is the SurfaceView's separate layer, and removing that removes the need. Kept as the record of how the mechanism was identified | Android | UR-003, UR-004 | Superseded by DR-192 |
|
||||
| DR-195 | Play/pause works on the native path, because the frontend stops claiming a webview element is playing when there is none. `html5_playing` is Rust's record of "a webview `<video>` is active and in this state", and `toggle_playback`, `play` and `pause` all route transport to that element whenever it is set. The player route mirrored element state into it **unconditionally** — from `handleReportStart` and, fatally, from `handleReportProgress`, which VideoPlayer calls on a 10-second interval — so on the native path the frontend re-declared every ten seconds that an element was playing when none existed, and every transport intent was emitted into the void. The pause button was dead from the on-screen tap, from the control bar, and from a direct `player_toggle` invocation, while seek and skip kept working because `player_seek_video` decides elsewhere; that asymmetry is the signature. It also explains the flashing, since the control bar and the JRay overlay both key off `isPlaying`, which was being contradicted on every interval tick. DR-193 clearing the flag at load was necessary but insufficient on its own — the interval put it straight back. The mirror now lives in `mirrorElementStateToRust` in VideoPlayer, gated on `useHtml5Element`, which is the only place that knows whether an element renders at all; the route cannot tell the two paths apart, which is precisely how it came to lie. Confirmed on device by ADB: surface tap and control bar each pause (position frozen across repeated samples, transport label flipped) and resume | Playback | UR-005, UR-003 | Done |
|
||||
| DR-195 | Play/pause works on the native path, because the frontend stops claiming a webview element is playing when there is none. `html5_playing` is Rust's record of "a webview `<video>` is active and in this state", and `toggle_playback`, `play` and `pause` all route transport to that element whenever it is set. The player route mirrored element state into it **unconditionally** — from `handleReportStart` and, fatally, from `handleReportProgress`, which VideoPlayer calls on a 10-second interval — so on the native path the frontend re-declared every ten seconds that an element was playing when none existed, and every transport intent was emitted into the void. The pause button was dead from the on-screen tap, from the control bar, and from a direct `player_toggle` invocation, while seek and skip kept working because `player_seek_video` decides elsewhere; that asymmetry is the signature. It also explains the flashing, since the control bar and the JRay overlay both key off `isPlaying`, which was being contradicted on every interval tick. DR-193 clearing the flag at load was necessary but insufficient on its own — the interval put it straight back. The mirror now lives in `mirrorElementStateToRust` in VideoPlayer, gated on `useHtml5Element`, which is the only place that knows whether an element renders at all; the route cannot tell the two paths apart, which is precisely how it came to lie. Confirmed on device by ADB: surface tap and control bar each pause (position frozen across repeated samples, transport label flipped) and resume | Playback | UR-005, UR-003 | Superseded by DR-235 |
|
||||
| DR-196 | Returning from background audio brings the picture back on the **native** path, because the return now restarts the renderer that is actually on screen. The two paths resume by different means: the webview `<video>` reloads off its stream URL, watched by an `$effect` that reinitialises HLS and lets `canplay` drive the seek — while ExoPlayer owns no element and nothing watches the URL on its behalf, so its playback is only ever started by an explicit `player_play_item` + adapter load, issued once from `onMount`. `exitBackgroundAudioHandoff` did only the URL assignment, for both paths, so on the native path it restarted nothing: `player_exit_background_audio` had already stopped the handoff's audio player, leaving the backend holding no item at all. The symptom is a black screen with a play overlay pinned at 0:00, a seek bar at zero, and a play button that does nothing — the process alive and the frontend still logging, since nothing crashed; the transition was simply dropped. The branch is decided by `planHandoffReturn` (pure, in `backgroundAudioHandoff.ts`), which also folds in `shouldResumeOnForeground` so a lockscreen pause during the handoff still wins over the snapshot taken on the way out. Subtitle configurations are reused from the ones resolved at mount, since ExoPlayer sideloads them as `MediaItem.SubtitleConfiguration`s and cannot accept one after `prepare()`. Verified on device: handoff to audio at 69:54, return restored video playing at 70:18 | Playback | UR-040, UR-003 | Done |
|
||||
| DR-197 | Continue Watching and Next Up stop showing the same episode. Jellyfin's `/Shows/NextUp` defaults `EnableResumable=true`, which returns a partially-watched episode as its own series' next up — precisely the episode `/Items/Resume` already returns — so the Home "Next Episode" row and the TV landing's Next Up row duplicated Continue Watching card for card. `build_next_up_endpoint` sends `EnableResumable=false`, and because servers predating that parameter ignore it, `filterInProgressNextUpItems` also drops any next-up entry whose id appears in the resume list. It is the mirror of DR-089 and lives beside it: same presentation-layer de-duplication over two lists the frontend already holds, no Jellyfin taxonomy involved. The resume filter still reads its frontier from the *unfiltered* Next Up list, so removing in-progress entries cannot resurrect a stale resume card. The division is then exact: Continue Watching offers episodes the viewer has started and not finished, Next Up offers the episode after the ones they finished | Repository | UR-059 | Done |
|
||||
| DR-200 | The lockscreen notification is exempt from `POST_NOTIFICATIONS`, because of the **session token**, not because it belongs to a foreground service — and the difference is what the code now records. `POST_NOTIFICATIONS` was declared in the manifest and requested nowhere, so on Android 13+ it sat permanently denied; an audit read that as a threat to UR-006, since the media notification is what carries the lockscreen transport controls. It is not. Android's own wording is that the permission covers "non-exempt (including Foreground Services (FGS)) notifications", with denied users seeing FGS notices "in the Task Manager but [not] in the notification drawer" — so an FGS notification is explicitly *not* exempt — while separately "Notifications related to media sessions are exempt from this behavior change". The platform predicate is `Notification.isMediaNotification()`, which requires `MediaStyle` **and** a non-null `EXTRA_MEDIA_SESSION`, and it is byte-identical across API 33–36. `NotificationManagerService` uses it to decide whether to drop the post, and SystemUI's media carousel (`MediaDataProcessor.onNotificationAdded`) is gated on the *same* predicate — so a token-less notification is not merely absent from the shade, it never reaches the notification listener and the lockscreen/Quick-Settings controls do not exist at all. Confirmed on device (HONOR ROD2-W09, Android 16 / SDK 36): appops `POST_NOTIFICATION: ignore`, `granted=false`, and the service simultaneously `isForeground=true` with `foregroundNoti=Notification(category=transport actions=3 vis=PUBLIC)`. So **no runtime permission request is added** — a prompt the app does not need is a prompt that can be permanently denied for nothing — and no `checkSelfPermission` gate is placed on `startForeground`, which would trade a cosmetic problem for the "did not then call Service.startForeground()" kill. What is added is the guard that matches the real precondition: `mediaSessionCompat?.sessionToken` is a null-safe call, and the exemption hangs entirely on it, so both builders now bind the token once and log an error if it is ever null while the permission is denied — converting a failure that is invisible unless the tester happened to deny the permission (most grant it reflexively) into a logcat line. The manifest declaration is *kept*, unrequested, and documented: media3 does not need it (media3-session declares no permissions and the `MediaSessionService` guide asks only for the two `FOREGROUND_SERVICE` ones), but the exemption covers media and self-managed-call notifications only, so a download-completion notice (UR-011) would be an ordinary notification and silently dropped — keeping the declaration is what makes adding one a one-file change | Android | UR-006 | Done |
|
||||
@@ -437,9 +437,9 @@ Internal architecture, components, and application logic.
|
||||
| DR-232 | The mpv render context's lifetime is bound to the GL context it draws into: created on `realize`, freed on `unrealize`, on the same thread, with the update callback unregistered *before* the free so a callback cannot land on a freed context. This is DR-184 on Android restated — a surface outliving its player — and it is a requirement in its own right rather than a fix for a specific crash. The spike observed one SIGSEGV in a decoder thread that three targeted soaks failed to reproduce; what is not in doubt is that the spike never called `mpv_render_context_free` and never tore down on `unrealize`, so nothing defended against the GL context being recreated underneath. Removing the likeliest cause is worth doing whether or not it was the cause | Playback | UR-080 | Proposed |
|
||||
| DR-233 | Frame pacing goes through mpv's update callback, with `mpv_render_context_report_swap` after each render. Recorded as a requirement because the failure mode misleads: driving the widget's frame clock every tick without reporting the swap leaves mpv with nothing to time against, which looks fine in a window and **judders at fullscreen** — reading as a compositing or GPU limit and being neither | Playback | UR-080 | Proposed |
|
||||
| DR-234 | The device profile is derived from the **renderer that will decode the stream**, not from a compile-time platform constant. `video_codecs` was `#[cfg(target_os)]`, which is correct only while a build has one video renderer; once mpv and the webview element coexist it must be runtime state. This is the change that converts the measured 7% desktop direct-play rate toward the 85% the Android profile achieves on the same library, because the two differ by nothing except which component decodes. It looks like configuration and is not — it is the input that decides whether the server re-encodes, and getting it wrong fails silently, a claimed codec the renderer cannot decode being a black picture or silence (DR-148, and DR-227's audio override). The webview's narrower *audio* set stops applying to the video path once mpv decodes it, while the multichannel bound still does, since a 5.1 track direct-played into a two-channel sink is silence or inaudible dialogue | Repository | UR-080, UR-070 | In Progress |
|
||||
| DR-235 | The webview video path is deleted, not merely bypassed. Staged, because a path cannot be removed while a shipped platform still needs it: Linux moves to mpv first, Windows follows, and only then do `hls.js`, `html5Adapter.ts`, `videoLoaderFor` and the `<video>` element go. The staging is the point — a Linux-only version would leave the fork alive permanently, taking video from three renderers to four and giving every seek strategy, track switch and lifecycle bug one more place to be got right. Android keeps ExoPlayer and keeps the webview as its documented opt-out; the background-audio `<audio>` path is untouched. With no HTML5 fallback left, a failed mpv init emits `backend-init-failed` and surfaces a real error rather than silently degrading to the transcode this work exists to stop paying for | Playback | UR-080 | Proposed |
|
||||
| DR-235 | The webview video path is deleted, not merely bypassed. Staged, because a path cannot be removed while a shipped platform still needs it: Linux moves to mpv first, Windows follows, and only then do `hls.js`, `html5Adapter.ts`, `videoLoaderFor` and the `<video>` element go. The staging is the point — a Linux-only version would leave the fork alive permanently, taking video from three renderers to four and giving every seek strategy, track switch and lifecycle bug one more place to be got right. Android keeps ExoPlayer and keeps the webview as its documented opt-out; the background-audio `<audio>` path is untouched. With no HTML5 fallback left, a failed mpv init emits `backend-init-failed` and surfaces a real error rather than silently degrading to the transcode this work exists to stop paying for | Playback | UR-080 | Done |
|
||||
| DR-236 | Hardware-decode policy is decided from what mpv reports it **selected** (`hwdec-current`), never from what it was asked for. The spike established that hardware decode works through the render API at all — the load-bearing result, since it means direct play is not bought with software decoding — but also that `auto` reached for the discrete GPU in copy-back mode on a hybrid Intel+NVIDIA laptop, the least efficient hardware path, and that `vaapi` fell back to software silently because the libva driver was absent. So zero-copy VA-API on the integrated GPU is preferred where the driver is present, `auto` is a fallback rather than the default, and a missing driver is detected and logged rather than mistaken for a compositing limit | Playback | UR-080 | Proposed |
|
||||
| DR-237 | Windows reaches the same mpv path, reusing everything except the surface. The surface is genuinely different code — a native child window beneath a transparent WebView2, not GTK — but the render context, lifetime discipline, frame pacing, device profile and hwdec policy are shared, which is why none of them may be guarded on `cfg!(target_os = "linux")`. The cost is mostly build, not video: `libmpv` is currently a Linux-only dependency while Windows is cross-compiled from Linux via `x86_64-pc-windows-msvc` + `cargo-xwin`, so a Windows libmpv must reach that cross-build and its DLL must ship in the NSIS bundle, carrying the LGPL obligations DR-216 already records — dynamic linkage, licence text shipped alongside. Windows gains a native audio decoder as a side effect, which is what the long-blocked Windows audio work wants and cannot otherwise have | Playback | UR-080 | Proposed |
|
||||
| DR-237 | Windows reaches the same mpv path, reusing everything except the surface. The surface is genuinely different code — a native child window beneath a transparent WebView2, not GTK — but the render context, lifetime discipline, frame pacing, device profile and hwdec policy are shared, which is why none of them may be guarded on `cfg!(target_os = "linux")`. The cost is mostly build, not video: `libmpv` is currently a Linux-only dependency while Windows is cross-compiled from Linux via `x86_64-pc-windows-msvc` + `cargo-xwin`, so a Windows libmpv must reach that cross-build and its DLL must ship in the NSIS bundle, carrying the LGPL obligations DR-216 already records — dynamic linkage, licence text shipped alongside. Windows gains a native audio decoder as a side effect, which is what the long-blocked Windows audio work wants and cannot otherwise have | Playback | UR-080 | In Progress |
|
||||
| DR-238 | A transcoded seek re-negotiates the stream on every renderer, not just the webview. Jellyfin produces a transcode *from* `StartTimeTicks`, so where a seek lands is a property of the request rather than of the stream in hand. `determine_video_seek_strategy` treated `is_hls` as a proxy for "seekable in place", which held only because hls.js was always the HLS renderer — it seeks within the VOD playlist it is handed and lets the server catch up. mpv's HLS demuxer cannot make the server transcode from a new offset, so with native video on, every transcoded seek became a backend seek that silently did nothing and presented as "resume does not work". The rule is now written on `needs_transcoding` with hls.js as the stated exception; all four webview cells are unchanged | Player | UR-040 | Done |
|
||||
| DR-239 | Properties the mpv event loop handles are registered with `observe_property`. libmpv delivers `PropertyChange` only for observed properties, so a `match` arm for an unobserved one is unreachable code that reads as implemented — the handler is right there. `pause` was handled and never observed, so `StateChanged` was never emitted on pause or resume and the play/pause control never moved. It stayed invisible while Linux video played in the webview, because the `<video>` element's own DOM events drove that control; native video made the UI depend on the event that never came | Player | UR-005 | Done |
|
||||
| DR-240 | Fullscreen moves whatever actually owns the pixels. `requestFullscreen()` fullscreens the *document*, which sufficed while every renderer lived inside it — the HTML5 `<video>` element is part of the document, so WebKit scaled it and the OS window's real size never mattered. A native surface is drawn behind the webview at **window** size, so a document-only fullscreen expands the page and leaves the picture where it was; on WebKitGTK the result is a maximised window with decorations still holding a strip of the screen, which reads as "fullscreen is broken" rather than as a windowing problem. Android needed the same rule for the system bars (DR-157); this is its desktop half | Player | UR-066 | Done |
|
||||
@@ -492,6 +492,15 @@ Internal architecture, components, and application logic.
|
||||
| DR-288 | A type-filtered listing states `Recursive` explicitly. Jellyfin 12.0 defaults it to true when the parent is a library folder and `IncludeItemTypes` is set, where 10.11 returned immediate children — the identical request, a different result set, with nothing in the response to say which rule applied. Sending the value the client actually wants makes both generations agree, and the value sent is the one that shipped rather than the new server-side default, so this is a compatibility fix and not a silent behaviour change | Repository | UR-085 | Proposed |
|
||||
| DR-289 | The download worker's HTTP client carries a **read** timeout and a connect timeout, never a total request timeout. reqwest's `Client::timeout` is a deadline that runs until the body has finished, and it was set to five minutes: every transfer longer than that was cut off mid-body as "error decoding response body" and retried. A transcode ignores `Range`, so each retry restarted from byte zero, met the same deadline, and after three attempts the download failed — no feature film at transcode speed ever completed on a device whose audio must be re-encoded, and a large direct copy limped through in five-minute slices with a backoff between each. A read timeout resets on every chunk, so it still catches a dead connection without capping how long a healthy transfer may run | Downloads | UR-071 | Done |
|
||||
| DR-290 | A download whose response states no length still reports progress against a predicted total. A transcode is produced as it is sent — chunked, no `Content-Length` — and the worker reported `progress: 0.0` for its whole duration: an empty bar reading "0%" while the byte count climbed for an hour, which is the case every film whose audio must be re-encoded lands in. The backend already fetches the item to decide the audio policy, and that item carries what a prediction needs: the source's size (an `original` download copies the picture, so the output is the source give or take the audio track — and exactly the source when nothing is re-encoded) and its runtime (a preset re-encodes at fixed rates, so the size is rate × runtime, from the same preset table the URL is built from so the two cannot drift). The prediction is made where the URL is resolved and persisted as the row's `file_size`; the worker uses it **only** when the response has no length, the server's figure always wins, an estimated bar is capped at 99% so a low prediction never shows a finished download still running, and the `Completed` event carries the bytes actually written so neither side persists the prediction as the real size. With no prediction the bar is indeterminate, which is honest and was the status quo. The single-video button joins the series/season buttons on the enqueue path so all three resolve — and predict — in one place | Downloads | UR-071 | Done |
|
||||
| DR-291 | The offline banner stays off the full-screen player. Every other shell rule in `layoutShell.ts` already treats `/player/*` as immersive; the amber "You're offline" strip was the one piece of chrome still rendered above it. On the native Android video path that is not cosmetic: VideoPlayer makes itself transparent so the ExoPlayer SurfaceView behind the WebView is visible (DR-185), so a shell child that still paints shows *through* the picture as a stripe across the top of the film. Offline is also precisely when a downloaded video plays, so the banner appeared when it was most in the way, and it offers the viewer nothing to act on — local playback needs no server. The rule moves into the pure module as `showOfflineBanner({ pathname, isAuthenticated, isConnected })` rather than staying an inline `{#if}` in the shell, so the immersive-route contract is stated in one tested place | UI | UR-003, UR-043 | Done |
|
||||
| DR-292 | The offline catalog reveal is one rule, applied by both library views. Two defects, one cause — "server only" was a private `$derived` inside `MediaCard`. (1) The list view (`LibraryListView`, what `LibraryGrid` renders when the stored view preference is `list`) had no notion of it at all, so a library browsed as a list offline showed every revealed item as an ordinary tappable row that plays nothing, with no way to queue it. (2) The rule asked the downloads store whether *this item id* was downloaded, but only a playable leaf (Audio, Movie, Episode) ever has a download row — an album's tracks carry them, the album does not — so a fully downloaded album greyed itself out and offered to queue what was already on the device, which is what "my downloaded music is greyed out" was. The rule moves to the pure `$lib/utils/serverOnly`, both views call it, and the container half is answered by the backend: `get_download_disk_usage().sizes` already carries container subtotals beside leaf sizes (DR-085), so `deviceContentIds` is membership in a Rust-computed map rather than a frontend guess at which item types are containers. That map was loaded only by the Downloads page, so the shell now primes it at startup and re-reads it whenever the offline gate settles (the DR-143 signal). Queueing is shared too (`queueOfflineDownload`), since the list view had no copy to diverge from | UI | UR-052, UR-055 | Done |
|
||||
| DR-293 | Android plays the original file: ExoPlayer decodes AC-3, E-AC-3, DTS and TrueHD in software through the FFmpeg extension, so neither a download nor a stream needs the server to re-encode its audio. These are licensed codecs that Android does not ship — the ROD2-W09 tablet has a vendor DTS decoder and no AC-3/E-AC-3 at all — so the download policy (DR-171) judged audio against the webview's list and turned most films into a server transcode: generated as it is sent, no `Content-Length`, `Range` ignored, measured at ~1 MB/s and restarting from zero on every network blip, against a direct copy that moved a 910 MB episode in 94 s. The renderer is `DefaultRenderersFactory` in `EXTENSION_RENDERER_MODE_ON` (platform decoders first, FFmpeg for what they lack), and `CodecDetector` reports the extension's codecs beside `MediaCodecList`'s, so the device profile and the download policy — now `renderer_can_decode_audio`, DR-234's per-platform answer, instead of the webview's list — agree with what actually decodes. The webview video path is gone on Android: it decodes none of those codecs, so an original-file download would play there as a silent film; `webview_video_fallback` (Rust) is false on Android and the frontend neither offers the switch nor honours a stored "off". Linux keeps the webview fallback beside mpv native video, and with it the server transcode for undecodable audio. Rejected: re-encoding audio on the device after download — minutes of CPU and twice the disk per film, and it would not have helped streaming. The decoder is Jellyfin's `media3-ffmpeg-decoder` build (GPL-3.0; the distributed APK carries its terms, the source stays MIT) and must be versioned in step with media3 | Playback | UR-004, UR-071 | Done |
|
||||
| DR-294 | A download plays with no network. Playing a downloaded item asked the server for its `PlaybackInfo` — only to read the media-source id that subtitle URLs are keyed by — and `HybridRepository::get_playback_info` went to the server alone, so offline the call retried for seven seconds, failed, and the file on disk was never opened. A completed download for the current user now answers playback info from its download row, first and regardless of reachability: the local path, direct play, and the item id as media-source id (a download names no source, so the server served its default, which carries the item's id). Next Up had the same shape — server-only — and the TV landing page loads it in one `Promise.all` with its other rows, so offline that single failure blanked the whole page with Continue Watching and Latest sitting in the cache; it now falls back to the cache when the server cannot answer. And a slow cache read is waited for, never discarded: the cache is one SQLite connection behind one mutex, so any write in progress (the catalog sync at every launch, a download finishing) pushes a read past the 100 ms fast path, and `get_items`, the library list, genres and playlist items discarded such a read, waited on the server, and offline returned its error over data on disk — "More info" on a downloaded show failed exactly so. They keep the read running (`cache_try`) and wait for it when the server fails (`settle`); the cache-only reads (search, favourites) simply await the cache | Repository | UR-002, UR-071 | Done |
|
||||
| DR-295 | A series page lists its episodes with one concurrent season fan-out. "More info" on Frasier took ~10 s: the page asked Rust for the episodes and for the current episode as two commands, each of which walked every season, and each walk fetched the eleven seasons one after another — so the wait was the sum of twenty-two listings, each a cache read slowed by whatever the database was writing (the catalog sync at launch measured it at ~4 s per walk). The seasons are now fetched together (`gather_season_episodes`, so the wait is the slowest season), and `repository_get_series_view` returns the episodes and the current episode from one walk, with Next Up and resume fetched alongside it | Repository | UR-062 | Done |
|
||||
| DR-296 | Returning from background audio resumes the item the native player is actually on, not the one the video page was mounted with. An episode that ends while backgrounded advances in the backend (`advance_to_next_episode_audio_only`), but `player_exit_background_audio` returned only a position, so the webview reloaded the *previous* episode at the new episode's timestamp. The command now returns `BackgroundAudioResume { itemId, positionSeconds }` (`PlayerController::background_audio_resume`); `planHandoffReturn` yields `other-item` when the id differs from the mounted one, and the player page navigates to that episode with `resumeAt=<seconds>`, recording the outgoing episode as watched and suppressing the stale unmount stop report | Playback | UR-040, UR-023 | Done (pending device verification) |
|
||||
| DR-297 | A background refresh never blanks a library detail page that is on screen, and every load that succeeds clears the page's error. Resuming the app after a few minutes in the background replaced the Frasier series page with "Failed to load item": the resume reload (reconnect / offline-filter change) is a refresh of content the cache had already answered, but any throw in it replaced the page with an error that no later successful reload of the same item cleared — and the catch logged nothing and turned backend errors (plain strings) into the generic text. A failed refresh now keeps the page and logs the thrown value; only failing to open an item shows an error, with the backend's own message | UI | UR-062 | Done |
|
||||
| DR-298 | mpv receives commands as an argument vector, never as a command string. The pinned `libmpv` crate's `Mpv::command` joins its arguments with spaces and calls `mpv_command_string`, which parses input.conf syntax — whitespace splits, `;` chains a second command, `#` comments out the rest — so a stream URL carrying a server-controlled item id or `TranscodingUrl`, or a download's `file://` path built from its track title, could run any mpv command, `run` included: a crafted title tag was arbitrary code execution on the Linux desktop. The same parse broke every downloaded title containing a space, `Song.mp3` landing in `loadfile`'s flags slot. Every call goes through `mpv_command::command`, which builds a NUL-terminated `argv` for `mpv_command` so each argument reaches mpv as one opaque string, and refuses an argument containing NUL rather than loading a truncated URL | Playback | UR-003, UR-004 | Done |
|
||||
| DR-299 | Every libmpv handle verifies TLS and never hands a URL to youtube-dl. mpv's `tls-verify` defaults to *no*, and the stream URLs it loads carry the account's `ApiKey`, so anyone able to present a certificate for the server's host — a hostile network, a spoofed DNS answer — read a long-lived token from the one HTTP path in the app that did not check. libmpv also loads its ytdl hook by default, which passes a URL that failed to open, token included, to an external `yt-dlp`. `mpv_command::harden` sets `tls-verify=yes` and `ytdl=no` on each handle before its first `loadfile`, and a handle that cannot be hardened fails construction instead of playing unverified | Playback | UR-012 | Done |
|
||||
|
||||
---
|
||||
|
||||
@@ -502,9 +511,9 @@ Internal architecture, components, and application logic.
|
||||
| User Req | Integration Requirements | Development Requirements |
|
||||
|----------|-------------------------|-------------------------|
|
||||
| UR-001 | IR-001, IR-002 | - |
|
||||
| UR-002 | IR-013 | DR-003, DR-012, DR-013, DR-014 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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, DR-298 |
|
||||
| UR-004 | IR-003, IR-004, IR-008, IR-011 | DR-002, DR-004, DR-006, DR-129, DR-171, DR-176, DR-177, DR-181, DR-182, DR-183, DR-185, DR-188, DR-203, DR-265, DR-293, DR-298 |
|
||||
| UR-005 | - | DR-001, DR-005, DR-009, DR-178, DR-179, DR-186, DR-193, DR-195 |
|
||||
| UR-006 | IR-005, IR-006, IR-007, IR-008 | DR-200, DR-201 |
|
||||
| UR-007 | IR-010 | DR-007, DR-008, DR-016, DR-257, DR-262, DR-277, DR-278 |
|
||||
@@ -512,7 +521,7 @@ Internal architecture, components, and application logic.
|
||||
| UR-009 | IR-009, IR-010, IR-011 | - |
|
||||
| UR-010 | IR-012, IR-021 | DR-037, DR-059 |
|
||||
| UR-011 | IR-013 | DR-003, DR-015, DR-018 |
|
||||
| UR-012 | IR-009, IR-014 | DR-198 |
|
||||
| UR-012 | IR-009, IR-014 | DR-198, DR-299 |
|
||||
| UR-013 | IR-013 | DR-017 |
|
||||
| UR-014 | IR-010 | DR-014, DR-019 |
|
||||
| UR-015 | - | DR-005, DR-020 |
|
||||
@@ -523,7 +532,7 @@ Internal architecture, components, and application logic.
|
||||
| UR-020 | IR-016, IR-018 | DR-023, DR-176 | <!-- IR-018 delivered by ExoPlayer + HTML5 `<track>`, not libmpv -->
|
||||
| UR-021 | IR-016, IR-019 | DR-024 | <!-- IR-019 delivered by ExoPlayer + HLS stream re-open, not libmpv -->
|
||||
| UR-022 | IR-017 | DR-025 |
|
||||
| UR-023 | IR-010 | DR-026, DR-047, DR-048, DR-049, DR-263 |
|
||||
| UR-023 | IR-010 | DR-026, DR-047, DR-048, DR-049, DR-263, DR-296 |
|
||||
| UR-024 | IR-010 | DR-027 |
|
||||
| UR-025 | IR-015 | DR-028, DR-131, DR-132, DR-178, DR-179 |
|
||||
| UR-026 | - | DR-029, DR-048, DR-050 |
|
||||
@@ -540,10 +549,10 @@ Internal architecture, components, and application logic.
|
||||
| UR-037 | IR-010 | DR-042 |
|
||||
| UR-038 | IR-010 | DR-043 |
|
||||
| UR-039 | - | DR-045, DR-046 |
|
||||
| UR-040 | IR-025 | DR-051, DR-052, DR-129, DR-130, DR-159, DR-178, DR-179, DR-180, DR-183, DR-190, DR-196, DR-201, DR-203, DR-263, DR-266 |
|
||||
| UR-040 | IR-025 | DR-051, DR-052, DR-129, DR-130, DR-159, DR-178, DR-179, DR-180, DR-183, DR-190, DR-196, DR-201, DR-203, DR-263, DR-266, DR-296 |
|
||||
| UR-041 | IR-026 | DR-053, DR-160, DR-161, DR-172, DR-182, DR-183, DR-184, DR-185, DR-188, DR-265, DR-266 |
|
||||
| UR-042 | IR-009, IR-014 | DR-054 |
|
||||
| UR-043 | IR-027 | DR-055 |
|
||||
| UR-043 | IR-027 | DR-055, DR-291 |
|
||||
| UR-044 | - | DR-056 |
|
||||
| UR-045 | - | DR-057 |
|
||||
| UR-046 | IR-028 | DR-058 |
|
||||
@@ -552,16 +561,16 @@ Internal architecture, components, and application logic.
|
||||
| UR-049 | IR-010 | DR-063, DR-064, DR-065, DR-147 |
|
||||
| UR-050 | - | DR-066, DR-067 |
|
||||
| UR-051 | - | DR-068, DR-069, DR-070 |
|
||||
| UR-052 | IR-027 | DR-078, DR-079, DR-080, DR-143 |
|
||||
| UR-052 | IR-027 | DR-078, DR-079, DR-080, DR-143, DR-292 |
|
||||
| UR-053 | IR-029 | DR-074 |
|
||||
| UR-054 | - | DR-075, DR-076, DR-077, DR-147 |
|
||||
| UR-055 | - | DR-081, DR-082, DR-083, DR-084, DR-167, DR-168, DR-169, DR-173 |
|
||||
| UR-055 | - | DR-081, DR-082, DR-083, DR-084, DR-167, DR-168, DR-169, DR-173, DR-292 |
|
||||
| UR-056 | - | DR-085 |
|
||||
| UR-057 | - | DR-086 |
|
||||
| UR-058 | - | DR-087, DR-142 |
|
||||
| UR-060 | - | DR-090, DR-091, DR-111 |
|
||||
| UR-061 | - | DR-092 |
|
||||
| UR-062 | - | DR-101, DR-102, DR-103, DR-104, DR-107 |
|
||||
| UR-062 | - | DR-101, DR-102, DR-103, DR-104, DR-107, DR-295, DR-297 |
|
||||
| UR-063 | - | DR-105 |
|
||||
| UR-064 | - | DR-106 |
|
||||
| UR-065 | IR-030 | DR-108, DR-109, DR-110, DR-111 |
|
||||
@@ -570,7 +579,7 @@ Internal architecture, components, and application logic.
|
||||
| UR-068 | - | DR-119 |
|
||||
| UR-069 | - | DR-113, DR-114, DR-120 |
|
||||
| UR-070 | - | DR-121, DR-122 |
|
||||
| UR-071 | IR-032 | DR-123, DR-124, DR-125, DR-126, DR-127, DR-128, DR-133, DR-134, DR-135, DR-136, DR-137, DR-138, DR-170, DR-171, DR-180, DR-198, DR-199, DR-289, DR-290 |
|
||||
| UR-071 | IR-032 | DR-123, DR-124, DR-125, DR-126, DR-127, DR-128, DR-133, DR-134, DR-135, DR-136, DR-137, DR-138, DR-170, DR-171, DR-180, DR-198, DR-199, DR-289, DR-290, DR-293, DR-294 |
|
||||
| UR-072 | - | DR-156 |
|
||||
| UR-073 | - | DR-158 |
|
||||
| UR-074 | - | DR-162, DR-177, DR-181 |
|
||||
@@ -730,7 +739,7 @@ Internal architecture, components, and application logic.
|
||||
| UT-140 | `useOfflineFilterReload` skips the value a page already loaded under and reloads on each later change | DR-143 | Done |
|
||||
| UT-141 | The advertised channel cap: an unknown or zero reading falls back to stereo, a real route keeps its channels, an absurd driver reading is capped at 7.1, and mono is taken at its word | DR-141 | Done |
|
||||
| UT-148 | Forcing a transcode from the client: an undecodable default track forces one, a decodable track does not, the default track decides rather than the first, the first decides when nothing is marked default, and neither an audio-less source nor an unnamed codec is second-guessed | DR-149 | Done |
|
||||
| UT-149 | `createAdapter` returns the native adapter only when Rust reports native AND `experimentalNativeVideo` is on; the flag off forces HTML5 even when Rust says native, and the flag on never promotes a platform Rust reported as HTML5 | DR-150 | Done |
|
||||
| UT-149 | `createAdapter` returns the native adapter only when Rust reports native AND `experimentalNativeVideo` is on; the flag off forces HTML5 even when Rust says native, and the flag on never promotes a platform Rust reported as HTML5 | DR-150 | Superseded by DR-235 |
|
||||
| UT-150 | `set-version.sh` stamps all four manifests without touching dependency versions, and the Android versionCode is monotonic across an upgrade sequence, clears the 1000 floor, and survives a prerelease suffix | DR-153 | Done |
|
||||
| UT-151 | An unreportable stop lands in the queue and is pushed by the existing drain; re-queueing the same item supersedes the earlier position rather than adding a row, distinct items keep their own positions, and an abandoned row is not revived by a later report | DR-154 | Done |
|
||||
| UT-152 | Caching a server result mirrors its watch position locally — including for an item carrying a position but no favourite flag — without inventing a row for an item the server reported no user data for, and without pulling a still-unsynced local position backwards | DR-155 | Done |
|
||||
@@ -764,7 +773,7 @@ Internal architecture, components, and application logic.
|
||||
| UT-153 | Scroll handling per navigation kind: a forward move always lands at the top even when the previous page was scrolled and even when the target was visited before, Back restores that route's own saved offset (and the top when it has none), offsets are kept per route rather than shared, a repeated Back still restores, and the initial load leaves the container alone | DR-156 | Done |
|
||||
| UT-142 | The audio codecs offered for video direct play: a Dolby device's real `MediaCodecList` output drops `ac3`/`eac3`, AMR and raw PCM are dropped too, a fully-supported list is passed through untouched, a list with nothing decodable still claims `aac`, and stray spacing or casing does not decide whether the user gets sound | DR-148 | Done |
|
||||
| UT-143 | Subtitle URLs resolve to plain strings before they reach the markup (never a Promise), unresolvable tracks are dropped, a stale selection collapses to "Off", and a server-default track is never auto-selected | UR-020, DR-023 | Done |
|
||||
| UT-144 | VideoPlayer actually renders `<track kind="subtitles">` children carrying `data-stream-index`, with no `default` attribute and no async `getSubtitleUrl()` bound to `src` | UR-020, DR-023 | Done |
|
||||
| UT-144 | VideoPlayer actually renders `<track kind="subtitles">` children carrying `data-stream-index`, with no `default` attribute and no async `getSubtitleUrl()` bound to `src` | UR-020, DR-023 | Superseded by DR-235 |
|
||||
| UT-145 | The frontend's subtitle payload survives the IPC hop: a camelCase `PlayItemRequest` carrying `subtitles` deserializes, `create_media_item` lands them on `MediaItem.subtitles` in the order sent, and a request without the field still defaults to empty | UR-020, IR-016 | Done |
|
||||
| UT-146 | The subtitle JSON serialized across the JNI boundary uses the keys `JellyTauPlayer.load()` reads — `url`, `language`, `label` and `mime_type`, never `mimeType` | UR-020, IR-016, JA-008 | Done |
|
||||
| UT-147 | The native subtitle payload and the track-selection index come from the same resolved list: the wire shape keeps `mime_type` and stream order, `playerPlayItem` actually sends it, and the index is a position in the sent list (so a track whose URL failed to resolve cannot shift the others) rather than the menu's row number | UR-020, IR-016 | Done |
|
||||
@@ -772,7 +781,7 @@ Internal architecture, components, and application logic.
|
||||
| UT-183 | A reloaded stream is resumed by seeking the element to the absolute position with the transcode offset cleared to zero — never by carrying the position as an offset base, which since DR-181 would display the position while playing the item from its start — and a reload to 0:00 waits for no seek | DR-181 | Done |
|
||||
| UT-184 | The native reveal rule fires on `state === "playing"` and on a position tick carrying a position or a duration, and on nothing else — not `buffering`, `paused`, `stopped`, `ended` or `error`, not an empty tick, and not a negative position | DR-182 | Done |
|
||||
| UT-188 | The control-bar auto-hide rule permits hiding only during uninterrupted playback: it declines while paused, while a seek is in flight, and while a track/subtitle/quality menu is open — asserted against the pure `shouldHideControls` rule rather than a clock or a DOM | DR-189 | Done |
|
||||
| UT-189 | On the native path the player never calls `player_report_state` — driven through the real 10-second progress interval under fake timers, which is the call site that mattered; asserting on a freshly mounted player passes with the guard deleted and guards nothing | DR-195 | Done |
|
||||
| UT-189 | On the native path the player never calls `player_report_state` — driven through the real 10-second progress interval under fake timers, which is the call site that mattered; asserting on a freshly mounted player passes with the guard deleted and guards nothing | DR-195 | Superseded by DR-235 |
|
||||
| UT-187 | On the native path the play overlay follows the backend: it clears when the backend resumes after a pause and is raised again when the backend pauses, and the system bars are hidden on player entry rather than only by the fullscreen button | DR-186, DR-187 | Done |
|
||||
| UT-186 | Every attribute the native-video compositing block in app.css targets is set somewhere in the app — `[data-app-shell]` in particular — so a selector aimed at nothing fails the suite instead of failing silently on a device | DR-185 | Done |
|
||||
| UT-185 | Mounted on the native path (backend reports native, opt-in flag on, no `<video>` element rendered and the backend not stopped), VideoPlayer keeps the poster card up until the backend reports something, drops it on a playing state or a position tick with a duration, and keeps it up through `error` and `stopped` | DR-182 | Done |
|
||||
@@ -800,9 +809,9 @@ Internal architecture, components, and application logic.
|
||||
| UT-211 | The background decision: a video with the toggle off pauses (the reported defect, where the media service kept playing regardless), a video with it on hands off to audio, music keeps playing whatever the toggle says because it has no picture to lose, picture-in-picture keeps playing in every combination since the window is still visible, and the answer does not vary by renderer | DR-224 | Done |
|
||||
| UT-212 | The stream-selection contract. `Transport` and `PlaybackKind` each serialise to exactly the tag the frontend matches (`{"type":"hls"}`, `{"type":"directPlay"}`, …) and round-trip; nested `StreamSelection` fields are camelCase on the wire including `playbackKind`, `mediaSourceId` and `maxBitrate`; only `Transcode` counts as transcoding, so a direct stream does not; a local file is a direct play over a local transport with no ladder. The ladder: every rung at or above a 1.12 Mbps source is marked redundant while the three that constrain it are not, `Original` is never marked for any bitrate including zero and unknown, an unreported source bitrate keeps all eight rungs offered, a 40 Mbps source marks none, and each option carries the ladder's own label and detail | DR-224, DR-226 | Done |
|
||||
| UT-213 | The direct-play negotiation, one test per branch, against `PlaybackInfo` fixtures whose shapes were all observed on a live server: a supported source direct-plays; a remuxable one direct-streams and reports itself as *not* transcoding; an unsupported codec transcodes; undecodable audio overrides the server's direct-play offer (silent picture is worse than a transcode); a pinned audio track forces a transcode; a ceiling below the source bitrate transcodes even though the codec is fine, and the ladder agrees that rung constrains it; direct play wins over direct stream when both are offered. Plus the ceiling: a per-playback override governs the stream being opened without disturbing the durable default the Settings screen shows, and dropping it returns to that default | DR-225, DR-227 | Done |
|
||||
| UT-214 | The loader comes from the transport, never the URL. hls.js is attached for `hls` when available and the element's own loader when not; progressive and local files load directly; the element's `src` is emptied only when hls.js drives it. The two cases that fail against a substring check, and the reason the field exists: a `progressive` stream whose URL contains `.m3u8` is *not* given an HLS loader, and an `hls` stream whose URL contains no `.m3u8` *is*. Both failed against the pre-DR-225 implementation before the fix landed | DR-224 | Done |
|
||||
| UT-214 | The loader comes from the transport, never the URL. hls.js is attached for `hls` when available and the element's own loader when not; progressive and local files load directly; the element's `src` is emptied only when hls.js drives it. The two cases that fail against a substring check, and the reason the field exists: a `progressive` stream whose URL contains `.m3u8` is *not* given an HLS loader, and an `hls` stream whose URL contains no `.m3u8` *is*. Both failed against the pre-DR-225 implementation before the fix landed | DR-224 | Superseded by DR-235 |
|
||||
| UT-215 | Waiting for the repository rather than racing it: it resolves immediately when the session is already restored, resolves when the session arrives later (the race the player page lost on mount), still rejects when there genuinely is no session, unsubscribes once settled so a later store change cannot re-settle it, and leaves no armed timer to reject an already-resolved promise | DR-013 | Done |
|
||||
| UT-216 | The native-video opt-in is read from one place and only explicit truthy values enable it: absent, empty, `0`, `no`, `false` and anything unrecognised all mean off, because a half-set variable that half-enabled the renderer would configure mpv for video with nothing drawing it — audio over a black rectangle | DR-231 | Done |
|
||||
| UT-216 | The native-video opt-in is read from one place and only explicit truthy values enable it: absent, empty, `0`, `no`, `false` and anything unrecognised all mean off, because a half-set variable that half-enabled the renderer would configure mpv for video with nothing drawing it — audio over a black rectangle | DR-231 | Superseded by UT-271 |
|
||||
| UT-217 | A transcoded HLS stream on the native backend re-negotiates rather than seeking in place, while the same stream under hls.js still seeks in place — the cell that native video made reachable for the first time | DR-238 | Done |
|
||||
| UT-218 | Every property name matched by the mpv event loop also appears in an `observe_property` call, asserted against the source because the registration cannot be observed at runtime without a live mpv | DR-239 | Done |
|
||||
| UT-219 | A fullscreen toggle moves the document only when an in-document `<video>` renders, and moves the OS window as well when a native surface does | DR-240 | Done |
|
||||
@@ -842,6 +851,26 @@ Internal architecture, components, and application logic.
|
||||
| UT-252 | A predicted total fills in only when the server sent no length, the server's length always wins, an estimated fraction is capped below 1.0, and the prediction is rate × runtime for a preset and the source's size for `original` | DR-290 | Done |
|
||||
| UT-253 | Resolving a queued video row persists its predicted size, and resolving an audio row (no prediction) leaves a size the row already holds untouched | DR-290 | Done |
|
||||
| UT-254 | The progress row renders an unknown total as indeterminate rather than "0%", an estimated total as "~N%", an exact one plainly; the store carries the estimate flag through progress and persists the worker's byte count, never the prediction, on completion | DR-290 | 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-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 | Superseded by UT-272 |
|
||||
| UT-260 | A downloaded item gets playback info with the server unreachable — immediately, from its download row (local path, direct play, item id as media source) — while an unfinished download, another user's, or an item never downloaded is left to the server | DR-294 | Done |
|
||||
| UT-261 | Next Up answers from the cache, rather than failing, when the server is unreachable | DR-294 | Done |
|
||||
| UT-262 | The Android webview fallback is neither offered in Settings nor honoured by the player unless Rust reports it, so a stored "native video off" cannot route video to a renderer that plays the original file silent | DR-293 | Superseded by UT-272 |
|
||||
| UT-263 | With the database held past the 100 ms fast path and the server unreachable, `get_items`, the library list, a cache-only search and cache-only favourites all answer from the cache instead of failing | DR-294 | Done |
|
||||
| UT-264 | Ten seasons whose listings each take 100 ms are gathered in well under the 1 s a sequential walk takes, and a season that fails to load leaves the other nine seasons' episodes in the result | DR-295 | Done |
|
||||
| UT-265 | `planHandoffReturn` switches to the item the backend advanced to while backgrounded, and reloads in place when the backend is still on the mounted item or reports none | DR-296 | Done |
|
||||
| UT-266 | After a background-audio episode advance, the controller's resume point names the new episode and carries no base from the previous one | DR-296 | Done |
|
||||
| UT-267 | A failed refresh of a detail page already on screen shows no error, a successful load clears any error, a failure to open an item shows its message, and a backend error's plain-string text is shown rather than a generic fallback | DR-297 | Done |
|
||||
| UT-268 | Against a real libmpv, a URL containing `;set volume 13;#` is loaded as one URL and the volume is unchanged, and an argument containing a space stays one argument | DR-298 | Done |
|
||||
| UT-269 | Neither mpv player calls the string-joining `Mpv::command`; every command goes through `mpv_command::command` | DR-298 | Done |
|
||||
| UT-270 | A hardened handle reads back `tls-verify=yes` and `ytdl=no`, and both mpv players harden the handle they create | DR-299 | Done |
|
||||
| UT-271 | Native video is on for Linux whatever `JELLYTAU_NATIVE_VIDEO` says, including unset and explicit "off" values, and off where mpv is not the video renderer | DR-235 | Done |
|
||||
| UT-272 | No platform reports a webview video fallback, Linux reports native video, and the player status on Linux never sends video to the `<video>` element | DR-235 | Done |
|
||||
| UT-273 | `player_play_item`, `get_player_status` and `player_get_capabilities` answer "does a native renderer draw video here" from one function, so the backend is loaded with video exactly where the frontend is told not to use a `<video>` element — on Windows, where mpv now plays audio, the film's soundtrack is not decoded twice | DR-237 | Done |
|
||||
| UT-274 | mpv's video output is decided per platform: Windows renders into the app window's HWND (`wid`, set before initialisation) with `vo=gpu-next,gpu` and mpv's own controller, bindings and cursor handling off; Windows with no handle draws nothing rather than opening a window of its own; Linux uses the render API; no native video means `video=no`. Every option is accepted by the real libmpv, on Linux and by the shipped Windows DLL | DR-237 | Done |
|
||||
| UT-275 | mpv selects sideloaded subtitles and audio tracks by position, as ExoPlayer does: against a real file, a sideloaded WebVTT arrives, starts hidden, and is shown and hidden by its position in the sent list; position 1 plays the file's second audio track; and preparing the next load forgets the last item's subtitle files, subtitle choice and audio track. Positions resolve to mpv ids per kind, embedded before external | DR-023, DR-024, DR-235 | Done |
|
||||
### Integration Tests
|
||||
|
||||
| Test ID | Test Description | Traces To | Status |
|
||||
|
||||
@@ -28,7 +28,10 @@ know how something *works*, read
|
||||
|
||||
**Next free requirement ids** (always re-check
|
||||
[requirements.md](../requirements.md) before allocating): **UR-086**,
|
||||
**IR-036**, **JA-038**, **DR-289**. Three specs below suggested ids that have
|
||||
**IR-036**, **JA-038**, **DR-295**, **UT-264** (DR-289/290 went to the v0.12.2
|
||||
download fixes; DR-291/292 and UT-255/257/258 to the offline-banner and
|
||||
server-only-reveal work; DR-293/294 and UT-259-263 to Android's FFmpeg decoding
|
||||
and offline-without-network). Three specs below suggested ids that have
|
||||
since been taken by other work; each carries a ⚠️ note at the top — this line
|
||||
was itself stale by five, two and forty-seven until 2026-09-08, which is why the
|
||||
re-check is not optional.
|
||||
@@ -82,4 +85,5 @@ Where to look for each:
|
||||
| Video background audio | [05-platform-backends.md](../architecture/05-platform-backends.md) — Background Audio Handoff |
|
||||
| Traceability gate repair | [traceability-ci.md](../traceability-ci.md) |
|
||||
| Boundary tripwire hardening | `scripts/check-frontend-boundary.sh` (its header is the spec) |
|
||||
| Original-file downloads & Android FFmpeg decoding (was on-device-audio-remux) | [05-platform-backends.md](../architecture/05-platform-backends.md) — Licensed audio codecs; [06-downloads-and-offline.md](../architecture/06-downloads-and-offline.md) — What a Video Download Fetches, Offline Means No Network |
|
||||
| Playback docs corrections · req-coverage script removal | Nothing to document — both were corrections that have been applied |
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
# Spec: Desktop native video — mpv renders the picture, everywhere
|
||||
|
||||
**Status:** Proposed
|
||||
**Status:** Partially implemented — all three phases' *code* has shipped: mpv
|
||||
is the only video renderer on Linux (render API into the GTK vbox) and Windows
|
||||
(`wid` into the app window, `vo=gpu-next,gpu`), and the webview video path is
|
||||
deleted — hls.js, the HTML5 adapter, the `<video>` element, the frontend switch,
|
||||
the `use_html5` command parameters and the Android HTML5 PiP/screen-wake state
|
||||
(DR-235). mpv selects subtitles and audio tracks itself (`mpv_tracks`).
|
||||
**Left:** Linux/Windows still claim only `h264` (DR-234), so video is still a
|
||||
server transcode — mpv plays it, but the direct-play gain is not taken; `hwdec`
|
||||
is unset, so mpv decodes in software (DR-236); a failed surface attach only
|
||||
logs; and every hardware criterion is unrecorded — the X11/Wayland check and
|
||||
soak on Linux, and **any** run of Windows video, which has only been verified by
|
||||
unit tests under wine (options accepted by the shipped DLL), never on screen.
|
||||
**Requirements:** UR-080 (new) → DR-231 … DR-237 (new); IR-033 (new)
|
||||
**UX spec:** n/a — nothing about the player's appearance changes. What changes is
|
||||
what is behind the controls.
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
# Spec: Windows native audio backend
|
||||
|
||||
**Status:** Proposed — not started. Windows still runs on
|
||||
`WebviewAudioBackend`. Blocked on [libmpv2-migration.md](libmpv2-migration.md),
|
||||
whose crate swap has not landed either.
|
||||
**Status:** Partially implemented — code and packaging done, **unverified on
|
||||
Windows hardware**. `MpvBackend` is the Windows audio backend (`ao=wasapi`);
|
||||
the builder image carries zhongfly's pinned LGPL `libmpv-2.dll` plus a generated
|
||||
MSVC `mpv.lib`, and the NSIS installer ships the DLL beside `jellytau.exe` with
|
||||
its licence texts ([build-windows.md](../build/build-windows.md)). Done *before*
|
||||
the libmpv2 migration after all: the current `libmpv` pin cross-links fine, and
|
||||
the migration now has one call site to keep safe (`mpv_command`, DR-298) rather
|
||||
than a crate API to port twice. **Left:** every acceptance criterion that needs a
|
||||
Windows machine — audible volume/EQ/normalization/gapless, seek/queue/sleep
|
||||
timer, and the clean-VM install test.
|
||||
**Requirements:** UR-003, UR-027, UR-032, UR-033 → DR-030, DR-035, DR-036;
|
||||
⚠️ the suggested id **IR-030 has since been allocated** to the scheduled catalog
|
||||
crawl — allocate a fresh id (IR-033 or later) on implementation
|
||||
|
||||
+6492
-5640
File diff suppressed because it is too large
Load Diff
+1
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "jellytau",
|
||||
"version": "0.12.2",
|
||||
"version": "0.14.0",
|
||||
"description": "A cross-platform Jellyfin client built with Tauri, SvelteKit and Rust.",
|
||||
"author": "Duncan Tourolle <duncan@tourolle.paris>",
|
||||
"license": "MIT",
|
||||
@@ -64,7 +64,6 @@
|
||||
"@tauri-apps/plugin-os": "^2.3.2",
|
||||
"@tauri-apps/plugin-process": "^2.3.1",
|
||||
"@tauri-apps/plugin-updater": "2.10.1",
|
||||
"hls.js": "^1.6.15",
|
||||
"svelte-dnd-action": "^0.9.69"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -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
|
||||
# it can bundle the NSIS installer from a Linux host.
|
||||
#
|
||||
# Playback on Windows: video renders via WebView2 and audio via the webview
|
||||
# <audio> backend (WebviewAudioBackend) — see docs/build/build-windows.md.
|
||||
# Playback on Windows: video renders via WebView2; audio via mpv, whose
|
||||
# libmpv-2.dll ships beside jellytau.exe — see docs/build/build-windows.md.
|
||||
#
|
||||
# Requirements (present in the Docker windows-cross target / unified builder):
|
||||
# - rustup target x86_64-pc-windows-msvc
|
||||
# - cargo-xwin (cargo install --locked cargo-xwin)
|
||||
# - lld, llvm (linker + llvm-lib used by cargo-xwin)
|
||||
# - nsis (makensis) (installer generator)
|
||||
# - libmpv for Windows ($LIBMPV_WIN_DIR: libmpv-2.dll + mpv.lib, pinned and
|
||||
# sha256-checked in Dockerfile.builder)
|
||||
#
|
||||
# Usage:
|
||||
# scripts/build-windows-cross.sh # exe + NSIS installer
|
||||
@@ -29,10 +31,26 @@ WIN_BUNDLES="${WIN_BUNDLES:-nsis}"
|
||||
|
||||
echo "🪟 Cross-compiling JellyTau for Windows ($TARGET, via cargo-xwin)"
|
||||
echo "================================================================"
|
||||
echo "Video plays via WebView2; audio via the webview <audio> backend."
|
||||
echo "Video plays via WebView2; audio via mpv (libmpv-2.dll)."
|
||||
echo "Bundles: $WIN_BUNDLES"
|
||||
echo ""
|
||||
|
||||
# Stage libmpv where build.rs links it from and tauri.windows.conf.json bundles
|
||||
# it from — one directory, so the DLL shipped is the one linked against.
|
||||
# Copied fresh every build: a stale DLL left from an older image would ship
|
||||
# silently. TRACES: UR-004 | DR-237
|
||||
LIBMPV_WIN_DIR="${LIBMPV_WIN_DIR:-/opt/libmpv-win64}"
|
||||
for f in libmpv-2.dll mpv.lib; do
|
||||
if [[ ! -f "$LIBMPV_WIN_DIR/$f" ]]; then
|
||||
echo "❌ $LIBMPV_WIN_DIR/$f not found. Build inside the jellytau-builder image,"
|
||||
echo " or set LIBMPV_WIN_DIR to a directory holding libmpv-2.dll and mpv.lib."
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
rm -rf src-tauri/windows-libs
|
||||
mkdir -p src-tauri/windows-libs
|
||||
cp -v "$LIBMPV_WIN_DIR/libmpv-2.dll" "$LIBMPV_WIN_DIR/mpv.lib" src-tauri/windows-libs/
|
||||
|
||||
bun install --frozen-lockfile 2>/dev/null || bun install
|
||||
bun run build
|
||||
|
||||
|
||||
@@ -54,21 +54,29 @@ describe("tauri.conf.json CSP", () => {
|
||||
expect(csp["img-src"]).toContain("asset:");
|
||||
expect(csp["img-src"]).toContain("http://asset.localhost");
|
||||
expect(csp["media-src"]).toContain("asset:");
|
||||
// hls.js: MSE object URLs, and its demuxer worker built from a blob.
|
||||
expect(csp["media-src"]).toContain("blob:");
|
||||
expect(csp["worker-src"]).toContain("blob:");
|
||||
// The token-guarded loopback media server (DR-137).
|
||||
expect(csp["media-src"]).toContain("http://127.0.0.1:*");
|
||||
// Tauri's invoke transport.
|
||||
expect(csp["connect-src"]).toContain("ipc:");
|
||||
expect(csp["connect-src"]).toContain("http://ipc.localhost");
|
||||
// The user's Jellyfin server: an arbitrary run-time origin, http on a LAN.
|
||||
for (const directive of ["img-src", "media-src", "connect-src"]) {
|
||||
for (const directive of ["img-src", "media-src"]) {
|
||||
expect(csp[directive]).toContain("http:");
|
||||
expect(csp[directive]).toContain("https:");
|
||||
}
|
||||
});
|
||||
|
||||
// The page makes no network requests of its own — all traffic goes through
|
||||
// Rust — so `connect-src` is IPC only. It allowed any http(s) host while
|
||||
// hls.js fetched segments in the page; with hls.js deleted (DR-235) that
|
||||
// grant was only an exfiltration channel for injected script. Likewise the
|
||||
// blob worker was hls.js' demuxer.
|
||||
it("gives injected script no network egress and no blob workers", () => {
|
||||
expect(csp["connect-src"]).not.toContain("http:");
|
||||
expect(csp["connect-src"]).not.toContain("https:");
|
||||
expect(csp["worker-src"]).not.toContain("blob:");
|
||||
});
|
||||
|
||||
it("never widens a data directive into script execution", () => {
|
||||
for (const [name, sources] of Object.entries(csp)) {
|
||||
if (name === "script-src" || name === "worker-src") {
|
||||
|
||||
@@ -22,3 +22,7 @@ local.properties
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
|
||||
# Windows libmpv (import library + DLL), staged from the builder image by
|
||||
# scripts/build-windows-cross.sh. Never committed: 100 MB of LGPL binary.
|
||||
/windows-libs/
|
||||
|
||||
Generated
+1
-13
@@ -2275,7 +2275,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "jellytau"
|
||||
version = "0.12.2"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"argon2",
|
||||
@@ -2315,7 +2315,6 @@ dependencies = [
|
||||
"tempfile",
|
||||
"tiny_http",
|
||||
"tokio",
|
||||
"tokio-rusqlite",
|
||||
"tokio-util",
|
||||
"urlencoding",
|
||||
"uuid",
|
||||
@@ -5340,17 +5339,6 @@ dependencies = [
|
||||
"syn 2.0.112",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-rusqlite"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b65501378eb676f400c57991f42cbd0986827ab5c5200c53f206d710fb32a945"
|
||||
dependencies = [
|
||||
"crossbeam-channel",
|
||||
"rusqlite",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-rustls"
|
||||
version = "0.26.4"
|
||||
|
||||
@@ -4,7 +4,7 @@ name = "jellytau"
|
||||
# `player-conformance`, and a second binary makes a bare `cargo run` —
|
||||
# which `tauri dev` issues — ambiguous.
|
||||
default-run = "jellytau"
|
||||
version = "0.12.2"
|
||||
version = "0.14.0"
|
||||
description = "A cross-platform Jellyfin client"
|
||||
authors = ["Duncan Tourolle <duncan@tourolle.paris>"]
|
||||
license = "MIT"
|
||||
@@ -53,7 +53,6 @@ futures-util = "0.3"
|
||||
async-trait = "0.1"
|
||||
|
||||
# SQLite for offline storage
|
||||
tokio-rusqlite = "0.6"
|
||||
rusqlite = { version = "0.32", features = ["bundled"] }
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
directories = "5"
|
||||
@@ -109,9 +108,10 @@ tiny_http = { version = "0.12.0", default-features = false }
|
||||
tauri-plugin-updater = "2"
|
||||
tauri-plugin-process = "2"
|
||||
|
||||
# Linux-specific dependencies
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
hostname = "0.4"
|
||||
# mpv platforms: Linux (system libmpv) and Windows (libmpv-2.dll shipped in the
|
||||
# installer, import library generated in the builder image — see build.rs and
|
||||
# scripts/build-windows-cross.sh). TRACES: UR-004 | DR-237
|
||||
[target.'cfg(any(target_os = "linux", target_os = "windows"))'.dependencies]
|
||||
libc = "0.2"
|
||||
# The crates.io release of libmpv predates the MPV versions we support, so this
|
||||
# tracks the upstream git repo.
|
||||
@@ -138,6 +138,9 @@ libmpv = { git = "https://github.com/ParadoxSpiral/libmpv-rs.git", rev = "3e6c38
|
||||
# TRACES: UR-080 | DR-230, IR-033
|
||||
libmpv-sys = { git = "https://github.com/ParadoxSpiral/libmpv-rs.git", rev = "3e6c389b716f52a595cc5e8e3fa1f96cb76b3de7" }
|
||||
|
||||
# Linux-specific dependencies
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
hostname = "0.4"
|
||||
# Same major as the one Tauri/wry already resolve, so `gtk_window()` and
|
||||
# `default_vbox()` hand back types this crate can name rather than a second,
|
||||
# incompatible GTK.
|
||||
|
||||
@@ -172,6 +172,16 @@ dependencies {
|
||||
// itself: without a view to hand them to, a selected subtitle track renders
|
||||
// nowhere. See JellyTauPlayer.onCues. (DR-260)
|
||||
implementation("androidx.media3:media3-ui:1.5.0")
|
||||
// Software audio decoders for what Android does not ship: AC-3, E-AC-3,
|
||||
// DTS and TrueHD are licensed codecs, present only where a vendor paid for
|
||||
// them (the ROD2-W09 tablet has DTS but no AC-3/E-AC-3 at all). With this,
|
||||
// ExoPlayer plays the source file as-is, so neither a download nor a stream
|
||||
// needs the server to re-encode its audio (DR-293). Jellyfin's own build of
|
||||
// the media3 FFmpeg extension, versioned to match media3 above — keep the
|
||||
// two in step. Licence: GPL-3.0 — the distributed APK carries its terms,
|
||||
// the source stays MIT; see THIRD_PARTY_NOTICES.md and
|
||||
// docs/architecture/05-platform-backends.md.
|
||||
implementation("org.jellyfin.media3:media3-ffmpeg-decoder:1.5.0+1")
|
||||
implementation("com.google.guava:guava:33.0.0-android")
|
||||
|
||||
// Media library for VolumeProviderCompat (remote volume control)
|
||||
|
||||
@@ -85,10 +85,9 @@ class MainActivity : TauriActivity() {
|
||||
super.onWebViewCreate(webView)
|
||||
android.util.Log.d("MainActivity", "onWebViewCreate - installing bridges before first page load")
|
||||
mediaWebView = webView
|
||||
// A new WebView means a new page, which reports no video yet. Anything the
|
||||
// previous one left held would otherwise pin the screen on for the life of
|
||||
// the process, since a page that goes away never sends its final
|
||||
// setHtml5VideoState(false, …). (DR-202)
|
||||
// A new WebView means a new page, which plays no video yet. Anything held
|
||||
// for the previous one would otherwise pin the screen on for the life of
|
||||
// the process. (DR-202)
|
||||
ScreenWakeManager.releaseAll()
|
||||
installJavascriptBridges(webView)
|
||||
configureWebViewSettings(webView)
|
||||
@@ -327,22 +326,6 @@ class MainActivity : TauriActivity() {
|
||||
autoEnterPipEnabled = enabled
|
||||
}
|
||||
|
||||
/**
|
||||
* Report the WebView `<video>` state.
|
||||
*
|
||||
* Without this PiP only ever knew about the native ExoPlayer surface,
|
||||
* which is behind an experimental flag that defaults to off — so in the
|
||||
* shipping configuration nothing ever satisfied canEnterPip and the
|
||||
* button did nothing. (DR-160)
|
||||
*/
|
||||
@JavascriptInterface
|
||||
fun setHtml5VideoState(active: Boolean, width: Int, height: Int, playing: Boolean) {
|
||||
PictureInPictureManager.setHtml5VideoState(active, width, height, playing)
|
||||
// The same report is what keeps the display awake on the webview
|
||||
// rendering path — the WebView takes no display wake lock of its own
|
||||
// for `<video>`. (DR-202)
|
||||
ScreenWakeManager.onHtml5VideoState(active, playing)
|
||||
}
|
||||
}, "AndroidPictureInPicture")
|
||||
android.util.Log.d("MainActivity", "JavaScript interface 'AndroidPictureInPicture' added")
|
||||
|
||||
|
||||
@@ -46,52 +46,7 @@ object PictureInPictureManager {
|
||||
private var receiver: BroadcastReceiver? = null
|
||||
private var hiddenWebView: WebView? = null
|
||||
|
||||
/**
|
||||
* State of an HTML5 `<video>` playing inside the WebView, reported by the
|
||||
* frontend.
|
||||
*
|
||||
* PiP was written for the native ExoPlayer surface only — [canEnterPip]
|
||||
* required a SurfaceView to be attached and rendering. But native video is
|
||||
* behind `experimentalNativeVideo`, which defaults to **off**, so in the
|
||||
* shipping configuration video plays in the WebView's `<video>` element and
|
||||
* every one of those conditions is false. `enterPip` therefore always bailed
|
||||
* with "no local video playing": PiP could not work at all, however the
|
||||
* button was pressed.
|
||||
*
|
||||
* On this path the WebView *is* the video, which inverts two things: the
|
||||
* WebView must stay visible in PiP rather than be hidden, and play/pause has
|
||||
* to reach the element rather than ExoPlayer. Both are handled below.
|
||||
*
|
||||
* TRACES: UR-041 | DR-160
|
||||
*/
|
||||
@Volatile
|
||||
private var html5VideoActive = false
|
||||
|
||||
@Volatile
|
||||
private var html5VideoPlaying = false
|
||||
|
||||
@Volatile
|
||||
private var html5AspectRatio: Rational? = null
|
||||
|
||||
/**
|
||||
* Report the WebView `<video>` state from the frontend.
|
||||
*
|
||||
* @param active whether a video element is currently the playback surface
|
||||
* @param width intrinsic video width, for the PiP window's aspect ratio
|
||||
* @param height intrinsic video height
|
||||
* @param playing whether it is playing right now, for the PiP play/pause action
|
||||
*/
|
||||
fun setHtml5VideoState(active: Boolean, width: Int, height: Int, playing: Boolean) {
|
||||
html5VideoActive = active
|
||||
html5VideoPlaying = playing
|
||||
html5AspectRatio = if (active && width > 0 && height > 0) {
|
||||
clampedRatio(width.toDouble() / height.toDouble())
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/** True when PiP would be showing the native surface rather than the WebView. */
|
||||
/** True when a native video surface is attached and playing. */
|
||||
private fun isNativeVideoPath(): Boolean = try {
|
||||
val player = JellyTauPlayer.getInstance()
|
||||
player.isPlayingVideo() &&
|
||||
@@ -120,10 +75,9 @@ object PictureInPictureManager {
|
||||
*/
|
||||
fun canEnterPip(activity: Activity): Boolean {
|
||||
if (!isPipSupported(activity)) return false
|
||||
// Either surface will do: the native one, or the WebView's `<video>`,
|
||||
// which is what actually plays while experimentalNativeVideo is off.
|
||||
// (DR-160)
|
||||
return isNativeVideoPath() || html5VideoActive
|
||||
// Video only ever renders on the native surface: the WebView `<video>`
|
||||
// path is gone (DR-235). (DR-160)
|
||||
return isNativeVideoPath()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -186,9 +140,7 @@ object PictureInPictureManager {
|
||||
return clampedRatio(surface.width.toDouble() / surface.height.toDouble())
|
||||
}
|
||||
|
||||
// No native surface: the WebView is the video, so use the intrinsic size
|
||||
// the frontend reported. (DR-160)
|
||||
return html5AspectRatio
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -207,17 +159,11 @@ object PictureInPictureManager {
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.O)
|
||||
private fun buildPlayPauseAction(activity: Activity): RemoteAction {
|
||||
// On the HTML5 path ExoPlayer is idle, so its `isPlaying` is always false
|
||||
// and the button would be stuck showing "Play" mid-playback. (DR-160)
|
||||
val isPlaying = if (isNativeVideoPath()) {
|
||||
try {
|
||||
val isPlaying = try {
|
||||
JellyTauPlayer.getInstance().getExoPlayer().isPlaying
|
||||
} catch (e: Exception) {
|
||||
false
|
||||
}
|
||||
} else {
|
||||
html5VideoPlaying
|
||||
}
|
||||
|
||||
val (iconRes, title, controlType, requestCode) = if (isPlaying) {
|
||||
Quad(
|
||||
@@ -288,11 +234,8 @@ object PictureInPictureManager {
|
||||
*/
|
||||
fun onPipModeChanged(activity: Activity, isInPipMode: Boolean) {
|
||||
if (isInPipMode) {
|
||||
// Hiding the WebView is correct only when the video is *behind* it on
|
||||
// the native surface. On the HTML5 path the WebView is the video, so
|
||||
// hiding it would leave an empty black PiP window — the frontend
|
||||
// instead strips its own chrome when it hears the event below.
|
||||
// (DR-160)
|
||||
// The video is *behind* the WebView on the native surface, so hiding
|
||||
// the WebView leaves only the picture. (DR-160)
|
||||
if (isNativeVideoPath()) {
|
||||
hideWebView(activity)
|
||||
}
|
||||
@@ -315,9 +258,8 @@ object PictureInPictureManager {
|
||||
/**
|
||||
* Fire a DOM event into the WebView.
|
||||
*
|
||||
* The HTML5 PiP path is a conversation with the frontend rather than
|
||||
* something native can do alone: it has to be told to strip its chrome when
|
||||
* the window shrinks, and to play/pause the element. (DR-160)
|
||||
* Tells the frontend the window shrank or grew, so it can strip or restore
|
||||
* its chrome. (DR-160)
|
||||
*/
|
||||
private fun dispatchWebEvent(activity: Activity, name: String) {
|
||||
val webView = findWebView(activity.window.decorView) ?: return
|
||||
@@ -358,7 +300,6 @@ object PictureInPictureManager {
|
||||
if (intent?.action != ACTION_MEDIA_CONTROL) return
|
||||
val control = intent.getIntExtra(EXTRA_CONTROL_TYPE, 0)
|
||||
|
||||
if (isNativeVideoPath()) {
|
||||
val player = try {
|
||||
JellyTauPlayer.getInstance()
|
||||
} catch (e: Exception) {
|
||||
@@ -368,19 +309,6 @@ object PictureInPictureManager {
|
||||
CONTROL_PLAY -> player.play()
|
||||
CONTROL_PAUSE -> player.pause()
|
||||
}
|
||||
} else {
|
||||
// The WebView owns playback here, so the command has to reach
|
||||
// the `<video>` element. Driving ExoPlayer instead would do
|
||||
// nothing at all, which is what a PiP button on the HTML5 path
|
||||
// used to do. (DR-160)
|
||||
val name = when (control) {
|
||||
CONTROL_PLAY -> "jellytau-pip-play"
|
||||
CONTROL_PAUSE -> "jellytau-pip-pause"
|
||||
else -> return
|
||||
}
|
||||
dispatchWebEvent(activity, name)
|
||||
html5VideoPlaying = control == CONTROL_PLAY
|
||||
}
|
||||
// Swap the button to reflect the new state.
|
||||
updatePipActions(activity)
|
||||
}
|
||||
|
||||
@@ -7,14 +7,12 @@ import android.view.WindowManager
|
||||
import java.lang.ref.WeakReference
|
||||
|
||||
/**
|
||||
* Which playback paths currently want the screen kept awake.
|
||||
* Whether playback currently wants the screen kept awake.
|
||||
*
|
||||
* Pure state, deliberately free of any Android type so it can be unit-tested —
|
||||
* see ScreenWakeStateTest. Two independent holders, because video can be
|
||||
* rendered by either renderer and only one of them is active at a time:
|
||||
*
|
||||
* - **native** — ExoPlayer drawing into the TextureView (DR-192)
|
||||
* - **html5** — a `<video>` inside the WebView, reported by the frontend
|
||||
* see ScreenWakeStateTest. The one holder is ExoPlayer drawing video into the
|
||||
* TextureView (DR-192); the WebView `<video>` that was a second holder is gone
|
||||
* (DR-235).
|
||||
*
|
||||
* Audio is deliberately *not* a holder. Playing music with the screen off is the
|
||||
* point of the audio path; only video needs the display alive.
|
||||
@@ -23,11 +21,10 @@ import java.lang.ref.WeakReference
|
||||
*/
|
||||
class ScreenWakeState {
|
||||
private var nativeVideoPlaying = false
|
||||
private var html5VideoPlaying = false
|
||||
|
||||
/** True while any video renderer is actively playing. */
|
||||
/** True while video is actively playing. */
|
||||
val keepScreenOn: Boolean
|
||||
get() = nativeVideoPlaying || html5VideoPlaying
|
||||
get() = nativeVideoPlaying
|
||||
|
||||
/**
|
||||
* @param playing whether ExoPlayer is playing right now
|
||||
@@ -37,18 +34,9 @@ class ScreenWakeState {
|
||||
nativeVideoPlaying = playing && isVideo
|
||||
}
|
||||
|
||||
/**
|
||||
* @param active whether a webview `<video>` is the current playback surface
|
||||
* @param playing whether that element is playing right now
|
||||
*/
|
||||
fun updateHtml5(active: Boolean, playing: Boolean) {
|
||||
html5VideoPlaying = active && playing
|
||||
}
|
||||
|
||||
/** Drop every hold (teardown, or a page that can no longer be trusted). */
|
||||
fun reset() {
|
||||
nativeVideoPlaying = false
|
||||
html5VideoPlaying = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,9 +54,7 @@ class ScreenWakeState {
|
||||
* appeared nowhere, and neither renderer supplies one for free — ExoPlayer's
|
||||
* `setWakeMode` is a *CPU/wifi* wake lock and says nothing about the display,
|
||||
* and it draws into a `TextureView` we own rather than a `PlayerView`, which is
|
||||
* the media3 widget that would otherwise set `keepScreenOn` itself. The WebView
|
||||
* `<video>` path does not either: the display wake lock Chrome takes for video
|
||||
* lives in the browser layer, not in an embedded WebView.
|
||||
* the media3 widget that would otherwise set `keepScreenOn` itself.
|
||||
*
|
||||
* ## Approach
|
||||
*
|
||||
@@ -78,15 +64,9 @@ class ScreenWakeState {
|
||||
* missed release the way an explicitly acquired wake lock can. It needs no
|
||||
* permission. (The manifest's `WAKE_LOCK` is the media service's, unrelated.)
|
||||
*
|
||||
* The two renderers report independently and are OR-ed together in
|
||||
* [ScreenWakeState]:
|
||||
*
|
||||
* - `JellyTauPlayer.onIsPlayingChanged` and its surface teardown drive the
|
||||
* native path — ExoPlayer is the authoritative source of playback state, so
|
||||
* the hold follows what it reports rather than what the UI intends.
|
||||
* - `MainActivity`'s `AndroidPictureInPicture.setHtml5VideoState` bridge drives
|
||||
* the webview path. The frontend already reports that state on every
|
||||
* play/pause and on player teardown for PiP, so no new bridge is needed.
|
||||
* `JellyTauPlayer.onIsPlayingChanged` and its surface teardown drive
|
||||
* [ScreenWakeState] — ExoPlayer is the authoritative source of playback state,
|
||||
* so the hold follows what it reports rather than what the UI intends.
|
||||
*
|
||||
* The Activity reference is weak and re-set on every `onCreate`, so a
|
||||
* recreation (rotation) re-applies the current hold to the new window.
|
||||
@@ -126,20 +106,9 @@ object ScreenWakeManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* The frontend reported the webview `<video>` state. Arrives on a WebView
|
||||
* binder thread, hence the synchronization and the post to the main thread.
|
||||
*/
|
||||
@Synchronized
|
||||
fun onHtml5VideoState(active: Boolean, playing: Boolean) {
|
||||
state.updateHtml5(active, playing)
|
||||
apply()
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop every hold. Used when a new WebView/page load invalidates whatever the
|
||||
* previous page last reported — a page that goes away without a final
|
||||
* `setHtml5VideoState(false, …)` would otherwise leave the screen pinned on
|
||||
* for the life of the process.
|
||||
* Drop every hold. Used when a new WebView/page load starts from scratch, so
|
||||
* nothing held for the previous page can pin the screen on for the life of
|
||||
* the process.
|
||||
*/
|
||||
@Synchronized
|
||||
fun releaseAll() {
|
||||
|
||||
@@ -3,8 +3,11 @@ package com.dtourolle.jellytau.player
|
||||
import android.content.Context
|
||||
import android.media.MediaCodecList
|
||||
import android.util.Log
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.media3.common.AudioAttributes
|
||||
import androidx.media3.common.MimeTypes
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.decoder.ffmpeg.FfmpegLibrary
|
||||
import androidx.media3.exoplayer.audio.AudioCapabilities
|
||||
|
||||
/**
|
||||
@@ -13,9 +16,24 @@ import androidx.media3.exoplayer.audio.AudioCapabilities
|
||||
* This class queries the device's media codec capabilities and reports
|
||||
* them to the Rust backend via JNI for accurate DeviceProfile generation.
|
||||
*/
|
||||
@OptIn(UnstableApi::class) // FfmpegLibrary and the licensed-codec MimeTypes
|
||||
object CodecDetector {
|
||||
private const val TAG = "CodecDetector"
|
||||
|
||||
/**
|
||||
* Formats the FFmpeg extension can decode, as Jellyfin codec names. The
|
||||
* platform decodes the rest itself; these are the licensed codecs a device
|
||||
* often lacks.
|
||||
*/
|
||||
private val FFMPEG_AUDIO_FORMATS = listOf(
|
||||
MimeTypes.AUDIO_AC3 to "ac3",
|
||||
MimeTypes.AUDIO_E_AC3 to "eac3",
|
||||
MimeTypes.AUDIO_E_AC3_JOC to "eac3",
|
||||
MimeTypes.AUDIO_DTS to "dts",
|
||||
MimeTypes.AUDIO_DTS_HD to "dts",
|
||||
MimeTypes.AUDIO_TRUEHD to "truehd",
|
||||
)
|
||||
|
||||
/**
|
||||
* Data class to hold detected codec capabilities.
|
||||
*/
|
||||
@@ -67,6 +85,25 @@ object CodecDetector {
|
||||
}
|
||||
}
|
||||
|
||||
// The FFmpeg extension decodes in software what the platform lacks.
|
||||
// ExoPlayer uses it for playback (JellyTauPlayer's renderers factory),
|
||||
// so it belongs in the same list: Rust judges both the streaming
|
||||
// profile and the download policy against this set, and a codec
|
||||
// missing here is re-encoded by the server for nothing. Asked per
|
||||
// format rather than assumed, so a build whose native library failed
|
||||
// to load reports only what the platform itself decodes.
|
||||
// TRACES: UR-004, UR-071 | DR-293
|
||||
if (FfmpegLibrary.isAvailable()) {
|
||||
for ((mime, codec) in FFMPEG_AUDIO_FORMATS) {
|
||||
if (FfmpegLibrary.supportsFormat(mime)) {
|
||||
audioCodecs.add(codec)
|
||||
Log.d(TAG, "Audio codec: $codec (MIME: $mime, FFmpeg extension)")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Log.w(TAG, "FFmpeg extension unavailable; reporting platform decoders only")
|
||||
}
|
||||
|
||||
Log.i(TAG, "Detected ${videoCodecs.size} video codecs: ${videoCodecs.sorted()}")
|
||||
Log.i(TAG, "Detected ${audioCodecs.size} audio codecs: ${audioCodecs.sorted()}")
|
||||
} catch (e: Exception) {
|
||||
@@ -148,7 +185,12 @@ object CodecDetector {
|
||||
"audio/eac3" -> "eac3"
|
||||
"audio/eac3-joc" -> "eac3"
|
||||
"audio/dts" -> "dts"
|
||||
// The platform's own spelling — what MediaCodecList reports on the
|
||||
// ROD2-W09. Only the `.hd` variant was listed, so plain DTS was
|
||||
// detected by luck, through the HD decoder advertising both.
|
||||
"audio/vnd.dts" -> "dts"
|
||||
"audio/vnd.dts.hd" -> "dts"
|
||||
"audio/true-hd" -> "truehd"
|
||||
"audio/x-ms-wma" -> "wma"
|
||||
"audio/amr-nb" -> "amrnb"
|
||||
"audio/amr-wb" -> "amrwb"
|
||||
|
||||
@@ -19,6 +19,7 @@ import androidx.media3.common.MediaMetadata
|
||||
import androidx.media3.common.PlaybackException
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.exoplayer.DefaultRenderersFactory
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
||||
import androidx.media3.exoplayer.upstream.DefaultLoadErrorHandlingPolicy
|
||||
@@ -332,6 +333,18 @@ class JellyTauPlayer(private val appContext: Context) {
|
||||
//
|
||||
// TRACES: UR-004, UR-006 | IR-008
|
||||
exoPlayer = ExoPlayer.Builder(appContext)
|
||||
// Extension renderers ON: the device's own decoders are tried first
|
||||
// (a vendor DTS decoder stays in charge where there is one), and the
|
||||
// FFmpeg audio renderer takes any format they cannot decode — AC-3,
|
||||
// E-AC-3, TrueHD on a device without Dolby licensing. This is what
|
||||
// lets the untouched source file play, instead of a server transcode.
|
||||
// CodecDetector reports the same codecs to Rust, so the device
|
||||
// profile and the download policy agree with what actually decodes.
|
||||
// TRACES: UR-004, UR-071 | DR-293
|
||||
.setRenderersFactory(
|
||||
DefaultRenderersFactory(appContext)
|
||||
.setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON)
|
||||
)
|
||||
// Decline the player's own load-error retry for a stream it could
|
||||
// only restart (DR-203). Every other source keeps the default
|
||||
// behaviour, which resumes the failed load where it stopped.
|
||||
|
||||
@@ -40,46 +40,9 @@ class ScreenWakeStateTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `webview video playing holds the screen on`() {
|
||||
val state = ScreenWakeState()
|
||||
state.updateHtml5(active = true, playing = true)
|
||||
assertTrue(state.keepScreenOn)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `webview video paused releases the screen`() {
|
||||
val state = ScreenWakeState()
|
||||
state.updateHtml5(active = true, playing = true)
|
||||
state.updateHtml5(active = true, playing = false)
|
||||
assertFalse(state.keepScreenOn)
|
||||
}
|
||||
|
||||
/** The element going away must release even if it never reported a pause. */
|
||||
@Test
|
||||
fun `webview video going inactive while playing releases the screen`() {
|
||||
val state = ScreenWakeState()
|
||||
state.updateHtml5(active = true, playing = true)
|
||||
state.updateHtml5(active = false, playing = true)
|
||||
assertFalse(state.keepScreenOn)
|
||||
}
|
||||
|
||||
/** The two rendering paths are independent holders; either one is enough. */
|
||||
@Test
|
||||
fun `one path releasing does not release while the other still plays`() {
|
||||
fun `teardown releases the hold`() {
|
||||
val state = ScreenWakeState()
|
||||
state.updateNative(playing = true, isVideo = true)
|
||||
state.updateHtml5(active = true, playing = true)
|
||||
state.updateHtml5(active = false, playing = false)
|
||||
assertTrue(state.keepScreenOn)
|
||||
state.updateNative(playing = false, isVideo = true)
|
||||
assertFalse(state.keepScreenOn)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `teardown releases both paths`() {
|
||||
val state = ScreenWakeState()
|
||||
state.updateNative(playing = true, isVideo = true)
|
||||
state.updateHtml5(active = true, playing = true)
|
||||
state.reset()
|
||||
assertFalse(state.keepScreenOn)
|
||||
}
|
||||
|
||||
@@ -1,3 +1,32 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn main() {
|
||||
link_windows_libmpv();
|
||||
tauri_build::build()
|
||||
}
|
||||
|
||||
/// Point the MSVC linker at `mpv.lib` for a Windows target.
|
||||
///
|
||||
/// `libmpv-sys` emits `rustc-link-lib=mpv` and nothing else; on Linux the
|
||||
/// system library satisfies it. For Windows the import library and the DLL it
|
||||
/// names are staged into `src-tauri/windows-libs/` by
|
||||
/// `scripts/build-windows-cross.sh` from the builder image's pinned libmpv
|
||||
/// build — the same directory `tauri.windows.conf.json` bundles the DLL from,
|
||||
/// so what is linked against and what is shipped cannot come from two places.
|
||||
///
|
||||
/// TRACES: UR-004 | DR-237
|
||||
fn link_windows_libmpv() {
|
||||
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") {
|
||||
return;
|
||||
}
|
||||
let dir = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap()).join("windows-libs");
|
||||
println!("cargo:rerun-if-changed={}", dir.join("mpv.lib").display());
|
||||
if !dir.join("mpv.lib").is_file() {
|
||||
panic!(
|
||||
"{} is missing. Windows builds link libmpv from the builder image; \
|
||||
build through scripts/build-windows-cross.sh, which stages it.",
|
||||
dir.join("mpv.lib").display()
|
||||
);
|
||||
}
|
||||
println!("cargo:rustc-link-search=native={}", dir.display());
|
||||
}
|
||||
|
||||
@@ -185,18 +185,45 @@ fn confine_to_root(root: &Path, candidate: &Path) -> Result<PathBuf, String> {
|
||||
///
|
||||
/// TRACES: DR-211 | UT-205
|
||||
fn confine_queued_path(root: &Path, file_path: &str) -> Result<String, String> {
|
||||
// The returned spelling keeps the caller's own separator. Rebuilding it with
|
||||
// `PathBuf::push` rewrote `downloads/x` as `downloads\x` on Windows, so the
|
||||
// stored row no longer spelled the path the app built — the contract the
|
||||
// test below pins, which only ever ran on Linux.
|
||||
// TRACES: DR-211 | UT-205
|
||||
let sep = file_path
|
||||
.chars()
|
||||
.find(|c| std::path::is_separator(*c))
|
||||
.unwrap_or('/');
|
||||
let mut sanitized = PathBuf::new();
|
||||
let mut spelled = String::new();
|
||||
let mut need_sep = false;
|
||||
for component in Path::new(file_path).components() {
|
||||
match component {
|
||||
Component::Normal(part) => sanitized.push(sanitize_filename(&part.to_string_lossy())),
|
||||
let piece = match component {
|
||||
Component::Normal(part) => sanitize_filename(&part.to_string_lossy()),
|
||||
// Kept as they are, so `confine_to_root` is the single thing
|
||||
// deciding whether what they add up to is still inside the root.
|
||||
other => sanitized.push(other),
|
||||
other => other.as_os_str().to_string_lossy().into_owned(),
|
||||
};
|
||||
sanitized.push(&piece);
|
||||
match component {
|
||||
Component::Prefix(_) => spelled.push_str(&piece),
|
||||
Component::RootDir => {
|
||||
spelled.push(sep);
|
||||
need_sep = false;
|
||||
continue;
|
||||
}
|
||||
_ => {
|
||||
if need_sep {
|
||||
spelled.push(sep);
|
||||
}
|
||||
spelled.push_str(&piece);
|
||||
}
|
||||
}
|
||||
need_sep = !matches!(component, Component::Prefix(_));
|
||||
}
|
||||
|
||||
confine_to_root(root, &root.join(&sanitized))?;
|
||||
Ok(sanitized.to_string_lossy().to_string())
|
||||
Ok(spelled)
|
||||
}
|
||||
|
||||
/// Request payload for download_item_and_start (bundled to stay within specta's
|
||||
|
||||
@@ -69,10 +69,6 @@ pub struct PlayerStatus {
|
||||
pub muted: bool,
|
||||
pub shuffle: bool,
|
||||
pub repeat: RepeatMode,
|
||||
/// Backend being used (native = ExoPlayer/libmpv, html5 = fallback)
|
||||
pub backend: VideoBackend,
|
||||
/// Whether frontend should render HTML5 video element
|
||||
pub use_html5_element: bool,
|
||||
|
||||
// Merged fields (prefer remote session when available)
|
||||
/// Media item from either local queue or remote session
|
||||
@@ -156,16 +152,6 @@ pub struct QueueStatus {
|
||||
pub has_previous: bool,
|
||||
}
|
||||
|
||||
/// Backend type for video playback
|
||||
#[derive(specta::Type, Debug, Serialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum VideoBackend {
|
||||
/// Native backend (ExoPlayer on Android, libmpv on Linux)
|
||||
Native,
|
||||
/// HTML5 video element fallback
|
||||
Html5,
|
||||
}
|
||||
|
||||
/// Request to play a single video item
|
||||
///
|
||||
/// Simplified to video playback only. Audio playback uses player_play_tracks
|
||||
@@ -217,9 +203,8 @@ pub struct PlayItemRequest {
|
||||
pub series_id: Option<String>,
|
||||
/// Subtitle tracks to sideload, with URLs the frontend has already resolved.
|
||||
///
|
||||
/// Only the native backends use these: on Android they become the
|
||||
/// `MediaItem.SubtitleConfiguration`s ExoPlayer renders. The HTML5 path
|
||||
/// builds its own `<track>` children instead and ignores this list.
|
||||
/// On Android they become the `MediaItem.SubtitleConfiguration`s ExoPlayer
|
||||
/// renders; mpv loads them as external subtitle files (`mpv_tracks`).
|
||||
///
|
||||
/// **Order is the contract.** `player_set_subtitle_track(n)` reaches
|
||||
/// `JellyTauPlayer.setSubtitleTrack(n)`, which indexes into ExoPlayer's
|
||||
@@ -328,19 +313,6 @@ pub enum VideoSeekResponse {
|
||||
/// Confirmed position after seek
|
||||
position: f64,
|
||||
},
|
||||
/// Reload stream from new position (transcoded non-HLS)
|
||||
ReloadStream {
|
||||
/// What to open, and how — transport included, so the frontend picks
|
||||
/// its loader from a tagged enum rather than by searching the URL for
|
||||
/// `.m3u8`. TRACES: UR-079 | DR-225
|
||||
selection: StreamSelection,
|
||||
/// `seek_offset` carries the position to RESUME AT, not a base to add to
|
||||
/// the element's clock. The reloaded stream starts at the item's zero —
|
||||
/// a position on an HLS playlist makes the server 400 every segment
|
||||
/// behind it (DR-181) — so the adapter reaches the position by seeking
|
||||
/// the element and leaves the transcode offset at zero.
|
||||
seek_offset: f64,
|
||||
},
|
||||
}
|
||||
|
||||
/// Response for audio track switching operations
|
||||
@@ -352,13 +324,6 @@ pub enum AudioTrackSwitchResponse {
|
||||
/// Confirmation message
|
||||
success: bool,
|
||||
},
|
||||
/// HTML5 needs to reload stream with new audio track
|
||||
ReloadStream {
|
||||
/// What to open, and how. TRACES: UR-079 | DR-225
|
||||
selection: StreamSelection,
|
||||
/// Current position to resume from
|
||||
position: f64,
|
||||
},
|
||||
}
|
||||
|
||||
/// Response for a mid-playback streaming-quality change.
|
||||
@@ -388,16 +353,6 @@ pub enum StreamQualityResponse {
|
||||
/// Position playback resumed at.
|
||||
position: f64,
|
||||
},
|
||||
/// HTML5 must reload its element with this selection.
|
||||
ReloadStream {
|
||||
/// What to open, and how — already negotiated against the requested
|
||||
/// ceiling. Carries `available` too, so a picker opened after a quality
|
||||
/// change still describes the source correctly.
|
||||
/// TRACES: UR-070, UR-079 | DR-225, DR-227
|
||||
selection: StreamSelection,
|
||||
/// Position to resume from.
|
||||
position: f64,
|
||||
},
|
||||
}
|
||||
|
||||
/// Helper function to create MediaItem from video request
|
||||
@@ -726,34 +681,18 @@ pub async fn player_play_item(
|
||||
}
|
||||
|
||||
let controller = player.0.lock().await;
|
||||
// Who gets the stream depends on who is going to *render* it, which is a
|
||||
// runtime question, not a platform constant.
|
||||
// The backend always gets the stream: every video renderer is native (mpv,
|
||||
// ExoPlayer) since the webview path was deleted (DR-235). This used to ask
|
||||
// who would render — the webview's `<video>` played it itself, so the
|
||||
// backend was only told about it (`set_current_item`) — and got the answer
|
||||
// wrong twice: the Linux guard silenced mpv video entirely once mpv drew the
|
||||
// picture, and on Windows it loaded video into the backend while the status
|
||||
// sent it to the element too.
|
||||
//
|
||||
// Historically Linux video was always the webview's (`use_html5_element`),
|
||||
// so handing the file to MPV as well would only have started a redundant
|
||||
// decode with no window to show it in — hence a `#[cfg(not(linux))]` guard
|
||||
// and a queue-only path here. With mpv drawing the picture that inverts:
|
||||
// the webview is no longer loading anything, so if this does not load the
|
||||
// file, *nothing does*. The symptom is total silence — no picture and no
|
||||
// audio — which reads like a broken stream rather than a stream nobody was
|
||||
// given.
|
||||
//
|
||||
// This is the fifth place in this cycle where a renderer's capability was
|
||||
// written as a compile-time platform fact. Same fix as the others: ask.
|
||||
//
|
||||
// TRACES: UR-080 | DR-231, DR-235
|
||||
let renders_natively = cfg!(not(target_os = "linux")) || crate::player::native_video::enabled();
|
||||
if renders_natively {
|
||||
// TRACES: UR-080 | DR-231, DR-235, DR-237
|
||||
controller
|
||||
.play_item(media_item)
|
||||
.map_err(|e| e.to_string())?;
|
||||
} else {
|
||||
// The webview will play it; keep the queue in sync for the UI and for a
|
||||
// remote transfer without starting a second decode.
|
||||
controller
|
||||
.set_current_item(media_item)
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
// Emit queue changed event
|
||||
controller.emit_queue_changed();
|
||||
@@ -776,8 +715,8 @@ pub async fn player_play_item(
|
||||
/// `stream_url` MUST be an audio-only URL (see
|
||||
/// `get_audio_only_stream_url_for_video`). The item is created as
|
||||
/// `MediaType::Audio` so it starts an audio session and loads into the native
|
||||
/// backend with `mediaType="audio"` — the WebView `<video>` is torn down on the
|
||||
/// frontend side, so exactly one audio source is ever active.
|
||||
/// backend with `mediaType="audio"`, replacing the video, so exactly one audio
|
||||
/// source is ever active.
|
||||
///
|
||||
/// This deliberately goes through the queue-based `play_item` path (NOT a
|
||||
/// side-channel) so end-of-track lands in `on_playback_ended`, which already
|
||||
@@ -893,7 +832,7 @@ pub async fn player_enter_background_audio(
|
||||
}
|
||||
|
||||
/// Exit background-audio mode: stop the native audio player and return its final
|
||||
/// position so the frontend can reload the WebView `<video>` there (UR-040).
|
||||
/// position so the frontend can reload the video there (UR-040).
|
||||
///
|
||||
/// Returns the position in seconds. The sleep timer is intentionally left
|
||||
/// untouched — if it fired while backgrounded, playback is already stopped and
|
||||
@@ -939,12 +878,17 @@ pub async fn player_background_action(
|
||||
Ok(action)
|
||||
}
|
||||
|
||||
/// TRACES: UR-040 | DR-052 | UT-061, IT-013
|
||||
/// Returns the item the native player is on and its absolute position. The
|
||||
/// item matters: an episode that ended while backgrounded has already advanced
|
||||
/// in the backend, so reloading the video the webview was mounted with would
|
||||
/// bring back the previous episode. (DR-296)
|
||||
///
|
||||
/// TRACES: UR-040, UR-023 | DR-052, DR-296 | UT-061, IT-013
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn player_exit_background_audio(
|
||||
player: State<'_, PlayerStateWrapper>,
|
||||
) -> Result<f64, String> {
|
||||
) -> Result<crate::player::BackgroundAudioResume, String> {
|
||||
let controller = player.0.lock().await;
|
||||
|
||||
// Read the position BEFORE clearing either base. The position tick applies the
|
||||
@@ -954,23 +898,23 @@ pub async fn player_exit_background_audio(
|
||||
// lock discipline from CLAUDE.md: never hold work across a re-entrant call.
|
||||
// (DR-159)
|
||||
//
|
||||
// `absolute_position` rather than `position`, because a tick that has not
|
||||
// `background_audio_resume` reads `absolute_position` rather than `position`, because a tick that has not
|
||||
// landed *yet* is the same hazard from the other side: returning to the
|
||||
// foreground while the audio-only transcode is still opening read 0.0, and
|
||||
// the video reloaded at StartTimeTicks=0 — the episode restarting from the
|
||||
// beginning. Flooring at the handoff base cannot overshoot: the stream is
|
||||
// physically incapable of being behind its own starting point. (DR-178)
|
||||
let absolute = controller.absolute_position();
|
||||
let resume = controller.background_audio_resume();
|
||||
|
||||
// Now safe to tear the handoff down, native side first.
|
||||
let _ = crate::player::set_lockscreen_position_offset(0.0);
|
||||
controller.exit_background_audio();
|
||||
controller.stop().map_err(|e| e.to_string())?;
|
||||
info!(
|
||||
"player_exit_background_audio: resuming the video at {:.1}s",
|
||||
absolute
|
||||
"player_exit_background_audio: resuming {:?} at {:.1}s",
|
||||
resume.item_id, resume.position_seconds
|
||||
);
|
||||
Ok(absolute)
|
||||
Ok(resume)
|
||||
}
|
||||
|
||||
/// Play a queue of media items
|
||||
@@ -1183,8 +1127,9 @@ pub async fn player_stop(
|
||||
let mode = playback_mode.0.get_mode();
|
||||
|
||||
// Stopping is a state transition worth seeing in a log. Native video is
|
||||
// what made its absence matter: the webview <video> stopped implicitly when
|
||||
// the component unmounted, so nothing ever had to call this — and "never
|
||||
// what made its absence matter: the (since deleted) webview <video> stopped
|
||||
// implicitly when the component unmounted, so nothing ever had to call
|
||||
// this — and "never
|
||||
// called" and "called but the backend kept playing" look identical from
|
||||
// outside without it.
|
||||
info!("[player_stop] called (mode: {:?})", mode);
|
||||
@@ -1425,8 +1370,8 @@ pub async fn player_seek(
|
||||
/// - Direct play streams: Use native seeking
|
||||
/// - Transcoded non-HLS: Request new stream URL from server starting at seek position
|
||||
///
|
||||
/// For native (non-HTML5) backends, this command handles the entire stream reload
|
||||
/// internally. For HTML5 backends, it returns the new URL for the frontend to handle.
|
||||
/// The backend always handles the seek itself, including re-opening a stream,
|
||||
/// since every video renderer is native (DR-235).
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn player_seek_video(
|
||||
@@ -1436,12 +1381,8 @@ pub async fn player_seek_video(
|
||||
position: f64,
|
||||
media_source_id: Option<String>,
|
||||
audio_stream_index: Option<i32>,
|
||||
use_html5: bool,
|
||||
) -> Result<VideoSeekResponse, String> {
|
||||
info!(
|
||||
"[player_seek_video] Seeking to {} seconds (use_html5: {})",
|
||||
position, use_html5
|
||||
);
|
||||
info!("[player_seek_video] Seeking to {} seconds", position);
|
||||
|
||||
// Get repository
|
||||
let repository = repository_manager
|
||||
@@ -1483,17 +1424,13 @@ pub async fn player_seek_video(
|
||||
let controller = player.0.lock().await;
|
||||
controller.capabilities().seeks_transcoded_in_place
|
||||
};
|
||||
let strategy = determine_video_seek_strategy(
|
||||
is_local,
|
||||
seeks_transcoded_in_place,
|
||||
needs_transcoding,
|
||||
use_html5,
|
||||
);
|
||||
let strategy =
|
||||
determine_video_seek_strategy(is_local, seeks_transcoded_in_place, needs_transcoding);
|
||||
|
||||
info!(
|
||||
"[player_seek_video] Stream analysis: is_local={}, seeks_transcoded_in_place={}, \
|
||||
needs_transcoding={}, use_html5={}, strategy={:?}",
|
||||
is_local, seeks_transcoded_in_place, needs_transcoding, use_html5, strategy
|
||||
needs_transcoding={}, strategy={:?}",
|
||||
is_local, seeks_transcoded_in_place, needs_transcoding, strategy
|
||||
);
|
||||
|
||||
match strategy {
|
||||
@@ -1504,35 +1441,6 @@ pub async fn player_seek_video(
|
||||
controller.seek(position).map_err(|e| e.to_string())?;
|
||||
Ok(VideoSeekResponse::Native { position })
|
||||
}
|
||||
VideoSeekStrategy::Html5NativeSeek => {
|
||||
// HTML5 backend with HLS or direct play - frontend handles seeking
|
||||
// We don't call backend.seek() because video is in HTML5 element, not in MPV
|
||||
info!("[player_seek_video] HTML5 native seek - returning position for frontend");
|
||||
Ok(VideoSeekResponse::Native { position })
|
||||
}
|
||||
VideoSeekStrategy::Html5ReloadStream => {
|
||||
// Transcoded non-HLS with HTML5 - frontend handles stream reload
|
||||
info!("[player_seek_video] HTML5 reload stream - requesting new stream URL");
|
||||
|
||||
let selection = repository
|
||||
.get_stream_selection(
|
||||
&jellyfin_item_id,
|
||||
media_source_id.as_deref(),
|
||||
audio_stream_index,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to select a stream: {:?}", e))?;
|
||||
|
||||
info!(
|
||||
"[player_seek_video] Selected {:?} over {:?} for position {}",
|
||||
selection.playback_kind, selection.transport, position
|
||||
);
|
||||
|
||||
Ok(VideoSeekResponse::ReloadStream {
|
||||
selection,
|
||||
seek_offset: position,
|
||||
})
|
||||
}
|
||||
VideoSeekStrategy::BackendReloadStream => {
|
||||
// Transcoded non-HLS with native backend - backend handles stream reload
|
||||
info!("[player_seek_video] Backend reload stream - requesting new stream URL");
|
||||
@@ -1603,9 +1511,6 @@ pub async fn player_seek_video(
|
||||
/// carries the requested track at all** — see
|
||||
/// [`determine_audio_track_switch_strategy`]:
|
||||
///
|
||||
/// - An HTML5 `<video>` element has no track-selection API, so the stream is
|
||||
/// always re-opened at the chosen `AudioStreamIndex` and the frontend seeks
|
||||
/// the reloaded element back to `position`.
|
||||
/// - A native backend playing a **direct play** holds the source file with
|
||||
/// every track in it, so ExoPlayer selects in place by track-group index.
|
||||
/// - A native backend playing a **transcode** does not. Jellyfin builds a
|
||||
@@ -1621,10 +1526,8 @@ pub async fn player_seek_video(
|
||||
/// audio track index` and dropped the request — the default track just kept
|
||||
/// playing, with nothing in the UI saying so.
|
||||
///
|
||||
/// libmpv implements neither selection nor reload here — it is the audio-only
|
||||
/// backend and leaves `PlayerBackend::set_audio_track` at its
|
||||
/// `not_implemented()` default, which is why IR-019 is met by these paths
|
||||
/// rather than by MPV.
|
||||
/// mpv selects in place the same way (`mpv_tracks::select_audio`, by position in
|
||||
/// the file's audio tracks), and re-opens a transcode through the same path.
|
||||
///
|
||||
/// TRACES: UR-021, UR-005 | IR-019, DR-024, DR-258
|
||||
#[tauri::command]
|
||||
@@ -1639,12 +1542,13 @@ pub async fn player_switch_audio_track(
|
||||
repository_handle: String,
|
||||
stream_index: i32,
|
||||
array_index: i32,
|
||||
use_html5: bool,
|
||||
current_position: Option<f64>,
|
||||
media_source_id: Option<String>,
|
||||
) -> Result<AudioTrackSwitchResponse, String> {
|
||||
info!("[player_switch_audio_track] Switching to audio track - stream_index: {}, array_index: {}, use_html5: {}",
|
||||
stream_index, array_index, use_html5);
|
||||
info!(
|
||||
"[player_switch_audio_track] Switching to audio track - stream_index: {}, array_index: {}",
|
||||
stream_index, array_index
|
||||
);
|
||||
|
||||
// Read what the engine is playing before deciding anything — including
|
||||
// where it is, which has to be captured before the stop below wipes it.
|
||||
@@ -1668,11 +1572,11 @@ pub async fn player_switch_audio_track(
|
||||
)
|
||||
};
|
||||
|
||||
let strategy = determine_audio_track_switch_strategy(needs_transcoding, use_html5);
|
||||
let strategy = determine_audio_track_switch_strategy(needs_transcoding);
|
||||
|
||||
info!(
|
||||
"[player_switch_audio_track] needs_transcoding={}, use_html5={}, strategy={:?}",
|
||||
needs_transcoding, use_html5, strategy
|
||||
"[player_switch_audio_track] needs_transcoding={}, strategy={:?}",
|
||||
needs_transcoding, strategy
|
||||
);
|
||||
|
||||
if strategy == AudioTrackSwitchStrategy::BackendSelectInPlace {
|
||||
@@ -1693,7 +1597,7 @@ pub async fn player_switch_audio_track(
|
||||
|
||||
// Select a stream carrying the chosen audio track. It starts at zero —
|
||||
// an HLS playlist cannot carry a position (DR-181) — so the position is
|
||||
// restored by seeking afterwards, here or in the frontend.
|
||||
// restored by seeking afterwards.
|
||||
//
|
||||
// Pinning a track is itself a reason the source cannot be direct-played:
|
||||
// the file has one default track and the viewer asked for another, so
|
||||
@@ -1714,10 +1618,6 @@ pub async fn player_switch_audio_track(
|
||||
let position = crate::player::track_switch::resume_position(current_position, engine_position);
|
||||
|
||||
match strategy {
|
||||
AudioTrackSwitchStrategy::Html5ReloadStream => Ok(AudioTrackSwitchResponse::ReloadStream {
|
||||
selection,
|
||||
position,
|
||||
}),
|
||||
AudioTrackSwitchStrategy::BackendReloadStream => {
|
||||
// The native backend re-opens its own stream, the same sequence the
|
||||
// transcoded seek and quality change use: stop, repoint the queue
|
||||
@@ -1776,9 +1676,7 @@ pub async fn player_switch_audio_track(
|
||||
/// A cap is a property of the stream the server is producing, so unlike a volume
|
||||
/// change it cannot be applied to a stream already in flight — the stream has to
|
||||
/// be re-opened at the new quality and resumed at the current position. That is
|
||||
/// the same reload the transcoded-seek and audio-track paths use, and the same
|
||||
/// two-sided split: HTML5 gets the URL back and reloads its own element, while a
|
||||
/// native backend is reloaded here.
|
||||
/// the same reload the transcoded-seek and audio-track paths use, done here.
|
||||
///
|
||||
/// The change applies to **this playback only**. The in-player picker is a
|
||||
/// "this film, this connection" control and its doc has always said so, but it
|
||||
@@ -1802,15 +1700,13 @@ pub async fn player_set_stream_quality(
|
||||
repository_manager: State<'_, super::repository::RepositoryManagerWrapper>,
|
||||
repository_handle: String,
|
||||
quality: crate::settings::StreamingQuality,
|
||||
use_html5: bool,
|
||||
current_position: Option<f64>,
|
||||
media_source_id: Option<String>,
|
||||
audio_stream_index: Option<i32>,
|
||||
) -> Result<StreamQualityResponse, String> {
|
||||
info!(
|
||||
"[player_set_stream_quality] Switching to {} (use_html5: {}, position: {:?})",
|
||||
"[player_set_stream_quality] Switching to {} (position: {:?})",
|
||||
quality.label(),
|
||||
use_html5,
|
||||
current_position
|
||||
);
|
||||
|
||||
@@ -1875,14 +1771,7 @@ pub async fn player_set_stream_quality(
|
||||
.map_err(|e| format!("Failed to select a stream: {:?}", e))?;
|
||||
let new_url = selection.url.clone();
|
||||
|
||||
if use_html5 {
|
||||
return Ok(StreamQualityResponse::ReloadStream {
|
||||
selection,
|
||||
position,
|
||||
});
|
||||
}
|
||||
|
||||
// Native backend (Android/ExoPlayer): stop, repoint the queue entry at the
|
||||
// The native backend (mpv, ExoPlayer): stop, repoint the queue entry at the
|
||||
// new URL, and reload — mirroring `VideoSeekStrategy::BackendReloadStream`.
|
||||
// The re-opened stream begins at zero (an HLS playlist cannot carry a start
|
||||
// position without 400ing every segment — DR-181), so it is seeked back to
|
||||
@@ -1935,8 +1824,8 @@ pub async fn player_set_audio_track(
|
||||
///
|
||||
/// On Android this indexes ExoPlayer's *text track groups* — i.e. the position
|
||||
/// of the sideloaded `MediaItem.SubtitleConfiguration`, not the Jellyfin stream
|
||||
/// index. The HTML5 path never reaches here; it toggles its own `<track>`
|
||||
/// children. libmpv implements neither, leaving the trait default in place.
|
||||
/// index. mpv gives it the same meaning: the position in the sideloaded WebVTT
|
||||
/// list, loaded as external subtitle files (`mpv_tracks`).
|
||||
///
|
||||
/// TRACES: UR-020 | IR-018, DR-023
|
||||
#[tauri::command]
|
||||
@@ -2179,14 +2068,13 @@ pub async fn player_get_queue(
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PlaybackCapabilities {
|
||||
/// True when audio is rendered by a webview `<audio>` element rather than a
|
||||
/// native backend. Native audio exists on Linux (mpv) and Android
|
||||
/// (ExoPlayer); everything else (Windows, future desktops) uses the webview.
|
||||
/// native backend. Native audio exists on Linux and Windows (mpv) and
|
||||
/// Android (ExoPlayer); only an unported desktop uses the webview.
|
||||
///
|
||||
/// Video has no counterpart: it is always drawn by the native backend, behind
|
||||
/// the transparent webview (DR-235) — there is no webview video renderer
|
||||
/// left to report.
|
||||
pub uses_webview_audio: bool,
|
||||
/// True when video can be rendered by a native surface composited *behind*
|
||||
/// a transparent webview. Android only: ExoPlayer draws into a SurfaceView
|
||||
/// beneath the WebView. Linux cannot do this (WebKitGTK/Wayland
|
||||
/// compositing), so it stays on the HTML5 element.
|
||||
pub supports_native_video: bool,
|
||||
}
|
||||
|
||||
/// Report this platform's playback capabilities to the frontend.
|
||||
@@ -2195,37 +2083,24 @@ pub struct PlaybackCapabilities {
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn player_get_capabilities() -> Result<PlaybackCapabilities, String> {
|
||||
// Mirrors the cfg gates the backends themselves are built under.
|
||||
let native_audio = cfg!(any(target_os = "android", target_os = "linux"));
|
||||
// Mirrors the cfg gates the backends themselves are built under: mpv on
|
||||
// Linux and Windows (DR-237), ExoPlayer on Android.
|
||||
let native_audio = cfg!(any(
|
||||
target_os = "android",
|
||||
target_os = "linux",
|
||||
target_os = "windows"
|
||||
));
|
||||
|
||||
Ok(PlaybackCapabilities {
|
||||
uses_webview_audio: !native_audio,
|
||||
// TRACES: UR-080 | DR-235
|
||||
supports_native_video: cfg!(target_os = "android")
|
||||
|| crate::player::native_video::enabled(),
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn get_player_status(controller: &PlayerController) -> PlayerStatus {
|
||||
// Determine backend at compile time based on platform
|
||||
let (backend, use_html5_element) = if cfg!(target_os = "android") {
|
||||
// Android uses ExoPlayer native backend
|
||||
(VideoBackend::Native, false)
|
||||
} else if crate::player::native_video::enabled() {
|
||||
// mpv draws the picture on this desktop; the frontend must not also
|
||||
// load it into a <video> element or the stream decodes twice and the
|
||||
// two fight over the audio. TRACES: UR-080 | DR-235
|
||||
(VideoBackend::Native, false)
|
||||
} else {
|
||||
// Linux and other platforms use HTML5 video element in frontend
|
||||
(VideoBackend::Html5, true)
|
||||
};
|
||||
|
||||
PlayerStatus {
|
||||
state: controller.state(),
|
||||
// The position on the item's timeline, whichever of the three paths is
|
||||
// rendering it — the native backend answers for only one of them, and
|
||||
// reads 0 for webview video and for a handoff that has not ticked yet.
|
||||
// The position on the item's timeline, whichever path is rendering it —
|
||||
// the native backend reads 0 for a handoff that has not ticked yet.
|
||||
// TRACES: UR-005 | DR-178
|
||||
position: controller.absolute_position(),
|
||||
duration: controller.duration(),
|
||||
@@ -2233,8 +2108,6 @@ pub(super) fn get_player_status(controller: &PlayerController) -> PlayerStatus {
|
||||
muted: controller.muted(),
|
||||
shuffle: controller.is_shuffle(),
|
||||
repeat: controller.repeat_mode(),
|
||||
backend,
|
||||
use_html5_element,
|
||||
|
||||
// Merged fields initialized to defaults (will be set by player_get_status)
|
||||
merged_media: None,
|
||||
@@ -3058,6 +2931,44 @@ pub async fn player_disable_jellyfin(player: State<'_, PlayerStateWrapper>) -> R
|
||||
mod tests {
|
||||
use crate::utils::lock::MutexSafe;
|
||||
|
||||
/// Video always goes to the backend. `player_play_item` once decided per
|
||||
/// platform whether the backend or the webview's `<video>` would render,
|
||||
/// and each wrong answer was silence (Linux, once mpv drew the picture) or a
|
||||
/// soundtrack decoded twice (Windows). With the webview video path deleted
|
||||
/// there is no second renderer to route to, and the queue-only branch is
|
||||
/// gone with it.
|
||||
///
|
||||
/// TRACES: UR-003, UR-080 | DR-235, DR-237 | UT-273
|
||||
#[test]
|
||||
fn test_video_always_goes_to_the_backend() {
|
||||
let src = include_str!("mod.rs");
|
||||
let play_item = src
|
||||
.split("pub async fn player_play_item(")
|
||||
.nth(1)
|
||||
.and_then(|rest| rest.split("\n}\n").next())
|
||||
.expect("player_play_item exists");
|
||||
assert!(play_item.contains(".play_item(media_item)"));
|
||||
assert!(
|
||||
!play_item.contains(".set_current_item("),
|
||||
"player_play_item must not keep video from the backend"
|
||||
);
|
||||
}
|
||||
|
||||
/// Only audio can still be the webview's, and only on a desktop with no mpv.
|
||||
///
|
||||
/// TRACES: UR-003, UR-080 | DR-235, DR-237 | UT-272
|
||||
#[tokio::test]
|
||||
async fn test_every_shipped_platform_plays_audio_natively() {
|
||||
let caps = super::player_get_capabilities().await.unwrap();
|
||||
if cfg!(any(
|
||||
target_os = "linux",
|
||||
target_os = "windows",
|
||||
target_os = "android"
|
||||
)) {
|
||||
assert!(!caps.uses_webview_audio);
|
||||
}
|
||||
}
|
||||
|
||||
/// UT-206 — the volume the command hands on is always a real number in
|
||||
/// 0.0..=1.0.
|
||||
///
|
||||
|
||||
@@ -138,7 +138,7 @@ pub async fn player_play_next_episode(
|
||||
|
||||
/// Handle playback ended event - triggers autoplay decision logic
|
||||
/// This is called from:
|
||||
/// - Frontend when HTML5 video ends (Linux/desktop) - passes itemId + repositoryHandle for the video
|
||||
/// - Frontend when a video ends - passes itemId + repositoryHandle for the video
|
||||
/// - Frontend when audio track ends via backend event - no itemId/repositoryHandle needed
|
||||
/// - Android JNI callback also triggers this logic directly
|
||||
///
|
||||
@@ -158,7 +158,7 @@ pub async fn player_on_playback_ended(
|
||||
let controller_arc = player.0.clone();
|
||||
|
||||
// Run autoplay decision logic
|
||||
// If item_id is provided (HTML5 video case), use the video-specific path
|
||||
// If item_id is provided (a video), use the video-specific path
|
||||
// that bypasses the backend queue and stale end_reason
|
||||
let decision = {
|
||||
let controller = controller_arc.lock().await;
|
||||
@@ -326,16 +326,17 @@ pub async fn player_recover_stream(player: State<'_, PlayerStateWrapper>) -> Res
|
||||
}
|
||||
}
|
||||
|
||||
// ===== HTML5 video state-report commands =====
|
||||
// ===== Webview media state-report commands =====
|
||||
//
|
||||
// On platforms where video renders in the webview (Linux WebKitGTK HTML5
|
||||
// <video>), the real player lives outside the native backend, so the frontend
|
||||
// HTML5 adapter reports DOM events back through these commands. The controller
|
||||
// Where media renders in the webview — the `<audio>` element of the webview
|
||||
// audio backend, on a desktop with no mpv; video never does since DR-235 — the
|
||||
// real player lives outside the native backend, so the frontend adapter reports
|
||||
// DOM events back through these commands. The controller
|
||||
// re-emits them through the same PlayerStatusEvent pipeline the native backends
|
||||
// use, keeping the Rust controller the single source of truth and the frontend
|
||||
// player store fed from one place (playerEvents.ts) in both modes.
|
||||
|
||||
/// Report an HTML5 <video> state change (playing/paused/loading/stopped/idle).
|
||||
/// Report a webview media element's state change (playing/paused/loading/stopped/idle).
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn player_report_state(
|
||||
@@ -348,7 +349,7 @@ pub async fn player_report_state(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Report an HTML5 <video> position tick (seconds). The adapter should throttle
|
||||
/// Report a webview media element's position tick (seconds). The adapter should throttle
|
||||
/// these to roughly match the native backends' ~250ms cadence.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
@@ -362,7 +363,7 @@ pub async fn player_report_position(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Report that the HTML5 <video> finished loading and knows its duration.
|
||||
/// Report that a webview media element finished loading and knows its duration.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn player_report_media_loaded(
|
||||
|
||||
@@ -514,6 +514,24 @@ pub async fn repository_get_series_current_episode(
|
||||
.map_err(|e| format!("{:?}", e))
|
||||
}
|
||||
|
||||
/// A series' episodes and the viewer's current episode, from one season
|
||||
/// fan-out. The series page used to ask for these as two commands, each of
|
||||
/// which walked every season.
|
||||
///
|
||||
/// TRACES: UR-062 | DR-101, DR-295
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn repository_get_series_view(
|
||||
manager: State<'_, RepositoryManagerWrapper>,
|
||||
handle: String,
|
||||
series_id: String,
|
||||
) -> Result<series_progress::SeriesView, String> {
|
||||
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||
series_progress::resolve_series_view(repo.as_ref(), &series_id)
|
||||
.await
|
||||
.map_err(|e| format!("{:?}", e))
|
||||
}
|
||||
|
||||
/// Erase the viewer's watch history for an item.
|
||||
///
|
||||
/// Clears the played flag and the resume position; on a series or season the
|
||||
|
||||
@@ -49,6 +49,19 @@ pub async fn sync_queue_mutation(
|
||||
Arc::new(database.service())
|
||||
};
|
||||
|
||||
enqueue_mutation(&*db_service, user_id, operation, item_id, payload).await
|
||||
}
|
||||
|
||||
/// Insert one pending mutation and return the id of *that* row.
|
||||
///
|
||||
/// TRACES: UR-002, UR-017 | DR-014
|
||||
pub(crate) async fn enqueue_mutation<S: DatabaseService>(
|
||||
db_service: &S,
|
||||
user_id: String,
|
||||
operation: String,
|
||||
item_id: Option<String>,
|
||||
payload: Option<String>,
|
||||
) -> Result<i64, String> {
|
||||
let query = Query::with_params(
|
||||
"INSERT INTO sync_queue (user_id, operation, item_id, payload, status, created_at)
|
||||
VALUES (?, ?, ?, ?, 'pending', CURRENT_TIMESTAMP)",
|
||||
@@ -60,13 +73,9 @@ pub async fn sync_queue_mutation(
|
||||
],
|
||||
);
|
||||
|
||||
db_service.execute(query).await.map_err(|e| e.to_string())?;
|
||||
let id = db_service
|
||||
.last_insert_rowid()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(id)
|
||||
// `insert`, not `execute` + `last_insert_rowid`: the id must be read in
|
||||
// the same job as the insert, or a concurrent write hands us its row.
|
||||
db_service.insert(query).await
|
||||
}
|
||||
|
||||
/// Get all pending sync operations for a user
|
||||
@@ -387,4 +396,58 @@ mod tests {
|
||||
assert!(item.retry_count == i);
|
||||
}
|
||||
}
|
||||
|
||||
/// Each queued mutation must get back the id of its *own* row.
|
||||
///
|
||||
/// The id used to come from a separate `last_insert_rowid()` call — a
|
||||
/// second trip to the shared connection — so another insert landing in
|
||||
/// between handed this mutation someone else's id, and marking it synced
|
||||
/// later completed the wrong row.
|
||||
///
|
||||
/// TRACES: UR-002, UR-017 | DR-014 | UT-014
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 8)]
|
||||
async fn concurrent_enqueues_each_get_their_own_row_id() {
|
||||
let database = crate::storage::Database::open_in_memory().unwrap();
|
||||
let service = Arc::new(database.service());
|
||||
service
|
||||
.execute(Query::new(
|
||||
"INSERT INTO servers (id, name, url) VALUES ('s', 'S', 'http://s')",
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
service
|
||||
.execute(Query::new(
|
||||
"INSERT INTO users (id, server_id, username) VALUES ('u', 's', 'u')",
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let tasks: Vec<_> = (0..200)
|
||||
.map(|i| {
|
||||
let service = Arc::clone(&service);
|
||||
tokio::spawn(async move {
|
||||
let op = format!("op-{i}");
|
||||
let id = enqueue_mutation(&*service, "u".into(), op.clone(), None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
(op, id)
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
for task in tasks {
|
||||
let (op, id) = task.await.unwrap();
|
||||
let stored: String = service
|
||||
.query_one(
|
||||
Query::with_params(
|
||||
"SELECT operation FROM sync_queue WHERE id = ?",
|
||||
vec![QueryParam::Int64(id)],
|
||||
),
|
||||
|row| row.get(0),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(stored, op, "mutation {op} was handed row {id}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,6 +150,7 @@ pub fn run_engine(url: &str, engine: Engine) -> u32 {
|
||||
None,
|
||||
std::sync::Arc::new(tokio::sync::Mutex::new(None)),
|
||||
std::sync::Arc::new(crate::playback_reporting::throttle::EventThrottler::new()),
|
||||
None,
|
||||
)
|
||||
.expect("could not create the legacy backend"),
|
||||
crate::player::media_player::Capabilities::mpv(),
|
||||
|
||||
+50
-126
@@ -238,6 +238,7 @@ use commands::{
|
||||
repository_get_resume_movies,
|
||||
repository_get_series_current_episode,
|
||||
repository_get_series_episodes,
|
||||
repository_get_series_view,
|
||||
repository_get_similar_items,
|
||||
repository_get_stream_selection,
|
||||
repository_get_subtitle_url,
|
||||
@@ -351,7 +352,7 @@ use player::{MediaSessionManager, PlayerBackend, PlayerController, TauriEventEmi
|
||||
// still launch (browse library, manage downloads, see an error) instead of crashing.
|
||||
use player::NullBackend;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
||||
use player::MpvBackend;
|
||||
use settings::VideoSettings;
|
||||
use storage::Database;
|
||||
@@ -693,15 +694,44 @@ fn create_player_backend(
|
||||
}
|
||||
}
|
||||
|
||||
// For Linux, use MPV backend for audio playback
|
||||
#[cfg(target_os = "linux")]
|
||||
// Linux and Windows: mpv. On Windows libmpv-2.dll ships beside the exe in
|
||||
// the installer (DR-237); on Linux it is the system library.
|
||||
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
||||
{
|
||||
info!("Linux platform detected - initializing MPV backend for audio");
|
||||
match MpvBackend::new(Some(_event_emitter), playback_reporter, position_throttler) {
|
||||
info!("Initializing MPV backend");
|
||||
// Windows: mpv draws video into the main window itself (DR-237), so it
|
||||
// needs the HWND before it initialises. Linux draws through the render
|
||||
// API into a GTK surface attached later, and needs no handle.
|
||||
#[cfg(target_os = "windows")]
|
||||
let video_window = {
|
||||
use tauri::Manager;
|
||||
app_handle
|
||||
.get_webview_window("main")
|
||||
.and_then(|w| w.hwnd().ok())
|
||||
.map(|hwnd| hwnd.0 as i64)
|
||||
};
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let video_window = None;
|
||||
match MpvBackend::new(
|
||||
Some(_event_emitter),
|
||||
playback_reporter,
|
||||
position_throttler,
|
||||
video_window,
|
||||
) {
|
||||
Ok(backend) => {
|
||||
info!("Successfully initialized MPV backend for Linux");
|
||||
info!("Successfully initialized MPV backend");
|
||||
Box::new(backend)
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
Err(e) => {
|
||||
// The DLL ships in the installer, so there is no package to
|
||||
// tell the user to install; a failure here is a broken install.
|
||||
error!("FATAL ERROR: Failed to initialize MPV backend: {}", e);
|
||||
error!("libmpv-2.dll should sit beside jellytau.exe; reinstall JellyTau.");
|
||||
emit_backend_init_failed(&app_handle, "mpv", e.to_string());
|
||||
Box::new(NullBackend::new())
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
Err(e) => {
|
||||
error!("\n========================================");
|
||||
error!("FATAL ERROR: Failed to initialize MPV backend");
|
||||
@@ -730,10 +760,10 @@ fn create_player_backend(
|
||||
}
|
||||
}
|
||||
|
||||
// Platforms with no native audio backend (e.g. Windows): render audio-only
|
||||
// playback through a webview <audio> element (all video already renders in
|
||||
// the webview). Falls back to NullBackend only if the backend can't init.
|
||||
#[cfg(not(any(target_os = "linux", target_os = "android")))]
|
||||
// Platforms with no native audio backend (none that ships since Windows
|
||||
// moved to mpv): render audio-only playback through a webview <audio>
|
||||
// element. Falls back to NullBackend only if the backend can't init.
|
||||
#[cfg(not(any(target_os = "linux", target_os = "android", target_os = "windows")))]
|
||||
{
|
||||
info!("No native audio backend for this platform - using webview <audio> backend");
|
||||
match player::WebviewAudioBackend::new(_event_emitter) {
|
||||
@@ -1014,6 +1044,7 @@ fn specta_builder() -> Builder<tauri::Wry> {
|
||||
repository_get_next_up_episodes,
|
||||
repository_get_series_episodes,
|
||||
repository_get_series_current_episode,
|
||||
repository_get_series_view,
|
||||
repository_clear_watch_history,
|
||||
repository_get_recently_played_audio,
|
||||
repository_get_resume_movies,
|
||||
@@ -1071,86 +1102,6 @@ fn specta_builder() -> Builder<tauri::Wry> {
|
||||
])
|
||||
}
|
||||
|
||||
/// Configure GStreamer (the media backend behind WebKitGTK's HTML5 `<video>`
|
||||
/// element on Linux) to prefer hardware-accelerated VAAPI decoding when the
|
||||
/// host provides it, falling back to software decoding otherwise.
|
||||
///
|
||||
/// All variables are only set if the user has not already exported them, so an
|
||||
/// explicit override (e.g. forcing software decode for debugging) is respected.
|
||||
/// They must be applied before WebKitGTK builds its GStreamer pipeline, hence the
|
||||
/// call at the very top of `run()`.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn enable_linux_hardware_video_decoding() {
|
||||
// Boost the rank of the modern stateless VAAPI decoders (gst-plugins-bad
|
||||
// `va` plugin) so GStreamer selects them ahead of the software decoders. The
|
||||
// `MAX` rank wins decoder autoplugging when the hardware/driver supports the
|
||||
// codec; unsupported codecs simply fall through to software.
|
||||
let rank_overrides = "vah264dec:MAX,vah265dec:MAX,vavp9dec:MAX,vaav1dec:MAX,\
|
||||
vampeg2dec:MAX,vavp8dec:MAX";
|
||||
|
||||
set_env_if_unset("GST_PLUGIN_FEATURE_RANK", rank_overrides);
|
||||
|
||||
// Ensure WebKit keeps GStreamer's hardware/DMABUF video path enabled. Setting
|
||||
// this to "0" would force software decoding, so only default it to "1".
|
||||
set_env_if_unset("WEBKIT_GST_ENABLE_HW_VIDEO_DECODER", "1");
|
||||
|
||||
info!("[INIT] Linux hardware video decoding (VAAPI) enabled where supported");
|
||||
|
||||
log_available_vaapi_decoders();
|
||||
}
|
||||
|
||||
/// Probe (via `gst-inspect-1.0`, which ships with GStreamer) which VAAPI hardware
|
||||
/// video decoders GStreamer can actually load on this host, and log the result so
|
||||
/// it is clear at startup whether hardware decoding is genuinely available or
|
||||
/// whether playback will fall back to software.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn log_available_vaapi_decoders() {
|
||||
const HW_DECODERS: &[&str] = &[
|
||||
"vah264dec",
|
||||
"vah265dec",
|
||||
"vavp9dec",
|
||||
"vaav1dec",
|
||||
"vampeg2dec",
|
||||
"vavp8dec",
|
||||
];
|
||||
|
||||
let available: Vec<&str> = HW_DECODERS
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|name| {
|
||||
std::process::Command::new("gst-inspect-1.0")
|
||||
.arg(name)
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status()
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.collect();
|
||||
|
||||
if available.is_empty() {
|
||||
log::warn!(
|
||||
"[INIT] No VAAPI hardware video decoders found via gst-inspect-1.0; \
|
||||
video will use software decoding. Install the GStreamer 'va' plugin \
|
||||
(gst-plugins-bad) and a VAAPI driver to enable hardware decoding."
|
||||
);
|
||||
} else {
|
||||
info!(
|
||||
"[INIT] VAAPI hardware video decoders available to GStreamer: {}",
|
||||
available.join(", ")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn set_env_if_unset(key: &str, value: &str) {
|
||||
if std::env::var_os(key).is_none() {
|
||||
// SAFETY: called once at startup before any threads that read the
|
||||
// environment (WebKitGTK/GStreamer) are spawned.
|
||||
std::env::set_var(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
/// Cached thumbnails are handed to the webview as asset-protocol URLs by
|
||||
/// `convertFileSrc` (`asset://localhost/…` on Linux/macOS,
|
||||
/// `http://asset.localhost/…` on Windows/Android). Tauri only answers that
|
||||
@@ -1230,13 +1181,6 @@ pub fn run() {
|
||||
// TRACES: UR-078 | DR-218
|
||||
crate::utils::diagnostics::install_panic_hook();
|
||||
|
||||
// On Linux, video plays through WebKitGTK's HTML5 <video> element, which uses
|
||||
// GStreamer as its media backend. Enable hardware-accelerated (VAAPI) decoding
|
||||
// when available so video transcoding/decoding does not fall back to the CPU.
|
||||
// These must be set before WebKitGTK initializes its GStreamer pipeline.
|
||||
#[cfg(target_os = "linux")]
|
||||
enable_linux_hardware_video_decoding();
|
||||
|
||||
// NOTE: TypeScript bindings are generated by the `export_typescript_bindings`
|
||||
// test (`cargo test export_typescript_bindings`), NOT at runtime. Calling
|
||||
// `.export()` here would try to write `../src/lib/api/bindings.ts` at app
|
||||
@@ -1398,40 +1342,20 @@ pub fn run() {
|
||||
// during its construction, and doing this in the order the code
|
||||
// used to read produced "no mpv handle" every time — the surface was
|
||||
// built before there was anything to draw from.
|
||||
// Native video surface: put a GL area under Tauri's webview so mpv
|
||||
// can draw beneath the controls (UR-080 / DR-231).
|
||||
// Native video surface: mpv draws into the main window's own vbox,
|
||||
// underneath Tauri's webview, so the Svelte controls composite over
|
||||
// the picture (UR-080 / DR-231). The widget tree is left exactly as
|
||||
// Tauri built it — wrapping the webview in a GtkOverlay aborts the
|
||||
// process on the first click; `video_surface` explains why.
|
||||
//
|
||||
// 🔴 OFF BY DEFAULT — the naive reparent crashes the app on the
|
||||
// first click. `tauri-runtime-wry`'s undecorated-resizing handler
|
||||
// walks a hard-coded two-hop path on every button press in the
|
||||
// webview:
|
||||
// Unconditional on Linux since DR-235: mpv is the only Linux video
|
||||
// renderer, so there is no webview path to fall back to if this
|
||||
// fails — the warnings below are the whole diagnosis.
|
||||
//
|
||||
// webview.parent() // "This one should be GtkBox"
|
||||
// .parent() // ...and this one the GtkWindow
|
||||
// .downcast::<gtk::Window>().unwrap()
|
||||
//
|
||||
// Wrapping the webview in a GtkOverlay makes that chain
|
||||
// webview → GtkOverlay → GtkBox, the downcast fails, and because the
|
||||
// panic is non-unwinding it aborts the process. The decoration check
|
||||
// that would otherwise make this handler inert runs *after* the
|
||||
// unwrap, so no window configuration avoids it.
|
||||
//
|
||||
// This is the "only place Tauri-specific behaviour could still bite"
|
||||
// that the spike named as the untested half of G1. It bites. The
|
||||
// surface attaches perfectly and then dies on interaction, so
|
||||
// "attached successfully" in the log is not the gate — a click is.
|
||||
//
|
||||
// Kept behind an env var rather than deleted so the next attempt has
|
||||
// something to iterate on: JELLYTAU_NATIVE_VIDEO=1 bun run tauri dev
|
||||
//
|
||||
// TRACES: UR-080 | DR-231
|
||||
// TRACES: UR-080 | DR-231, DR-235
|
||||
#[cfg(target_os = "linux")]
|
||||
if crate::player::native_video::enabled() {
|
||||
use tauri::Manager;
|
||||
log::warn!(
|
||||
"[INIT] JELLYTAU_NATIVE_VIDEO=1 — attaching the experimental \
|
||||
video surface (mpv drawn behind the webview, no reparenting)"
|
||||
);
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
match window.default_vbox() {
|
||||
Ok(vbox) => {
|
||||
|
||||
@@ -98,10 +98,9 @@ pub trait PlayerBackend: Send + Sync {
|
||||
|
||||
/// Set the active audio track by stream index
|
||||
///
|
||||
/// Overridden by the Android (ExoPlayer) backend. `MpvBackend` deliberately
|
||||
/// does **not** override it — MPV is the audio-only backend here, so it keeps
|
||||
/// this `not_implemented()` default and the Linux video path switches track by
|
||||
/// re-opening the stream instead (`player_switch_audio_track`).
|
||||
/// Overridden by both video backends, ExoPlayer and `MpvBackend`; the
|
||||
/// argument is a position among the file's audio tracks. A transcode is
|
||||
/// re-opened instead (`player_switch_audio_track`).
|
||||
///
|
||||
/// TRACES: UR-021 | IR-019, DR-024
|
||||
fn set_audio_track(&mut self, _stream_index: i32) -> Result<(), PlayerError> {
|
||||
@@ -111,10 +110,9 @@ pub trait PlayerBackend: Send + Sync {
|
||||
|
||||
/// Set the active subtitle track by stream index (None to disable subtitles)
|
||||
///
|
||||
/// Overridden by the Android (ExoPlayer) backend. `MpvBackend` deliberately
|
||||
/// does **not** override it, so it keeps this `not_implemented()` default;
|
||||
/// the Linux video path renders subtitles as `<track>` children of the
|
||||
/// WebKitGTK HTML5 `<video>` element and never calls this.
|
||||
/// Overridden by both video backends, ExoPlayer and `MpvBackend`; the
|
||||
/// argument is a position in the sideloaded subtitle list the play request
|
||||
/// carried.
|
||||
///
|
||||
/// TRACES: UR-020 | IR-018, DR-023
|
||||
fn set_subtitle_track(&mut self, _stream_index: Option<i32>) -> Result<(), PlayerError> {
|
||||
|
||||
@@ -85,9 +85,8 @@ pub enum PlayerStatusEvent {
|
||||
remaining_seconds: u32,
|
||||
},
|
||||
/// Time-based sleep timer expired: playback must stop. The backend stops
|
||||
/// its own (MPV/ExoPlayer) playback, but HTML5 video on Linux plays in the
|
||||
/// webview outside the backend's control — the frontend pauses it on this
|
||||
/// event.
|
||||
/// its own (MPV/ExoPlayer) playback; the frontend pauses the active adapter
|
||||
/// on this event, which reaches a webview `<audio>` element where one plays.
|
||||
SleepTimerExpired,
|
||||
/// Show next episode popup with countdown
|
||||
ShowNextEpisodePopup {
|
||||
@@ -152,9 +151,9 @@ pub enum PlayerStatusEvent {
|
||||
/// media item locally), so the native side only signals intent here.
|
||||
RemoteDisconnectRequested,
|
||||
/// Backend-originated control command targeting the active frontend player
|
||||
/// adapter (the HTML5 <video> that lives in the webview, which Rust cannot
|
||||
/// drive directly). Emitted by control paths like the sleep timer, lockscreen,
|
||||
/// or remote so they can pause/play/seek/stop the webview element.
|
||||
/// adapter — the webview `<audio>` element, which Rust cannot drive
|
||||
/// directly. Emitted by control paths like the sleep timer, lockscreen, or
|
||||
/// remote so they can pause/play/seek/stop it.
|
||||
/// `playerEvents.ts` routes this to the active PlayerAdapter via the facade.
|
||||
ControlCommand {
|
||||
/// One of: "play", "pause", "stop", "seek".
|
||||
@@ -164,10 +163,10 @@ pub enum PlayerStatusEvent {
|
||||
},
|
||||
/// Ask the frontend webview `<audio>` element to load and play a stream.
|
||||
///
|
||||
/// Emitted by `WebviewAudioBackend` on platforms with no native audio
|
||||
/// backend (e.g. Windows): audio-only playback is rendered by an `<audio>`
|
||||
/// element in the webview, mirroring how all video already renders through
|
||||
/// the webview `<video>`. The element then reports its state/position back
|
||||
/// Emitted by `WebviewAudioBackend` on a desktop with no native audio
|
||||
/// backend (none that ships: Linux and Windows have mpv): audio-only
|
||||
/// playback is rendered by an `<audio>` element in the webview. The element
|
||||
/// then reports its state/position back
|
||||
/// through the `player_report_*` commands, so the Rust controller stays the
|
||||
/// single source of truth. Subsequent play/pause/seek/stop reach the element
|
||||
/// via `ControlCommand`.
|
||||
|
||||
@@ -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,
|
||||
/// A *server-side transcode* can be seeked without re-opening the stream.
|
||||
///
|
||||
/// True for hls.js, which seeks within the VOD playlist it is handed and
|
||||
/// True for ExoPlayer, which seeks within the VOD playlist it is handed and
|
||||
/// lets the server catch up. False for mpv, whose HLS demuxer cannot make
|
||||
/// the server transcode from a new offset.
|
||||
///
|
||||
@@ -173,9 +173,8 @@ impl Capabilities {
|
||||
|
||||
/// ExoPlayer.
|
||||
///
|
||||
/// **Can** seek a transcode in place. It is a full HLS client, so like
|
||||
/// hls.js it seeks within the VOD playlist it was handed and lets the
|
||||
/// server catch up. Grouping it with mpv as "a native engine" gets this
|
||||
/// **Can** seek a transcode in place. It is a full HLS client, so it seeks
|
||||
/// within the VOD playlist it was handed and lets the server catch up. Grouping it with mpv as "a native engine" gets this
|
||||
/// exactly backwards — being native is not the property that matters here,
|
||||
/// speaking HLS is, and that is the whole reason this is declared per
|
||||
/// engine rather than inferred from a category.
|
||||
@@ -188,18 +187,6 @@ impl Capabilities {
|
||||
seeks_transcoded_in_place: true,
|
||||
}
|
||||
}
|
||||
|
||||
/// An engine that renders through the webview element, where hls.js seeks
|
||||
/// within the playlist it was handed.
|
||||
pub fn webview() -> Self {
|
||||
Self {
|
||||
video: true,
|
||||
audio_settings: false,
|
||||
subtitle_switching: true,
|
||||
audio_track_switching: false,
|
||||
seeks_transcoded_in_place: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A request to present an item.
|
||||
@@ -242,7 +229,7 @@ impl OpenRequest {
|
||||
/// Anything that can present media.
|
||||
///
|
||||
/// Implementations: `MpvPlayer` (Linux/Windows), `ExoPlayerPlayer` (Android),
|
||||
/// `WebviewPlayer` (HTML5 element), and `FakePlayer` for tests. Every one of
|
||||
/// and `FakePlayer` for tests. Every one of
|
||||
/// them must pass [`super::conformance`].
|
||||
pub trait MediaPlayer: Send {
|
||||
/// Present `req.selection`, beginning at `req.start`.
|
||||
@@ -267,7 +254,7 @@ pub trait MediaPlayer: Send {
|
||||
/// Seek to an absolute position on the item's own timeline.
|
||||
///
|
||||
/// Whether that is an in-place seek or a re-open of the stream is the
|
||||
/// engine's business: hls.js seeks within a VOD playlist, mpv's HLS demuxer
|
||||
/// engine's business: ExoPlayer seeks within a VOD playlist, mpv's HLS demuxer
|
||||
/// cannot make a server transcode from a new offset. Callers state the
|
||||
/// destination and nothing else.
|
||||
fn seek(&mut self, to: Duration) -> Result<(), PlayerError>;
|
||||
|
||||
+98
-45
@@ -15,8 +15,12 @@ mod fake_player_conformance;
|
||||
pub mod legacy_player;
|
||||
pub mod media;
|
||||
pub mod media_player;
|
||||
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
||||
pub mod mpv_command;
|
||||
#[cfg(target_os = "linux")]
|
||||
pub mod mpv_player;
|
||||
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
||||
pub mod mpv_tracks;
|
||||
pub mod queue;
|
||||
pub mod seek;
|
||||
pub mod session;
|
||||
@@ -40,7 +44,7 @@ pub mod jni_guard;
|
||||
#[cfg(target_os = "android")]
|
||||
pub mod android;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
||||
pub mod mpv_backend;
|
||||
|
||||
/// Whether this process renders video natively — one answer, three consumers
|
||||
@@ -62,9 +66,10 @@ pub mod mpv_render;
|
||||
#[cfg(target_os = "linux")]
|
||||
pub mod video_surface;
|
||||
|
||||
// Platforms with no native audio backend (e.g. Windows) render audio-only
|
||||
// playback through a webview <audio> element, mirroring how all video renders.
|
||||
#[cfg(not(any(target_os = "linux", target_os = "android")))]
|
||||
// Platforms with no native audio backend render audio-only playback through a
|
||||
// webview <audio> element. None that ships: Windows moved to mpv (DR-237); this
|
||||
// remains for an unported desktop (macOS).
|
||||
#[cfg(not(any(target_os = "linux", target_os = "android", target_os = "windows")))]
|
||||
pub mod webview_audio_backend;
|
||||
|
||||
// Re-export commonly used types
|
||||
@@ -86,10 +91,10 @@ pub use track_switch::{determine_audio_track_switch_strategy, AudioTrackSwitchSt
|
||||
#[cfg(target_os = "android")]
|
||||
pub use android::ExoPlayerBackend;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
||||
pub use mpv_backend::MpvBackend;
|
||||
|
||||
#[cfg(not(any(target_os = "linux", target_os = "android")))]
|
||||
#[cfg(not(any(target_os = "linux", target_os = "android", target_os = "windows")))]
|
||||
pub use webview_audio_backend::WebviewAudioBackend;
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
@@ -177,6 +182,20 @@ fn completion_report_position(runtime: Option<f64>, last_position: f64) -> f64 {
|
||||
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
|
||||
const RESUME_BACKOFF_STEP_SECS: u64 = 2;
|
||||
|
||||
/// Where playback stands when a background-audio handoff returns to the
|
||||
/// foreground. See [`PlayerController::background_audio_resume`].
|
||||
///
|
||||
/// TRACES: UR-040, UR-023 | DR-296
|
||||
#[derive(specta::Type, Debug, Clone, PartialEq, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BackgroundAudioResume {
|
||||
/// Item the native audio player is on — `None` if the queue emptied (e.g.
|
||||
/// the sleep timer stopped playback while backgrounded).
|
||||
pub item_id: Option<String>,
|
||||
/// Absolute position in that item, in seconds.
|
||||
pub position_seconds: f64,
|
||||
}
|
||||
|
||||
/// Metadata for the lockscreen / media notification.
|
||||
///
|
||||
/// Used to drive the Android MediaSession from Rust in remote (cast) mode, where
|
||||
@@ -305,9 +324,10 @@ pub struct PlayerController {
|
||||
background_audio_base: Arc<Mutex<f64>>,
|
||||
|
||||
// True while a background-audio handoff owns playback: the native audio
|
||||
// player is the real player and the webview <video> has been torn down.
|
||||
// player is the real player and the video has been replaced.
|
||||
//
|
||||
// The teardown is what makes this necessary. It fires a DOM `pause` that the
|
||||
// The teardown is what made this necessary (when a webview <video> was
|
||||
// torn down). It fires a DOM `pause` that the
|
||||
// frontend reports like any other, which would otherwise leave the controller
|
||||
// believing webview media is still active — aiming lockscreen transport at an
|
||||
// element that no longer exists (see `is_html5_active`).
|
||||
@@ -324,7 +344,8 @@ pub struct PlayerController {
|
||||
// TRACES: UR-040 | DR-129
|
||||
stream_resume: Arc<Mutex<stream_end::ResumeTracker>>,
|
||||
|
||||
// Last state reported by a webview-rendered HTML5 <video>/<audio> element.
|
||||
// Last state reported by a webview-rendered `<audio>` element (the webview
|
||||
// audio backend; video never renders in the webview since DR-235).
|
||||
//
|
||||
// Webview-rendered media is played by an element the native backend cannot
|
||||
// reach, so the backend's own state() says nothing about it. Tracking the
|
||||
@@ -404,7 +425,7 @@ impl PlayerController {
|
||||
/// Configure the media repository used for next-episode lookups.
|
||||
///
|
||||
/// The Android ExoPlayer ended-callback calls `on_playback_ended` with no
|
||||
/// repository handle (unlike the Linux HTML5 path, which passes one per
|
||||
/// repository handle (unlike a frontend-reported end, which passes one per
|
||||
/// call), so the controller needs a repository of its own or episode
|
||||
/// autoplay silently decides Stop.
|
||||
pub fn set_repository(&self, repo: Arc<dyn MediaRepository>) {
|
||||
@@ -524,37 +545,6 @@ impl PlayerController {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Set the current queue item without loading it into the playback backend.
|
||||
///
|
||||
/// Used on platforms where video is rendered outside the native backend
|
||||
/// (Linux WebKitGTK HTML5 <video>): the queue/UI state must reflect the
|
||||
/// item, but MPV must not start a redundant decode for it.
|
||||
///
|
||||
/// Not gated to Linux. Its caller stopped being a `#[cfg]` branch and became
|
||||
/// a runtime question — "does this renderer draw the picture?" — so the
|
||||
/// `else` arm is compiled on every platform even where it never runs. The
|
||||
/// gate outliving its caller broke the Android build outright, which went
|
||||
/// unnoticed because nothing built for Android afterwards.
|
||||
pub fn set_current_item(&self, item: MediaItem) -> Result<(), PlayerError> {
|
||||
debug!(
|
||||
"[PlayerController] set_current_item (no backend load): {}",
|
||||
item.title
|
||||
);
|
||||
|
||||
self.reset_autoplay_count();
|
||||
// A different item is current; the last one's reported position must not
|
||||
// be reported against it. This path is how webview-rendered video is
|
||||
// queued (no backend load at all), so it is exactly where a stale
|
||||
// reading would otherwise survive.
|
||||
// TRACES: UR-005 | DR-178
|
||||
self.clear_reported_time();
|
||||
|
||||
let mut queue = self.queue.lock_safe();
|
||||
queue.set_queue(vec![item], 0);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Load and play an item without modifying the queue
|
||||
/// Use this when the queue is already set up and you just want to play a specific item from it
|
||||
pub fn load_and_play(&self, item: &MediaItem) -> Result<(), PlayerError> {
|
||||
@@ -579,7 +569,7 @@ impl PlayerController {
|
||||
// intermittent.
|
||||
//
|
||||
// The webview re-establishes its own authority the moment an element
|
||||
// reports again, so nothing is lost on the HTML5 path: this is the same
|
||||
// reports again, so nothing is lost on the webview path: this is the same
|
||||
// "element is gone" semantics as the "stopped"/"idle" report, applied at
|
||||
// the point where we can know it directly.
|
||||
//
|
||||
@@ -703,7 +693,7 @@ impl PlayerController {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// True while webview-rendered media (HTML5 `<video>`/`<audio>`) is the real
|
||||
/// True while webview-rendered media (a webview `<audio>`) is the real
|
||||
/// player, so transport must be routed to it rather than the native backend.
|
||||
///
|
||||
/// TRACES: UR-005 | DR-097
|
||||
@@ -755,7 +745,7 @@ impl PlayerController {
|
||||
/// Toggle play/pause.
|
||||
///
|
||||
/// The decision is made HERE, from authoritative state — the reported webview
|
||||
/// state for HTML5-rendered media, or the native backend's state otherwise.
|
||||
/// state for webview-rendered audio, or the native backend's state otherwise.
|
||||
/// The frontend must never decide this from the DOM (see DR-097).
|
||||
///
|
||||
/// TRACES: UR-005 | DR-097
|
||||
@@ -1032,7 +1022,7 @@ impl PlayerController {
|
||||
/// truncation comparison. `position()` alone answers for exactly one of the
|
||||
/// three ways this app plays media, and reads 0 for the other two:
|
||||
///
|
||||
/// - **Webview `<video>`/`<audio>`**: nothing is loaded into the native
|
||||
/// - **Webview `<audio>`**: nothing is loaded into the native
|
||||
/// backend, so its position is a permanent 0. The element's own reports are
|
||||
/// the only reading there is.
|
||||
/// - **Background-audio handoff**: the audio-only stream's zero is the
|
||||
@@ -1728,6 +1718,23 @@ impl PlayerController {
|
||||
/// reports are honoured from here on.
|
||||
///
|
||||
/// TRACES: UR-040, UR-005 | DR-052, DR-097
|
||||
/// Where the foreground should pick up from a background-audio handoff: the
|
||||
/// item the native player is on now, and its absolute position.
|
||||
///
|
||||
/// The item is not necessarily the one the handoff started from — an episode
|
||||
/// that ends while backgrounded advances in the backend
|
||||
/// (`advance_to_next_episode_audio_only`) — so the webview must not assume
|
||||
/// it can reload the video it was mounted with. Read-only: call it before
|
||||
/// `exit_background_audio` clears the base the position depends on.
|
||||
///
|
||||
/// TRACES: UR-040, UR-023 | DR-296 | UT-266
|
||||
pub fn background_audio_resume(&self) -> BackgroundAudioResume {
|
||||
BackgroundAudioResume {
|
||||
item_id: self.queue.lock_safe().current().map(|item| item.id.clone()),
|
||||
position_seconds: self.absolute_position(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn exit_background_audio(&self) -> f64 {
|
||||
*self.background_audio_active.lock_safe() = false;
|
||||
self.take_background_audio_base()
|
||||
@@ -4411,6 +4418,52 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Returning to the foreground after the backend advanced to the next episode
|
||||
/// must bring back THAT episode, not the one the handoff started from.
|
||||
///
|
||||
/// The return used to carry only a position; the webview reloaded the video it
|
||||
/// was mounted with, so the user came back to the previous episode — at the new
|
||||
/// episode's timestamp. The resume point therefore names the item the native
|
||||
/// player is actually on.
|
||||
///
|
||||
/// TRACES: UR-040 | DR-296 | UT-266
|
||||
#[tokio::test]
|
||||
async fn test_background_audio_resume_names_the_advanced_episode() {
|
||||
let controller = PlayerController::default();
|
||||
controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
|
||||
|
||||
let episode = MediaItem {
|
||||
transport: None,
|
||||
id: "ep1".to_string(),
|
||||
item_type: Some("Episode".to_string()),
|
||||
media_type: MediaType::Audio,
|
||||
series_id: Some("series1".to_string()),
|
||||
..create_test_items(1).remove(0)
|
||||
};
|
||||
controller.play_queue(vec![episode], 0).unwrap();
|
||||
controller.enter_background_audio(1200.0);
|
||||
|
||||
let before = controller.background_audio_resume();
|
||||
assert_eq!(before.item_id.as_deref(), Some("ep1"));
|
||||
assert_eq!(before.position_seconds, 1200.0);
|
||||
|
||||
controller
|
||||
.advance_to_next_episode_audio_only("ep2")
|
||||
.await
|
||||
.expect("advance should succeed");
|
||||
|
||||
let after = controller.background_audio_resume();
|
||||
assert_eq!(
|
||||
after.item_id.as_deref(),
|
||||
Some("ep2"),
|
||||
"the foreground must resume the episode the backend advanced to"
|
||||
);
|
||||
assert!(
|
||||
after.position_seconds < 1200.0,
|
||||
"the previous episode's handoff base must not leak into the new one"
|
||||
);
|
||||
}
|
||||
|
||||
/// A background audio-only episode must advance IN THE BACKEND when the
|
||||
/// autoplay decision comes back as ShowNextEpisodePopup — never by starting a
|
||||
/// countdown the frontend is supposed to act on.
|
||||
|
||||
@@ -9,7 +9,6 @@ use crate::utils::conversions::{seconds_to_ticks, volume_to_percent};
|
||||
use crate::utils::lock::MutexSafe;
|
||||
use libmpv::Mpv;
|
||||
use log::{debug, error, info, warn};
|
||||
use std::process::Command;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
@@ -54,8 +53,21 @@ struct InternalState {
|
||||
volume: f32,
|
||||
}
|
||||
|
||||
/// Detect which audio system is available on the system
|
||||
/// The audio output mpv should use on Windows: WASAPI, the only one it ships
|
||||
/// there. Nothing to probe — and spawning `pactl` from a GUI app on Windows
|
||||
/// would at best fail and at worst flash a console window.
|
||||
///
|
||||
/// TRACES: UR-004 | DR-237
|
||||
#[cfg(target_os = "windows")]
|
||||
fn detect_audio_system() -> String {
|
||||
"wasapi".to_string()
|
||||
}
|
||||
|
||||
/// Detect which audio system is available on the system
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn detect_audio_system() -> String {
|
||||
use std::process::Command;
|
||||
|
||||
info!("[MpvBackend] Detecting audio system...");
|
||||
|
||||
// Try PulseAudio/PipeWire first (most common on modern Linux)
|
||||
@@ -95,6 +107,13 @@ fn detect_audio_system() -> String {
|
||||
fn get_stream_url(media: &MediaItem) -> String {
|
||||
match &media.source {
|
||||
MediaSource::Remote { stream_url, .. } => stream_url.clone(),
|
||||
// A Windows path is not a URL (`file://C:\...` is malformed); mpv
|
||||
// takes the native path as it is. Safe to pass verbatim because it goes
|
||||
// to mpv as one argv element (DR-298), not through a command string.
|
||||
// TRACES: UR-004, UR-071 | DR-237
|
||||
MediaSource::Local { file_path, .. } if cfg!(target_os = "windows") => {
|
||||
file_path.to_string_lossy().into_owned()
|
||||
}
|
||||
MediaSource::Local { file_path, .. } => {
|
||||
format!("file://{}", file_path.to_string_lossy())
|
||||
}
|
||||
@@ -121,6 +140,8 @@ static MPV_HANDLE: std::sync::OnceLock<usize> = std::sync::OnceLock::new();
|
||||
/// can fail, and the app falls back to a no-op backend rather than dying).
|
||||
///
|
||||
/// TRACES: UR-080 | DR-231
|
||||
// Only the Linux video surface reads it until Windows gets one (DR-237).
|
||||
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
|
||||
pub fn registered_handle() -> *mut libmpv_sys::mpv_handle {
|
||||
MPV_HANDLE
|
||||
.get()
|
||||
@@ -128,12 +149,73 @@ pub fn registered_handle() -> *mut libmpv_sys::mpv_handle {
|
||||
.unwrap_or(std::ptr::null_mut())
|
||||
}
|
||||
|
||||
/// How mpv shows video on this platform.
|
||||
///
|
||||
/// TRACES: UR-080 | DR-231, DR-237
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) enum VideoOutput {
|
||||
/// No picture: audio-only playback, or nowhere to draw.
|
||||
Off,
|
||||
/// Linux: frames through the render API into the GTK surface beneath the
|
||||
/// webview (`video_surface`).
|
||||
RenderApi,
|
||||
/// Windows: mpv renders as a child of the app's own window (`wid`, set
|
||||
/// before initialisation), beneath the transparent WebView2 — the
|
||||
/// arrangement tauri-plugin-libmpv ships on Windows.
|
||||
Window(i64),
|
||||
}
|
||||
|
||||
impl VideoOutput {
|
||||
/// Runtime options for this output. `wid` is not among them: it only takes
|
||||
/// effect before initialisation, so the constructor sets it separately.
|
||||
pub(crate) fn options(&self) -> Vec<(&'static str, String)> {
|
||||
match self {
|
||||
VideoOutput::Off => vec![("video", "no".to_string())],
|
||||
VideoOutput::RenderApi => vec![("vo", "libmpv".to_string())],
|
||||
VideoOutput::Window(_) => [
|
||||
// libplacebo's renderer, with the classic one as fallback for a
|
||||
// build or GPU that lacks it.
|
||||
("vo", "gpu-next,gpu"),
|
||||
// mpv is a surface here, not a player: the app's controls are
|
||||
// drawn over it, so its own controller and bindings must not
|
||||
// answer clicks, keys or the cursor.
|
||||
("osc", "no"),
|
||||
("input-default-bindings", "no"),
|
||||
("input-vo-keyboard", "no"),
|
||||
("input-cursor", "no"),
|
||||
("cursor-autohide", "no"),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|(k, v)| (k, v.to_string()))
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Decide the video output from whether native video is on, the platform, and
|
||||
/// the app window's handle (Windows only).
|
||||
///
|
||||
/// TRACES: UR-080 | DR-231, DR-237 | UT-274
|
||||
pub(crate) fn video_output(native: bool, is_windows: bool, window: Option<i64>) -> VideoOutput {
|
||||
match (native, is_windows, window) {
|
||||
(false, _, _) => VideoOutput::Off,
|
||||
(true, true, Some(wid)) => VideoOutput::Window(wid),
|
||||
// No handle: mpv would open a top-level window of its own.
|
||||
(true, true, None) => VideoOutput::Off,
|
||||
(true, false, _) => VideoOutput::RenderApi,
|
||||
}
|
||||
}
|
||||
|
||||
impl MpvBackend {
|
||||
/// Create a new MPV backend
|
||||
///
|
||||
/// `video_window` is the app window's native handle (an HWND), which mpv
|
||||
/// draws video into on Windows; `None` elsewhere.
|
||||
pub fn new(
|
||||
event_emitter: Option<Arc<dyn PlayerEventEmitter>>,
|
||||
playback_reporter: Arc<TokioMutex<Option<PlaybackReporter>>>,
|
||||
position_throttler: Arc<EventThrottler>,
|
||||
video_window: Option<i64>,
|
||||
) -> Result<Self, PlayerError> {
|
||||
info!("[MpvBackend] Initializing MPV backend...");
|
||||
|
||||
@@ -145,9 +227,26 @@ impl MpvBackend {
|
||||
libc::setlocale(libc::LC_NUMERIC, c_locale.as_ptr());
|
||||
}
|
||||
|
||||
let mpv = Mpv::new().map_err(|e| PlayerError {
|
||||
let output = video_output(
|
||||
super::native_video::enabled(),
|
||||
cfg!(target_os = "windows"),
|
||||
video_window,
|
||||
);
|
||||
if super::native_video::enabled() && output == VideoOutput::Off {
|
||||
error!("[MpvBackend] no window handle to draw video into; video will have no picture");
|
||||
}
|
||||
// `wid` only takes effect before initialisation. TRACES: UR-080 | DR-237
|
||||
let mpv = Mpv::with_initializer(|init| {
|
||||
if let VideoOutput::Window(wid) = output {
|
||||
init.set_property("wid", wid)?;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.map_err(|e| PlayerError {
|
||||
message: format!("Failed to initialize MPV: {:?}", e),
|
||||
})?;
|
||||
// TRACES: UR-012 | DR-299
|
||||
super::mpv_command::harden(&mpv).map_err(|message| PlayerError { message })?;
|
||||
|
||||
// Detect and configure audio output
|
||||
let audio_driver = detect_audio_system();
|
||||
@@ -178,26 +277,23 @@ impl MpvBackend {
|
||||
|
||||
// Video is disabled unless this process is drawing it.
|
||||
//
|
||||
// `video: no` is why mpv has never decoded a frame here: Linux video has
|
||||
// always gone through the webview, and decoding it twice would burn a
|
||||
// core for a picture nobody sees. With native video on, mpv needs both
|
||||
// the decoder *and* `vo=libmpv` — the render API only works through that
|
||||
// output, and the default would try to open a window of its own.
|
||||
// Linux video went through the webview until DR-235, and decoding it
|
||||
// here too would have burned a core for a picture nobody saw — hence
|
||||
// `video: no`. With native video, mpv needs the decoder *and* an output
|
||||
// that draws where the app wants it: the render API on Linux (the default
|
||||
// would open a window of its own), the app's window on Windows.
|
||||
//
|
||||
// Set at construction because mpv resolves the video output when it
|
||||
// initialises; flipping it later does not re-open one.
|
||||
//
|
||||
// TRACES: UR-080 | DR-231, DR-235
|
||||
if super::native_video::enabled() {
|
||||
mpv.set_property("vo", "libmpv").map_err(|e| PlayerError {
|
||||
message: format!("Failed to select the libmpv video output: {:?}", e),
|
||||
})?;
|
||||
info!("[MpvBackend] native video enabled (vo=libmpv)");
|
||||
} else {
|
||||
mpv.set_property("video", "no").map_err(|e| PlayerError {
|
||||
message: format!("Failed to configure MPV video: {:?}", e),
|
||||
// TRACES: UR-080 | DR-231, DR-235, DR-237
|
||||
for (name, value) in output.options() {
|
||||
mpv.set_property(name, value.as_str())
|
||||
.map_err(|e| PlayerError {
|
||||
message: format!("Failed to set {name}={value}: {:?}", e),
|
||||
})?;
|
||||
}
|
||||
info!("[MpvBackend] video output: {:?}", output);
|
||||
|
||||
// Set volume to 100% (we'll control via MPV's volume property)
|
||||
mpv.set_property("volume", 100i64)
|
||||
@@ -288,8 +384,8 @@ impl MpvBackend {
|
||||
// StateChanged rather than tracking playback itself, per the
|
||||
// one-directional state rule. Unobserved, the event never came and
|
||||
// the button never moved. Invisible until native video shipped,
|
||||
// because the webview <video> element's own DOM events drove that
|
||||
// control on Linux.
|
||||
// because the (since deleted) webview <video> element's own DOM
|
||||
// events drove that control on Linux.
|
||||
//
|
||||
// TRACES: UR-005 | DR-239
|
||||
ev_ctx
|
||||
@@ -600,11 +696,22 @@ impl PlayerBackend for MpvBackend {
|
||||
// TRACES: UR-040, UR-005 | DR-253
|
||||
*self.pending_seek.lock_safe() = None;
|
||||
|
||||
// Load the media file
|
||||
self.mpv
|
||||
.command("loadfile", &[&stream_url])
|
||||
.map_err(|e| PlayerError {
|
||||
message: format!("Failed to load file: {:?}", e),
|
||||
// The item's own sideloaded subtitles, none shown, and its default audio
|
||||
// track — whatever the previous item had chosen. Only video carries
|
||||
// subtitles; for audio this just clears the last item's.
|
||||
// TRACES: UR-020, UR-021 | DR-023, DR-024, DR-235
|
||||
let subtitle_urls: Vec<&str> = media.subtitles.iter().map(|t| t.url.as_str()).collect();
|
||||
super::mpv_tracks::prepare_load(&self.mpv, &subtitle_urls).map_err(|e| PlayerError {
|
||||
message: format!("Failed to prepare tracks: {e}"),
|
||||
})?;
|
||||
|
||||
// Load the media file. Through `mpv_command::command`, never
|
||||
// `Mpv::command`: the URL carries server-controlled text.
|
||||
// TRACES: UR-003, UR-004 | DR-298
|
||||
super::mpv_command::command(&self.mpv, &["loadfile", &stream_url]).map_err(|e| {
|
||||
PlayerError {
|
||||
message: format!("Failed to load file: {e}"),
|
||||
}
|
||||
})?;
|
||||
|
||||
debug!("[MpvBackend] Load command sent successfully");
|
||||
@@ -638,8 +745,8 @@ impl PlayerBackend for MpvBackend {
|
||||
fn stop(&mut self) -> Result<(), PlayerError> {
|
||||
debug!("[MpvBackend] Stop command");
|
||||
|
||||
self.mpv.command("stop", &[]).map_err(|e| PlayerError {
|
||||
message: format!("Failed to stop: {:?}", e),
|
||||
super::mpv_command::command(&self.mpv, &["stop"]).map_err(|e| PlayerError {
|
||||
message: format!("Failed to stop: {e}"),
|
||||
})?;
|
||||
|
||||
// Stopping ends the seek's subject along with the playback.
|
||||
@@ -760,6 +867,36 @@ impl PlayerBackend for MpvBackend {
|
||||
state.volume
|
||||
}
|
||||
|
||||
/// `stream_index` is a *position*: the n-th audio track of the file, the
|
||||
/// same meaning ExoPlayer gives it (`player_switch_audio_track` passes the
|
||||
/// array index). Only reached for a direct play/stream — a transcode carries
|
||||
/// one track and is re-opened instead.
|
||||
///
|
||||
/// TRACES: UR-021 | IR-019, DR-024, DR-235
|
||||
fn set_audio_track(&mut self, stream_index: i32) -> Result<(), PlayerError> {
|
||||
let position = usize::try_from(stream_index).map_err(|_| PlayerError {
|
||||
message: format!("Invalid audio track position {stream_index}"),
|
||||
})?;
|
||||
super::mpv_tracks::select_audio(&self.mpv, position)
|
||||
.map_err(|message| PlayerError { message })
|
||||
}
|
||||
|
||||
/// `stream_index` is the position in the sideloaded subtitle list the play
|
||||
/// request carried (`nativeSubtitleArrayIndex`), `None` to hide subtitles.
|
||||
///
|
||||
/// TRACES: UR-020 | IR-018, DR-023, DR-235
|
||||
fn set_subtitle_track(&mut self, stream_index: Option<i32>) -> Result<(), PlayerError> {
|
||||
let position = stream_index
|
||||
.map(|i| {
|
||||
usize::try_from(i).map_err(|_| PlayerError {
|
||||
message: format!("Invalid subtitle position {i}"),
|
||||
})
|
||||
})
|
||||
.transpose()?;
|
||||
super::mpv_tracks::select_subtitle(&self.mpv, position)
|
||||
.map_err(|message| PlayerError { message })
|
||||
}
|
||||
|
||||
fn set_audio_settings(&mut self, settings: &AudioSettings) -> Result<(), PlayerError> {
|
||||
info!("[MpvBackend] Applying audio settings");
|
||||
self.audio_settings = settings.clone();
|
||||
@@ -998,3 +1135,97 @@ mod af_filter_tests {
|
||||
assert!(eq_pos < norm_pos, "normalizer runs after EQ: {af}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod video_output_tests {
|
||||
use super::{video_output, VideoOutput};
|
||||
|
||||
/// Windows draws into the app's own window: mpv is handed its HWND before
|
||||
/// initialising and renders as a child of it, beneath the transparent
|
||||
/// WebView2.
|
||||
///
|
||||
/// TRACES: UR-080 | DR-237 | UT-274
|
||||
#[test]
|
||||
fn windows_native_video_renders_into_the_app_window() {
|
||||
assert_eq!(
|
||||
video_output(true, true, Some(0x1234)),
|
||||
VideoOutput::Window(0x1234)
|
||||
);
|
||||
}
|
||||
|
||||
/// Without a handle mpv would open a top-level window of its own, a second
|
||||
/// window floating beside the app. No picture is the honest failure.
|
||||
///
|
||||
/// TRACES: UR-080 | DR-237 | UT-274
|
||||
#[test]
|
||||
fn windows_without_a_window_handle_draws_nothing() {
|
||||
assert_eq!(video_output(true, true, None), VideoOutput::Off);
|
||||
}
|
||||
|
||||
/// Linux keeps the render API the GTK surface draws from.
|
||||
///
|
||||
/// TRACES: UR-080 | DR-231 | UT-274
|
||||
#[test]
|
||||
fn linux_native_video_uses_the_render_api() {
|
||||
assert_eq!(video_output(true, false, None), VideoOutput::RenderApi);
|
||||
}
|
||||
|
||||
/// TRACES: UR-080 | DR-231 | UT-274
|
||||
#[test]
|
||||
fn no_native_video_decodes_no_picture() {
|
||||
assert_eq!(video_output(false, true, Some(1)), VideoOutput::Off);
|
||||
assert_eq!(video_output(false, false, None), VideoOutput::Off);
|
||||
}
|
||||
|
||||
/// In the app's window mpv must not act as a player of its own: its
|
||||
/// on-screen controller and key/mouse bindings would compete with the
|
||||
/// Svelte controls drawn over it.
|
||||
///
|
||||
/// TRACES: UR-080 | DR-237 | UT-274
|
||||
#[test]
|
||||
fn a_window_output_hands_all_input_to_the_app() {
|
||||
let opts = VideoOutput::Window(7).options();
|
||||
for (k, v) in [
|
||||
("vo", "gpu-next,gpu"),
|
||||
("osc", "no"),
|
||||
("input-default-bindings", "no"),
|
||||
("input-vo-keyboard", "no"),
|
||||
("input-cursor", "no"),
|
||||
("cursor-autohide", "no"),
|
||||
] {
|
||||
assert!(
|
||||
opts.iter().any(|(ok, ov)| *ok == k && ov == v),
|
||||
"missing {k}={v} in {opts:?}"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
VideoOutput::Off.options(),
|
||||
vec![("video", "no".to_string())]
|
||||
);
|
||||
}
|
||||
|
||||
/// Every option the outputs set is one this libmpv accepts, `wid` included —
|
||||
/// against the real library, so a misspelt or removed option fails here
|
||||
/// rather than as a player that will not start on a user's machine. Runs on
|
||||
/// the Windows DLL too (under wine in the cross-build).
|
||||
///
|
||||
/// TRACES: UR-080 | DR-237 | UT-274
|
||||
#[test]
|
||||
fn libmpv_accepts_every_video_output_option() {
|
||||
let mpv = libmpv::Mpv::with_initializer(|init| {
|
||||
init.set_property("wid", 0i64)?;
|
||||
Ok(())
|
||||
})
|
||||
.expect("libmpv must accept wid before initialisation");
|
||||
for output in [
|
||||
VideoOutput::Off,
|
||||
VideoOutput::RenderApi,
|
||||
VideoOutput::Window(0),
|
||||
] {
|
||||
for (name, value) in output.options() {
|
||||
mpv.set_property(name, value.as_str())
|
||||
.unwrap_or_else(|e| panic!("libmpv rejected {name}={value}: {e:?}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
message: format!("mpv_create failed: {e:?}"),
|
||||
})?;
|
||||
// TRACES: UR-012 | DR-299
|
||||
super::mpv_command::harden(&mpv).map_err(|message| PlayerError { message })?;
|
||||
|
||||
let set = |k: &str, v: &str| {
|
||||
if let Err(e) = mpv.set_property(k, v) {
|
||||
@@ -229,10 +231,10 @@ impl MediaPlayer for MpvPlayer {
|
||||
})?;
|
||||
|
||||
info!("[MpvPlayer] open {} at {:?}", req.selection.url, req.start);
|
||||
self.mpv
|
||||
.command("loadfile", &[&req.selection.url, "replace"])
|
||||
// TRACES: UR-003, UR-004 | DR-298
|
||||
super::mpv_command::command(&self.mpv, &["loadfile", &req.selection.url, "replace"])
|
||||
.map_err(|e| PlayerError {
|
||||
message: format!("loadfile failed: {e:?}"),
|
||||
message: format!("loadfile failed: {e}"),
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -275,8 +277,8 @@ impl MediaPlayer for MpvPlayer {
|
||||
}
|
||||
// Idempotent: stopping an already-stopped mpv is not an error worth
|
||||
// propagating, and callers legitimately close twice on teardown.
|
||||
if let Err(e) = self.mpv.command("stop", &[]) {
|
||||
debug!("[MpvPlayer] stop on an idle player: {e:?}");
|
||||
if let Err(e) = super::mpv_command::command(&self.mpv, &["stop"]) {
|
||||
debug!("[MpvPlayer] stop on an idle player: {e}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -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
|
||||
//! configured for video *at construction*, before anything plays), the video
|
||||
//! surface (which has nothing to draw otherwise), and `get_player_status`
|
||||
//! (which tells the frontend whether to use a webview `<video>` element).
|
||||
//! surface (which has nothing to draw otherwise), and the device profile.
|
||||
//!
|
||||
//! It is a function rather than three `env::var` checks for the reason this
|
||||
//! codebase keeps rediscovering: a capability answered in several places is a
|
||||
@@ -14,65 +13,49 @@
|
||||
//!
|
||||
//! TRACES: UR-080 | DR-231, DR-235
|
||||
|
||||
/// The opt-in for native desktop video.
|
||||
///
|
||||
/// Off by default while the render path is unproven — the webview path still
|
||||
/// works and is what ships. This becomes the *default* (and then the only path)
|
||||
/// when DR-235 lands; the variable is how it is exercised until then.
|
||||
const ENV_FLAG: &str = "JELLYTAU_NATIVE_VIDEO";
|
||||
|
||||
/// Whether mpv should decode and draw video in this process.
|
||||
///
|
||||
/// Read fresh rather than cached: it is consulted a handful of times at startup,
|
||||
/// and a `OnceLock` here would only make it harder to test.
|
||||
/// True wherever mpv is the desktop video renderer: Linux since DR-235 phase 1,
|
||||
/// Windows since DR-237. There is no opt-out and no webview fallback — the
|
||||
/// webview `<video>` path is gone, so "off" would leave nothing drawing the
|
||||
/// picture. It was the `JELLYTAU_NATIVE_VIDEO` opt-in while the render path was
|
||||
/// being proven; the variable is ignored.
|
||||
///
|
||||
/// TRACES: UR-080 | DR-231, DR-235
|
||||
pub fn enabled() -> bool {
|
||||
// Only where a native renderer exists. On Android ExoPlayer already does
|
||||
// this and `use_html5_element` is false for entirely separate reasons.
|
||||
if !cfg!(all(target_os = "linux", not(target_os = "android"))) {
|
||||
return false;
|
||||
}
|
||||
matches!(
|
||||
std::env::var(ENV_FLAG).as_deref(),
|
||||
Ok("1") | Ok("true") | Ok("yes")
|
||||
)
|
||||
// On Android ExoPlayer draws video and `use_html5_element` is false for
|
||||
// entirely separate reasons.
|
||||
cfg!(any(target_os = "linux", target_os = "windows"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Absent, empty, or anything unrecognised means off. A half-set variable
|
||||
/// must not half-enable a renderer — the failure mode would be mpv
|
||||
/// configured for video with nothing drawing it, i.e. audio playing over a
|
||||
/// black rectangle.
|
||||
/// mpv draws video on Linux and Windows with nothing to opt into, and the retired
|
||||
/// variable cannot opt back out: with the webview path gone, "off"
|
||||
/// would configure mpv for audio only with nothing else to draw the picture.
|
||||
///
|
||||
/// TRACES: UR-080 | DR-231 | UT-216
|
||||
/// TRACES: UR-080 | DR-235, DR-237 | UT-271
|
||||
#[test]
|
||||
fn test_only_explicit_truthy_values_enable_it() {
|
||||
let restore = std::env::var(ENV_FLAG).ok();
|
||||
fn desktop_always_renders_video_natively() {
|
||||
let restore = std::env::var("JELLYTAU_NATIVE_VIDEO").ok();
|
||||
|
||||
for value in ["", "0", "no", "false", "maybe", "2"] {
|
||||
std::env::set_var(ENV_FLAG, value);
|
||||
assert!(!enabled(), "{value:?} must not enable native video");
|
||||
for value in [None, Some("0"), Some("false"), Some("1")] {
|
||||
match value {
|
||||
Some(v) => std::env::set_var("JELLYTAU_NATIVE_VIDEO", v),
|
||||
None => std::env::remove_var("JELLYTAU_NATIVE_VIDEO"),
|
||||
}
|
||||
|
||||
for value in ["1", "true", "yes"] {
|
||||
std::env::set_var(ENV_FLAG, value);
|
||||
assert_eq!(
|
||||
enabled(),
|
||||
cfg!(all(target_os = "linux", not(target_os = "android"))),
|
||||
"{value:?} enables it exactly where a native renderer exists"
|
||||
cfg!(any(target_os = "linux", target_os = "windows")),
|
||||
"JELLYTAU_NATIVE_VIDEO={value:?} must not decide the renderer"
|
||||
);
|
||||
}
|
||||
|
||||
std::env::remove_var(ENV_FLAG);
|
||||
assert!(!enabled(), "absent means off");
|
||||
|
||||
match restore {
|
||||
Some(v) => std::env::set_var(ENV_FLAG, v),
|
||||
None => std::env::remove_var(ENV_FLAG),
|
||||
Some(v) => std::env::set_var("JELLYTAU_NATIVE_VIDEO", v),
|
||||
None => std::env::remove_var("JELLYTAU_NATIVE_VIDEO"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+33
-122
@@ -5,17 +5,18 @@
|
||||
//! [`VideoSeekStrategy`] into a concrete backend/frontend action.
|
||||
|
||||
/// Seek strategy for video playback, derived from a stream's characteristics.
|
||||
///
|
||||
/// Every video renderer is a native backend (mpv, ExoPlayer) since the webview
|
||||
/// `<video>` path was deleted (DR-235), so the backend performs every seek; what
|
||||
/// remains to decide is whether it can move the stream in place.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum VideoSeekStrategy {
|
||||
/// Local file - always use native seek on backend
|
||||
LocalNativeSeek,
|
||||
/// HLS or direct stream with HTML5 - frontend handles seek, skip backend
|
||||
Html5NativeSeek,
|
||||
/// HLS or direct stream with native backend - backend handles seek
|
||||
/// Seekable where it sits (direct play/stream, or a transcode the engine
|
||||
/// can move) - backend seeks
|
||||
BackendNativeSeek,
|
||||
/// Transcoded non-HLS with HTML5 - reload stream, frontend handles
|
||||
Html5ReloadStream,
|
||||
/// Transcoded non-HLS with native backend - reload stream, backend handles
|
||||
/// A transcode the engine cannot move - re-open the stream at the target
|
||||
BackendReloadStream,
|
||||
}
|
||||
|
||||
@@ -29,12 +30,10 @@ pub enum VideoSeekStrategy {
|
||||
/// can seek a server-side transcode without re-opening it. Declared by the
|
||||
/// engine via `Capabilities`, never inferred from the URL or the renderer.
|
||||
/// * `needs_transcoding` - Whether the content needs transcoding
|
||||
/// * `use_html5` - Whether frontend is using HTML5 video element
|
||||
pub fn determine_video_seek_strategy(
|
||||
is_local: bool,
|
||||
seeks_transcoded_in_place: bool,
|
||||
needs_transcoding: bool,
|
||||
use_html5: bool,
|
||||
) -> VideoSeekStrategy {
|
||||
// Local files always support native seeking via backend
|
||||
if is_local {
|
||||
@@ -42,39 +41,20 @@ pub fn determine_video_seek_strategy(
|
||||
}
|
||||
|
||||
// A server-side transcode is produced *from* `StartTimeTicks`, so where the
|
||||
// seek lands is a property of the request, not of the stream in hand.
|
||||
//
|
||||
// hls.js is the exception: handed a VOD playlist it seeks within it and lets
|
||||
// the server catch up segment by segment. mpv's HLS demuxer cannot make
|
||||
// Jellyfin transcode from a new offset, so for the native backend a
|
||||
// seek lands is a property of the request, not of the stream in hand. mpv's
|
||||
// HLS demuxer cannot make Jellyfin transcode from a new offset, so for it a
|
||||
// transcoded seek must re-negotiate the stream regardless of container.
|
||||
//
|
||||
// Before native video shipped, `use_html5` was always true for HLS and the
|
||||
// native+HLS+transcode cell was unreachable, which is why `is_hls` alone
|
||||
// used to be a safe proxy for "seekable in place". It no longer is: turning
|
||||
// native video on routed every transcoded seek into a backend seek that
|
||||
// silently does nothing, and presents as "resume does not work".
|
||||
if needs_transcoding {
|
||||
// Whether a transcode can be seeked in place is a property of the
|
||||
// engine, and the engine states it. This used to be inferred from
|
||||
// `is_hls`, which held only while hls.js was the sole HLS renderer —
|
||||
// and stopped holding the moment mpv became one (DR-238).
|
||||
return match (seeks_transcoded_in_place, use_html5) {
|
||||
(true, true) => VideoSeekStrategy::Html5NativeSeek,
|
||||
(true, false) => VideoSeekStrategy::BackendNativeSeek,
|
||||
(false, true) => VideoSeekStrategy::Html5ReloadStream,
|
||||
(false, false) => VideoSeekStrategy::BackendReloadStream,
|
||||
};
|
||||
// Whether a transcode can be seeked in place is a property of the engine,
|
||||
// and the engine states it. This used to be inferred from `is_hls`, which
|
||||
// held only while hls.js was the sole HLS renderer — and stopped holding the
|
||||
// moment mpv became one (DR-238).
|
||||
if needs_transcoding && !seeks_transcoded_in_place {
|
||||
return VideoSeekStrategy::BackendReloadStream;
|
||||
}
|
||||
|
||||
// Direct play and direct stream are seekable where they sit.
|
||||
if use_html5 {
|
||||
// The frontend seeks via videoElement.currentTime; calling backend.seek()
|
||||
// would move a player that is not the one rendering.
|
||||
VideoSeekStrategy::Html5NativeSeek
|
||||
} else {
|
||||
// Direct play, direct stream, or a transcode the engine can move.
|
||||
VideoSeekStrategy::BackendNativeSeek
|
||||
}
|
||||
}
|
||||
|
||||
// The four items below are consumed by the Android MediaSessionHandler; on other
|
||||
@@ -224,114 +204,45 @@ mod tests {
|
||||
#[test]
|
||||
fn test_seek_strategy_local_file() {
|
||||
// Local files always use native backend seek regardless of other flags
|
||||
for (in_place, transcode) in [(false, false), (true, true), (false, true)] {
|
||||
assert_eq!(
|
||||
determine_video_seek_strategy(true, false, false, false),
|
||||
VideoSeekStrategy::LocalNativeSeek
|
||||
);
|
||||
assert_eq!(
|
||||
determine_video_seek_strategy(true, false, false, true),
|
||||
VideoSeekStrategy::LocalNativeSeek
|
||||
);
|
||||
assert_eq!(
|
||||
determine_video_seek_strategy(true, true, true, true),
|
||||
determine_video_seek_strategy(true, in_place, transcode),
|
||||
VideoSeekStrategy::LocalNativeSeek
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Non-transcoded streams seek in place regardless of the engine's
|
||||
/// transcode ability, which only applies to transcodes.
|
||||
/// Direct play and direct stream seek in place, whatever the engine's
|
||||
/// transcode ability — that only applies to transcodes.
|
||||
#[test]
|
||||
fn test_seek_strategy_direct_stream() {
|
||||
// HTML5 renders, so the frontend seeks the element
|
||||
for in_place in [false, true] {
|
||||
assert_eq!(
|
||||
determine_video_seek_strategy(false, true, false, true),
|
||||
VideoSeekStrategy::Html5NativeSeek
|
||||
);
|
||||
// The native engine renders, so it seeks
|
||||
assert_eq!(
|
||||
determine_video_seek_strategy(false, true, false, false),
|
||||
determine_video_seek_strategy(false, in_place, false),
|
||||
VideoSeekStrategy::BackendNativeSeek
|
||||
);
|
||||
// A transcode an engine says it can move: seek in place
|
||||
assert_eq!(
|
||||
determine_video_seek_strategy(false, true, true, true),
|
||||
VideoSeekStrategy::Html5NativeSeek
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A server-side transcode cannot be seeked by the native backend.
|
||||
/// A server-side transcode is re-opened by an engine that cannot move it,
|
||||
/// and seeked in place by one that says it can.
|
||||
///
|
||||
/// Jellyfin produces a transcode from `StartTimeTicks`; hls.js can seek
|
||||
/// within the VOD playlist it is handed, but mpv's HLS demuxer cannot make
|
||||
/// the server transcode from a new offset, so the stream has to be
|
||||
/// re-negotiated. Before native video existed, `use_html5` was always true
|
||||
/// for HLS and this case was unreachable — turning native video on routed
|
||||
/// every transcoded seek into a native seek that silently does nothing,
|
||||
/// which presents as "resume does not work".
|
||||
/// Jellyfin produces a transcode from `StartTimeTicks`; mpv's HLS demuxer
|
||||
/// cannot make the server transcode from a new offset, so the stream has to
|
||||
/// be re-negotiated. Inferring this from the container once routed every
|
||||
/// transcoded seek into a native seek that silently does nothing, which
|
||||
/// presents as "resume does not work".
|
||||
///
|
||||
/// TRACES: UR-040 | DR-238, DR-246 | UT-217
|
||||
#[test]
|
||||
fn test_transcoded_seek_follows_the_engines_declared_ability() {
|
||||
// An engine that cannot move a server-side transcode re-opens it,
|
||||
// whichever side is rendering.
|
||||
assert_eq!(
|
||||
determine_video_seek_strategy(false, false, true, false),
|
||||
determine_video_seek_strategy(false, false, true),
|
||||
VideoSeekStrategy::BackendReloadStream
|
||||
);
|
||||
assert_eq!(
|
||||
determine_video_seek_strategy(false, false, true, true),
|
||||
VideoSeekStrategy::Html5ReloadStream
|
||||
);
|
||||
// hls.js can, and says so, so it seeks in place.
|
||||
assert_eq!(
|
||||
determine_video_seek_strategy(false, true, true, true),
|
||||
VideoSeekStrategy::Html5NativeSeek
|
||||
);
|
||||
// The container the stream arrives in no longer decides anything: the
|
||||
// same declared ability gives the same answer on the native side.
|
||||
assert_eq!(
|
||||
determine_video_seek_strategy(false, true, true, false),
|
||||
determine_video_seek_strategy(false, true, true),
|
||||
VideoSeekStrategy::BackendNativeSeek
|
||||
);
|
||||
}
|
||||
|
||||
/// Test video seek strategy for direct play (non-transcoded) streams
|
||||
#[test]
|
||||
fn test_seek_strategy_direct_play() {
|
||||
// Direct play with HTML5 - frontend handles seek
|
||||
assert_eq!(
|
||||
determine_video_seek_strategy(false, false, false, true),
|
||||
VideoSeekStrategy::Html5NativeSeek
|
||||
);
|
||||
// Direct play with native backend - backend handles seek
|
||||
assert_eq!(
|
||||
determine_video_seek_strategy(false, false, false, false),
|
||||
VideoSeekStrategy::BackendNativeSeek
|
||||
);
|
||||
}
|
||||
|
||||
/// Test video seek strategy for transcoded non-HLS streams
|
||||
#[test]
|
||||
fn test_seek_strategy_transcoded_non_hls() {
|
||||
// Transcoded non-HLS with HTML5 - need to reload stream, frontend handles
|
||||
assert_eq!(
|
||||
determine_video_seek_strategy(false, false, true, true),
|
||||
VideoSeekStrategy::Html5ReloadStream
|
||||
);
|
||||
// Transcoded non-HLS with native backend - need to reload stream, backend handles
|
||||
assert_eq!(
|
||||
determine_video_seek_strategy(false, false, true, false),
|
||||
VideoSeekStrategy::BackendReloadStream
|
||||
);
|
||||
}
|
||||
|
||||
/// Test the specific bug fix: HLS + HTML5 should NOT call backend seek
|
||||
/// This was the bug causing "Raw(-10)" errors
|
||||
#[test]
|
||||
fn test_hls_html5_does_not_use_backend_seek() {
|
||||
let strategy = determine_video_seek_strategy(false, true, false, true);
|
||||
// Should be Html5NativeSeek, NOT BackendNativeSeek
|
||||
assert_eq!(strategy, VideoSeekStrategy::Html5NativeSeek);
|
||||
assert_ne!(strategy, VideoSeekStrategy::BackendNativeSeek);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,10 +13,6 @@
|
||||
/// How a request to change audio track has to be carried out.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AudioTrackSwitchStrategy {
|
||||
/// Re-open the stream pinned to the chosen track; the frontend reloads its
|
||||
/// `<video>` element. An HTML5 element cannot select an audio track at all,
|
||||
/// so this holds whether or not the current stream is a transcode.
|
||||
Html5ReloadStream,
|
||||
/// Re-open the stream pinned to the chosen track; the backend reloads
|
||||
/// itself and restores the position.
|
||||
BackendReloadStream,
|
||||
@@ -29,17 +25,9 @@ pub enum AudioTrackSwitchStrategy {
|
||||
/// # Arguments
|
||||
/// * `needs_transcoding` - Whether the stream now playing is a server-side
|
||||
/// transcode, which carries exactly the one audio track it was built around.
|
||||
/// * `use_html5` - Whether the frontend `<video>` element is rendering.
|
||||
///
|
||||
/// TRACES: UR-021 | IR-019, DR-024, DR-258 | UT-232
|
||||
pub fn determine_audio_track_switch_strategy(
|
||||
needs_transcoding: bool,
|
||||
use_html5: bool,
|
||||
) -> AudioTrackSwitchStrategy {
|
||||
if use_html5 {
|
||||
return AudioTrackSwitchStrategy::Html5ReloadStream;
|
||||
}
|
||||
|
||||
pub fn determine_audio_track_switch_strategy(needs_transcoding: bool) -> AudioTrackSwitchStrategy {
|
||||
if needs_transcoding {
|
||||
AudioTrackSwitchStrategy::BackendReloadStream
|
||||
} else {
|
||||
@@ -86,8 +74,7 @@ mod tests {
|
||||
assert_eq!(resume_position(None, 1337.5), 1337.5);
|
||||
}
|
||||
|
||||
/// The HTML5 path does have an element and its clock is the honest answer
|
||||
/// there, so what the caller supplies wins.
|
||||
/// A caller that does know the position is believed.
|
||||
#[test]
|
||||
fn a_caller_that_knows_its_position_is_believed() {
|
||||
assert_eq!(resume_position(Some(42.0), 1337.5), 42.0);
|
||||
@@ -123,7 +110,7 @@ mod tests {
|
||||
#[test]
|
||||
fn a_transcode_is_re_opened_because_it_carries_only_one_track() {
|
||||
assert_eq!(
|
||||
determine_audio_track_switch_strategy(true, false),
|
||||
determine_audio_track_switch_strategy(true),
|
||||
AudioTrackSwitchStrategy::BackendReloadStream
|
||||
);
|
||||
}
|
||||
@@ -133,23 +120,8 @@ mod tests {
|
||||
#[test]
|
||||
fn a_direct_play_switches_in_place() {
|
||||
assert_eq!(
|
||||
determine_audio_track_switch_strategy(false, false),
|
||||
determine_audio_track_switch_strategy(false),
|
||||
AudioTrackSwitchStrategy::BackendSelectInPlace
|
||||
);
|
||||
}
|
||||
|
||||
/// An HTML5 `<video>` element has no track-selection API, so it reloads
|
||||
/// either way. This is the path that already worked, and it must keep
|
||||
/// working: the fix is about the native side only.
|
||||
#[test]
|
||||
fn html5_always_reloads_because_the_element_cannot_select() {
|
||||
assert_eq!(
|
||||
determine_audio_track_switch_strategy(true, true),
|
||||
AudioTrackSwitchStrategy::Html5ReloadStream
|
||||
);
|
||||
assert_eq!(
|
||||
determine_audio_track_switch_strategy(false, true),
|
||||
AudioTrackSwitchStrategy::Html5ReloadStream
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,25 +1,18 @@
|
||||
//! Webview audio backend — audio-only playback for platforms without a native
|
||||
//! audio backend (currently Windows).
|
||||
//! Webview audio backend — audio-only playback for a desktop without a native
|
||||
//! audio backend. No shipped platform uses it: Linux and Windows play through
|
||||
//! mpv, Android through ExoPlayer.
|
||||
//!
|
||||
//! ## Why this exists
|
||||
//! All *video* already renders through the webview HTML5 `<video>` element on
|
||||
//! every platform (see `VideoPlayer.svelte`); libmpv/ExoPlayer only ever drive
|
||||
//! *audio-only* (music) playback. On Windows there is no native audio backend,
|
||||
//! so `create_player_backend()` used to fall back to `NullBackend` and music was
|
||||
//! silent.
|
||||
//!
|
||||
//! This backend fills that gap without any C dependency (so it still
|
||||
//! cross-compiles from Linux): instead of decoding audio itself, it hands the
|
||||
//! stream URL to a frontend `<audio>` element via a `WebviewAudioLoad` event and
|
||||
//! then drives play/pause/seek/stop through `ControlCommand` events — exactly the
|
||||
//! round-trip the HTML5 video path already uses. The `<audio>` element reports
|
||||
//! its real state/position back through the `player_report_*` commands, so the
|
||||
//! Rust `PlayerController` remains the single source of truth (the controller's
|
||||
//! `report_html5_*` methods fold those reports into the normal event pipeline).
|
||||
//! Instead of decoding audio itself, it hands the stream URL to a frontend
|
||||
//! `<audio>` element via a `WebviewAudioLoad` event and then drives
|
||||
//! play/pause/seek/stop through `ControlCommand` events. The `<audio>` element
|
||||
//! reports its real state/position back through the `player_report_*` commands,
|
||||
//! so the Rust `PlayerController` remains the single source of truth (the
|
||||
//! controller's `report_html5_*` methods fold those reports into the normal
|
||||
//! event pipeline).
|
||||
//!
|
||||
//! Because the reported state flows through the event pipeline (not through this
|
||||
//! backend's `position()`/`state()` pollers — the timer loop does not poll the
|
||||
//! backend for HTML5-rendered media), this backend only needs to keep a
|
||||
//! backend for webview-rendered media), this backend only needs to keep a
|
||||
//! best-effort local mirror for direct `player_get_state` queries.
|
||||
//!
|
||||
//! TRACES: UR-003, UR-004, UR-005 | DR-004
|
||||
|
||||
@@ -195,17 +195,15 @@ pub fn subtitle_supports_external_delivery(codec: Option<&str>) -> bool {
|
||||
/// the raw list makes Jellyfin direct-play a track the webview cannot decode, and
|
||||
/// the user gets picture with no sound.
|
||||
///
|
||||
/// Which renderer gets it is not fixed: Linux is always the element, and Android
|
||||
/// follows `experimentalNativeVideo`, which took ExoPlayer as its default in
|
||||
/// DR-161 but is a user setting either way. So the *narrow* list is the only one
|
||||
/// that holds on both sides of that switch. The cost is a Dolby-licensed Android
|
||||
/// device transcoding an E-AC-3 track its ExoPlayer could have direct-played;
|
||||
/// the alternative is silence for everyone the switch lands the other way, which
|
||||
/// is the bug this exists to prevent.
|
||||
///
|
||||
/// The gap is widest on devices whose vendor licenses Dolby: a phone with
|
||||
/// `c2.dolby.eac3.decoder` reports `eac3`, so it — and only it — gets a silent
|
||||
/// direct play where a leaner device is transcoded to AAC and plays fine.
|
||||
/// Which renderer gets it depends on the platform. Linux draws video in the
|
||||
/// element (unless mpv native video is switched on), so it gets the narrow list.
|
||||
/// Android draws video only in ExoPlayer: it used to follow the
|
||||
/// `experimentalNativeVideo` setting, which could send video to the webview, and
|
||||
/// while that switch existed the narrow list was the only one true on both sides
|
||||
/// of it. DR-293 removed the webview video path on Android, so there the
|
||||
/// platform list is the whole answer — it includes the FFmpeg extension's
|
||||
/// AC-3/E-AC-3/DTS/TrueHD, which `CodecDetector` reports alongside the
|
||||
/// `MediaCodecList` decoders.
|
||||
///
|
||||
/// This applies to the *video* direct-play profile only. Audio-only playback
|
||||
/// really is ExoPlayer's, so its profile keeps the full platform list.
|
||||
@@ -323,6 +321,9 @@ pub fn renderer_can_decode_audio(codec: &str) -> bool {
|
||||
/// Whether the webview `<video>` element can decode this audio codec.
|
||||
///
|
||||
/// TRACES: UR-004 | DR-149 | UT-148
|
||||
// Unreachable on Android since DR-293: video renders only in ExoPlayer there,
|
||||
// so every caller goes through `renderer_can_decode_audio`'s device-list arm.
|
||||
#[cfg_attr(target_os = "android", allow(dead_code))]
|
||||
pub fn webview_can_decode_audio(codec: &str) -> bool {
|
||||
WEBVIEW_AUDIO_CODECS
|
||||
.iter()
|
||||
|
||||
+431
-111
@@ -50,6 +50,42 @@ impl<T> CacheLeg<T> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Log a `get_items` leg that took long enough to be felt, so a slow page can
|
||||
/// be attributed to the cache or the server from a device log alone.
|
||||
fn log_slow_leg(
|
||||
leg: &str,
|
||||
parent_id: &str,
|
||||
started: std::time::Instant,
|
||||
result: &Result<SearchResult, RepoError>,
|
||||
) {
|
||||
let ms = started.elapsed().as_millis();
|
||||
let outcome = match result {
|
||||
Ok(data) => format!("{} rows", data.items.len()),
|
||||
Err(e) => format!("error: {e:?}"),
|
||||
};
|
||||
let parent = &parent_id[..8.min(parent_id.len())];
|
||||
if ms >= 250 {
|
||||
log::info!("[HybridRepo] get_items {leg} leg for {parent} took {ms} ms ({outcome})");
|
||||
} else {
|
||||
debug!("[HybridRepo] get_items {leg} leg for {parent} took {ms} ms ({outcome})");
|
||||
}
|
||||
}
|
||||
|
||||
/// Outcome of racing a cache read that missed the fast path against the
|
||||
/// server. See [`HybridRepository::race_slow_cache`].
|
||||
enum Raced<T> {
|
||||
/// The cache answered first with something to show (exclusions applied).
|
||||
Cache(T),
|
||||
/// The server answered first, or the cache had nothing. Raw: the caller
|
||||
/// caches the full page and applies exclusions to what it returns.
|
||||
Server(T),
|
||||
/// The server failed; this is what the cache said instead (exclusions
|
||||
/// applied), possibly an empty listing — which still beats an error.
|
||||
Fallback(T),
|
||||
/// Neither could answer; the server's error.
|
||||
Failed(RepoError),
|
||||
}
|
||||
|
||||
/// Hybrid repository combining online and offline data sources
|
||||
///
|
||||
/// Uses cache-first parallel racing strategy:
|
||||
@@ -235,7 +271,10 @@ impl HybridRepository {
|
||||
) -> Result<SearchResult, RepoError> {
|
||||
let offline = Arc::clone(&self.offline);
|
||||
let query = query.to_string();
|
||||
self.cache_with_timeout(async move { offline.search(&query, options).await })
|
||||
// Cache-only: there is no server to fall back to, so a busy database
|
||||
// delays the answer rather than failing it (DR-294).
|
||||
offline
|
||||
.search(&query, options)
|
||||
.await
|
||||
.map(ExcludeHidden::without_excluded)
|
||||
}
|
||||
@@ -250,7 +289,9 @@ impl HybridRepository {
|
||||
options: Option<GetItemsOptions>,
|
||||
) -> Result<SearchResult, RepoError> {
|
||||
let offline = Arc::clone(&self.offline);
|
||||
self.cache_with_timeout(async move { offline.get_favorites(scope, options).await })
|
||||
// Cache-only, as `search_cache_only` above (DR-294).
|
||||
offline
|
||||
.get_favorites(scope, options)
|
||||
.await
|
||||
.map(ExcludeHidden::without_excluded)
|
||||
}
|
||||
@@ -392,9 +433,67 @@ impl HybridRepository {
|
||||
}
|
||||
}
|
||||
|
||||
/// Race a cache read that missed the fast path against the server:
|
||||
/// whichever answers first *with something to show* wins.
|
||||
///
|
||||
/// The fast-path deadline bounds how long a cache hit may delay the UI; it
|
||||
/// must not also decide the race. It used to: past 100 ms the query waited
|
||||
/// for the server even when the cache answered moments later, so a page
|
||||
/// whose cache read took 150 ms always paid the full server round trip
|
||||
/// (about a second on a phone). An empty or failed cache answer is not a
|
||||
/// win — the server decides then — and a failed server falls back to
|
||||
/// whatever the cache said.
|
||||
///
|
||||
/// TRACES: UR-002 | DR-013
|
||||
async fn race_slow_cache<T, F>(
|
||||
mut slow: tokio::task::JoinHandle<Result<T, RepoError>>,
|
||||
server: F,
|
||||
) -> Raced<T>
|
||||
where
|
||||
T: MeaningfulContent + ExcludeHidden,
|
||||
F: std::future::Future<Output = Result<T, RepoError>>,
|
||||
{
|
||||
tokio::pin!(server);
|
||||
// `biased`, server first: if both are ready at once, the fresher
|
||||
// answer wins.
|
||||
tokio::select! {
|
||||
biased;
|
||||
server_result = &mut server => match server_result {
|
||||
Ok(data) => Raced::Server(data),
|
||||
Err(e) => {
|
||||
debug!("[HybridRepo] Server failed; waiting for the slow cache query");
|
||||
match slow.await {
|
||||
Ok(Ok(data)) => Raced::Fallback(data.without_excluded()),
|
||||
_ => Raced::Failed(e),
|
||||
}
|
||||
}
|
||||
},
|
||||
cache_result = &mut slow => {
|
||||
let cache = cache_result
|
||||
.unwrap_or_else(|join| Err(RepoError::Database {
|
||||
message: format!("Cache query failed: {join}"),
|
||||
}))
|
||||
.map(ExcludeHidden::without_excluded);
|
||||
match cache {
|
||||
Ok(data) if data.has_content() => {
|
||||
debug!("[HybridRepo] Slow cache answered before the server");
|
||||
Raced::Cache(data)
|
||||
}
|
||||
other => match server.await {
|
||||
Ok(data) => Raced::Server(data),
|
||||
Err(e) => match other {
|
||||
Ok(data) => Raced::Fallback(data),
|
||||
Err(_) => Raced::Failed(e),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Cache-first query: try cache, fall back to server on miss.
|
||||
///
|
||||
/// 1. Check cache (100ms timeout applied by caller via cache_with_timeout)
|
||||
/// 1. Check cache (100ms fast path, via `cache_leg`; a slow read keeps running)
|
||||
/// 2. If cache has meaningful content → return immediately (fast path)
|
||||
/// 3. If cache is empty/stale → query server (fresh data)
|
||||
/// 4. If server fails → return cache even if empty (offline fallback)
|
||||
@@ -454,33 +553,28 @@ impl HybridRepository {
|
||||
}
|
||||
}
|
||||
|
||||
debug!("[HybridRepo] Cache miss or slow, querying server");
|
||||
match server_future.await {
|
||||
Ok(data) => Ok(data.without_excluded()),
|
||||
Err(e) => {
|
||||
// The server cannot answer. If the cache is still working, it is
|
||||
// now the only thing that can, so wait it out rather than
|
||||
// reporting the server's failure over data we are about to hold.
|
||||
// This is the offline path: a cache read slowed by a concurrent
|
||||
// write used to surface as a network error.
|
||||
// Still running: race it against the server rather than waiting the
|
||||
// server out. If the server then fails, the cache is the only thing
|
||||
// that can answer — offline, a cache read slowed by a concurrent write
|
||||
// used to surface as a network error.
|
||||
if let Some(handle) = slow {
|
||||
debug!("[HybridRepo] Server failed; waiting for the slow cache query");
|
||||
return match handle.await {
|
||||
Ok(Ok(data)) => Ok(data.without_excluded()),
|
||||
Ok(Err(cache_err)) => {
|
||||
debug!("[HybridRepo] Slow cache query also failed: {cache_err}");
|
||||
Err(e)
|
||||
}
|
||||
Err(join) => {
|
||||
debug!("[HybridRepo] Slow cache query panicked: {join}");
|
||||
Err(e)
|
||||
return match Self::race_slow_cache(handle, server_future).await {
|
||||
Raced::Cache(data) => {
|
||||
on_cache_hit();
|
||||
Ok(data)
|
||||
}
|
||||
Raced::Server(data) => Ok(data.without_excluded()),
|
||||
Raced::Fallback(data) => Ok(data),
|
||||
Raced::Failed(e) => Err(e),
|
||||
};
|
||||
}
|
||||
|
||||
debug!("[HybridRepo] Cache miss, querying server");
|
||||
match server_future.await {
|
||||
Ok(data) => Ok(data.without_excluded()),
|
||||
// Cache answered in time but had nothing: return that, so an
|
||||
// empty-but-valid cached listing still beats a network error.
|
||||
fast.unwrap_or(Err(e))
|
||||
}
|
||||
Err(e) => fast.unwrap_or(Err(e)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -493,10 +587,10 @@ impl HybridRepository {
|
||||
///
|
||||
/// Missing the deadline does **not** cancel the query — it keeps running on
|
||||
/// its own task and [`CacheLeg::settle`] can still collect it. That
|
||||
/// distinction is the whole point. The database is one SQLite connection
|
||||
/// behind one mutex, so a concurrent write (a sync drain, a bulk
|
||||
/// `save_to_cache`) blocks reads for its duration and this deadline trips
|
||||
/// routinely on slow storage. Treating that as "the cache is empty" while
|
||||
/// distinction is the whole point. A read can miss the deadline for many
|
||||
/// reasons — a large listing, slow storage, a busy reader pool (and, before
|
||||
/// reads had their own connections, any write in progress). Treating that
|
||||
/// as "the cache is empty" while
|
||||
/// throwing the answer away meant that offline — where the server leg also
|
||||
/// fails — browsing surfaced a network error instead of the cached content
|
||||
/// sitting right there on disk.
|
||||
@@ -523,18 +617,106 @@ impl HybridRepository {
|
||||
}
|
||||
}
|
||||
|
||||
/// Await a cache query that is still running, however long it takes.
|
||||
async fn cache_with_timeout<T>(
|
||||
&self,
|
||||
future: impl std::future::Future<Output = Result<T, RepoError>> + Send,
|
||||
) -> Result<T, RepoError> {
|
||||
timeout(Self::CACHE_FAST_PATH, future)
|
||||
.await
|
||||
.unwrap_or_else(|_| {
|
||||
/// Start a cache read with [`Self::CACHE_FAST_PATH`] to answer: its result
|
||||
/// if it made it, and otherwise the read itself, still running.
|
||||
///
|
||||
/// The cache-then-server queries used to *discard* a read that missed the
|
||||
/// deadline. When reads shared one connection with writes, any write in
|
||||
/// progress — the catalog sync that starts at every launch, a download
|
||||
/// finishing — pushed a read past 100 ms routinely; offline the
|
||||
/// server then failed too, and the page reported a network error over data
|
||||
/// sitting on disk. Keeping the read lets [`Self::settle`] wait for it.
|
||||
///
|
||||
/// TRACES: UR-002 | DR-013, DR-294
|
||||
async fn cache_try<T>(
|
||||
future: impl std::future::Future<Output = Result<T, RepoError>> + Send + 'static,
|
||||
) -> (
|
||||
Result<T, RepoError>,
|
||||
Option<tokio::task::JoinHandle<Result<T, RepoError>>>,
|
||||
)
|
||||
where
|
||||
T: Send + 'static,
|
||||
{
|
||||
let (fast, slow) = Self::cache_leg(future).await.split();
|
||||
let fast = fast.unwrap_or_else(|| {
|
||||
Err(RepoError::Database {
|
||||
message: "Cache query timeout".to_string(),
|
||||
})
|
||||
message: "Cache query still running".to_string(),
|
||||
})
|
||||
});
|
||||
(fast, slow)
|
||||
}
|
||||
|
||||
/// The server could not answer: the cache's answer if it has one —
|
||||
/// waiting for a read still in flight — else the server's error.
|
||||
///
|
||||
/// TRACES: UR-002 | DR-294 | UT-263
|
||||
async fn settle<T>(
|
||||
fast: Result<T, RepoError>,
|
||||
slow: Option<tokio::task::JoinHandle<Result<T, RepoError>>>,
|
||||
server_err: RepoError,
|
||||
) -> Result<T, RepoError> {
|
||||
if let Some(handle) = slow {
|
||||
debug!("[HybridRepo] Server failed; waiting for the slow cache read");
|
||||
return match handle.await {
|
||||
Ok(Ok(data)) => Ok(data),
|
||||
_ => Err(server_err),
|
||||
};
|
||||
}
|
||||
fast.or(Err(server_err))
|
||||
}
|
||||
|
||||
/// Cache the server's page once it arrives, without holding up the
|
||||
/// caller, who has already answered from the cache. A failed or empty
|
||||
/// server answer leaves the existing cache alone.
|
||||
fn save_when_server_answers(
|
||||
server: tokio::task::JoinHandle<Result<SearchResult, RepoError>>,
|
||||
offline: Arc<OfflineRepository>,
|
||||
parent_id: String,
|
||||
) {
|
||||
tokio::spawn(async move {
|
||||
if let Ok(Ok(server_data)) = server.await {
|
||||
Self::save_in_background(offline, parent_id, &server_data);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Cache a server page in the background (one transaction; see
|
||||
/// `OfflineRepository::save_to_cache`).
|
||||
fn save_in_background(
|
||||
offline: Arc<OfflineRepository>,
|
||||
parent_id: String,
|
||||
server_data: &SearchResult,
|
||||
) {
|
||||
if server_data.items.is_empty() {
|
||||
return;
|
||||
}
|
||||
let items = server_data.items.clone();
|
||||
tokio::spawn(async move {
|
||||
match offline.save_to_cache(&parent_id, &items).await {
|
||||
Ok(_) => debug!(
|
||||
"[HybridRepo] Cached {} items for parent {}",
|
||||
items.len(),
|
||||
&parent_id[..8.min(parent_id.len())]
|
||||
),
|
||||
Err(e) => warn!(
|
||||
"[HybridRepo] Failed to cache {} items: {:?}",
|
||||
items.len(),
|
||||
e
|
||||
),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// [`Self::settle`] for `get_items`, whose slow read has not yet had
|
||||
/// exclusions applied.
|
||||
async fn cache_or(
|
||||
fast: Result<SearchResult, RepoError>,
|
||||
slow: Option<tokio::task::JoinHandle<Result<SearchResult, RepoError>>>,
|
||||
server_err: RepoError,
|
||||
) -> Result<SearchResult, RepoError> {
|
||||
Self::settle(fast, slow, server_err)
|
||||
.await
|
||||
.map(ExcludeHidden::without_excluded)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -544,7 +726,9 @@ impl MediaRepository for HybridRepository {
|
||||
// Cache-first (100ms). On a cache hit, refresh the cache from the server
|
||||
// in the background. On a miss, fetch from the server and persist so the
|
||||
// list is available on the next (possibly offline) startup.
|
||||
let cache_result = self.cache_with_timeout(self.offline.get_libraries()).await;
|
||||
let offline_read = Arc::clone(&self.offline);
|
||||
let (cache_result, slow_cache) =
|
||||
Self::cache_try(async move { offline_read.get_libraries().await }).await;
|
||||
|
||||
if let Ok(libs) = &cache_result {
|
||||
if libs.has_content() {
|
||||
@@ -581,7 +765,8 @@ impl MediaRepository for HybridRepository {
|
||||
}
|
||||
Ok(server_libs)
|
||||
}
|
||||
Err(e) => cache_result.or(Err(e)),
|
||||
// TRACES: UR-002 | DR-294 | UT-263
|
||||
Err(e) => Self::settle(cache_result, slow_cache, e).await,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -599,8 +784,12 @@ impl MediaRepository for HybridRepository {
|
||||
let opts_clone = options.clone();
|
||||
|
||||
// Start server request in background (non-blocking)
|
||||
let server_handle =
|
||||
tokio::spawn(async move { online.get_items(&parent_id_clone, options).await });
|
||||
let mut server_handle = tokio::spawn(async move {
|
||||
let started = std::time::Instant::now();
|
||||
let result = online.get_items(&parent_id_clone, options).await;
|
||||
log_slow_leg("server", &parent_id_clone, started, &result);
|
||||
result
|
||||
});
|
||||
|
||||
// Check cache first (fast, 100ms timeout).
|
||||
//
|
||||
@@ -610,10 +799,21 @@ impl MediaRepository for HybridRepository {
|
||||
// `parallel_race` — it interleaves the downloads-only gate and a
|
||||
// background cache write — so it applies the filter itself.
|
||||
// TRACES: UR-076 | DR-209
|
||||
let cache_result = self
|
||||
.cache_with_timeout(async move { offline.get_items(&parent_id, opts_clone).await })
|
||||
.await
|
||||
.map(ExcludeHidden::without_excluded);
|
||||
//
|
||||
// A read that misses the fast path is kept running, not discarded: if
|
||||
// the server then fails, the cache is the only thing that can answer,
|
||||
// and it is waited for (see the end of this function). Discarding it
|
||||
// is what made a downloaded show fail offline ("Failed to load item")
|
||||
// whenever a write held the database past 100 ms — the catalog sync
|
||||
// that starts at every launch does, routinely. TRACES: UR-002 | DR-294
|
||||
let (cache_result, slow_cache) = Self::cache_try(async move {
|
||||
let started = std::time::Instant::now();
|
||||
let result = offline.get_items(&parent_id, opts_clone).await;
|
||||
log_slow_leg("cache", &parent_id, started, &result);
|
||||
result
|
||||
})
|
||||
.await;
|
||||
let cache_result = cache_result.map(ExcludeHidden::without_excluded);
|
||||
|
||||
// Downloads-only gate: when the "Show all server media" toggle is off
|
||||
// (offline), an empty offline result is authoritative — the user asked
|
||||
@@ -642,54 +842,45 @@ impl MediaRepository for HybridRepository {
|
||||
"[HybridRepo] Cache hit for get_items, returning immediately for parent {}",
|
||||
&parent_id_for_save[..8.min(parent_id_for_save.len())]
|
||||
);
|
||||
// Background: save server result to cache when it arrives
|
||||
tokio::spawn(async move {
|
||||
match server_handle.await {
|
||||
Ok(Ok(server_data)) if !server_data.items.is_empty() => {
|
||||
if let Err(e) = offline_for_save
|
||||
.save_to_cache(&parent_id_for_save, &server_data.items)
|
||||
.await
|
||||
{
|
||||
warn!("[HybridRepo] Background cache update failed: {:?}", e);
|
||||
} else {
|
||||
debug!(
|
||||
"[HybridRepo] Background updated {} cached items for parent {}",
|
||||
server_data.items.len(),
|
||||
&parent_id_for_save[..8.min(parent_id_for_save.len())]
|
||||
);
|
||||
}
|
||||
}
|
||||
_ => {} // Server failed or returned empty — keep existing cache
|
||||
}
|
||||
});
|
||||
Self::save_when_server_answers(server_handle, offline_for_save, parent_id_for_save);
|
||||
return Ok(data.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// Cache miss — wait for server result
|
||||
// The cache missed the fast path but is still reading: race it against
|
||||
// the server instead of waiting the server out (DR-013).
|
||||
if let Some(slow) = slow_cache {
|
||||
let server = async {
|
||||
(&mut server_handle).await.unwrap_or_else(|join| {
|
||||
Err(RepoError::Network {
|
||||
message: format!("Server task failed: {}", join),
|
||||
})
|
||||
})
|
||||
};
|
||||
return match Self::race_slow_cache(slow, server).await {
|
||||
Raced::Cache(data) => {
|
||||
// The server is still in flight: cache its answer when it
|
||||
// lands, exactly as on a fast-path hit.
|
||||
Self::save_when_server_answers(
|
||||
server_handle,
|
||||
offline_for_save,
|
||||
parent_id_for_save,
|
||||
);
|
||||
Ok(data)
|
||||
}
|
||||
Raced::Server(server_data) => {
|
||||
Self::save_in_background(offline_for_save, parent_id_for_save, &server_data);
|
||||
Ok(server_data.without_excluded())
|
||||
}
|
||||
Raced::Fallback(data) => Ok(data),
|
||||
Raced::Failed(e) => Err(e),
|
||||
};
|
||||
}
|
||||
|
||||
// Cache answered in time with nothing — wait for the server.
|
||||
match server_handle.await {
|
||||
Ok(Ok(server_data)) => {
|
||||
if !server_data.items.is_empty() {
|
||||
let items_clone = server_data.items.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = offline_for_save
|
||||
.save_to_cache(&parent_id_for_save, &items_clone)
|
||||
.await
|
||||
{
|
||||
warn!(
|
||||
"[HybridRepo] Failed to save {} items to cache: {:?}",
|
||||
items_clone.len(),
|
||||
e
|
||||
);
|
||||
} else {
|
||||
debug!(
|
||||
"[HybridRepo] Saved {} items to cache for parent {}",
|
||||
items_clone.len(),
|
||||
&parent_id_for_save[..8.min(parent_id_for_save.len())]
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
Self::save_in_background(offline_for_save, parent_id_for_save, &server_data);
|
||||
// The cache keeps the server's full page (above) — an exclusion
|
||||
// is a view preference and can be undone, so hiding items from
|
||||
// the *cache* would make un-hiding them require a re-crawl. Only
|
||||
@@ -697,10 +888,13 @@ impl MediaRepository for HybridRepository {
|
||||
// TRACES: UR-076 | DR-209
|
||||
Ok(server_data.without_excluded())
|
||||
}
|
||||
Ok(Err(e)) => cache_result.or(Err(e)),
|
||||
Err(join_err) => cache_result.or(Err(RepoError::Network {
|
||||
Ok(Err(e)) => Self::cache_or(cache_result, None, e).await,
|
||||
Err(join_err) => {
|
||||
let e = RepoError::Network {
|
||||
message: format!("Server task failed: {}", join_err),
|
||||
})),
|
||||
};
|
||||
Self::cache_or(cache_result, None, e).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -708,9 +902,9 @@ impl MediaRepository for HybridRepository {
|
||||
/// background so the stored copy keeps up with the server.
|
||||
///
|
||||
/// The background refresh is what carries per-user state home: caching an
|
||||
/// item runs `mirror_user_data`, which is the only path by which a watch
|
||||
/// position set on another device reaches the local `user_data` row the
|
||||
/// resume check reads. Without it a cache hit returned this device's own
|
||||
/// item also writes its `user_data_mirror_query` row, the only path by
|
||||
/// which a watch position set on another device reaches the local
|
||||
/// `user_data` row the resume check reads. Without it a cache hit returned this device's own
|
||||
/// stale position forever and cross-device resume silently did nothing —
|
||||
/// `get_items` already refreshes this way, so browsing a season worked
|
||||
/// while opening the episode directly did not.
|
||||
@@ -806,11 +1000,32 @@ impl MediaRepository for HybridRepository {
|
||||
series_id: Option<&str>,
|
||||
limit: Option<usize>,
|
||||
) -> Result<Vec<MediaItem>, RepoError> {
|
||||
// Next up is dynamic, always fetch from server
|
||||
self.online
|
||||
.get_next_up_episodes(series_id, limit)
|
||||
// Cache-first like every other query: the local answer is computed
|
||||
// from the same watch state the cache refreshes from the server in the
|
||||
// background (`user_data_mirror_query`), and whichever answers first
|
||||
// with content wins. It used to wait for the server outright, which
|
||||
// held the series page's episode list for 2-3 s on a phone. An empty
|
||||
// local answer still defers to the server, and a failed server falls
|
||||
// back to the cache — offline, the TV page's Next Up row must not blank
|
||||
// the page (DR-294).
|
||||
// TRACES: UR-002 | DR-013, DR-294 | UT-261
|
||||
let offline = Arc::clone(&self.offline);
|
||||
let online = Arc::clone(&self.online);
|
||||
let series = series_id.map(str::to_string);
|
||||
let series_for_server = series.clone();
|
||||
|
||||
let cache_future =
|
||||
Self::cache_leg(
|
||||
async move { offline.get_next_up_episodes(series.as_deref(), limit).await },
|
||||
)
|
||||
.await;
|
||||
let server_future = async move {
|
||||
online
|
||||
.get_next_up_episodes(series_for_server.as_deref(), limit)
|
||||
.await
|
||||
.map(ExcludeHidden::without_excluded)
|
||||
};
|
||||
|
||||
Self::parallel_race(cache_future, server_future).await
|
||||
}
|
||||
|
||||
async fn get_recently_played_audio(
|
||||
@@ -878,8 +1093,8 @@ impl MediaRepository for HybridRepository {
|
||||
|
||||
let cache_offline = Arc::clone(&self.offline);
|
||||
let cache_pid = parent_id_str.clone();
|
||||
let cache_result = self
|
||||
.cache_with_timeout(async move { cache_offline.get_genres(cache_pid.as_deref()).await })
|
||||
let (cache_result, slow_cache) =
|
||||
Self::cache_try(async move { cache_offline.get_genres(cache_pid.as_deref()).await })
|
||||
.await;
|
||||
|
||||
if let Ok(genres) = &cache_result {
|
||||
@@ -922,7 +1137,7 @@ impl MediaRepository for HybridRepository {
|
||||
}
|
||||
Ok(server_genres)
|
||||
}
|
||||
Err(e) => cache_result.or(Err(e)),
|
||||
Err(e) => Self::settle(cache_result, slow_cache, e).await,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -946,7 +1161,15 @@ impl MediaRepository for HybridRepository {
|
||||
}
|
||||
|
||||
async fn get_playback_info(&self, item_id: &str) -> Result<PlaybackInfo, RepoError> {
|
||||
// Playback info requires server communication for transcoding decisions
|
||||
// A downloaded item is played from disk and needs nothing the server
|
||||
// negotiates, so its answer comes from the download row — first, and
|
||||
// whether or not the server is reachable. Asking the server first is
|
||||
// what made a download unplayable offline (DR-294). Anything not held
|
||||
// locally is a streaming question, and only the server can answer it.
|
||||
// TRACES: UR-002, UR-071 | DR-294 | UT-260
|
||||
if let Some(local) = self.offline.local_playback_info(item_id).await? {
|
||||
return Ok(local);
|
||||
}
|
||||
self.online.get_playback_info(item_id).await
|
||||
}
|
||||
|
||||
@@ -1205,9 +1428,8 @@ impl MediaRepository for HybridRepository {
|
||||
tokio::spawn(async move { online.get_playlist_items(&playlist_id_clone).await });
|
||||
|
||||
// Check cache first (fast, 100ms timeout)
|
||||
let cache_result = self
|
||||
.cache_with_timeout(async move { offline.get_playlist_items(&playlist_id).await })
|
||||
.await;
|
||||
let (cache_result, slow_cache) =
|
||||
Self::cache_try(async move { offline.get_playlist_items(&playlist_id).await }).await;
|
||||
|
||||
// Cache hit: return immediately, update cache in background
|
||||
if let Ok(data) = &cache_result {
|
||||
@@ -1244,10 +1466,13 @@ impl MediaRepository for HybridRepository {
|
||||
});
|
||||
Ok(entries)
|
||||
}
|
||||
Ok(Err(e)) => cache_result.or(Err(e)),
|
||||
Err(join_err) => cache_result.or(Err(RepoError::Network {
|
||||
Ok(Err(e)) => Self::settle(cache_result, slow_cache, e).await,
|
||||
Err(join_err) => {
|
||||
let e = RepoError::Network {
|
||||
message: format!("Server task failed: {}", join_err),
|
||||
})),
|
||||
};
|
||||
Self::settle(cache_result, slow_cache, e).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1301,9 +1526,9 @@ mod tests {
|
||||
|
||||
/// Offline, a cache read slowed past the fast path must still answer.
|
||||
///
|
||||
/// The database is one SQLite connection behind one mutex, so a concurrent
|
||||
/// write blocks reads for its duration and the 100 ms fast path trips on
|
||||
/// slow storage. The deadline used to *cancel* the read and report it as a
|
||||
/// The 100 ms fast path trips routinely on slow storage (and, while reads
|
||||
/// shared one connection with writes, behind any write). The deadline used
|
||||
/// to *cancel* the read and report it as a
|
||||
/// miss; with the server leg also failing (offline), the user got a network
|
||||
/// error over cached content that was sitting on disk.
|
||||
///
|
||||
@@ -1380,6 +1605,101 @@ mod tests {
|
||||
assert!(matches!(err, RepoError::Network { .. }), "got {err:?}");
|
||||
}
|
||||
|
||||
/// A cache read that misses the fast path but lands before the server must
|
||||
/// be what the user sees.
|
||||
///
|
||||
/// The 100 ms deadline used to decide the race outright: past it, the page
|
||||
/// waited for the server even when the cache answered a few milliseconds
|
||||
/// later — on a phone, a series page showed its seasons after the ~1 s
|
||||
/// server round trip instead of the ~150 ms cache read, on every visit.
|
||||
///
|
||||
/// TRACES: UR-002 | DR-013
|
||||
#[tokio::test]
|
||||
async fn a_slow_cache_that_beats_the_server_wins() {
|
||||
let cache = HybridRepository::cache_leg(async {
|
||||
tokio::time::sleep(Duration::from_millis(150)).await;
|
||||
Ok(vec![MediaItem {
|
||||
id: "slow-cache".to_string(),
|
||||
..Default::default()
|
||||
}])
|
||||
})
|
||||
.await;
|
||||
|
||||
let server = async {
|
||||
tokio::time::sleep(Duration::from_millis(1500)).await;
|
||||
Ok(vec![MediaItem {
|
||||
id: "server".to_string(),
|
||||
..Default::default()
|
||||
}])
|
||||
};
|
||||
|
||||
let started = std::time::Instant::now();
|
||||
let got = HybridRepository::parallel_race(cache, server)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
got[0].id, "slow-cache",
|
||||
"waited for the server over a cache answer"
|
||||
);
|
||||
assert!(started.elapsed() < Duration::from_millis(1000));
|
||||
}
|
||||
|
||||
/// A server that beats a slow cache still answers first.
|
||||
///
|
||||
/// TRACES: UR-002 | DR-013
|
||||
#[tokio::test]
|
||||
async fn a_server_that_beats_a_slow_cache_wins() {
|
||||
let cache = HybridRepository::cache_leg(async {
|
||||
tokio::time::sleep(Duration::from_millis(1500)).await;
|
||||
Ok(vec![MediaItem {
|
||||
id: "slow-cache".to_string(),
|
||||
..Default::default()
|
||||
}])
|
||||
})
|
||||
.await;
|
||||
|
||||
let server = async {
|
||||
tokio::time::sleep(Duration::from_millis(150)).await;
|
||||
Ok(vec![MediaItem {
|
||||
id: "server".to_string(),
|
||||
..Default::default()
|
||||
}])
|
||||
};
|
||||
|
||||
let started = std::time::Instant::now();
|
||||
let got = HybridRepository::parallel_race(cache, server)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(got[0].id, "server");
|
||||
assert!(started.elapsed() < Duration::from_millis(1000));
|
||||
}
|
||||
|
||||
/// A slow cache that comes back *empty* is not an answer: the server
|
||||
/// decides.
|
||||
///
|
||||
/// TRACES: UR-002 | DR-013
|
||||
#[tokio::test]
|
||||
async fn an_empty_slow_cache_defers_to_the_server() {
|
||||
let cache = HybridRepository::cache_leg(async {
|
||||
tokio::time::sleep(Duration::from_millis(150)).await;
|
||||
Ok(Vec::<MediaItem>::new())
|
||||
})
|
||||
.await;
|
||||
|
||||
let server = async {
|
||||
tokio::time::sleep(Duration::from_millis(400)).await;
|
||||
Ok(vec![MediaItem {
|
||||
id: "server".to_string(),
|
||||
..Default::default()
|
||||
}])
|
||||
};
|
||||
|
||||
let got = HybridRepository::parallel_race(cache, server)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(got[0].id, "server");
|
||||
}
|
||||
|
||||
/// Mock offline repository that tracks queries and saves
|
||||
struct MockOfflineRepo {
|
||||
items: Arc<Mutex<Vec<MediaItem>>>,
|
||||
|
||||
+1145
-489
File diff suppressed because it is too large
Load Diff
@@ -2530,28 +2530,32 @@ impl MediaRepository for OnlineRepository {
|
||||
params.push("allowVideoStreamCopy=false".to_string());
|
||||
}
|
||||
// "original" (and any unknown value) → direct, resumable copy —
|
||||
// unless the audio in that copy is undecodable where the file will
|
||||
// be played back. A download is watched with no server in reach, so
|
||||
// it has to satisfy the same constraint DR-149 applies to streams:
|
||||
// the webview `<video>` element renders video on both platforms and
|
||||
// decodes none of AC-3/E-AC-3/DTS/TrueHD. Copying those bytes to
|
||||
// disk is what made a downloaded film play offline as picture with
|
||||
// no sound while the same film had sound when streamed.
|
||||
// unless the audio in that copy is undecodable by the renderer that
|
||||
// will play the file. A download is watched with no server in reach,
|
||||
// so there is nothing to fall back to: copying a track the renderer
|
||||
// cannot decode is what made a downloaded film play offline as
|
||||
// picture with no sound while the same film had sound when streamed
|
||||
// (DR-171).
|
||||
//
|
||||
// Only the *audio* is re-encoded. `allowVideoStreamCopy` keeps an
|
||||
// h264 source's picture byte-for-byte, so "original" still means
|
||||
// original quality, and no bitrate or resolution cap is added. A
|
||||
// source the webview could not have rendered anyway (HEVC) is
|
||||
// re-encoded to h264 as a side effect, which is the only form of it
|
||||
// that would have played.
|
||||
// "The renderer" is DR-234's per-platform answer, not the webview's
|
||||
// list. On Android that is ExoPlayer — the only video renderer there
|
||||
// since DR-293 removed the webview path — which decodes the device's
|
||||
// own codecs plus AC-3/E-AC-3/DTS/TrueHD through the FFmpeg
|
||||
// extension. So on Android every `original` download is a
|
||||
// `Static=true` copy: fast, resumable (HTTP 206), and the real file.
|
||||
// Judging against the webview's list instead turned most films into a
|
||||
// server transcode — generated as it is sent, no `Content-Length`,
|
||||
// `Range` ignored — measured at ~1 MB/s against 14.5 MB/s for the
|
||||
// copy, and restarting from zero on every network blip.
|
||||
//
|
||||
// The cost of the transcode is that the response is no longer
|
||||
// range-resumable, which is exactly why this is decided per item
|
||||
// rather than applied to every `original` download.
|
||||
// On Linux the webview still draws video, so the renderer's list *is*
|
||||
// the webview's and the transcode below still applies there. Only the
|
||||
// *audio* is re-encoded: `allowVideoStreamCopy` keeps an h264 source's
|
||||
// picture byte-for-byte, so "original" still means original quality.
|
||||
//
|
||||
// TRACES: UR-071, UR-004 | DR-171 | UT-166
|
||||
// TRACES: UR-071, UR-004 | DR-171, DR-293 | UT-166
|
||||
None => match source_audio_codec {
|
||||
Some(codec) if !super::device_profile::webview_can_decode_audio(codec) => {
|
||||
Some(codec) if !super::device_profile::renderer_can_decode_audio(codec) => {
|
||||
params.push("videoCodec=h264".to_string());
|
||||
params.push("allowVideoStreamCopy=true".to_string());
|
||||
params.push("audioCodec=aac".to_string());
|
||||
@@ -3731,13 +3735,17 @@ mod tests {
|
||||
/// holds audio this device cannot decode.
|
||||
///
|
||||
/// `Static=true` hands back the source bytes untouched, E-AC-3/AC-3/DTS
|
||||
/// track included, and video plays through the webview `<video>` element on
|
||||
/// both platforms — which decodes none of them. Streaming already knows this
|
||||
/// (DR-149 forces a transcode over the server's own direct-play offer); the
|
||||
/// download path did not, so a downloaded film played offline as picture with
|
||||
/// no sound while the very same film had sound when streamed.
|
||||
/// track included. Where the webview `<video>` element renders video —
|
||||
/// Linux, which is where this test runs — none of them decode. Streaming
|
||||
/// already knew this (DR-149); the download path did not, so a downloaded
|
||||
/// film played offline as picture with no sound.
|
||||
///
|
||||
/// TRACES: UR-071, UR-004 | DR-171 | UT-166
|
||||
/// On Android the renderer is ExoPlayer with the FFmpeg extension, which
|
||||
/// decodes all of these, so the same call there yields a `Static=true` copy
|
||||
/// (DR-293). The policy is `renderer_can_decode_audio`; this test pins its
|
||||
/// webview half.
|
||||
///
|
||||
/// TRACES: UR-071, UR-004 | DR-171, DR-293 | UT-166
|
||||
#[test]
|
||||
fn test_video_download_url_original_transcodes_undecodable_audio() {
|
||||
let repo = create_test_repository();
|
||||
|
||||
@@ -209,21 +209,16 @@ pub async fn fetch_series_episodes(
|
||||
) -> Result<Vec<MediaItem>, RepoError> {
|
||||
let children = repo.get_items(series_id, list_options()).await?;
|
||||
|
||||
let mut episodes: Vec<MediaItem> = Vec::new();
|
||||
for season in children.items.iter().filter(|i| is_season(i)) {
|
||||
// One failing season must not blank the whole show.
|
||||
match repo.get_items(&season.id, list_options()).await {
|
||||
Ok(result) => episodes.extend(result.items.into_iter().filter(is_episode)),
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"[series] season {} of {} failed to load: {:?}",
|
||||
season.id,
|
||||
series_id,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
let seasons: Vec<MediaItem> = children
|
||||
.items
|
||||
.iter()
|
||||
.filter(|i| is_season(i))
|
||||
.cloned()
|
||||
.collect();
|
||||
let mut episodes = gather_season_episodes(&seasons, |season_id| async move {
|
||||
repo.get_items(&season_id, list_options()).await
|
||||
})
|
||||
.await;
|
||||
|
||||
// Flat series: the children *are* the episodes.
|
||||
if episodes.is_empty() {
|
||||
@@ -234,6 +229,114 @@ pub async fn fetch_series_episodes(
|
||||
Ok(episodes)
|
||||
}
|
||||
|
||||
/// Every episode in `seasons`, fetched with `fetch_season` (a season id → its
|
||||
/// children), **all at once**.
|
||||
///
|
||||
/// They used to be fetched one after another, so the wait was the sum of every
|
||||
/// season's listing: ~4 s for Frasier's eleven, on a phone whose cache reads
|
||||
/// were slowed by a catalog sync writing in the background. Concurrently it is
|
||||
/// the slowest single season. Order is restored afterwards by
|
||||
/// `sort_series_order`, so completion order does not matter.
|
||||
///
|
||||
/// One failing season must not blank the whole show: its episodes are left out
|
||||
/// and the rest returned.
|
||||
///
|
||||
/// TRACES: UR-062 | DR-295 | UT-264
|
||||
pub async fn gather_season_episodes<F, Fut>(
|
||||
seasons: &[MediaItem],
|
||||
fetch_season: F,
|
||||
) -> Vec<MediaItem>
|
||||
where
|
||||
F: Fn(String) -> Fut,
|
||||
Fut: std::future::Future<Output = Result<super::SearchResult, RepoError>>,
|
||||
{
|
||||
let results = futures_util::future::join_all(
|
||||
seasons.iter().map(|season| fetch_season(season.id.clone())),
|
||||
)
|
||||
.await;
|
||||
|
||||
let mut episodes = Vec::new();
|
||||
for (season, result) in seasons.iter().zip(results) {
|
||||
match result {
|
||||
Ok(result) => episodes.extend(result.items.into_iter().filter(is_episode)),
|
||||
Err(e) => log::warn!("[series] season {} failed to load: {:?}", season.id, e),
|
||||
}
|
||||
}
|
||||
episodes
|
||||
}
|
||||
|
||||
/// A series' episodes and the one the viewer is up to, from **one** season
|
||||
/// fan-out.
|
||||
///
|
||||
/// The series page needs both, and asked for them as two commands; each walked
|
||||
/// every season, so every visit listed the show twice. One call, one walk.
|
||||
///
|
||||
/// TRACES: UR-062 | DR-101, DR-295
|
||||
#[derive(Debug, Clone, serde::Serialize, specta::Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SeriesView {
|
||||
pub episodes: Vec<MediaItem>,
|
||||
pub current: Option<MediaItem>,
|
||||
}
|
||||
|
||||
/// Build a [`SeriesView`]: one fan-out, with Next Up and resume fetched
|
||||
/// alongside it rather than after.
|
||||
///
|
||||
/// TRACES: UR-062 | DR-101, DR-295
|
||||
pub async fn resolve_series_view(
|
||||
repo: &dyn MediaRepository,
|
||||
series_id: &str,
|
||||
) -> Result<SeriesView, RepoError> {
|
||||
let (episodes, (next_up, resume)) = with_hints(fetch_series_episodes(repo, series_id), async {
|
||||
futures_util::join!(
|
||||
async {
|
||||
repo.get_next_up_episodes(Some(series_id), Some(1))
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
},
|
||||
async {
|
||||
repo.get_resume_items(Some(series_id), Some(10))
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
},
|
||||
)
|
||||
})
|
||||
.await;
|
||||
let episodes = episodes?;
|
||||
let current = pick_current_episode(series_id, &episodes, &next_up, &resume);
|
||||
Ok(SeriesView { episodes, current })
|
||||
}
|
||||
|
||||
/// Run `primary` and `hints` together, but never hold `primary` back for
|
||||
/// `hints`: once `primary` is ready, the hints are taken if they have already
|
||||
/// answered and dropped (`H::default()`) if not.
|
||||
///
|
||||
/// For the series view the primary is the episode list and the hints are Next
|
||||
/// Up and resume, which only refine which episode is "current" — and the
|
||||
/// picker falls back to the episodes' own watch state without them. Waiting
|
||||
/// for them made the episode list wait for the server (2-3 s on a phone)
|
||||
/// although every episode was in the cache in 50 ms. The cache legs of the
|
||||
/// hints usually answer before the episodes do, so they are normally kept.
|
||||
///
|
||||
/// TRACES: UR-062 | DR-101, DR-295
|
||||
async fn with_hints<P, H>(
|
||||
primary: impl std::future::Future<Output = P>,
|
||||
hints: impl std::future::Future<Output = H>,
|
||||
) -> (P, H)
|
||||
where
|
||||
H: Default,
|
||||
{
|
||||
use futures_util::future::{select, Either};
|
||||
use futures_util::FutureExt;
|
||||
|
||||
let primary = std::pin::pin!(primary);
|
||||
let hints = std::pin::pin!(hints);
|
||||
match select(primary, hints).await {
|
||||
Either::Left((primary, hints)) => (primary, hints.now_or_never().unwrap_or_default()),
|
||||
Either::Right((hints, primary)) => (primary.await, hints),
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the current episode, fetching everything the policy needs.
|
||||
///
|
||||
/// Next Up and resume are best-effort: offline they fail or come back empty, and
|
||||
@@ -293,6 +396,95 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// "More info" on Frasier took ~4 s to list its episodes, twice over: the
|
||||
/// eleven seasons were fetched one after another, so the wait was the *sum*
|
||||
/// of eleven listings. Fetched together it is the slowest one.
|
||||
///
|
||||
/// TRACES: UR-062 | DR-295 | UT-264
|
||||
#[tokio::test]
|
||||
async fn seasons_are_fetched_concurrently_not_one_after_another() {
|
||||
let seasons: Vec<MediaItem> = (1..=10)
|
||||
.map(|n| MediaItem {
|
||||
id: format!("season-{n}"),
|
||||
item_type: "Season".to_string(),
|
||||
..Default::default()
|
||||
})
|
||||
.collect();
|
||||
|
||||
let started = std::time::Instant::now();
|
||||
let episodes = gather_season_episodes(&seasons, |season_id| async move {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||
if season_id == "season-3" {
|
||||
// One failing season must not blank the show.
|
||||
return Err(RepoError::Network {
|
||||
message: "gone".to_string(),
|
||||
});
|
||||
}
|
||||
let n: i32 = season_id.trim_start_matches("season-").parse().unwrap();
|
||||
Ok(crate::repository::SearchResult {
|
||||
items: vec![episode(&format!("e{n}"), n, 1)],
|
||||
total_record_count: 1,
|
||||
})
|
||||
})
|
||||
.await;
|
||||
let elapsed = started.elapsed();
|
||||
|
||||
assert!(
|
||||
elapsed < std::time::Duration::from_millis(400),
|
||||
"ten 100 ms seasons took {elapsed:?} — fetched in sequence, not together"
|
||||
);
|
||||
assert_eq!(episodes.len(), 9, "every season but the failing one");
|
||||
}
|
||||
|
||||
/// The episode list must not wait for Next Up or resume.
|
||||
///
|
||||
/// The series page rendered its episodes only once Next Up had come back
|
||||
/// from the server — 2-3 s on a phone while the page's other requests were
|
||||
/// in flight — although every episode was in the cache after 50 ms. Those
|
||||
/// two only refine which episode is "current", and the picker falls back
|
||||
/// to the episodes' own watch state without them.
|
||||
///
|
||||
/// TRACES: UR-062 | DR-101, DR-295
|
||||
#[tokio::test]
|
||||
async fn the_episode_list_does_not_wait_for_slow_hints() {
|
||||
let started = std::time::Instant::now();
|
||||
let (episodes, hints) = with_hints(
|
||||
async {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
||||
vec![episode("e1", 1, 1)]
|
||||
},
|
||||
async {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(2000)).await;
|
||||
vec![episode("from-server", 1, 2)]
|
||||
},
|
||||
)
|
||||
.await;
|
||||
let elapsed = started.elapsed();
|
||||
|
||||
assert_eq!(episodes.len(), 1);
|
||||
assert!(hints.is_empty(), "late hints are dropped, not waited for");
|
||||
assert!(
|
||||
elapsed < std::time::Duration::from_millis(500),
|
||||
"the episode list waited {elapsed:?} for Next Up / resume"
|
||||
);
|
||||
}
|
||||
|
||||
/// Hints that are already in (a cache answer) are used.
|
||||
///
|
||||
/// TRACES: UR-062 | DR-101, DR-295
|
||||
#[tokio::test]
|
||||
async fn hints_that_answer_first_are_kept() {
|
||||
let (_, hints) = with_hints(
|
||||
async {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||
vec![episode("e1", 1, 1)]
|
||||
},
|
||||
async { vec![episode("cached", 1, 2)] },
|
||||
)
|
||||
.await;
|
||||
assert_eq!(hints.len(), 1);
|
||||
}
|
||||
|
||||
fn watched(mut item: MediaItem) -> MediaItem {
|
||||
item.user_data = Some(UserData {
|
||||
is_played: Some(true),
|
||||
|
||||
@@ -1,16 +1,31 @@
|
||||
//! Database service abstraction layer
|
||||
//! Database service: the single owner of the SQLite database.
|
||||
//!
|
||||
//! This module provides an async database interface that abstracts away
|
||||
//! the underlying database implementation. This makes it easy to:
|
||||
//! - Switch between sync (rusqlite) and async (tokio-rusqlite) implementations
|
||||
//! - Prevent blocking the async runtime with synchronous database calls
|
||||
//! - Test with different database backends
|
||||
//! - Migrate to other database systems in the future
|
||||
//! Every query in the app goes through [`RusqliteService`], which owns the
|
||||
//! connections and hands work to them — callers never touch a `Connection`.
|
||||
//!
|
||||
//! - **Writes** (`execute`, `insert`, `transaction`, …) are sent as jobs to one
|
||||
//! dedicated writer thread that owns the read-write connection. SQLite allows
|
||||
//! one writer at a time anyway; owning it on one thread makes that explicit,
|
||||
//! keeps connection-wide state (pragmas) out of reach of concurrent callers,
|
||||
//! and parks no tokio blocking threads on a mutex while writes queue up.
|
||||
//! - **Reads** (`query_*`) run on a small pool of read-only connections. The
|
||||
//! database is in WAL mode, so readers see the last committed state and never
|
||||
//! wait for the writer — a large catalog-cache transaction no longer stalls
|
||||
//! library pages, thumbnail lookups or settings reads.
|
||||
//!
|
||||
//! A service built with [`RusqliteService::new`] has no reader pool (in-memory
|
||||
//! databases cannot be shared between connections) and routes reads through
|
||||
//! the writer, which is the old single-connection behaviour tests rely on.
|
||||
//!
|
||||
//! See `docs/architecture/08-database-design.md` → "Connection ownership".
|
||||
|
||||
use crate::utils::lock::MutexSafe;
|
||||
use async_trait::async_trait;
|
||||
use log::{debug, error};
|
||||
use rusqlite::{params_from_iter, Connection, Result as SqliteResult, Row};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::panic::AssertUnwindSafe;
|
||||
use std::sync::mpsc;
|
||||
use std::sync::{Arc, Condvar, Mutex};
|
||||
|
||||
/// Database query result type
|
||||
pub type DbResult<T> = Result<T, String>;
|
||||
@@ -84,8 +99,28 @@ pub trait DatabaseService: Send + Sync {
|
||||
F: FnOnce(&mut Transaction) -> DbResult<T> + Send + 'static,
|
||||
T: Send + 'static;
|
||||
|
||||
/// Get the row ID of the most recent successful INSERT
|
||||
async fn last_insert_rowid(&self) -> DbResult<i64>;
|
||||
/// Run a transaction with foreign-key enforcement switched off for its
|
||||
/// duration only.
|
||||
///
|
||||
/// `PRAGMA foreign_keys` is per connection and is a no-op inside a
|
||||
/// transaction, so it has to be flipped around the `BEGIN`/`COMMIT` — and
|
||||
/// all of that must happen as one job on the writer, or any other write
|
||||
/// that got in between would run unchecked too.
|
||||
async fn transaction_without_foreign_keys<F, T>(&self, f: F) -> DbResult<T>
|
||||
where
|
||||
F: FnOnce(&mut Transaction) -> DbResult<T> + Send + 'static,
|
||||
T: Send + 'static;
|
||||
|
||||
/// Execute an INSERT and return the rowid of the row it inserted.
|
||||
///
|
||||
/// The rowid is read in the same job as the insert. Reading it with a
|
||||
/// second call would race every other write, returning someone else's id.
|
||||
async fn insert(&self, query: Query) -> DbResult<i64>;
|
||||
|
||||
/// Queue a write without waiting for it — for best-effort bookkeeping (an
|
||||
/// LRU access time) that must not hold up the caller. It still runs in
|
||||
/// order with every other write; failures are only logged.
|
||||
fn execute_detached(&self, query: Query);
|
||||
}
|
||||
|
||||
/// Transaction handle for batching multiple operations
|
||||
@@ -110,48 +145,183 @@ impl<'a> Transaction<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Rusqlite-based database service implementation
|
||||
///
|
||||
/// This implementation wraps synchronous rusqlite operations in tokio::task::spawn_blocking
|
||||
/// to prevent blocking the async runtime.
|
||||
type Job = Box<dyn FnOnce(&Connection) + Send>;
|
||||
|
||||
/// The thread that owns the read-write connection. Jobs run one at a time, in
|
||||
/// the order they were sent; the thread exits when the last service handle
|
||||
/// (and so the last sender) is dropped.
|
||||
struct Writer {
|
||||
jobs: mpsc::Sender<Job>,
|
||||
}
|
||||
|
||||
impl Writer {
|
||||
fn spawn(conn: Arc<Mutex<Connection>>) -> Self {
|
||||
let (jobs, queue) = mpsc::channel::<Job>();
|
||||
std::thread::Builder::new()
|
||||
.name("db-writer".into())
|
||||
.spawn(move || {
|
||||
for job in queue {
|
||||
// The connection stays behind a mutex only so migrations and
|
||||
// tests can reach it; in the app this thread is its sole user.
|
||||
// `lock_safe` so a poisoned lock is recovered, not fatal.
|
||||
let conn = conn.lock_safe();
|
||||
// A panicking row mapper must not take the owner down with
|
||||
// it: the job's reply channel drops, its caller gets an
|
||||
// error, and the next job runs normally. The guard lives
|
||||
// outside the unwind, so the mutex is not poisoned either.
|
||||
if std::panic::catch_unwind(AssertUnwindSafe(|| job(&conn))).is_err() {
|
||||
error!("[db] a database job panicked; the writer carries on");
|
||||
// Undo whatever connection state the job was midway
|
||||
// through: an open transaction would make the next
|
||||
// job's BEGIN fail, and a job that switched foreign
|
||||
// keys off would leave them off for everyone.
|
||||
if !conn.is_autocommit() {
|
||||
let _ = conn.execute_batch("ROLLBACK");
|
||||
}
|
||||
let _ = conn.execute_batch("PRAGMA foreign_keys = ON");
|
||||
}
|
||||
}
|
||||
})
|
||||
.expect("failed to spawn the database writer thread");
|
||||
Self { jobs }
|
||||
}
|
||||
|
||||
async fn run<T, F>(&self, f: F) -> DbResult<T>
|
||||
where
|
||||
T: Send + 'static,
|
||||
F: FnOnce(&Connection) -> DbResult<T> + Send + 'static,
|
||||
{
|
||||
let (reply, result) = tokio::sync::oneshot::channel();
|
||||
self.jobs
|
||||
.send(Box::new(move |conn| {
|
||||
let _ = reply.send(f(conn));
|
||||
}))
|
||||
.map_err(|_| "database writer has stopped".to_string())?;
|
||||
result
|
||||
.await
|
||||
.map_err(|_| "database job panicked".to_string())?
|
||||
}
|
||||
|
||||
fn run_detached(&self, f: impl FnOnce(&Connection) + Send + 'static) {
|
||||
if self.jobs.send(Box::new(f)).is_err() {
|
||||
debug!("[db] writer stopped; dropped a detached write");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Read-only connections, checked out one per query. WAL gives each a
|
||||
/// snapshot of the last commit, so they never wait for the writer.
|
||||
struct ReaderPool {
|
||||
idle: Mutex<Vec<Connection>>,
|
||||
returned: Condvar,
|
||||
}
|
||||
|
||||
impl ReaderPool {
|
||||
/// Blocking: waits for a free connection. Call from `spawn_blocking`.
|
||||
fn run<T>(&self, f: impl FnOnce(&Connection) -> T) -> T {
|
||||
let conn = {
|
||||
let mut idle = self.idle.lock_safe();
|
||||
loop {
|
||||
if let Some(conn) = idle.pop() {
|
||||
break conn;
|
||||
}
|
||||
idle = self
|
||||
.returned
|
||||
.wait(idle)
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
}
|
||||
};
|
||||
// Returned on drop, so a panicking mapper does not leak the connection.
|
||||
let checkout = Checkout {
|
||||
pool: self,
|
||||
conn: Some(conn),
|
||||
};
|
||||
f(checkout.conn.as_ref().expect("checked-out connection"))
|
||||
}
|
||||
}
|
||||
|
||||
struct Checkout<'a> {
|
||||
pool: &'a ReaderPool,
|
||||
conn: Option<Connection>,
|
||||
}
|
||||
|
||||
impl Drop for Checkout<'_> {
|
||||
fn drop(&mut self) {
|
||||
if let Some(conn) = self.conn.take() {
|
||||
self.pool.idle.lock_safe().push(conn);
|
||||
self.pool.returned.notify_one();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Rusqlite-based database service: a cheap, cloneable handle to the writer
|
||||
/// thread and reader pool. See the module docs.
|
||||
#[derive(Clone)]
|
||||
pub struct RusqliteService {
|
||||
conn: Arc<Mutex<Connection>>,
|
||||
writer: Arc<Writer>,
|
||||
readers: Option<Arc<ReaderPool>>,
|
||||
}
|
||||
|
||||
impl RusqliteService {
|
||||
/// A service over a single connection: writes *and* reads go through the
|
||||
/// writer thread. Used for in-memory databases, which cannot be shared
|
||||
/// between connections.
|
||||
#[cfg_attr(not(test), allow(dead_code))]
|
||||
pub fn new(conn: Arc<Mutex<Connection>>) -> Self {
|
||||
Self { conn }
|
||||
Self {
|
||||
writer: Arc::new(Writer::spawn(conn)),
|
||||
readers: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A service whose reads run on `readers` — read-only connections to the
|
||||
/// same (file-backed, WAL-mode) database — alongside the writer.
|
||||
pub fn with_readers(conn: Arc<Mutex<Connection>>, readers: Vec<Connection>) -> Self {
|
||||
let readers = (!readers.is_empty()).then(|| {
|
||||
Arc::new(ReaderPool {
|
||||
idle: Mutex::new(readers),
|
||||
returned: Condvar::new(),
|
||||
})
|
||||
});
|
||||
Self {
|
||||
writer: Arc::new(Writer::spawn(conn)),
|
||||
readers,
|
||||
}
|
||||
}
|
||||
|
||||
async fn read<T, F>(&self, f: F) -> DbResult<T>
|
||||
where
|
||||
T: Send + 'static,
|
||||
F: FnOnce(&Connection) -> DbResult<T> + Send + 'static,
|
||||
{
|
||||
match &self.readers {
|
||||
Some(pool) => {
|
||||
let pool = Arc::clone(pool);
|
||||
tokio::task::spawn_blocking(move || pool.run(f))
|
||||
.await
|
||||
.map_err(|e| format!("Task join error: {}", e))?
|
||||
}
|
||||
None => self.writer.run(f).await,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl DatabaseService for RusqliteService {
|
||||
async fn execute(&self, query: Query) -> DbResult<usize> {
|
||||
let conn = Arc::clone(&self.conn);
|
||||
tokio::task::spawn_blocking(move || {
|
||||
// `lock_safe`, not `lock`: this is the busiest lock in the app and a
|
||||
// panic under the guard would otherwise poison it, failing every
|
||||
// later query with "poisoned lock" until the process restarts.
|
||||
let conn = conn.lock_safe();
|
||||
execute_query(&conn, query)
|
||||
})
|
||||
self.writer
|
||||
.run(move |conn| execute_query(conn, query))
|
||||
.await
|
||||
.map_err(|e| format!("Task join error: {}", e))?
|
||||
}
|
||||
|
||||
async fn execute_batch(&self, sql: &str) -> DbResult<()> {
|
||||
let conn = Arc::clone(&self.conn);
|
||||
let sql = sql.to_string();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
// `lock_safe`, not `lock`: this is the busiest lock in the app and a
|
||||
// panic under the guard would otherwise poison it, failing every
|
||||
// later query with "poisoned lock" until the process restarts.
|
||||
let conn = conn.lock_safe();
|
||||
self.writer
|
||||
.run(move |conn| {
|
||||
conn.execute_batch(&sql)
|
||||
.map_err(|e| format!("Execute batch failed: {}", e))
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("Task join error: {}", e))?
|
||||
}
|
||||
|
||||
async fn query_one<T, F>(&self, query: Query, mapper: F) -> DbResult<T>
|
||||
@@ -159,16 +329,7 @@ impl DatabaseService for RusqliteService {
|
||||
T: Send + 'static,
|
||||
F: Fn(&Row) -> SqliteResult<T> + Send + 'static,
|
||||
{
|
||||
let conn = Arc::clone(&self.conn);
|
||||
tokio::task::spawn_blocking(move || {
|
||||
// `lock_safe`, not `lock`: this is the busiest lock in the app and a
|
||||
// panic under the guard would otherwise poison it, failing every
|
||||
// later query with "poisoned lock" until the process restarts.
|
||||
let conn = conn.lock_safe();
|
||||
query_one(&conn, query, mapper)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("Task join error: {}", e))?
|
||||
self.read(move |conn| query_one(conn, query, mapper)).await
|
||||
}
|
||||
|
||||
async fn query_optional<T, F>(&self, query: Query, mapper: F) -> DbResult<Option<T>>
|
||||
@@ -176,16 +337,8 @@ impl DatabaseService for RusqliteService {
|
||||
T: Send + 'static,
|
||||
F: Fn(&Row) -> SqliteResult<T> + Send + 'static,
|
||||
{
|
||||
let conn = Arc::clone(&self.conn);
|
||||
tokio::task::spawn_blocking(move || {
|
||||
// `lock_safe`, not `lock`: this is the busiest lock in the app and a
|
||||
// panic under the guard would otherwise poison it, failing every
|
||||
// later query with "poisoned lock" until the process restarts.
|
||||
let conn = conn.lock_safe();
|
||||
query_optional(&conn, query, mapper)
|
||||
})
|
||||
self.read(move |conn| query_optional(conn, query, mapper))
|
||||
.await
|
||||
.map_err(|e| format!("Task join error: {}", e))?
|
||||
}
|
||||
|
||||
async fn query_many<T, F>(&self, query: Query, mapper: F) -> DbResult<Vec<T>>
|
||||
@@ -193,16 +346,7 @@ impl DatabaseService for RusqliteService {
|
||||
T: Send + 'static,
|
||||
F: Fn(&Row) -> SqliteResult<T> + Send + 'static,
|
||||
{
|
||||
let conn = Arc::clone(&self.conn);
|
||||
tokio::task::spawn_blocking(move || {
|
||||
// `lock_safe`, not `lock`: this is the busiest lock in the app and a
|
||||
// panic under the guard would otherwise poison it, failing every
|
||||
// later query with "poisoned lock" until the process restarts.
|
||||
let conn = conn.lock_safe();
|
||||
query_many(&conn, query, mapper)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("Task join error: {}", e))?
|
||||
self.read(move |conn| query_many(conn, query, mapper)).await
|
||||
}
|
||||
|
||||
async fn transaction<F, T>(&self, f: F) -> DbResult<T>
|
||||
@@ -210,20 +354,55 @@ impl DatabaseService for RusqliteService {
|
||||
F: FnOnce(&mut Transaction) -> DbResult<T> + Send + 'static,
|
||||
T: Send + 'static,
|
||||
{
|
||||
let conn = Arc::clone(&self.conn);
|
||||
tokio::task::spawn_blocking(move || {
|
||||
// `lock_safe`, not `lock`: this is the busiest lock in the app and a
|
||||
// panic under the guard would otherwise poison it, failing every
|
||||
// later query with "poisoned lock" until the process restarts.
|
||||
let conn = conn.lock_safe();
|
||||
self.writer.run(move |conn| run_transaction(conn, f)).await
|
||||
}
|
||||
|
||||
async fn transaction_without_foreign_keys<F, T>(&self, f: F) -> DbResult<T>
|
||||
where
|
||||
F: FnOnce(&mut Transaction) -> DbResult<T> + Send + 'static,
|
||||
T: Send + 'static,
|
||||
{
|
||||
self.writer
|
||||
.run(move |conn| {
|
||||
conn.execute_batch("PRAGMA foreign_keys = OFF")
|
||||
.map_err(|e| format!("Failed to disable foreign keys: {}", e))?;
|
||||
let result = run_transaction(conn, f);
|
||||
// Always restored, whatever the transaction did.
|
||||
if let Err(e) = conn.execute_batch("PRAGMA foreign_keys = ON") {
|
||||
error!("[db] failed to re-enable foreign keys: {}", e);
|
||||
}
|
||||
result
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
async fn insert(&self, query: Query) -> DbResult<i64> {
|
||||
self.writer
|
||||
.run(move |conn| {
|
||||
execute_query(conn, query)?;
|
||||
Ok(conn.last_insert_rowid())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
fn execute_detached(&self, query: Query) {
|
||||
self.writer.run_detached(move |conn| {
|
||||
if let Err(e) = execute_query(conn, query) {
|
||||
debug!("[db] detached write failed: {}", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn run_transaction<F, T>(conn: &Connection, f: F) -> DbResult<T>
|
||||
where
|
||||
F: FnOnce(&mut Transaction) -> DbResult<T>,
|
||||
{
|
||||
conn.execute("BEGIN TRANSACTION", [])
|
||||
.map_err(|e| format!("Failed to begin transaction: {}", e))?;
|
||||
|
||||
let mut transaction = Transaction::new(&conn);
|
||||
let result = f(&mut transaction);
|
||||
|
||||
match result {
|
||||
let mut transaction = Transaction::new(conn);
|
||||
match f(&mut transaction) {
|
||||
Ok(value) => {
|
||||
conn.execute("COMMIT", [])
|
||||
.map_err(|e| format!("Failed to commit transaction: {}", e))?;
|
||||
@@ -235,23 +414,6 @@ impl DatabaseService for RusqliteService {
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("Task join error: {}", e))?
|
||||
}
|
||||
|
||||
async fn last_insert_rowid(&self) -> DbResult<i64> {
|
||||
let conn = Arc::clone(&self.conn);
|
||||
tokio::task::spawn_blocking(move || {
|
||||
// `lock_safe`, not `lock`: this is the busiest lock in the app and a
|
||||
// panic under the guard would otherwise poison it, failing every
|
||||
// later query with "poisoned lock" until the process restarts.
|
||||
let conn = conn.lock_safe();
|
||||
Ok(conn.last_insert_rowid())
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("Task join error: {}", e))?
|
||||
}
|
||||
}
|
||||
|
||||
// Helper functions for executing queries synchronously
|
||||
|
||||
+327
-26
@@ -17,9 +17,27 @@ use rusqlite::{Connection, Result as SqliteResult};
|
||||
pub use db_service::{DatabaseService, RusqliteService};
|
||||
use schema::MIGRATIONS;
|
||||
|
||||
/// Database connection wrapper with thread-safe access
|
||||
/// How long a connection retries a locked database before erroring — covers a
|
||||
/// reader meeting a WAL checkpoint, or the writer meeting a reader's snapshot.
|
||||
const BUSY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
|
||||
|
||||
/// How many read-only connections serve queries alongside the writer. Reads
|
||||
/// are short; a few cover a library page's parallel fetches plus background
|
||||
/// work without holding many file handles.
|
||||
const READER_CONNECTIONS: usize = 3;
|
||||
|
||||
/// The database: opened once at startup and owned for the life of the app.
|
||||
///
|
||||
/// All access goes through [`Database::service`], which hands out clones of one
|
||||
/// [`RusqliteService`] — a writer thread plus a pool of read-only connections
|
||||
/// (see `db_service`). Nothing else opens the database file.
|
||||
pub struct Database {
|
||||
/// The read-write connection. Owned by the service's writer thread; kept
|
||||
/// here only for migrations (which run before that thread starts taking
|
||||
/// work) and for tests.
|
||||
#[cfg_attr(not(test), allow(dead_code))]
|
||||
conn: Arc<Mutex<Connection>>,
|
||||
service: RusqliteService,
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
@@ -36,18 +54,48 @@ impl Database {
|
||||
// Enable foreign keys
|
||||
conn.execute_batch("PRAGMA foreign_keys = ON;")?;
|
||||
|
||||
// Enable WAL mode for better concurrent access
|
||||
// WAL lets the reader connections run alongside the writer.
|
||||
conn.execute_batch("PRAGMA journal_mode = WAL;")?;
|
||||
// In WAL mode NORMAL is corruption-safe and skips the fsync FULL pays on
|
||||
// every commit (a power cut can lose the last commits, an app crash
|
||||
// cannot). On Android flash that fsync dominated every small write.
|
||||
conn.execute_batch("PRAGMA synchronous = NORMAL;")?;
|
||||
conn.busy_timeout(BUSY_TIMEOUT)?;
|
||||
|
||||
let db = Self {
|
||||
conn: Arc::new(Mutex::new(conn)),
|
||||
let conn = Arc::new(Mutex::new(conn));
|
||||
Self::migrate_connection(&conn, MIGRATIONS)?;
|
||||
|
||||
// Planner statistics. Without them SQLite guesses between indexes, and
|
||||
// guessed badly for the listing query (see 08-database-design.md →
|
||||
// "Listing query shape"). `optimize` only analyses what is missing or
|
||||
// stale; `analysis_limit` bounds each table's scan so this stays in the
|
||||
// milliseconds on a large catalogue. Failure is not fatal.
|
||||
if let Err(e) = conn
|
||||
.lock_safe()
|
||||
.execute_batch("PRAGMA analysis_limit = 400; PRAGMA optimize = 0x10002;")
|
||||
{
|
||||
error!("PRAGMA optimize failed: {}", e);
|
||||
}
|
||||
|
||||
// Readers open after migrations, so they only ever see the final schema.
|
||||
let readers = (0..READER_CONNECTIONS)
|
||||
.map(|_| Self::open_reader(path))
|
||||
.collect::<SqliteResult<Vec<_>>>()?;
|
||||
|
||||
Ok(Self {
|
||||
service: RusqliteService::with_readers(Arc::clone(&conn), readers),
|
||||
conn,
|
||||
path: path.clone(),
|
||||
};
|
||||
})
|
||||
}
|
||||
|
||||
// Run migrations
|
||||
db.migrate()?;
|
||||
|
||||
Ok(db)
|
||||
/// A connection that can only read. `query_only` makes an accidental write
|
||||
/// routed to the pool fail loudly instead of racing the writer.
|
||||
fn open_reader(path: &PathBuf) -> SqliteResult<Connection> {
|
||||
let conn = Connection::open(path)?;
|
||||
conn.busy_timeout(BUSY_TIMEOUT)?;
|
||||
conn.execute_batch("PRAGMA query_only = ON;")?;
|
||||
Ok(conn)
|
||||
}
|
||||
|
||||
/// Open an in-memory database (for testing)
|
||||
@@ -58,15 +106,16 @@ impl Database {
|
||||
// Enable foreign keys
|
||||
conn.execute_batch("PRAGMA foreign_keys = ON;")?;
|
||||
|
||||
let db = Self {
|
||||
conn: Arc::new(Mutex::new(conn)),
|
||||
let conn = Arc::new(Mutex::new(conn));
|
||||
Self::migrate_connection(&conn, MIGRATIONS)?;
|
||||
|
||||
// An in-memory database cannot be shared between connections, so this
|
||||
// one has no reader pool: reads go through the writer.
|
||||
Ok(Self {
|
||||
service: RusqliteService::new(Arc::clone(&conn)),
|
||||
conn,
|
||||
path: PathBuf::from(":memory:"),
|
||||
};
|
||||
|
||||
// Run migrations
|
||||
db.migrate()?;
|
||||
|
||||
Ok(db)
|
||||
})
|
||||
}
|
||||
|
||||
/// Get connection (for testing)
|
||||
@@ -75,11 +124,19 @@ impl Database {
|
||||
Arc::clone(&self.conn)
|
||||
}
|
||||
|
||||
/// Run all pending migrations.
|
||||
/// Re-run all migrations against an open database (tests only; `open` runs
|
||||
/// them before the service starts).
|
||||
#[cfg(test)]
|
||||
pub fn migrate(&self) -> SqliteResult<()> {
|
||||
self.migrate_with(MIGRATIONS)
|
||||
}
|
||||
|
||||
/// Test seam: inject a failing migration. See [`Self::migrate_connection`].
|
||||
#[cfg(test)]
|
||||
fn migrate_with(&self, migrations: &[(&str, &str)]) -> SqliteResult<()> {
|
||||
Self::migrate_connection(&self.conn, migrations)
|
||||
}
|
||||
|
||||
/// Apply `migrations` in order, skipping ones `_migrations` already records.
|
||||
///
|
||||
/// **Each migration is one transaction, and the `_migrations` row is written
|
||||
@@ -96,12 +153,16 @@ impl Database {
|
||||
/// Every migration is pure DDL/DML, which SQLite runs transactionally — a
|
||||
/// `PRAGMA` or `VACUUM` added to one would not roll back and must not be.
|
||||
///
|
||||
/// Split out from [`Self::migrate`] so tests can inject a failing migration.
|
||||
/// Runs on the bare connection, before the writer thread takes it, so
|
||||
/// tests can also inject a failing migration through `migrate_with`.
|
||||
///
|
||||
/// TRACES: UR-002 | DR-012 | UT-014
|
||||
fn migrate_with(&self, migrations: &[(&str, &str)]) -> SqliteResult<()> {
|
||||
fn migrate_connection(
|
||||
conn: &Mutex<Connection>,
|
||||
migrations: &[(&str, &str)],
|
||||
) -> SqliteResult<()> {
|
||||
info!("Starting database migrations...");
|
||||
let conn = self.conn.lock_safe();
|
||||
let conn = conn.lock_safe();
|
||||
|
||||
// Create migrations table if it doesn't exist
|
||||
debug!("Creating _migrations table if it doesn't exist...");
|
||||
@@ -160,12 +221,10 @@ impl Database {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get a database service for async-safe operations
|
||||
///
|
||||
/// This wraps all blocking database operations in spawn_blocking to prevent
|
||||
/// freezing the async runtime.
|
||||
/// A handle to the database service. Cheap: every call returns a clone of
|
||||
/// the same writer thread and reader pool.
|
||||
pub fn service(&self) -> RusqliteService {
|
||||
RusqliteService::new(Arc::clone(&self.conn))
|
||||
self.service.clone()
|
||||
}
|
||||
|
||||
/// Get the database file path
|
||||
@@ -880,4 +939,246 @@ mod tests {
|
||||
assert_eq!(user_id, "user2");
|
||||
assert_eq!(username, "recent_user");
|
||||
}
|
||||
|
||||
/// A read must not queue behind a long write.
|
||||
///
|
||||
/// The database is in WAL mode precisely so readers can run alongside a
|
||||
/// writer, but every query used to go through one connection behind one
|
||||
/// mutex — so a big catalog-cache transaction stalled every library page,
|
||||
/// thumbnail lookup and settings read in the app until it committed.
|
||||
///
|
||||
/// TRACES: UR-002 | DR-012 | UT-014
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn reads_do_not_wait_for_an_in_flight_write() {
|
||||
use crate::storage::db_service::{DatabaseService, Query};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let db = Database::open(&dir.path().join("jellytau.db")).unwrap();
|
||||
let service = db.service();
|
||||
|
||||
let writer = service.clone();
|
||||
let write = tokio::spawn(async move {
|
||||
writer
|
||||
.transaction(|_tx| {
|
||||
std::thread::sleep(Duration::from_millis(600));
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
});
|
||||
// Let the write take the connection first.
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
|
||||
let started = Instant::now();
|
||||
let servers: i64 = service
|
||||
.query_one(Query::new("SELECT COUNT(*) FROM servers"), |r| r.get(0))
|
||||
.await
|
||||
.unwrap();
|
||||
let waited = started.elapsed();
|
||||
|
||||
assert_eq!(servers, 0);
|
||||
assert!(
|
||||
waited < Duration::from_millis(250),
|
||||
"a read waited {waited:?} for an unrelated write to commit"
|
||||
);
|
||||
write.await.unwrap().unwrap();
|
||||
}
|
||||
|
||||
/// Commit cost: in WAL mode `synchronous = NORMAL` is corruption-safe and
|
||||
/// skips the per-commit fsync that FULL (the default) pays — on Android
|
||||
/// flash that is the dominant cost of every small write. A busy timeout
|
||||
/// lets the reader connections ride out a checkpoint instead of failing.
|
||||
///
|
||||
/// TRACES: UR-002 | DR-012 | UT-014
|
||||
#[test]
|
||||
fn open_configures_wal_for_interactive_use() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let db = Database::open(&dir.path().join("jellytau.db")).unwrap();
|
||||
let conn = db.connection();
|
||||
let conn = conn.lock_safe();
|
||||
|
||||
let mode: String = conn
|
||||
.query_row("PRAGMA journal_mode", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
let synchronous: i64 = conn
|
||||
.query_row("PRAGMA synchronous", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
let busy_timeout: i64 = conn
|
||||
.query_row("PRAGMA busy_timeout", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(mode, "wal");
|
||||
assert_eq!(synchronous, 1, "expected synchronous = NORMAL");
|
||||
assert!(busy_timeout > 0, "expected a busy timeout");
|
||||
}
|
||||
|
||||
/// The planner gets statistics: the app used to never run `ANALYZE`, so
|
||||
/// SQLite guessed between indexes — and for the listing query guessed the
|
||||
/// `server_id` index, which every row shares, turning an index lookup into
|
||||
/// a walk of the whole catalogue. `PRAGMA optimize` at open refreshes
|
||||
/// whatever statistics are missing or stale, bounded by `analysis_limit`.
|
||||
///
|
||||
/// TRACES: UR-002 | DR-012 | UT-014
|
||||
#[test]
|
||||
fn open_gives_the_planner_statistics() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("jellytau.db");
|
||||
{
|
||||
let db = Database::open(&path).unwrap();
|
||||
let conn = db.connection();
|
||||
let conn = conn.lock_safe();
|
||||
conn.execute_batch(
|
||||
"INSERT INTO servers (id, name, url) VALUES ('s', 'S', 'http://s');",
|
||||
)
|
||||
.unwrap();
|
||||
for i in 0..2000 {
|
||||
conn.execute(
|
||||
"INSERT INTO items (id, server_id, name, item_type) VALUES (?1, 's', 'n', 'Audio')",
|
||||
[format!("i{i}")],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
let db = Database::open(&path).unwrap();
|
||||
let conn = db.connection();
|
||||
let conn = conn.lock_safe();
|
||||
let analysed: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM sqlite_master WHERE name = 'sqlite_stat1'",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(analysed, 1, "the planner has no statistics");
|
||||
let items_stats: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM sqlite_stat1 WHERE tbl = 'items'",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(items_stats > 0, "no statistics for items");
|
||||
}
|
||||
|
||||
/// Writes a phone-sized catalogue to `$JELLYTAU_BENCH_DB` for timing
|
||||
/// queries with the `sqlite3` CLI. Not a test; run explicitly with
|
||||
/// `--ignored`.
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn write_bench_database() {
|
||||
let Ok(path) = std::env::var("JELLYTAU_BENCH_DB") else {
|
||||
return;
|
||||
};
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let db = Database::open(&PathBuf::from(&path)).unwrap();
|
||||
let conn = db.connection();
|
||||
let conn = conn.lock_safe();
|
||||
conn.execute_batch(
|
||||
"BEGIN;
|
||||
INSERT INTO servers (id, name, url) VALUES ('srv', 'S', 'http://s');
|
||||
INSERT INTO users (id, server_id, username) VALUES ('u', 'srv', 'u');
|
||||
INSERT INTO libraries (id, server_id, name, collection_type) VALUES ('tv', 'srv', 'TV', 'tvshows');
|
||||
INSERT INTO libraries (id, server_id, name, collection_type) VALUES ('music', 'srv', 'Music', 'music');",
|
||||
)
|
||||
.unwrap();
|
||||
let now = "2026-09-23T00:00:00Z";
|
||||
let mut item = conn
|
||||
.prepare(
|
||||
"INSERT INTO items (id, server_id, library_id, parent_id, name, sort_name, item_type,
|
||||
series_id, season_id, album_id, synced_at)
|
||||
VALUES (?1, 'srv', ?2, ?3, ?4, ?4, ?5, ?6, ?7, ?8, ?9)",
|
||||
)
|
||||
.unwrap();
|
||||
let none: Option<String> = None;
|
||||
for s in 0..300 {
|
||||
let series = format!("series-{s}");
|
||||
item.execute(rusqlite::params![
|
||||
series,
|
||||
"tv",
|
||||
none,
|
||||
format!("Show {s}"),
|
||||
"Series",
|
||||
none,
|
||||
none,
|
||||
none,
|
||||
now
|
||||
])
|
||||
.unwrap();
|
||||
for n in 0..11 {
|
||||
let season = format!("{series}-s{n}");
|
||||
item.execute(rusqlite::params![
|
||||
season,
|
||||
"tv",
|
||||
series,
|
||||
format!("Season {n}"),
|
||||
"Season",
|
||||
series,
|
||||
none,
|
||||
none,
|
||||
now
|
||||
])
|
||||
.unwrap();
|
||||
for e in 0..24 {
|
||||
let ep = format!("{season}-e{e}");
|
||||
item.execute(rusqlite::params![
|
||||
ep,
|
||||
"tv",
|
||||
season,
|
||||
format!("A Title {e}"),
|
||||
"Episode",
|
||||
series,
|
||||
season,
|
||||
none,
|
||||
now
|
||||
])
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO user_data (user_id, item_id, playback_position_ticks) VALUES ('u', ?1, 5)",
|
||||
[&ep],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
for a in 0..2000 {
|
||||
let album = format!("album-{a}");
|
||||
item.execute(rusqlite::params![
|
||||
album,
|
||||
"music",
|
||||
none,
|
||||
format!("Album {a}"),
|
||||
"MusicAlbum",
|
||||
none,
|
||||
none,
|
||||
none,
|
||||
now
|
||||
])
|
||||
.unwrap();
|
||||
for t in 0..12 {
|
||||
item.execute(rusqlite::params![
|
||||
format!("{album}-t{t}"),
|
||||
"music",
|
||||
album,
|
||||
format!("Track {t}"),
|
||||
"Audio",
|
||||
none,
|
||||
none,
|
||||
album,
|
||||
now
|
||||
])
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
for d in 0..400 {
|
||||
conn.execute(
|
||||
"INSERT INTO downloads (item_id, user_id, file_path, status) VALUES (?1, 'u', '/x', 'completed')",
|
||||
[format!("series-{}-s1-e{}", d % 300, d % 24)],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
drop(item);
|
||||
// No ANALYZE: the app never runs it, so the planner works without stats.
|
||||
conn.execute_batch("COMMIT;").unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ pub const MIGRATIONS: &[(&str, &str)] = &[
|
||||
("024_multi_user_profiles", MIGRATION_024),
|
||||
("025_backfill_item_library_id", MIGRATION_025),
|
||||
("026_server_catalog_generation", MIGRATION_026),
|
||||
("027_items_container_id", MIGRATION_027),
|
||||
];
|
||||
|
||||
/// Initial schema migration
|
||||
@@ -941,6 +942,169 @@ const MIGRATION_026: &str = r#"
|
||||
ALTER TABLE servers ADD COLUMN catalog_generation TEXT;
|
||||
"#;
|
||||
|
||||
/// One canonical "which container lists this item" link, plus an index that
|
||||
/// serves a listing in display order.
|
||||
///
|
||||
/// Jellyfin's `ParentId` is the *storage* parent, not the logical one: in a
|
||||
/// series without season folders an episode's `ParentId` is the series while
|
||||
/// its `SeasonId` names a virtual season, and a cached episode may arrive
|
||||
/// without its season row at all. So listings matched children on four
|
||||
/// columns at once (`parent_id`, `album_id`, `season_id`, `series_id`). That
|
||||
/// was slow — the `OR` defeated the planner into walking the whole table — and
|
||||
/// wrong: every episode carries its series id, so a series answered with its
|
||||
/// seasons *and* all their episodes.
|
||||
///
|
||||
/// `container_id` resolves the logical container once, by rule: an episode
|
||||
/// belongs to its season (else its series, else its parent), a season to its
|
||||
/// series, a track to its album, anything else to its parent. It is a VIRTUAL
|
||||
/// generated column, so every write path — cache, downloads, catalog crawl —
|
||||
/// is covered without touching any of them, and it cannot drift from the
|
||||
/// columns it is computed from. The index covers the listing's
|
||||
/// `ORDER BY sort_name, name` (`sort_name` is usually NULL in the cache).
|
||||
///
|
||||
/// The placeholders keep offline navigation intact: an episode whose season
|
||||
/// or series row was never cached used to surface directly under the series
|
||||
/// through the `series_id` match. Now it lists under its season, so the season
|
||||
/// (and series, and a track's album) must exist. They are built from the
|
||||
/// names the child rows already carry, with `synced_at` NULL — they only show
|
||||
/// when a download makes them available, and a real row from the server
|
||||
/// replaces them wholesale (`save_to_cache` upserts every field).
|
||||
///
|
||||
/// TRACES: UR-002, UR-007 | DR-013
|
||||
const MIGRATION_027: &str = r#"
|
||||
ALTER TABLE items ADD COLUMN container_id TEXT GENERATED ALWAYS AS (
|
||||
CASE item_type
|
||||
WHEN 'Episode' THEN COALESCE(season_id, series_id, parent_id)
|
||||
WHEN 'Season' THEN COALESCE(series_id, parent_id)
|
||||
WHEN 'Audio' THEN COALESCE(album_id, parent_id)
|
||||
ELSE parent_id
|
||||
END
|
||||
) VIRTUAL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_items_container ON items(container_id, sort_name, name);
|
||||
|
||||
INSERT OR IGNORE INTO items (id, server_id, library_id, name, item_type, is_folder, series_id, series_name)
|
||||
SELECT season_id, server_id, MAX(library_id), COALESCE(MAX(season_name), 'Season'), 'Season', 1,
|
||||
MAX(series_id), MAX(series_name)
|
||||
FROM items
|
||||
WHERE item_type = 'Episode' AND season_id IS NOT NULL
|
||||
GROUP BY season_id;
|
||||
|
||||
INSERT OR IGNORE INTO items (id, server_id, library_id, name, item_type, is_folder)
|
||||
SELECT series_id, server_id, MAX(library_id), COALESCE(MAX(series_name), 'Series'), 'Series', 1
|
||||
FROM items
|
||||
WHERE item_type IN ('Episode', 'Season') AND series_id IS NOT NULL
|
||||
GROUP BY series_id;
|
||||
|
||||
INSERT OR IGNORE INTO items (id, server_id, library_id, name, item_type, is_folder, album_artist)
|
||||
SELECT album_id, server_id, MAX(library_id), COALESCE(MAX(album_name), 'Album'), 'MusicAlbum', 1,
|
||||
MAX(album_artist)
|
||||
FROM items
|
||||
WHERE item_type = 'Audio' AND album_id IS NOT NULL
|
||||
GROUP BY album_id;
|
||||
"#;
|
||||
|
||||
#[cfg(test)]
|
||||
mod migration_027_tests {
|
||||
use super::*;
|
||||
use rusqlite::Connection;
|
||||
|
||||
fn pre_027_db() -> Connection {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
let upto = MIGRATIONS
|
||||
.iter()
|
||||
.position(|(name, _)| *name == "027_items_container_id")
|
||||
.expect("migration 027 must be registered");
|
||||
for (_, sql) in &MIGRATIONS[..upto] {
|
||||
conn.execute_batch(sql).unwrap();
|
||||
}
|
||||
conn.execute_batch(
|
||||
"INSERT INTO servers (id, name, url) VALUES ('s', 'S', 'http://s');
|
||||
-- An episode cached without its season or series rows.
|
||||
INSERT INTO items (id, server_id, name, item_type, parent_id, season_id, season_name,
|
||||
series_id, series_name, library_id)
|
||||
VALUES ('ep', 's', 'Pilot', 'Episode', NULL, 'season', 'Season 1',
|
||||
'show', 'Show', NULL);
|
||||
-- A track cached without its album.
|
||||
INSERT INTO items (id, server_id, name, item_type, album_id, album_name, album_artist)
|
||||
VALUES ('trk', 's', 'Song', 'Audio', 'alb', 'Record', 'Band');
|
||||
-- A folder child: its container is just its parent.
|
||||
INSERT INTO items (id, server_id, name, item_type) VALUES ('box', 's', 'Box', 'BoxSet');
|
||||
INSERT INTO items (id, server_id, name, item_type, parent_id)
|
||||
VALUES ('film', 's', 'Film', 'Movie', 'box');",
|
||||
)
|
||||
.unwrap();
|
||||
conn
|
||||
}
|
||||
|
||||
fn container(conn: &Connection, id: &str) -> Option<String> {
|
||||
conn.query_row("SELECT container_id FROM items WHERE id = ?1", [id], |r| {
|
||||
r.get(0)
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// TRACES: UR-002, UR-007 | DR-013
|
||||
#[test]
|
||||
fn every_item_resolves_to_its_logical_container() {
|
||||
let conn = pre_027_db();
|
||||
conn.execute_batch(MIGRATION_027).unwrap();
|
||||
|
||||
assert_eq!(container(&conn, "ep").as_deref(), Some("season"));
|
||||
assert_eq!(container(&conn, "season").as_deref(), Some("show"));
|
||||
assert_eq!(container(&conn, "trk").as_deref(), Some("alb"));
|
||||
assert_eq!(container(&conn, "film").as_deref(), Some("box"));
|
||||
assert_eq!(container(&conn, "show"), None);
|
||||
}
|
||||
|
||||
/// Containers that were never cached get placeholders named from their
|
||||
/// children, so an offline episode is still reachable series → season.
|
||||
///
|
||||
/// TRACES: UR-002, UR-007 | DR-013
|
||||
#[test]
|
||||
fn missing_containers_get_named_placeholders() {
|
||||
let conn = pre_027_db();
|
||||
conn.execute_batch(MIGRATION_027).unwrap();
|
||||
|
||||
let row = |id: &str| -> (String, String, Option<String>) {
|
||||
conn.query_row(
|
||||
"SELECT name, item_type, synced_at FROM items WHERE id = ?1",
|
||||
[id],
|
||||
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
|
||||
)
|
||||
.unwrap()
|
||||
};
|
||||
assert_eq!(row("season"), ("Season 1".into(), "Season".into(), None));
|
||||
assert_eq!(row("show"), ("Show".into(), "Series".into(), None));
|
||||
assert_eq!(row("alb"), ("Record".into(), "MusicAlbum".into(), None));
|
||||
}
|
||||
|
||||
/// Listing a container is one index range, already in display order.
|
||||
///
|
||||
/// TRACES: UR-002, UR-007 | DR-013
|
||||
#[test]
|
||||
fn a_container_listing_is_an_ordered_index_range() {
|
||||
let conn = pre_027_db();
|
||||
conn.execute_batch(MIGRATION_027).unwrap();
|
||||
let plan: Vec<String> = conn
|
||||
.prepare(
|
||||
"EXPLAIN QUERY PLAN SELECT id FROM items
|
||||
WHERE container_id = ?1 ORDER BY sort_name, name",
|
||||
)
|
||||
.unwrap()
|
||||
.query_map(["season"], |r| r.get::<_, String>(3))
|
||||
.unwrap()
|
||||
.map(Result::unwrap)
|
||||
.collect();
|
||||
let plan = plan.join("\n");
|
||||
assert!(plan.contains("idx_items_container"), "{plan}");
|
||||
assert!(
|
||||
!plan.contains("TEMP B-TREE"),
|
||||
"listing needs a sort step:\n{plan}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod migration_024_tests {
|
||||
use super::*;
|
||||
|
||||
@@ -122,7 +122,7 @@ impl ThumbnailCache {
|
||||
{
|
||||
let path = PathBuf::from(&path_str);
|
||||
if path.exists() {
|
||||
self.touch(&db, item_id, image_type, Some(tag)).await;
|
||||
self.touch(&db, item_id, image_type, Some(tag));
|
||||
return Some(path);
|
||||
}
|
||||
// File gone — drop the stale row and fall through to the tag-agnostic
|
||||
@@ -157,7 +157,7 @@ impl ThumbnailCache {
|
||||
let path_str: String = db.query_optional(any_tag, |row| row.get(0)).await.ok()??;
|
||||
let path = PathBuf::from(&path_str);
|
||||
if path.exists() {
|
||||
self.touch(&db, item_id, image_type, None).await;
|
||||
self.touch(&db, item_id, image_type, None);
|
||||
Some(path)
|
||||
} else {
|
||||
None
|
||||
@@ -166,13 +166,7 @@ impl ThumbnailCache {
|
||||
|
||||
/// Update `last_accessed` for LRU tracking. When `tag` is `Some`, scope to
|
||||
/// that exact row; when `None`, touch every row for the item + type.
|
||||
async fn touch(
|
||||
&self,
|
||||
db: &Arc<RusqliteService>,
|
||||
item_id: &str,
|
||||
image_type: &str,
|
||||
tag: Option<&str>,
|
||||
) {
|
||||
fn touch(&self, db: &Arc<RusqliteService>, item_id: &str, image_type: &str, tag: Option<&str>) {
|
||||
let query = match tag {
|
||||
Some(tag) => Query::with_params(
|
||||
"UPDATE thumbnails SET last_accessed = CURRENT_TIMESTAMP
|
||||
@@ -192,7 +186,10 @@ impl ThumbnailCache {
|
||||
],
|
||||
),
|
||||
};
|
||||
let _ = db.execute(query).await;
|
||||
// Detached: an LRU timestamp is bookkeeping, and a grid scroll does
|
||||
// one of these per visible poster — awaiting each write held every
|
||||
// thumbnail lookup behind the writer queue.
|
||||
db.execute_detached(query);
|
||||
}
|
||||
|
||||
/// Save thumbnail to cache
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "JellyTau",
|
||||
"version": "0.12.2",
|
||||
"version": "0.14.0",
|
||||
"identifier": "com.dtourolle.jellytau",
|
||||
"build": {
|
||||
"beforeDevCommand": "bun run dev",
|
||||
@@ -22,8 +22,8 @@
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self' data:; img-src 'self' data: blob: asset: http://asset.localhost http: https:; media-src 'self' blob: asset: http://asset.localhost http://127.0.0.1:* http: https:; connect-src 'self' ipc: http://ipc.localhost http: https:; worker-src 'self' blob:; object-src 'none'; frame-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'",
|
||||
"devCsp": "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; font-src 'self' data:; img-src 'self' data: blob: asset: http://asset.localhost http: https:; media-src 'self' blob: asset: http://asset.localhost http://127.0.0.1:* http: https:; connect-src 'self' ipc: http://ipc.localhost http: https: ws: wss:; worker-src 'self' blob:; object-src 'none'; frame-src 'none'; base-uri 'self'; form-action 'self'",
|
||||
"csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self' data:; img-src 'self' data: blob: asset: http://asset.localhost http: https:; media-src 'self' blob: asset: http://asset.localhost http://127.0.0.1:* http: https:; connect-src 'self' ipc: http://ipc.localhost; worker-src 'self'; object-src 'none'; frame-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'",
|
||||
"devCsp": "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; font-src 'self' data:; img-src 'self' data: blob: asset: http://asset.localhost http: https:; media-src 'self' blob: asset: http://asset.localhost http://127.0.0.1:* http: https:; connect-src 'self' ipc: http://ipc.localhost ws: wss:; worker-src 'self'; object-src 'none'; frame-src 'none'; base-uri 'self'; form-action 'self'",
|
||||
"assetProtocol": {
|
||||
"enable": true,
|
||||
"scope": [
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
+84
-87
@@ -21,7 +21,7 @@ async playerPlayItem(item: PlayItemRequest) : Promise<PlayerStatus> {
|
||||
},
|
||||
/**
|
||||
* Exit background-audio mode: stop the native audio player and return its final
|
||||
* position so the frontend can reload the WebView `<video>` there (UR-040).
|
||||
* position so the frontend can reload the video there (UR-040).
|
||||
*
|
||||
* Returns the position in seconds. The sleep timer is intentionally left
|
||||
* untouched — if it fired while backgrounded, playback is already stopped and
|
||||
@@ -52,8 +52,8 @@ async playerBackgroundAction(backgroundAudioArmed: boolean, inPictureInPicture:
|
||||
* `stream_url` MUST be an audio-only URL (see
|
||||
* `get_audio_only_stream_url_for_video`). The item is created as
|
||||
* `MediaType::Audio` so it starts an audio session and loads into the native
|
||||
* backend with `mediaType="audio"` — the WebView `<video>` is torn down on the
|
||||
* frontend side, so exactly one audio source is ever active.
|
||||
* backend with `mediaType="audio"`, replacing the video, so exactly one audio
|
||||
* source is ever active.
|
||||
*
|
||||
* This deliberately goes through the queue-based `play_item` path (NOT a
|
||||
* side-channel) so end-of-track lands in `on_playback_ended`, which already
|
||||
@@ -66,9 +66,14 @@ async playerEnterBackgroundAudio(item: PlayItemRequest, positionSeconds: number)
|
||||
return await TAURI_INVOKE("player_enter_background_audio", { item, positionSeconds });
|
||||
},
|
||||
/**
|
||||
* TRACES: UR-040 | DR-052 | UT-061, IT-013
|
||||
* Returns the item the native player is on and its absolute position. The
|
||||
* item matters: an episode that ended while backgrounded has already advanced
|
||||
* in the backend, so reloading the video the webview was mounted with would
|
||||
* bring back the previous episode. (DR-296)
|
||||
*
|
||||
* TRACES: UR-040, UR-023 | DR-052, DR-296 | UT-061, IT-013
|
||||
*/
|
||||
async playerExitBackgroundAudio() : Promise<number> {
|
||||
async playerExitBackgroundAudio() : Promise<BackgroundAudioResume> {
|
||||
return await TAURI_INVOKE("player_exit_background_audio");
|
||||
},
|
||||
/**
|
||||
@@ -124,11 +129,11 @@ async playerSeek(position: number) : Promise<PlayerStatus> {
|
||||
* - Direct play streams: Use native seeking
|
||||
* - Transcoded non-HLS: Request new stream URL from server starting at seek position
|
||||
*
|
||||
* For native (non-HTML5) backends, this command handles the entire stream reload
|
||||
* internally. For HTML5 backends, it returns the new URL for the frontend to handle.
|
||||
* The backend always handles the seek itself, including re-opening a stream,
|
||||
* since every video renderer is native (DR-235).
|
||||
*/
|
||||
async playerSeekVideo(repositoryHandle: string, position: number, mediaSourceId: string | null, audioStreamIndex: number | null, useHtml5: boolean) : Promise<VideoSeekResponse> {
|
||||
return await TAURI_INVOKE("player_seek_video", { repositoryHandle, position, mediaSourceId, audioStreamIndex, useHtml5 });
|
||||
async playerSeekVideo(repositoryHandle: string, position: number, mediaSourceId: string | null, audioStreamIndex: number | null) : Promise<VideoSeekResponse> {
|
||||
return await TAURI_INVOKE("player_seek_video", { repositoryHandle, position, mediaSourceId, audioStreamIndex });
|
||||
},
|
||||
async playerSetVolume(volume: number) : Promise<PlayerStatus> {
|
||||
return await TAURI_INVOKE("player_set_volume", { volume });
|
||||
@@ -152,9 +157,6 @@ async playerSetAudioTrack(streamIndex: number) : Promise<PlayerStatus> {
|
||||
* carries the requested track at all** — see
|
||||
* [`determine_audio_track_switch_strategy`]:
|
||||
*
|
||||
* - An HTML5 `<video>` element has no track-selection API, so the stream is
|
||||
* always re-opened at the chosen `AudioStreamIndex` and the frontend seeks
|
||||
* the reloaded element back to `position`.
|
||||
* - A native backend playing a **direct play** holds the source file with
|
||||
* every track in it, so ExoPlayer selects in place by track-group index.
|
||||
* - A native backend playing a **transcode** does not. Jellyfin builds a
|
||||
@@ -170,23 +172,21 @@ async playerSetAudioTrack(streamIndex: number) : Promise<PlayerStatus> {
|
||||
* audio track index` and dropped the request — the default track just kept
|
||||
* playing, with nothing in the UI saying so.
|
||||
*
|
||||
* libmpv implements neither selection nor reload here — it is the audio-only
|
||||
* backend and leaves `PlayerBackend::set_audio_track` at its
|
||||
* `not_implemented()` default, which is why IR-019 is met by these paths
|
||||
* rather than by MPV.
|
||||
* mpv selects in place the same way (`mpv_tracks::select_audio`, by position in
|
||||
* the file's audio tracks), and re-opens a transcode through the same path.
|
||||
*
|
||||
* TRACES: UR-021, UR-005 | IR-019, DR-024, DR-258
|
||||
*/
|
||||
async playerSwitchAudioTrack(repositoryHandle: string, streamIndex: number, arrayIndex: number, useHtml5: boolean, currentPosition: number | null, mediaSourceId: string | null) : Promise<AudioTrackSwitchResponse> {
|
||||
return await TAURI_INVOKE("player_switch_audio_track", { repositoryHandle, streamIndex, arrayIndex, useHtml5, currentPosition, mediaSourceId });
|
||||
async playerSwitchAudioTrack(repositoryHandle: string, streamIndex: number, arrayIndex: number, currentPosition: number | null, mediaSourceId: string | null) : Promise<AudioTrackSwitchResponse> {
|
||||
return await TAURI_INVOKE("player_switch_audio_track", { repositoryHandle, streamIndex, arrayIndex, currentPosition, mediaSourceId });
|
||||
},
|
||||
/**
|
||||
* Set (or clear, with `None`) the active subtitle track on a native backend.
|
||||
*
|
||||
* On Android this indexes ExoPlayer's *text track groups* — i.e. the position
|
||||
* of the sideloaded `MediaItem.SubtitleConfiguration`, not the Jellyfin stream
|
||||
* index. The HTML5 path never reaches here; it toggles its own `<track>`
|
||||
* children. libmpv implements neither, leaving the trait default in place.
|
||||
* index. mpv gives it the same meaning: the position in the sideloaded WebVTT
|
||||
* list, loaded as external subtitle files (`mpv_tracks`).
|
||||
*
|
||||
* TRACES: UR-020 | IR-018, DR-023
|
||||
*/
|
||||
@@ -278,9 +278,7 @@ async playerGetStreamingQualities() : Promise<([StreamingQuality, string, string
|
||||
* A cap is a property of the stream the server is producing, so unlike a volume
|
||||
* change it cannot be applied to a stream already in flight — the stream has to
|
||||
* be re-opened at the new quality and resumed at the current position. That is
|
||||
* the same reload the transcoded-seek and audio-track paths use, and the same
|
||||
* two-sided split: HTML5 gets the URL back and reloads its own element, while a
|
||||
* native backend is reloaded here.
|
||||
* the same reload the transcoded-seek and audio-track paths use, done here.
|
||||
*
|
||||
* The change applies to **this playback only**. The in-player picker is a
|
||||
* "this film, this connection" control and its doc has always said so, but it
|
||||
@@ -294,8 +292,8 @@ async playerGetStreamingQualities() : Promise<([StreamingQuality, string, string
|
||||
*
|
||||
* TRACES: UR-074, UR-079 | DR-162, DR-226
|
||||
*/
|
||||
async playerSetStreamQuality(repositoryHandle: string, quality: StreamingQuality, useHtml5: boolean, currentPosition: number | null, mediaSourceId: string | null, audioStreamIndex: number | null) : Promise<StreamQualityResponse> {
|
||||
return await TAURI_INVOKE("player_set_stream_quality", { repositoryHandle, quality, useHtml5, currentPosition, mediaSourceId, audioStreamIndex });
|
||||
async playerSetStreamQuality(repositoryHandle: string, quality: StreamingQuality, currentPosition: number | null, mediaSourceId: string | null, audioStreamIndex: number | null) : Promise<StreamQualityResponse> {
|
||||
return await TAURI_INVOKE("player_set_stream_quality", { repositoryHandle, quality, currentPosition, mediaSourceId, audioStreamIndex });
|
||||
},
|
||||
/**
|
||||
* Set sleep timer mode
|
||||
@@ -342,7 +340,7 @@ async playerPlayNextEpisode(item: PlayItemRequest) : Promise<PlayerStatus> {
|
||||
/**
|
||||
* Handle playback ended event - triggers autoplay decision logic
|
||||
* This is called from:
|
||||
* - Frontend when HTML5 video ends (Linux/desktop) - passes itemId + repositoryHandle for the video
|
||||
* - Frontend when a video ends - passes itemId + repositoryHandle for the video
|
||||
* - Frontend when audio track ends via backend event - no itemId/repositoryHandle needed
|
||||
* - Android JNI callback also triggers this logic directly
|
||||
*
|
||||
@@ -375,20 +373,20 @@ async playerRecoverStream() : Promise<boolean> {
|
||||
return await TAURI_INVOKE("player_recover_stream");
|
||||
},
|
||||
/**
|
||||
* Report an HTML5 <video> state change (playing/paused/loading/stopped/idle).
|
||||
* Report a webview media element's state change (playing/paused/loading/stopped/idle).
|
||||
*/
|
||||
async playerReportState(state: string, mediaId: string | null) : Promise<null> {
|
||||
return await TAURI_INVOKE("player_report_state", { state, mediaId });
|
||||
},
|
||||
/**
|
||||
* Report an HTML5 <video> position tick (seconds). The adapter should throttle
|
||||
* Report a webview media element's position tick (seconds). The adapter should throttle
|
||||
* these to roughly match the native backends' ~250ms cadence.
|
||||
*/
|
||||
async playerReportPosition(position: number, duration: number) : Promise<null> {
|
||||
return await TAURI_INVOKE("player_report_position", { position, duration });
|
||||
},
|
||||
/**
|
||||
* Report that the HTML5 <video> finished loading and knows its duration.
|
||||
* Report that a webview media element finished loading and knows its duration.
|
||||
*/
|
||||
async playerReportMediaLoaded(duration: number) : Promise<null> {
|
||||
return await TAURI_INVOKE("player_report_media_loaded", { duration });
|
||||
@@ -1590,6 +1588,16 @@ async repositoryGetSeriesEpisodes(handle: string, seriesId: string) : Promise<Me
|
||||
async repositoryGetSeriesCurrentEpisode(handle: string, seriesId: string) : Promise<MediaItem | null> {
|
||||
return await TAURI_INVOKE("repository_get_series_current_episode", { handle, seriesId });
|
||||
},
|
||||
/**
|
||||
* A series' episodes and the viewer's current episode, from one season
|
||||
* fan-out. The series page used to ask for these as two commands, each of
|
||||
* which walked every season.
|
||||
*
|
||||
* TRACES: UR-062 | DR-101, DR-295
|
||||
*/
|
||||
async repositoryGetSeriesView(handle: string, seriesId: string) : Promise<SeriesView> {
|
||||
return await TAURI_INVOKE("repository_get_series_view", { handle, seriesId });
|
||||
},
|
||||
/**
|
||||
* Erase the viewer's watch history for an item.
|
||||
*
|
||||
@@ -2119,11 +2127,7 @@ export type AudioTrackSwitchResponse =
|
||||
/**
|
||||
* Native backend handled it (Android ExoPlayer)
|
||||
*/
|
||||
{ strategy: "native"; success: boolean } |
|
||||
/**
|
||||
* HTML5 needs to reload stream with new audio track
|
||||
*/
|
||||
{ strategy: "reloadStream"; selection: StreamSelection; position: number }
|
||||
{ strategy: "native"; success: boolean }
|
||||
/**
|
||||
* Authentication result
|
||||
*/
|
||||
@@ -2180,6 +2184,22 @@ export type BackgroundAction =
|
||||
* Stop making sound. The user did not ask for background playback.
|
||||
*/
|
||||
"pause"
|
||||
/**
|
||||
* Where playback stands when a background-audio handoff returns to the
|
||||
* foreground. See [`PlayerController::background_audio_resume`].
|
||||
*
|
||||
* TRACES: UR-040, UR-023 | DR-296
|
||||
*/
|
||||
export type BackgroundAudioResume = {
|
||||
/**
|
||||
* Item the native audio player is on — `None` if the queue emptied (e.g.
|
||||
* the sleep timer stopped playback while backgrounded).
|
||||
*/
|
||||
itemId: string | null;
|
||||
/**
|
||||
* Absolute position in that item, in seconds.
|
||||
*/
|
||||
positionSeconds: number }
|
||||
/**
|
||||
* Smart caching configuration
|
||||
*/
|
||||
@@ -2805,9 +2825,8 @@ seriesId?: string | null;
|
||||
/**
|
||||
* Subtitle tracks to sideload, with URLs the frontend has already resolved.
|
||||
*
|
||||
* Only the native backends use these: on Android they become the
|
||||
* `MediaItem.SubtitleConfiguration`s ExoPlayer renders. The HTML5 path
|
||||
* builds its own `<track>` children instead and ignores this list.
|
||||
* On Android they become the `MediaItem.SubtitleConfiguration`s ExoPlayer
|
||||
* renders; mpv loads them as external subtitle files (`mpv_tracks`).
|
||||
*
|
||||
* **Order is the contract.** `player_set_subtitle_track(n)` reaches
|
||||
* `JellyTauPlayer.setSubtitleTrack(n)`, which indexes into ExoPlayer's
|
||||
@@ -2876,17 +2895,14 @@ startPosition?: number | null }
|
||||
export type PlaybackCapabilities = {
|
||||
/**
|
||||
* True when audio is rendered by a webview `<audio>` element rather than a
|
||||
* native backend. Native audio exists on Linux (mpv) and Android
|
||||
* (ExoPlayer); everything else (Windows, future desktops) uses the webview.
|
||||
* native backend. Native audio exists on Linux and Windows (mpv) and
|
||||
* Android (ExoPlayer); only an unported desktop uses the webview.
|
||||
*
|
||||
* Video has no counterpart: it is always drawn by the native backend, behind
|
||||
* the transparent webview (DR-235) — there is no webview video renderer
|
||||
* left to report.
|
||||
*/
|
||||
usesWebviewAudio: boolean;
|
||||
/**
|
||||
* True when video can be rendered by a native surface composited *behind*
|
||||
* a transparent webview. Android only: ExoPlayer draws into a SurfaceView
|
||||
* beneath the WebView. Linux cannot do this (WebKitGTK/Wayland
|
||||
* compositing), so it stays on the HTML5 element.
|
||||
*/
|
||||
supportsNativeVideo: boolean }
|
||||
usesWebviewAudio: boolean }
|
||||
/**
|
||||
* Playback information
|
||||
*/
|
||||
@@ -3098,14 +3114,6 @@ export type PlayerState =
|
||||
* Response for player state queries
|
||||
*/
|
||||
export type PlayerStatus = { state: PlayerState; position: number; duration: number | null; volume: number; muted: boolean; shuffle: boolean; repeat: RepeatMode;
|
||||
/**
|
||||
* Backend being used (native = ExoPlayer/libmpv, html5 = fallback)
|
||||
*/
|
||||
backend: VideoBackend;
|
||||
/**
|
||||
* Whether frontend should render HTML5 video element
|
||||
*/
|
||||
useHtml5Element: boolean;
|
||||
/**
|
||||
* Media item from either local queue or remote session
|
||||
*/
|
||||
@@ -3161,9 +3169,8 @@ export type PlayerStatusEvent =
|
||||
{ type: "sleep_timer_changed"; mode: SleepTimerMode; remaining_seconds: number } |
|
||||
/**
|
||||
* Time-based sleep timer expired: playback must stop. The backend stops
|
||||
* its own (MPV/ExoPlayer) playback, but HTML5 video on Linux plays in the
|
||||
* webview outside the backend's control — the frontend pauses it on this
|
||||
* event.
|
||||
* its own (MPV/ExoPlayer) playback; the frontend pauses the active adapter
|
||||
* on this event, which reaches a webview `<audio>` element where one plays.
|
||||
*/
|
||||
{ type: "sleep_timer_expired" } |
|
||||
/**
|
||||
@@ -3208,19 +3215,19 @@ export type PlayerStatusEvent =
|
||||
{ type: "remote_disconnect_requested" } |
|
||||
/**
|
||||
* Backend-originated control command targeting the active frontend player
|
||||
* adapter (the HTML5 <video> that lives in the webview, which Rust cannot
|
||||
* drive directly). Emitted by control paths like the sleep timer, lockscreen,
|
||||
* or remote so they can pause/play/seek/stop the webview element.
|
||||
* adapter — the webview `<audio>` element, which Rust cannot drive
|
||||
* directly. Emitted by control paths like the sleep timer, lockscreen, or
|
||||
* remote so they can pause/play/seek/stop it.
|
||||
* `playerEvents.ts` routes this to the active PlayerAdapter via the facade.
|
||||
*/
|
||||
{ type: "control_command"; action: string; position: number | null } |
|
||||
/**
|
||||
* Ask the frontend webview `<audio>` element to load and play a stream.
|
||||
*
|
||||
* Emitted by `WebviewAudioBackend` on platforms with no native audio
|
||||
* backend (e.g. Windows): audio-only playback is rendered by an `<audio>`
|
||||
* element in the webview, mirroring how all video already renders through
|
||||
* the webview `<video>`. The element then reports its state/position back
|
||||
* Emitted by `WebviewAudioBackend` on a desktop with no native audio
|
||||
* backend (none that ships: Linux and Windows have mpv): audio-only
|
||||
* playback is rendered by an `<audio>` element in the webview. The element
|
||||
* then reports its state/position back
|
||||
* through the `player_report_*` commands, so the Rust controller stays the
|
||||
* single source of truth. Subsequent play/pause/seek/stop reach the element
|
||||
* via `ControlCommand`.
|
||||
@@ -3439,6 +3446,16 @@ export type SecurityStatus = { usingKeyring: boolean; storageType: string }
|
||||
* Audio track preference for a series
|
||||
*/
|
||||
export type SeriesAudioPreference = { seriesId: string; audioTrackDisplayTitle: string | null; audioTrackLanguage: string | null; audioTrackIndex: number | null }
|
||||
/**
|
||||
* A series' episodes and the one the viewer is up to, from **one** season
|
||||
* fan-out.
|
||||
*
|
||||
* The series page needs both, and asked for them as two commands; each walked
|
||||
* every season, so every visit listed the show twice. One call, one walk.
|
||||
*
|
||||
* TRACES: UR-062 | DR-101, DR-295
|
||||
*/
|
||||
export type SeriesView = { episodes: MediaItem[]; current: MediaItem | null }
|
||||
/**
|
||||
* The verdict on a server's version.
|
||||
*
|
||||
@@ -3565,11 +3582,7 @@ export type StreamQualityResponse =
|
||||
*
|
||||
* TRACES: UR-074, UR-079 | DR-226, DR-227
|
||||
*/
|
||||
{ strategy: "native"; selection: StreamSelection; position: number } |
|
||||
/**
|
||||
* HTML5 must reload its element with this selection.
|
||||
*/
|
||||
{ strategy: "reloadStream"; selection: StreamSelection; position: number }
|
||||
{ strategy: "native"; selection: StreamSelection; position: number }
|
||||
/**
|
||||
* Everything a player backend needs to open a stream, and everything the UI
|
||||
* needs to describe it.
|
||||
@@ -3798,18 +3811,6 @@ playbackPositionMs?: number | null; isPlayed?: boolean | null; isFavorite?: bool
|
||||
* User info returned to frontend
|
||||
*/
|
||||
export type UserInfo = { id: string; serverId: string; username: string; isActive: boolean }
|
||||
/**
|
||||
* Backend type for video playback
|
||||
*/
|
||||
export type VideoBackend =
|
||||
/**
|
||||
* Native backend (ExoPlayer on Android, libmpv on Linux)
|
||||
*/
|
||||
"native" |
|
||||
/**
|
||||
* HTML5 video element fallback
|
||||
*/
|
||||
"html5"
|
||||
/**
|
||||
* Response for video seek operations
|
||||
*/
|
||||
@@ -3817,11 +3818,7 @@ export type VideoSeekResponse =
|
||||
/**
|
||||
* Use native seeking (HLS or direct stream)
|
||||
*/
|
||||
{ strategy: "native"; position: number } |
|
||||
/**
|
||||
* Reload stream from new position (transcoded non-HLS)
|
||||
*/
|
||||
{ strategy: "reloadStream"; selection: StreamSelection; seek_offset: number }
|
||||
{ strategy: "native"; position: number }
|
||||
/**
|
||||
* Video playback settings
|
||||
*/
|
||||
|
||||
@@ -3,7 +3,13 @@
|
||||
// NO direct HTTP calls - everything routes through Rust backend
|
||||
|
||||
import { commands } from "./bindings";
|
||||
import type { DownloadDiskUsage, JRayActor, SearchScope, StreamSelection } from "./bindings";
|
||||
import type {
|
||||
DownloadDiskUsage,
|
||||
JRayActor,
|
||||
SearchScope,
|
||||
SeriesView,
|
||||
StreamSelection,
|
||||
} from "./bindings";
|
||||
import type { QualityPreset } from "./quality-presets";
|
||||
import type {
|
||||
Library,
|
||||
@@ -164,6 +170,17 @@ export class RepositoryClient {
|
||||
return commands.repositoryGetSeriesCurrentEpisode(this.ensureHandle(), seriesId);
|
||||
}
|
||||
|
||||
/**
|
||||
* A series' episodes and the episode the viewer is up to, from one season
|
||||
* fan-out in Rust. Prefer this over calling `getSeriesEpisodes` and
|
||||
* `getSeriesCurrentEpisode` together — each of those walks every season.
|
||||
*
|
||||
* TRACES: UR-062 | DR-101, DR-295
|
||||
*/
|
||||
async getSeriesView(seriesId: string): Promise<SeriesView> {
|
||||
return commands.repositoryGetSeriesView(this.ensureHandle(), seriesId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Erase watch history for an item. On a series or season the server applies
|
||||
* it to everything inside, so the container returns to "never watched".
|
||||
|
||||
@@ -57,25 +57,29 @@
|
||||
const episodeNumber = $derived(episode.indexNumber || 0);
|
||||
</script>
|
||||
|
||||
<button
|
||||
<!-- Narrow rows stack the title under a full-width thumbnail instead of
|
||||
squeezing it beside a fixed 160px one. A container query, not a viewport
|
||||
breakpoint, so it follows the row's own width. -->
|
||||
<div class="@container">
|
||||
<button
|
||||
bind:this={buttonRef}
|
||||
type="button"
|
||||
class="group/row flex gap-4 w-full text-left p-3 rounded-lg hover:bg-[var(--color-surface-hover)] transition-colors {focused
|
||||
class="group/row flex flex-col @md:flex-row gap-3 @md:gap-4 w-full text-left p-3 rounded-lg hover:bg-[var(--color-surface-hover)] transition-colors {focused
|
||||
? 'ring-2 ring-[var(--color-jellyfin)] bg-[var(--color-surface)]'
|
||||
: current
|
||||
? 'ring-2 ring-yellow-400 bg-[var(--color-surface)]'
|
||||
: ''}"
|
||||
{onclick}
|
||||
>
|
||||
>
|
||||
<!-- Thumbnail -->
|
||||
<div
|
||||
class="relative flex-shrink-0 w-40 aspect-video rounded-lg overflow-hidden bg-[var(--color-surface)]"
|
||||
class="relative flex-shrink-0 w-full @md:w-40 aspect-video rounded-lg overflow-hidden bg-[var(--color-surface)]"
|
||||
>
|
||||
<CachedImage
|
||||
itemId={episode.id}
|
||||
imageType="Primary"
|
||||
tag={episode.imageId}
|
||||
maxWidth={320}
|
||||
maxWidth={640}
|
||||
alt={episode.name}
|
||||
class="w-full h-full object-cover transition-transform group-hover/row:scale-105"
|
||||
/>
|
||||
@@ -104,9 +108,14 @@
|
||||
|
||||
<!-- Download indicator -->
|
||||
{#if isDownloaded || isDownloading}
|
||||
<div class="absolute bottom-2 right-2" title={isDownloaded ? "Downloaded" : "Downloading..."}>
|
||||
<div
|
||||
class="absolute bottom-2 right-2"
|
||||
title={isDownloaded ? "Downloaded" : "Downloading..."}
|
||||
>
|
||||
{#if isDownloaded}
|
||||
<div class="w-5 h-5 rounded-full bg-green-600 flex items-center justify-center shadow-lg">
|
||||
<div
|
||||
class="w-5 h-5 rounded-full bg-green-600 flex items-center justify-center shadow-lg"
|
||||
>
|
||||
<svg
|
||||
class="w-3 h-3 text-white"
|
||||
fill="none"
|
||||
@@ -114,7 +123,11 @@
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2.5"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v12m0 0l-4-4m4 4l4-4" />
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M12 4v12m0 0l-4-4m4 4l4-4"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
{:else if isDownloading}
|
||||
@@ -148,7 +161,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Episode info -->
|
||||
<div class="flex-1 min-w-0 py-1">
|
||||
<div class="flex-1 min-w-0 @md:py-1">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="min-w-0 flex-1">
|
||||
<!-- Episode number and title -->
|
||||
@@ -157,7 +170,7 @@
|
||||
{episodeNumber}.
|
||||
</span>
|
||||
<h3
|
||||
class="text-white font-medium truncate group-hover/row:text-[var(--color-jellyfin)] transition-colors"
|
||||
class="text-white font-medium line-clamp-2 @md:truncate group-hover/row:text-[var(--color-jellyfin)] transition-colors"
|
||||
>
|
||||
{truncateMiddle(episode.name, 56)}
|
||||
</h3>
|
||||
@@ -220,4 +233,5 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* The list view must grey and offer to queue server-only media exactly as the
|
||||
* grid does. It did neither: switching a library to list view offline turned
|
||||
* every non-downloaded item back into a normal, tappable row that plays
|
||||
* nothing.
|
||||
*
|
||||
* TRACES: UR-052 | DR-292 | UT-258
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/svelte";
|
||||
|
||||
const h = vi.hoisted(() => {
|
||||
function shim<T>(initial: T) {
|
||||
let value = initial;
|
||||
const subs = new Set<(v: T) => void>();
|
||||
return {
|
||||
set(v: T) {
|
||||
value = v;
|
||||
subs.forEach((fn) => fn(value));
|
||||
},
|
||||
subscribe(fn: (v: T) => void) {
|
||||
subs.add(fn);
|
||||
fn(value);
|
||||
return () => subs.delete(fn);
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
isConnectedStore: shim(true),
|
||||
showServerCatalogStore: shim(false),
|
||||
downloadsStore: shim({ downloads: {} as Record<string, unknown> }),
|
||||
deviceContentIdsStore: shim(new Set<string>()),
|
||||
downloadItem: vi.fn(async () => 1),
|
||||
getUserId: vi.fn(() => "user-1"),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("$lib/stores/connectivity", () => ({
|
||||
isConnected: { subscribe: h.isConnectedStore.subscribe },
|
||||
}));
|
||||
|
||||
vi.mock("$lib/services/offlineCatalog", () => ({
|
||||
showServerCatalog: { subscribe: h.showServerCatalogStore.subscribe },
|
||||
}));
|
||||
|
||||
vi.mock("$lib/services/downloadedCatalog", () => ({
|
||||
deviceContentIds: { subscribe: h.deviceContentIdsStore.subscribe },
|
||||
}));
|
||||
|
||||
vi.mock("$lib/stores/downloads", () => ({
|
||||
downloads: { subscribe: h.downloadsStore.subscribe, downloadItem: h.downloadItem },
|
||||
}));
|
||||
|
||||
vi.mock("$lib/stores/auth", () => ({
|
||||
auth: { getUserId: h.getUserId },
|
||||
}));
|
||||
|
||||
vi.mock("$lib/components/common/CachedImage.svelte", async () => ({
|
||||
default: (await import("./__mocks__/StubImage.svelte")).default,
|
||||
}));
|
||||
|
||||
import LibraryListView from "./LibraryListView.svelte";
|
||||
|
||||
const movie = {
|
||||
id: "movie-1",
|
||||
name: "Some Film",
|
||||
type: "Movie" as const,
|
||||
serverId: "server-1",
|
||||
productionYear: 1999,
|
||||
};
|
||||
|
||||
const album = {
|
||||
id: "album-1",
|
||||
name: "Some Album",
|
||||
type: "MusicAlbum" as const,
|
||||
serverId: "server-1",
|
||||
};
|
||||
|
||||
describe("LibraryListView server-only rows", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
h.isConnectedStore.set(true);
|
||||
h.showServerCatalogStore.set(false);
|
||||
h.downloadsStore.set({ downloads: {} });
|
||||
h.deviceContentIdsStore.set(new Set());
|
||||
});
|
||||
|
||||
it("shows no queue control while online", () => {
|
||||
render(LibraryListView, { props: { items: [movie] } });
|
||||
expect(screen.queryByLabelText(/Queue download/i)).toBeNull();
|
||||
});
|
||||
|
||||
it("offers to queue a server-only row when offline with the reveal on", () => {
|
||||
h.isConnectedStore.set(false);
|
||||
h.showServerCatalogStore.set(true);
|
||||
render(LibraryListView, { props: { items: [movie] } });
|
||||
expect(screen.getByLabelText(/Queue download for Some Film/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("queues the row's item on click", async () => {
|
||||
h.isConnectedStore.set(false);
|
||||
h.showServerCatalogStore.set(true);
|
||||
render(LibraryListView, { props: { items: [movie] } });
|
||||
|
||||
await fireEvent.click(screen.getByLabelText(/Queue download for Some Film/i));
|
||||
|
||||
expect(h.downloadItem).toHaveBeenCalledTimes(1);
|
||||
const args = h.downloadItem.mock.calls[0] as unknown as unknown[];
|
||||
expect(args[0]).toBe("movie-1");
|
||||
expect(args[1]).toBe("user-1");
|
||||
});
|
||||
|
||||
it("makes a server-only row inert: tapping it cannot start playback", async () => {
|
||||
h.isConnectedStore.set(false);
|
||||
h.showServerCatalogStore.set(true);
|
||||
const onItemClick = vi.fn();
|
||||
render(LibraryListView, { props: { items: [movie], onItemClick } });
|
||||
|
||||
await fireEvent.click(screen.getByText("Some Film"));
|
||||
|
||||
expect(onItemClick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("leaves a downloaded row alone", () => {
|
||||
h.isConnectedStore.set(false);
|
||||
h.showServerCatalogStore.set(true);
|
||||
h.downloadsStore.set({
|
||||
downloads: { "movie-1": { itemId: "movie-1", status: "completed", progress: 1 } },
|
||||
});
|
||||
render(LibraryListView, { props: { items: [movie] } });
|
||||
expect(screen.queryByLabelText(/Queue download/i)).toBeNull();
|
||||
});
|
||||
|
||||
it("leaves a container alone when the device holds its children", () => {
|
||||
h.isConnectedStore.set(false);
|
||||
h.showServerCatalogStore.set(true);
|
||||
h.deviceContentIdsStore.set(new Set(["album-1"]));
|
||||
render(LibraryListView, { props: { items: [album] } });
|
||||
expect(screen.queryByLabelText(/Queue download/i)).toBeNull();
|
||||
});
|
||||
|
||||
it("shows a Queued badge instead of the button for a pending row", () => {
|
||||
h.isConnectedStore.set(false);
|
||||
h.showServerCatalogStore.set(true);
|
||||
h.downloadsStore.set({
|
||||
downloads: { "movie-1": { itemId: "movie-1", status: "pending", progress: 0 } },
|
||||
});
|
||||
render(LibraryListView, { props: { items: [movie] } });
|
||||
|
||||
expect(screen.getByText(/Queued/i)).toBeTruthy();
|
||||
expect(screen.queryByLabelText(/Queue download/i)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,26 @@
|
||||
<!--
|
||||
List counterpart of the grid's MediaCard. The two must agree about what a row
|
||||
offline means: before DR-292 this view knew nothing about the offline catalog
|
||||
reveal, so a library switched to list view showed every server-only item as a
|
||||
normal, tappable row that plays nothing.
|
||||
|
||||
TRACES: UR-052 | DR-292
|
||||
-->
|
||||
<script lang="ts">
|
||||
import type { MediaItem, Library } from "$lib/api/types";
|
||||
import { truncateMiddle } from "$lib/utils/truncateMiddle";
|
||||
import { downloads } from "$lib/stores/downloads";
|
||||
import { isConnected } from "$lib/stores/connectivity";
|
||||
import { showServerCatalog } from "$lib/services/offlineCatalog";
|
||||
import { deviceContentIds } from "$lib/services/downloadedCatalog";
|
||||
import { isServerOnly } from "$lib/utils/serverOnly";
|
||||
import { queueOfflineDownload } from "$lib/services/queueOfflineDownload";
|
||||
import { formatDuration } from "$lib/utils/duration";
|
||||
import { createLogger } from "$lib/utils/logger";
|
||||
import CachedImage from "$lib/components/common/CachedImage.svelte";
|
||||
|
||||
const log = createLogger("LibraryListView");
|
||||
|
||||
interface Props {
|
||||
items: (MediaItem | Library)[];
|
||||
showProgress?: boolean;
|
||||
@@ -18,6 +34,33 @@
|
||||
return Object.values($downloads.downloads).find((d) => d.itemId === itemId);
|
||||
}
|
||||
|
||||
/** Same rule the grid card applies — see $lib/utils/serverOnly. */
|
||||
function serverOnly(item: MediaItem | Library): boolean {
|
||||
const info = getDownloadInfo(item.id);
|
||||
return isServerOnly({
|
||||
isMediaItem: "type" in item,
|
||||
isConnected: $isConnected,
|
||||
revealServerCatalog: $showServerCatalog,
|
||||
isDownloaded: info?.status === "completed",
|
||||
isActivelyDownloading: info?.status === "downloading",
|
||||
hasDeviceContent: $deviceContentIds.has(item.id),
|
||||
});
|
||||
}
|
||||
|
||||
let queueError = $state<string | null>(null);
|
||||
|
||||
async function queueForDownload(e: Event, item: MediaItem | Library) {
|
||||
e.stopPropagation();
|
||||
if (!("type" in item)) return;
|
||||
try {
|
||||
queueError = null;
|
||||
await queueOfflineDownload(item);
|
||||
} catch (err) {
|
||||
log.error("Failed to queue download:", err);
|
||||
queueError = err instanceof Error ? err.message : "Failed to queue";
|
||||
}
|
||||
}
|
||||
|
||||
function getImageTag(item: MediaItem | Library): string | undefined {
|
||||
return "imageId" in item
|
||||
? (item.imageId ?? undefined)
|
||||
@@ -78,12 +121,20 @@
|
||||
{@const isDownloaded = downloadInfo?.status === "completed"}
|
||||
{@const isDownloading =
|
||||
downloadInfo?.status === "downloading" || downloadInfo?.status === "pending"}
|
||||
{@const isQueued = downloadInfo?.status === "pending"}
|
||||
{@const rowServerOnly = serverOnly(item)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
<!-- A server-only row is not a button: there is nothing to open offline,
|
||||
and the queue control it carries cannot live inside one. -->
|
||||
<svelte:element
|
||||
this={rowServerOnly ? "div" : "button"}
|
||||
type={rowServerOnly ? undefined : "button"}
|
||||
role={rowServerOnly ? "group" : undefined}
|
||||
data-grid-index={index}
|
||||
onclick={() => onItemClick?.(item)}
|
||||
class="w-full flex items-center gap-3 p-2 rounded-lg hover:bg-[var(--color-surface)] transition-colors group"
|
||||
onclick={rowServerOnly ? undefined : () => onItemClick?.(item)}
|
||||
class="w-full flex items-center gap-3 p-2 rounded-lg transition-colors group {rowServerOnly
|
||||
? 'opacity-60'
|
||||
: 'hover:bg-[var(--color-surface)]'}"
|
||||
>
|
||||
<!-- Track number or index -->
|
||||
<span class="text-gray-500 w-6 text-right text-sm flex-shrink-0">
|
||||
@@ -103,9 +154,11 @@
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
|
||||
<!-- Play overlay on hover -->
|
||||
<!-- Play overlay on hover (never on an inert server-only row) -->
|
||||
<div
|
||||
class="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center"
|
||||
class="absolute inset-0 bg-black/50 opacity-0 transition-opacity flex items-center justify-center {rowServerOnly
|
||||
? ''
|
||||
: 'group-hover:opacity-100'}"
|
||||
>
|
||||
<svg class="w-5 h-5 text-white" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z" />
|
||||
@@ -123,7 +176,9 @@
|
||||
<!-- Title & Subtitle -->
|
||||
<div class="flex-1 min-w-0 text-left">
|
||||
<p
|
||||
class="text-sm font-medium text-white truncate group-hover:text-[var(--color-jellyfin)] transition-colors"
|
||||
class="text-sm font-medium text-white truncate transition-colors {rowServerOnly
|
||||
? ''
|
||||
: 'group-hover:text-[var(--color-jellyfin)]'}"
|
||||
>
|
||||
{truncateMiddle(item.name, 56)}
|
||||
</p>
|
||||
@@ -192,6 +247,39 @@
|
||||
{#if duration}
|
||||
<span class="text-xs text-gray-400 flex-shrink-0">{duration}</span>
|
||||
{/if}
|
||||
|
||||
<!-- Server-only: queue for the next reconnect, mirroring the grid card.
|
||||
A row already queued shows the badge instead of the button. -->
|
||||
{#if rowServerOnly}
|
||||
{#if isQueued}
|
||||
<span
|
||||
class="text-[10px] font-medium bg-white/10 text-gray-300 px-2 py-0.5 rounded-full flex-shrink-0"
|
||||
title="Queued — will download on reconnect">Queued</span
|
||||
>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
onclick={(e) => queueForDownload(e, item)}
|
||||
class="w-8 h-8 rounded-full bg-[var(--color-jellyfin)] hover:bg-[var(--color-jellyfin)]/90 flex items-center justify-center flex-shrink-0 transition-colors"
|
||||
title="Queue download for next connection"
|
||||
aria-label="Queue download for {item.name}"
|
||||
>
|
||||
<svg
|
||||
class="w-4 h-4 text-white"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v12m0 0l-4-4m4 4l4-4" />
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</svelte:element>
|
||||
{/each}
|
||||
|
||||
{#if queueError}
|
||||
<p class="text-xs text-red-400 px-2">{queueError}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -24,12 +24,20 @@ const h = vi.hoisted(() => {
|
||||
isConnectedStore: shim(true),
|
||||
showServerCatalogStore: shim(false),
|
||||
downloadsStore: shim({ downloads: {} as Record<string, any> }),
|
||||
deviceContentIdsStore: shim(new Set<string>()),
|
||||
downloadItem: vi.fn(async () => 1),
|
||||
getUserId: vi.fn(() => "user-1"),
|
||||
};
|
||||
});
|
||||
|
||||
const { isConnectedStore, showServerCatalogStore, downloadsStore, downloadItem, getUserId } = h;
|
||||
const {
|
||||
isConnectedStore,
|
||||
showServerCatalogStore,
|
||||
downloadsStore,
|
||||
deviceContentIdsStore,
|
||||
downloadItem,
|
||||
getUserId,
|
||||
} = h;
|
||||
|
||||
vi.mock("$lib/stores/connectivity", () => ({
|
||||
isConnected: { subscribe: h.isConnectedStore.subscribe },
|
||||
@@ -39,6 +47,10 @@ vi.mock("$lib/services/offlineCatalog", () => ({
|
||||
showServerCatalog: { subscribe: h.showServerCatalogStore.subscribe },
|
||||
}));
|
||||
|
||||
vi.mock("$lib/services/downloadedCatalog", () => ({
|
||||
deviceContentIds: { subscribe: h.deviceContentIdsStore.subscribe },
|
||||
}));
|
||||
|
||||
vi.mock("$lib/stores/downloads", () => ({
|
||||
downloads: { subscribe: h.downloadsStore.subscribe, downloadItem: h.downloadItem },
|
||||
}));
|
||||
@@ -69,6 +81,7 @@ describe("MediaCard server-only (offline browse & queue)", () => {
|
||||
isConnectedStore.set(true);
|
||||
showServerCatalogStore.set(false);
|
||||
downloadsStore.set({ downloads: {} });
|
||||
deviceContentIdsStore.set(new Set());
|
||||
});
|
||||
|
||||
it("shows no queue button while online", () => {
|
||||
@@ -117,6 +130,23 @@ describe("MediaCard server-only (offline browse & queue)", () => {
|
||||
expect(screen.queryByLabelText(/Queue download/i)).toBeNull();
|
||||
});
|
||||
|
||||
// An album carries no download row of its own — its tracks do — so the card
|
||||
// greyed out a fully downloaded album and offered to queue what was already
|
||||
// on the device. The backend's disk-usage map answers for containers too.
|
||||
// TRACES: UR-052 | DR-292 | UT-257
|
||||
it("does not grey a container whose children are on the device", () => {
|
||||
isConnectedStore.set(false);
|
||||
showServerCatalogStore.set(true);
|
||||
deviceContentIdsStore.set(new Set(["album-1"]));
|
||||
render(MediaCard, {
|
||||
props: {
|
||||
item: { id: "album-1", name: "Album X", type: "MusicAlbum", serverId: "server-1" },
|
||||
},
|
||||
});
|
||||
|
||||
expect(screen.queryByLabelText(/Queue download/i)).toBeNull();
|
||||
});
|
||||
|
||||
it("does not grey out a completed download", () => {
|
||||
isConnectedStore.set(false);
|
||||
showServerCatalogStore.set(true);
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
<!-- TRACES: UR-037, UR-051, UR-052, UR-068 | DR-042, DR-068, DR-078, DR-119 -->
|
||||
<!-- TRACES: UR-037, UR-051, UR-052, UR-068 | DR-042, DR-068, DR-078, DR-119, DR-292 -->
|
||||
<script lang="ts">
|
||||
import type { MediaItem, Library } from "$lib/api/types";
|
||||
import { truncateMiddle } from "$lib/utils/truncateMiddle";
|
||||
import { downloads } from "$lib/stores/downloads";
|
||||
import { isConnected } from "$lib/stores/connectivity";
|
||||
import { showServerCatalog } from "$lib/services/offlineCatalog";
|
||||
import { auth } from "$lib/stores/auth";
|
||||
import { deviceContentIds } from "$lib/services/downloadedCatalog";
|
||||
import { isServerOnly as computeIsServerOnly } from "$lib/utils/serverOnly";
|
||||
import { queueOfflineDownload } from "$lib/services/queueOfflineDownload";
|
||||
import CachedImage from "$lib/components/common/CachedImage.svelte";
|
||||
import FavoriteButton from "$lib/components/FavoriteButton.svelte";
|
||||
import { favoriteOverrides, resolveIsFavorite } from "$lib/stores/favorites";
|
||||
@@ -143,11 +145,21 @@
|
||||
const isQueued = $derived(downloadInfo?.status === "pending");
|
||||
// Actively transferring (as opposed to merely queued/pending for reconnect).
|
||||
const isActivelyDownloading = $derived(downloadInfo?.status === "downloading");
|
||||
// "Server only" = offline, reveal on, and not already downloaded or actively
|
||||
// transferring. A `pending` (queued-for-reconnect) item stays server-only so
|
||||
// it can show the Queued badge in place of the queue button.
|
||||
// "Server only" = offline, reveal on, and nothing on the device behind this
|
||||
// card — neither its own download nor, for a container, its children's (the
|
||||
// album case: only tracks carry download rows). A `pending`
|
||||
// (queued-for-reconnect) item stays server-only so it can show the Queued
|
||||
// badge in place of the queue button. Shared with the list view; see
|
||||
// $lib/utils/serverOnly.
|
||||
const isServerOnly = $derived(
|
||||
isMediaItem && !$isConnected && $showServerCatalog && !isDownloaded && !isActivelyDownloading,
|
||||
computeIsServerOnly({
|
||||
isMediaItem,
|
||||
isConnected: $isConnected,
|
||||
revealServerCatalog: $showServerCatalog,
|
||||
isDownloaded,
|
||||
isActivelyDownloading,
|
||||
hasDeviceContent: $deviceContentIds.has(item.id),
|
||||
}),
|
||||
);
|
||||
|
||||
// The heart is about an item, so libraries never get one, and a greyed
|
||||
@@ -165,29 +177,13 @@
|
||||
async function queueForDownload(e: Event) {
|
||||
e.stopPropagation();
|
||||
if (!isMediaItem) return;
|
||||
const media = item as MediaItem;
|
||||
const userId = auth.getUserId();
|
||||
if (!userId) {
|
||||
queueError = "Not signed in";
|
||||
return;
|
||||
}
|
||||
try {
|
||||
queueError = null;
|
||||
// Derive a sensible on-disk path; the backend heals stream_url on reconnect.
|
||||
const filePath = `downloads/${media.id}`;
|
||||
await downloads.downloadItem(
|
||||
media.id,
|
||||
userId,
|
||||
filePath,
|
||||
undefined,
|
||||
undefined,
|
||||
media.name,
|
||||
media.artists?.join(", ") ?? undefined,
|
||||
media.albumName ?? undefined,
|
||||
);
|
||||
await queueOfflineDownload(item as MediaItem);
|
||||
} catch (err) {
|
||||
log.error("Failed to queue download:", err);
|
||||
queueError = "Failed to queue";
|
||||
queueError =
|
||||
err instanceof Error && err.message === "Not signed in" ? err.message : "Failed to queue";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { errorAfterLoad, loadErrorMessage } from "./detailLoadError";
|
||||
|
||||
/**
|
||||
* TRACES: UR-062 | DR-297 | UT-267
|
||||
*
|
||||
* Resuming the app after a few minutes in the background replaced a series page
|
||||
* that was on screen with "Failed to load item". The resume reload is a refresh
|
||||
* of content the cache had already answered, yet any throw in it blanked the
|
||||
* page — and no later successful reload of the same item ever cleared it.
|
||||
*/
|
||||
describe("errorAfterLoad", () => {
|
||||
it("clears the error when a load succeeds", () => {
|
||||
expect(errorAfterLoad({ ok: true }, { refreshing: true })).toBeNull();
|
||||
expect(errorAfterLoad({ ok: true }, { refreshing: false })).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps a page on screen when a refresh of it fails", () => {
|
||||
expect(errorAfterLoad({ ok: false, error: "boom" }, { refreshing: true })).toBeNull();
|
||||
});
|
||||
|
||||
it("reports a failure to open an item that is not on screen yet", () => {
|
||||
expect(errorAfterLoad({ ok: false, error: new Error("Offline") }, { refreshing: false })).toBe(
|
||||
"Offline",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadErrorMessage", () => {
|
||||
it("shows the text of a backend error, which arrives as a plain string", () => {
|
||||
expect(loadErrorMessage("Repository not found")).toBe("Repository not found");
|
||||
});
|
||||
|
||||
it("shows an Error's message", () => {
|
||||
expect(loadErrorMessage(new TypeError("x is undefined"))).toBe("x is undefined");
|
||||
});
|
||||
|
||||
it("falls back to a generic message for anything else", () => {
|
||||
expect(loadErrorMessage(undefined)).toBe("Failed to load item");
|
||||
expect(loadErrorMessage({})).toBe("Failed to load item");
|
||||
expect(loadErrorMessage("")).toBe("Failed to load item");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
// What the library detail page's error slot shows after a load.
|
||||
//
|
||||
// Extracted from `/library/[id]/+page.svelte` so it can be unit-tested.
|
||||
|
||||
export type LoadOutcome = { ok: true } | { ok: false; error: unknown };
|
||||
|
||||
/**
|
||||
* The text of whatever a load threw. Backend commands reject with a plain
|
||||
* string, not an `Error`, so reading only `Error.message` replaced every
|
||||
* backend error with the generic fallback and hid what actually failed.
|
||||
*/
|
||||
export function loadErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error && error.message) return error.message;
|
||||
if (typeof error === "string" && error) return error;
|
||||
return "Failed to load item";
|
||||
}
|
||||
|
||||
/**
|
||||
* The error to show once a load has ended.
|
||||
*
|
||||
* A success always clears it — a same-item reload used to leave a stale error
|
||||
* up for good. A failed *refresh* of a page already on screen shows none: the
|
||||
* page is still showing what the cache answered, and a background refresh
|
||||
* (reconnect, resume, filter change) must not blank it. Only failing to open an
|
||||
* item leaves the viewer with nothing to see.
|
||||
*
|
||||
* TRACES: UR-062 | DR-297 | UT-267
|
||||
*/
|
||||
export function errorAfterLoad(
|
||||
outcome: LoadOutcome,
|
||||
options: { refreshing: boolean },
|
||||
): string | null {
|
||||
if (outcome.ok || options.refreshing) return null;
|
||||
return loadErrorMessage(outcome.error);
|
||||
}
|
||||
@@ -1,66 +1,27 @@
|
||||
/**
|
||||
* VideoPlayer scrub regression tests (Android backend path)
|
||||
* VideoPlayer scrub regression tests.
|
||||
*
|
||||
* Reproduces the reported bug: with a sleep timer active, scrubbing the
|
||||
* video seek bar "seeks, then jumps back to the old position".
|
||||
*
|
||||
* Root cause history:
|
||||
* - Native init called onDestroy() after an await -> lifecycle_outside_component
|
||||
* -> the catch treated init as failed and silently flipped useHtml5Element to
|
||||
* true, so seeks went down the HTML5 path while ExoPlayer kept playing.
|
||||
* - The native SurfaceView has never been visible through the webview, so the
|
||||
* INTERIM behavior (until the video-player API refactor) is: when the backend
|
||||
* reports native mode, VideoPlayer deliberately overrides to HTML5 rendering
|
||||
* and stops the native backend (single audio source, webview owns playback).
|
||||
* Root cause history: native init called onDestroy() after an await ->
|
||||
* lifecycle_outside_component -> the catch treated init as failed and silently
|
||||
* switched seeks to the (since deleted) webview `<video>` path while the native
|
||||
* player kept playing.
|
||||
*
|
||||
* These tests pin the interim behavior: Android's native response is
|
||||
* overridden, the backend is stopped exactly once, and scrubbing keeps
|
||||
* working (and holds its position) with a sleep timer active.
|
||||
* These tests pin that scrubbing reaches the backend and holds its position
|
||||
* with a sleep timer active. Every video renderer is native now (DR-235).
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
// ---- Mocks (must precede component import) --------------------------------
|
||||
|
||||
const channelHandlers: Record<string, (event: any) => void> = {};
|
||||
// These tests pin the **flag-off** behaviour: when `experimentalNativeVideo` is
|
||||
// off, VideoPlayer overrides Android's native backend response to HTML5
|
||||
// rendering and stops the native backend. That is the default again (DR-172,
|
||||
// after native video shipped as audio with no picture), so this mock now agrees
|
||||
// with the default rather than opposing it — kept explicit so the tests state
|
||||
// which path they guard instead of inheriting whatever the default happens to be.
|
||||
vi.mock("$lib/stores/nativeVideo", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("$lib/stores/nativeVideo")>();
|
||||
return {
|
||||
...actual,
|
||||
experimentalNativeVideo: {
|
||||
subscribe: (run: (v: boolean) => void) => {
|
||||
run(false);
|
||||
return () => {};
|
||||
},
|
||||
set: () => {},
|
||||
current: () => false,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@tauri-apps/api/event", () => ({
|
||||
listen: vi.fn(async (channel: string, handler: any) => {
|
||||
channelHandlers[channel] = handler;
|
||||
return () => {
|
||||
delete channelHandlers[channel];
|
||||
};
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@tauri-apps/api/core", () => ({
|
||||
invoke: vi.fn(),
|
||||
}));
|
||||
|
||||
const playerPlayItem = vi.fn(async () => ({
|
||||
// What Android reports: native ExoPlayer backend
|
||||
useHtml5Element: false,
|
||||
backend: "exoplayer",
|
||||
state: { kind: "playing" },
|
||||
}));
|
||||
const playerSeekVideo = vi.fn(async (_h: string, position: number) => ({
|
||||
@@ -152,7 +113,7 @@ function sleepTimerTick(remaining = 2) {
|
||||
});
|
||||
}
|
||||
|
||||
async function mountAndroidPlayer() {
|
||||
async function mountPlayer() {
|
||||
const utils = render(VideoPlayer, {
|
||||
props: {
|
||||
media: makeEpisode(),
|
||||
@@ -163,63 +124,41 @@ async function mountAndroidPlayer() {
|
||||
},
|
||||
});
|
||||
|
||||
// Init: backend reports native, component overrides to HTML5 and stops it.
|
||||
await waitFor(() => expect(playerPlayItem).toHaveBeenCalled());
|
||||
await waitFor(() => expect(playerStop).toHaveBeenCalled());
|
||||
|
||||
const slider = utils.container.querySelector('input[type="range"]') as HTMLInputElement;
|
||||
const video = utils.container.querySelector("video") as HTMLVideoElement;
|
||||
expect(slider).not.toBeNull();
|
||||
expect(video).not.toBeNull();
|
||||
return { ...utils, slider, video };
|
||||
return { ...utils, slider };
|
||||
}
|
||||
|
||||
/** Scrub the seek bar to `target` seconds like a user drag. */
|
||||
async function scrubTo(slider: HTMLInputElement, video: HTMLVideoElement, target: number) {
|
||||
async function scrubTo(slider: HTMLInputElement, target: number) {
|
||||
await fireEvent.mouseDown(slider);
|
||||
slider.value = String(target);
|
||||
await fireEvent.input(slider);
|
||||
await fireEvent.change(slider);
|
||||
await fireEvent.mouseUp(slider);
|
||||
// Resolve the "wait for seeked" step of the HTML5 native-seek path.
|
||||
await fireEvent(video, new Event("seeked"));
|
||||
await tick();
|
||||
}
|
||||
|
||||
describe("VideoPlayer scrubbing with active sleep timer (Android)", () => {
|
||||
describe("VideoPlayer scrubbing with active sleep timer", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
for (const key of Object.keys(channelHandlers)) delete channelHandlers[key];
|
||||
sleepTimer.set({ mode: { kind: "off" }, remainingSeconds: 0 });
|
||||
sleepTimerExpiredSignal.set(0);
|
||||
});
|
||||
|
||||
it("overrides the native backend response to HTML5 rendering and stops the backend once", async () => {
|
||||
await mountAndroidPlayer();
|
||||
// The native backend must be stopped so it doesn't play audio behind the
|
||||
// webview (frozen picture + double audio source).
|
||||
expect(playerStop).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it("scrubbing without a timer seeks through the backend and keeps the new position", async () => {
|
||||
const { slider } = await mountPlayer();
|
||||
|
||||
it("scrubbing without a timer seeks via the HTML5 path and keeps the new position", async () => {
|
||||
const { slider, video } = await mountAndroidPlayer();
|
||||
await scrubTo(slider, 600);
|
||||
|
||||
await scrubTo(slider, video, 600);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(playerSeekVideo).toHaveBeenCalledWith(
|
||||
"repo-1",
|
||||
600,
|
||||
"src-1",
|
||||
null,
|
||||
true, // HTML5 path: the webview owns playback after the override
|
||||
),
|
||||
);
|
||||
await waitFor(() => expect(playerSeekVideo).toHaveBeenCalledWith("repo-1", 600, "src-1", null));
|
||||
expect(parseFloat(slider.value)).toBeCloseTo(600);
|
||||
});
|
||||
|
||||
it("scrubbing still works (and holds position) after enabling an episodes sleep timer", async () => {
|
||||
const { slider, video } = await mountAndroidPlayer();
|
||||
const { slider } = await mountPlayer();
|
||||
|
||||
// Enable "2 more episodes" timer; backend then ticks every second.
|
||||
sleepTimerTick(2);
|
||||
@@ -227,7 +166,7 @@ describe("VideoPlayer scrubbing with active sleep timer (Android)", () => {
|
||||
sleepTimerTick(2);
|
||||
await tick();
|
||||
|
||||
await scrubTo(slider, video, 600);
|
||||
await scrubTo(slider, 600);
|
||||
await waitFor(() => expect(playerSeekVideo).toHaveBeenCalledTimes(1));
|
||||
expect(parseFloat(slider.value)).toBeCloseTo(600);
|
||||
|
||||
@@ -237,13 +176,13 @@ describe("VideoPlayer scrubbing with active sleep timer (Android)", () => {
|
||||
expect(parseFloat(slider.value)).toBeCloseTo(600);
|
||||
|
||||
// A second scrub must also work.
|
||||
await scrubTo(slider, video, 900);
|
||||
await scrubTo(slider, 900);
|
||||
await waitFor(() => expect(playerSeekVideo).toHaveBeenCalledTimes(2));
|
||||
expect(parseFloat(slider.value)).toBeCloseTo(900);
|
||||
});
|
||||
|
||||
it("sleep-timer ticks alone never move the seek bar", async () => {
|
||||
const { slider } = await mountAndroidPlayer();
|
||||
const { slider } = await mountPlayer();
|
||||
|
||||
const before = slider.value;
|
||||
for (let i = 0; i < 5; i++) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -21,8 +21,9 @@
|
||||
* rather than internals.
|
||||
*
|
||||
* The specific traps encoded here, each a bug that shipped:
|
||||
* - pausing renders a full-screen <button> play overlay OVER the video, so the
|
||||
* second tap of a double tap lands on a button, not the video;
|
||||
* - pausing renders a full-screen <button> play overlay OVER the video
|
||||
* surface, so the second tap of a double tap lands on a button, not the
|
||||
* surface;
|
||||
* - the browser synthesizes a `click` after a touch tap, which must not toggle
|
||||
* a second time, on ANY layered target;
|
||||
* - the bottom controls bar must drive its own buttons and NOT the container's
|
||||
@@ -35,6 +36,7 @@ import { tick } from "svelte";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import VideoPlayer from "./VideoPlayer.svelte";
|
||||
import { SEEK_FORWARD_SECONDS } from "./tapGestures";
|
||||
import { player } from "$lib/stores/player";
|
||||
|
||||
/**
|
||||
* A `StreamSelection` for tests that only care about the URL. Transcoded HLS is
|
||||
@@ -94,18 +96,10 @@ vi.mock("$lib/player/adapters/rustReportHost", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("$lib/player/html5Adapter", () => ({
|
||||
reportState: vi.fn(),
|
||||
reportPosition: vi.fn(),
|
||||
reportMediaLoaded: vi.fn(),
|
||||
resetReporting: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("$lib/utils/pictureInPicture", () => ({
|
||||
isPipSupported: () => false,
|
||||
enterPip: vi.fn(),
|
||||
setAutoEnterEnabled: vi.fn(),
|
||||
setHtml5VideoState: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("$lib/stores/auth", () => ({
|
||||
@@ -143,9 +137,22 @@ function renderPlayer() {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The transparent area the native picture shows through — the video surface.
|
||||
* The first `[data-player-surface]`; the play overlay, when raised, is another.
|
||||
*/
|
||||
function videoSurface(container: HTMLElement): Element {
|
||||
const surface = container.querySelector("[data-player-surface]");
|
||||
expect(surface).toBeTruthy();
|
||||
return surface!;
|
||||
}
|
||||
|
||||
describe("VideoPlayer tap surface (real component)", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Playing, as the backend would report it, so no play overlay covers the
|
||||
// surface to begin with.
|
||||
player.setPlaying(MEDIA, 0, 600);
|
||||
// This file deliberately does NOT mock `$lib/api/bindings` — it renders the
|
||||
// real component against the real bindings, which bottom out in the globally
|
||||
// mocked `invoke`. That mock resolves `undefined` for every command, so the
|
||||
@@ -167,17 +174,17 @@ describe("VideoPlayer tap surface (real component)", () => {
|
||||
|
||||
it("a single tap on the video toggles play/pause exactly once", async () => {
|
||||
const { container } = renderPlayer();
|
||||
const video = container.querySelector("video");
|
||||
expect(video).toBeTruthy();
|
||||
await tick();
|
||||
|
||||
touchAt(video!, 900);
|
||||
touchAt(videoSurface(container), 900);
|
||||
|
||||
expect(toggleSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("the synthesized click after a tap does not toggle a second time", async () => {
|
||||
const { container } = renderPlayer();
|
||||
const video = container.querySelector("video")!;
|
||||
await tick();
|
||||
const video = videoSurface(container);
|
||||
|
||||
touchAt(video, 900);
|
||||
// The compatibility click the browser fires after a touch tap. detail=0 is
|
||||
@@ -195,21 +202,21 @@ describe("VideoPlayer tap surface (real component)", () => {
|
||||
// guard that does not know about that overlay discards it and seeking dies.
|
||||
//
|
||||
// Reproducing it requires the overlay to actually render, which means
|
||||
// driving `isPlaying` the way the real element does: via its `pause` event.
|
||||
// driving `isPlaying` the way production does: the player reports paused.
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const { container } = renderPlayer();
|
||||
const video = container.querySelector("video")!;
|
||||
await tick();
|
||||
|
||||
// Tap 1 on the video.
|
||||
touchAt(video, 900);
|
||||
// Tap 1 on the video surface.
|
||||
touchAt(videoSurface(container), 900);
|
||||
|
||||
// The element reports it paused → isPlaying=false → overlay renders.
|
||||
video.dispatchEvent(new Event("pause"));
|
||||
// The player reports it paused → isPlaying=false → overlay renders.
|
||||
player.setPaused(MEDIA, 0, 600);
|
||||
await Promise.resolve();
|
||||
await tick();
|
||||
|
||||
const overlay = container.querySelector("[data-player-surface]");
|
||||
const overlay = container.querySelector('[data-testid="play-overlay"]');
|
||||
expect(overlay, "the play overlay should be covering the video").toBeTruthy();
|
||||
|
||||
vi.advanceTimersByTime(120); // inside DOUBLE_TAP_WINDOW_MS
|
||||
|
||||
@@ -25,44 +25,11 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
// ---- Mocks (must precede component import) --------------------------------
|
||||
|
||||
const channelHandlers: Record<string, (event: any) => void> = {};
|
||||
// These tests pin the **flag-off** behaviour: when `experimentalNativeVideo` is
|
||||
// off, VideoPlayer overrides Android's native backend response to HTML5
|
||||
// rendering and stops the native backend. That is the default again (DR-172,
|
||||
// after native video shipped as audio with no picture), so this mock now agrees
|
||||
// with the default rather than opposing it — kept explicit so the tests state
|
||||
// which path they guard instead of inheriting whatever the default happens to be.
|
||||
vi.mock("$lib/stores/nativeVideo", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("$lib/stores/nativeVideo")>();
|
||||
return {
|
||||
...actual,
|
||||
experimentalNativeVideo: {
|
||||
subscribe: (run: (v: boolean) => void) => {
|
||||
run(false);
|
||||
return () => {};
|
||||
},
|
||||
set: () => {},
|
||||
current: () => false,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@tauri-apps/api/event", () => ({
|
||||
listen: vi.fn(async (channel: string, handler: any) => {
|
||||
channelHandlers[channel] = handler;
|
||||
return () => {
|
||||
delete channelHandlers[channel];
|
||||
};
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@tauri-apps/api/core", () => ({
|
||||
invoke: vi.fn(),
|
||||
}));
|
||||
|
||||
const playerPlayItem = vi.fn(async () => ({
|
||||
useHtml5Element: false,
|
||||
backend: "exoplayer",
|
||||
state: { kind: "playing" },
|
||||
}));
|
||||
const playerSeekVideo = vi.fn(async (_h: string, position: number) => ({
|
||||
@@ -154,12 +121,10 @@ async function mountAndroidPlayer() {
|
||||
});
|
||||
|
||||
await waitFor(() => expect(playerPlayItem).toHaveBeenCalled());
|
||||
await waitFor(() => expect(playerStop).toHaveBeenCalled());
|
||||
|
||||
const slider = utils.container.querySelector('input[type="range"]') as HTMLInputElement;
|
||||
const video = utils.container.querySelector("video") as HTMLVideoElement;
|
||||
expect(slider).not.toBeNull();
|
||||
return { ...utils, slider, video };
|
||||
return { ...utils, slider };
|
||||
}
|
||||
|
||||
function touch(x: number, y: number) {
|
||||
@@ -172,7 +137,7 @@ function touch(x: number, y: number) {
|
||||
* A real drag along the bar moves the finger far enough that the container's
|
||||
* swipe detector (50px) would trigger if it were still listening.
|
||||
*/
|
||||
async function touchScrubTo(slider: HTMLInputElement, video: HTMLVideoElement, target: number) {
|
||||
async function touchScrubTo(slider: HTMLInputElement, target: number) {
|
||||
await fireEvent.touchStart(slider, { touches: [touch(100, 700)] });
|
||||
// Finger travels across the bar. Small vertical wander is normal for a thumb
|
||||
// drag; the horizontal travel is what matters.
|
||||
@@ -182,31 +147,27 @@ async function touchScrubTo(slider: HTMLInputElement, video: HTMLVideoElement, t
|
||||
await fireEvent.touchMove(slider, { touches: [touch(700, 705)] });
|
||||
await fireEvent.change(slider);
|
||||
await fireEvent.touchEnd(slider, { touches: [] });
|
||||
if (video) await fireEvent(video, new Event("seeked"));
|
||||
await tick();
|
||||
}
|
||||
|
||||
describe("VideoPlayer seek bar — touch drag (Android)", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
for (const key of Object.keys(channelHandlers)) delete channelHandlers[key];
|
||||
});
|
||||
|
||||
it("a touch drag on the seek bar seeks to the dragged position", async () => {
|
||||
const { slider, video } = await mountAndroidPlayer();
|
||||
const { slider } = await mountAndroidPlayer();
|
||||
|
||||
await touchScrubTo(slider, video, 600);
|
||||
await touchScrubTo(slider, 600);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(playerSeekVideo).toHaveBeenCalledWith("repo-1", 600, "src-1", null, true),
|
||||
);
|
||||
await waitFor(() => expect(playerSeekVideo).toHaveBeenCalledWith("repo-1", 600, "src-1", null));
|
||||
expect(parseFloat(slider.value)).toBeCloseTo(600);
|
||||
});
|
||||
|
||||
it("a touch drag on the seek bar never toggles play/pause", async () => {
|
||||
const { slider, video } = await mountAndroidPlayer();
|
||||
const { slider } = await mountAndroidPlayer();
|
||||
|
||||
await touchScrubTo(slider, video, 600);
|
||||
await touchScrubTo(slider, 600);
|
||||
|
||||
// The container gesture layer must stay out of a control drag entirely:
|
||||
// no swipe mis-read, so no play/pause correction.
|
||||
@@ -214,7 +175,7 @@ describe("VideoPlayer seek bar — touch drag (Android)", () => {
|
||||
});
|
||||
|
||||
it("commits the seek on touchend even when the engine never fires `change`", async () => {
|
||||
const { slider, video } = await mountAndroidPlayer();
|
||||
const { slider } = await mountAndroidPlayer();
|
||||
|
||||
// Android's WebView does not reliably fire `change` for a touch interaction
|
||||
// on a range input. A tap on the track still moves the thumb and fires
|
||||
@@ -223,32 +184,29 @@ describe("VideoPlayer seek bar — touch drag (Android)", () => {
|
||||
slider.value = "600";
|
||||
await fireEvent.input(slider);
|
||||
await fireEvent.touchEnd(slider, { touches: [] });
|
||||
if (video) await fireEvent(video, new Event("seeked"));
|
||||
await tick();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(playerSeekVideo).toHaveBeenCalledWith("repo-1", 600, "src-1", null, true),
|
||||
);
|
||||
await waitFor(() => expect(playerSeekVideo).toHaveBeenCalledWith("repo-1", 600, "src-1", null));
|
||||
});
|
||||
|
||||
it("commits the seek exactly once when both touchend and change fire", async () => {
|
||||
const { slider, video } = await mountAndroidPlayer();
|
||||
const { slider } = await mountAndroidPlayer();
|
||||
|
||||
await touchScrubTo(slider, video, 600);
|
||||
await touchScrubTo(slider, 600);
|
||||
|
||||
expect(playerSeekVideo).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("a touch drag on the seek bar does not hijack into brightness control", async () => {
|
||||
const { slider, video, container } = await mountAndroidPlayer();
|
||||
const { slider, container } = await mountAndroidPlayer();
|
||||
|
||||
await touchScrubTo(slider, video, 600);
|
||||
|
||||
// Brightness is applied as a CSS filter on the <video>; a control drag must
|
||||
// leave it untouched.
|
||||
const el = container.querySelector("video") as HTMLVideoElement | null;
|
||||
if (el) {
|
||||
expect(el.style.filter).toBe("brightness(1)");
|
||||
}
|
||||
// Mid-drag, with the finger still down: a mis-read swipe raises the
|
||||
// brightness indicator for as long as the swipe lasts.
|
||||
await fireEvent.touchStart(slider, { touches: [touch(100, 700)] });
|
||||
await fireEvent.touchMove(slider, { touches: [touch(400, 600)] });
|
||||
await fireEvent.touchMove(slider, { touches: [touch(700, 500)] });
|
||||
await tick();
|
||||
expect(container.textContent).not.toContain("Brightness");
|
||||
await fireEvent.touchEnd(slider, { touches: [] });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -97,9 +97,8 @@ describe("backgroundAudioHandoff", () => {
|
||||
//
|
||||
// TRACES: UR-040, UR-003 | DR-196
|
||||
describe("planHandoffReturn", () => {
|
||||
it("restarts the native backend when the native path is rendering", () => {
|
||||
it("restarts the native backend", () => {
|
||||
const plan = planHandoffReturn({
|
||||
useHtml5Element: false,
|
||||
position: 4214,
|
||||
wasPlaying: true,
|
||||
nativeStateKind: "playing",
|
||||
@@ -109,19 +108,8 @@ describe("backgroundAudioHandoff", () => {
|
||||
expect(plan.shouldPlay).toBe(true);
|
||||
});
|
||||
|
||||
it("reloads the webview element when HTML5 is rendering", () => {
|
||||
const plan = planHandoffReturn({
|
||||
useHtml5Element: true,
|
||||
position: 120,
|
||||
wasPlaying: true,
|
||||
nativeStateKind: "playing",
|
||||
});
|
||||
expect(plan.target).toBe("html5-element");
|
||||
});
|
||||
|
||||
it("honours a lockscreen pause over the handoff snapshot", () => {
|
||||
const plan = planHandoffReturn({
|
||||
useHtml5Element: false,
|
||||
position: 300,
|
||||
wasPlaying: true,
|
||||
nativeStateKind: "paused",
|
||||
@@ -131,13 +119,54 @@ describe("backgroundAudioHandoff", () => {
|
||||
|
||||
it("never returns a negative resume position", () => {
|
||||
const plan = planHandoffReturn({
|
||||
useHtml5Element: false,
|
||||
position: -3,
|
||||
wasPlaying: false,
|
||||
nativeStateKind: undefined,
|
||||
});
|
||||
expect(plan.position).toBe(0);
|
||||
});
|
||||
|
||||
// An episode that ends while backgrounded advances in the backend. Reloading
|
||||
// the video the player was mounted with brought back the PREVIOUS episode,
|
||||
// at the new episode's timestamp.
|
||||
//
|
||||
// TRACES: UR-040, UR-023 | DR-296 | UT-265
|
||||
it("switches to the item the backend advanced to", () => {
|
||||
const plan = planHandoffReturn({
|
||||
position: 95,
|
||||
wasPlaying: true,
|
||||
nativeStateKind: "playing",
|
||||
mountedItemId: "ep1",
|
||||
resumeItemId: "ep2",
|
||||
});
|
||||
expect(plan.target).toBe("other-item");
|
||||
expect(plan.itemId).toBe("ep2");
|
||||
expect(plan.position).toBe(95);
|
||||
});
|
||||
|
||||
it("reloads in place when the backend is still on the mounted item", () => {
|
||||
const plan = planHandoffReturn({
|
||||
position: 95,
|
||||
wasPlaying: true,
|
||||
nativeStateKind: "playing",
|
||||
mountedItemId: "ep1",
|
||||
resumeItemId: "ep1",
|
||||
});
|
||||
expect(plan.target).toBe("native-backend");
|
||||
});
|
||||
|
||||
it("reloads in place when the backend reports no item", () => {
|
||||
// Queue emptied while backgrounded (e.g. the sleep timer): there is no
|
||||
// other item to go to, so the mounted one is the best we have.
|
||||
const plan = planHandoffReturn({
|
||||
position: 95,
|
||||
wasPlaying: false,
|
||||
nativeStateKind: undefined,
|
||||
mountedItemId: "ep1",
|
||||
resumeItemId: null,
|
||||
});
|
||||
expect(plan.target).toBe("native-backend");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -77,8 +77,14 @@ export function shouldResumeOnForeground(
|
||||
|
||||
/** What has to be restarted to put picture back on screen, and how. */
|
||||
export interface HandoffReturn {
|
||||
/** Which renderer must be brought back. */
|
||||
target: "html5-element" | "native-backend";
|
||||
/**
|
||||
* Which renderer must be brought back — or `other-item` when the backend is
|
||||
* no longer on the item this player was mounted with, so the player must
|
||||
* switch to `itemId` instead of reloading itself.
|
||||
*/
|
||||
target: "native-backend" | "other-item";
|
||||
/** The item to switch to; set only for `other-item`. */
|
||||
itemId?: string;
|
||||
/** Absolute position the background audio reached. */
|
||||
position: number;
|
||||
/** Whether playback should be running once it is back. */
|
||||
@@ -88,38 +94,41 @@ export interface HandoffReturn {
|
||||
/**
|
||||
* How to come back when the app returns to the foreground.
|
||||
*
|
||||
* The two render paths resume by completely different means, and conflating
|
||||
* them is what broke the native one:
|
||||
* The native backend owns no element, and nothing reacts to the stream URL on
|
||||
* its behalf. Native playback is only ever started by an explicit backend load,
|
||||
* which the component issues once, from `onMount`. So the return has to
|
||||
* re-issue it; reassigning the URL restarts nothing.
|
||||
*
|
||||
* - **html5-element** — assigning the stream URL is enough. An `$effect` in the
|
||||
* component watches it, (re)initialises HLS or sets `videoElement.src`, and
|
||||
* `canplay` then drives the seek and play.
|
||||
* - **native-backend** — ExoPlayer owns no element, and nothing reacts to the
|
||||
* stream URL on its behalf. Native playback is only ever started by an
|
||||
* explicit backend load, which the component issues once, from `onMount`. So
|
||||
* the return has to re-issue it; reassigning the URL restarts nothing.
|
||||
*
|
||||
* The component previously did only the URL assignment, for both paths. On the
|
||||
* native path that left the backend holding no item at all: a black screen with
|
||||
* The component once did only the URL assignment — which is how the deleted
|
||||
* webview `<video>` path came back. On the native path that left the backend
|
||||
* holding no item at all: a black screen with
|
||||
* a play overlay, a play button that did nothing, and the position pinned at
|
||||
* 0:00 — the handoff's own audio player having been stopped on the way out.
|
||||
*
|
||||
* `shouldPlay` folds in [shouldResumeOnForeground], so a lockscreen pause during
|
||||
* the handoff still wins over the snapshot taken on the way out.
|
||||
*
|
||||
* TRACES: UR-040, UR-003 | DR-196 | UT-060
|
||||
* An episode that ends while backgrounded advances in the backend, so the item
|
||||
* the native player returns on (`resumeItemId`) can differ from the one this
|
||||
* player was mounted with. Reloading the mounted one brought back the previous
|
||||
* episode at the new one's timestamp; in that case the plan is `other-item`.
|
||||
* A missing `resumeItemId` (queue emptied) keeps the in-place reload.
|
||||
*
|
||||
* TRACES: UR-040, UR-003, UR-023 | DR-196, DR-296 | UT-060, UT-265
|
||||
*/
|
||||
export function planHandoffReturn(opts: {
|
||||
useHtml5Element: boolean;
|
||||
position: number;
|
||||
wasPlaying: boolean;
|
||||
nativeStateKind: string | undefined;
|
||||
mountedItemId?: string | null;
|
||||
resumeItemId?: string | null;
|
||||
}): HandoffReturn {
|
||||
return {
|
||||
target: opts.useHtml5Element ? "html5-element" : "native-backend",
|
||||
position: opts.position > 0 ? opts.position : 0,
|
||||
shouldPlay: shouldResumeOnForeground(opts.wasPlaying, opts.nativeStateKind),
|
||||
};
|
||||
const position = opts.position > 0 ? opts.position : 0;
|
||||
const shouldPlay = shouldResumeOnForeground(opts.wasPlaying, opts.nativeStateKind);
|
||||
if (opts.resumeItemId && opts.resumeItemId !== opts.mountedItemId) {
|
||||
return { target: "other-item", itemId: opts.resumeItemId, position, shouldPlay };
|
||||
}
|
||||
return { target: "native-backend", position, shouldPlay };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,14 +2,9 @@ import { describe, it, expect } from "vitest";
|
||||
import { planFullscreen } from "./fullscreenTarget";
|
||||
|
||||
describe("planFullscreen", () => {
|
||||
it("fullscreens only the document when an in-document <video> renders", () => {
|
||||
// Unchanged behaviour: WebKit scales the element, the window need not move.
|
||||
expect(planFullscreen(false)).toEqual({ document: true, osWindow: false });
|
||||
});
|
||||
|
||||
it("also fullscreens the OS window when a native surface renders", () => {
|
||||
it("fullscreens the OS window as well as the document", () => {
|
||||
// The picture is drawn behind the webview at window size, so a
|
||||
// document-only fullscreen leaves it at the old size.
|
||||
expect(planFullscreen(true)).toEqual({ document: true, osWindow: true });
|
||||
expect(planFullscreen()).toEqual({ document: true, osWindow: true });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,9 +27,9 @@ export interface FullscreenPlan {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param rendersNatively true when a native surface (mpv/ExoPlayer) draws the
|
||||
* picture rather than an in-document `<video>` element.
|
||||
* Every video renderer is a native surface since the webview `<video>` path was
|
||||
* deleted (DR-235), so the OS window always has to move with the document.
|
||||
*/
|
||||
export function planFullscreen(rendersNatively: boolean): FullscreenPlan {
|
||||
return { document: true, osWindow: rendersNatively };
|
||||
export function planFullscreen(): FullscreenPlan {
|
||||
return { document: true, osWindow: true };
|
||||
}
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { fatalNetworkErrorAction } from "./hlsRecovery";
|
||||
|
||||
/**
|
||||
* A fatal hls.js network error mid-film must be retried, not reported as the
|
||||
* end of the stream — reporting "ended" hands control to autoplay and skips to
|
||||
* the next item while the user is still watching this one.
|
||||
*
|
||||
* The position the player displays is *already absolute*: the RAF loop sets
|
||||
* `currentTime = seekOffset + element.currentTime`. Anything that adds the
|
||||
* offset a second time doubles the apparent position, and after a quality
|
||||
* switch or a transcoded seek the offset is the whole resume position — so past
|
||||
* roughly the halfway mark the doubled value clears the near-end threshold and
|
||||
* every transient error is misread as the end.
|
||||
*
|
||||
* TRACES: UR-004, UR-074 | DR-177 | UT-174
|
||||
*/
|
||||
describe("fatalNetworkErrorAction", () => {
|
||||
it("retries a mid-film failure after a quality switch instead of ending playback", () => {
|
||||
// 90-minute film, quality switched at the 50-minute mark: the reloaded
|
||||
// stream's timeline starts at 0, so seekOffset carries the 50 minutes and
|
||||
// the displayed position — already absolute — is 3000s of 5400s, 56%
|
||||
// through and nowhere near the end.
|
||||
const action = fatalNetworkErrorAction({
|
||||
positionSeconds: 3000,
|
||||
knownDurationSeconds: 5400,
|
||||
attempts: 1,
|
||||
});
|
||||
|
||||
expect(action).toBe("retry");
|
||||
});
|
||||
|
||||
it("treats a failure in the last tenth of the stream as the end", () => {
|
||||
// Jellyfin's transcoded HLS does not always emit #EXT-X-ENDLIST, so a
|
||||
// genuine end-of-stream arrives as a fatal network error.
|
||||
const action = fatalNetworkErrorAction({
|
||||
positionSeconds: 5300,
|
||||
knownDurationSeconds: 5400,
|
||||
attempts: 1,
|
||||
});
|
||||
|
||||
expect(action).toBe("ended");
|
||||
});
|
||||
|
||||
it("stops retrying once the recovery budget is spent", () => {
|
||||
const action = fatalNetworkErrorAction({
|
||||
positionSeconds: 60,
|
||||
knownDurationSeconds: 5400,
|
||||
attempts: 4,
|
||||
});
|
||||
|
||||
expect(action).toBe("giveUp");
|
||||
});
|
||||
|
||||
it("retries when the runtime is not known yet", () => {
|
||||
const action = fatalNetworkErrorAction({
|
||||
positionSeconds: 120,
|
||||
knownDurationSeconds: 0,
|
||||
attempts: 1,
|
||||
});
|
||||
|
||||
expect(action).toBe("retry");
|
||||
});
|
||||
});
|
||||
@@ -1,52 +0,0 @@
|
||||
/**
|
||||
* What to do about a *fatal* hls.js network error.
|
||||
*
|
||||
* Jellyfin's transcoded HLS streams do not always terminate with an
|
||||
* `#EXT-X-ENDLIST`, so a stream that has simply run out looks identical to one
|
||||
* that broke: both arrive as a fatal network error. The only thing separating
|
||||
* them is how far playback had got, which is why this decision is worth
|
||||
* isolating from the player component — read the position wrong and a
|
||||
* recoverable stall turns into a skip to the next item.
|
||||
*
|
||||
* TRACES: UR-004, UR-074 | DR-177 | UT-174
|
||||
*/
|
||||
|
||||
/** Fraction of the runtime past which a fatal error reads as "the stream ended". */
|
||||
const NEAR_END_FRACTION = 0.9;
|
||||
|
||||
/** How many times to ask hls.js to resume before giving up on the stream. */
|
||||
export const MAX_FATAL_NETWORK_RECOVERIES = 3;
|
||||
|
||||
export type FatalNetworkErrorAction = "ended" | "retry" | "giveUp";
|
||||
|
||||
export interface FatalNetworkErrorInput {
|
||||
/**
|
||||
* Absolute position in the media, in seconds — the value the player displays.
|
||||
*
|
||||
* It is already absolute (`seekOffset + element.currentTime`): do NOT add the
|
||||
* transcode seek offset again. After a quality switch or a transcoded seek the
|
||||
* offset *is* the resume position, so double-counting it puts an apparent
|
||||
* position past the near-end threshold from roughly halfway through, and every
|
||||
* transient error then ends playback.
|
||||
*/
|
||||
positionSeconds: number;
|
||||
/** Known runtime in seconds; 0 or negative when the runtime isn't known yet. */
|
||||
knownDurationSeconds: number;
|
||||
/** Recovery attempts already made against this hls.js instance. */
|
||||
attempts: number;
|
||||
}
|
||||
|
||||
/** Whether a failure at this position should be read as the stream ending. */
|
||||
export function isNearEndOfStream(positionSeconds: number, knownDurationSeconds: number): boolean {
|
||||
if (knownDurationSeconds <= 0 || positionSeconds <= 0) return false;
|
||||
return positionSeconds / knownDurationSeconds > NEAR_END_FRACTION;
|
||||
}
|
||||
|
||||
export function fatalNetworkErrorAction({
|
||||
positionSeconds,
|
||||
knownDurationSeconds,
|
||||
attempts,
|
||||
}: FatalNetworkErrorInput): FatalNetworkErrorAction {
|
||||
if (isNearEndOfStream(positionSeconds, knownDurationSeconds)) return "ended";
|
||||
return attempts <= MAX_FATAL_NETWORK_RECOVERIES ? "retry" : "giveUp";
|
||||
}
|
||||
@@ -5,30 +5,19 @@ import {
|
||||
subtitleStreamsOf,
|
||||
subtitleTrackLabel,
|
||||
resolveSubtitleTracks,
|
||||
reconcileSelectedSubtitle,
|
||||
videoCrossOriginMode,
|
||||
nativeSubtitleTracks,
|
||||
nativeSubtitleArrayIndex,
|
||||
type SubtitleStreamLike,
|
||||
} from "./subtitleTracks";
|
||||
|
||||
/**
|
||||
* Subtitles on the Linux / WebKitGTK HTML5 `<video>` path.
|
||||
* Subtitle resolution — the list the play request carries.
|
||||
*
|
||||
* TRACES: UR-020 | DR-023 | UT-143, UT-144
|
||||
*
|
||||
* The bug this guards: VideoPlayer rendered no `<track>` children at all (the
|
||||
* block was commented out "to debug playback issues"), so
|
||||
* `Html5PlayerAdapter.selectSubtitle()` walked an empty `textTracks` list and
|
||||
* the subtitle menu was inert on Linux. The reason it had to be disabled is
|
||||
* visible in the original markup — `src={getSubtitleUrl(track.index)}` bound the
|
||||
* *Promise* returned by an async function to the attribute, so every track's src
|
||||
* stringified to "[object Promise]", an unloadable resource hanging off the
|
||||
* media element.
|
||||
*
|
||||
* So the fix has two halves and both are tested here: URLs must be resolved into
|
||||
* plain strings *before* they reach the markup, and the markup must actually
|
||||
* render the tracks (with the `data-stream-index` the adapter matches on).
|
||||
* URLs are resolved into plain strings before they reach the player: the
|
||||
* original markup bound the *Promise* returned by an async function to a
|
||||
* `<track src>`, so every track's src stringified to "[object Promise]".
|
||||
*/
|
||||
|
||||
const SUBS: SubtitleStreamLike[] = [
|
||||
@@ -177,56 +166,6 @@ describe("resolveSubtitleTracks", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("reconcileSelectedSubtitle", () => {
|
||||
it("starts off (null) and keeps 'off' selectable", async () => {
|
||||
const tracks = await resolveSubtitleTracks(SUBS, async (i) => url(i));
|
||||
expect(reconcileSelectedSubtitle(tracks, null)).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps a selection that is still renderable", async () => {
|
||||
const tracks = await resolveSubtitleTracks(SUBS, async (i) => url(i));
|
||||
expect(reconcileSelectedSubtitle(tracks, 3)).toBe(3);
|
||||
});
|
||||
|
||||
it("falls back to off when the selected track is gone (new item / failed URL)", async () => {
|
||||
const tracks = await resolveSubtitleTracks(SUBS, async (i) => url(i));
|
||||
expect(reconcileSelectedSubtitle(tracks, 9)).toBeNull();
|
||||
expect(reconcileSelectedSubtitle([], 3)).toBeNull();
|
||||
});
|
||||
|
||||
it("never auto-selects the server's default track", async () => {
|
||||
// The menu opens on "Off" and a <track default> would auto-show, so the UI
|
||||
// would claim subtitles are off while they are burned over the picture.
|
||||
const tracks = await resolveSubtitleTracks(SUBS, async (i) => url(i));
|
||||
expect(tracks[0].isDefault).toBe(true);
|
||||
expect(reconcileSelectedSubtitle(tracks, null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("videoCrossOriginMode", () => {
|
||||
it("opts into CORS for a server stream that has subtitles", () => {
|
||||
expect(videoCrossOriginMode("http://jelly.example/Videos/x/master.m3u8", 2)).toBe("anonymous");
|
||||
expect(videoCrossOriginMode("https://jelly.example/Videos/x/stream.mp4", 1)).toBe("anonymous");
|
||||
});
|
||||
|
||||
it("leaves a local/offline source alone so playback cannot regress", () => {
|
||||
expect(videoCrossOriginMode("asset://localhost/movie.mkv", 2)).toBeUndefined();
|
||||
expect(videoCrossOriginMode("file:///home/u/movie.mkv", 2)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("stays out of the way when there is nothing to load", () => {
|
||||
expect(videoCrossOriginMode("http://jelly.example/x.m3u8", 0)).toBeUndefined();
|
||||
expect(videoCrossOriginMode("", 0)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("is decided by inputs known at first render, so it cannot flip mid-load", () => {
|
||||
// Same answer before and after the async URL resolution completes.
|
||||
const before = videoCrossOriginMode("http://jelly.example/x.m3u8", SUBS.length);
|
||||
const after = videoCrossOriginMode("http://jelly.example/x.m3u8", SUBS.length);
|
||||
expect(before).toBe(after);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Subtitles on the Android / ExoPlayer native path.
|
||||
*
|
||||
@@ -320,31 +259,6 @@ describe("nativeSubtitleArrayIndex", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("VideoPlayer markup (the regression that made the menu inert)", () => {
|
||||
const source = readFileSync(resolve(__dirname, "VideoPlayer.svelte"), "utf-8");
|
||||
|
||||
it("renders <track> elements instead of leaving them commented out", () => {
|
||||
expect(source).not.toContain("Temporarily disabled to debug playback issues");
|
||||
expect(source).toMatch(/<track\b/);
|
||||
expect(source).toContain('kind="subtitles"');
|
||||
});
|
||||
|
||||
/** The rendered element, not a `<track>` mentioned in prose. */
|
||||
const trackElement = source.slice(source.search(/<track\s/), source.search(/<track\s/) + 400);
|
||||
|
||||
it("keeps data-stream-index — Html5PlayerAdapter.selectSubtitle matches on it", () => {
|
||||
expect(trackElement).toContain("data-stream-index");
|
||||
});
|
||||
|
||||
it("never binds the async getSubtitleUrl() Promise to src", () => {
|
||||
expect(source).not.toMatch(/src=\{\s*getSubtitleUrl\(/);
|
||||
});
|
||||
|
||||
it("does not mark any track default (a default track auto-shows)", () => {
|
||||
expect(trackElement).not.toMatch(/\bdefault=/);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The half of the Android fix that lives in the component: the resolved list has
|
||||
* to actually be handed to `playerPlayItem`, and the index sent to the backend
|
||||
|
||||
@@ -125,72 +125,13 @@ export async function resolveSubtitleTracks(
|
||||
return resolved.filter((t): t is RenderableSubtitleTrack => t !== null);
|
||||
}
|
||||
|
||||
/**
|
||||
* The selection to keep once the rendered track list changes.
|
||||
*
|
||||
* Subtitles are OFF unless the user turns them on: `null` in, `null` out. The
|
||||
* server's `isDefault` flag is deliberately NOT promoted to a selection (and the
|
||||
* markup deliberately omits the `default` attribute, which would auto-show the
|
||||
* track) — the menu opens on "Off", so auto-enabling would make the UI lie about
|
||||
* what is on screen, and it would change behaviour for every user who has never
|
||||
* asked for subtitles.
|
||||
*
|
||||
* A selection that is no longer renderable (new item, or a URL that failed to
|
||||
* resolve) collapses to off, so the menu's checkmark can never point at a track
|
||||
* that does not exist on the element.
|
||||
*/
|
||||
export function reconcileSelectedSubtitle(
|
||||
tracks: readonly RenderableSubtitleTrack[],
|
||||
selected: number | null,
|
||||
): number | null {
|
||||
if (selected === null) return null;
|
||||
return tracks.some((t) => t.streamIndex === selected) ? selected : null;
|
||||
}
|
||||
|
||||
function originOf(url: string): string | null {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null;
|
||||
return parsed.origin;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The `crossorigin` value for the `<video>` element, or undefined for none.
|
||||
*
|
||||
* Text-track fetches are CORS-enabled per the HTML spec and use the *media
|
||||
* element's* CORS setting, so a cross-origin `<track>` never loads unless the
|
||||
* element opts in. The webview page's origin is `tauri://localhost`, so every
|
||||
* subtitle served by Jellyfin is cross-origin.
|
||||
*
|
||||
* Opting in is only safe when the media itself comes from an http(s) server —
|
||||
* the same Jellyfin that already answers hls.js' cross-origin XHRs, so we know
|
||||
* it sends the headers. For a local/offline source (`file:`/`asset:`) we leave
|
||||
* the attribute off: subtitles staying dark there is the status quo, whereas
|
||||
* forcing CORS onto the video fetch could break playback outright.
|
||||
*
|
||||
* Deliberately keyed on the *count of subtitle streams* rather than on the
|
||||
* resolved tracks: both inputs are known at first render, so the attribute is
|
||||
* decided before the element starts loading and never flips underneath an
|
||||
* in-flight media fetch.
|
||||
*/
|
||||
export function videoCrossOriginMode(
|
||||
streamUrl: string,
|
||||
subtitleStreamCount: number,
|
||||
): "anonymous" | undefined {
|
||||
if (subtitleStreamCount <= 0) return undefined;
|
||||
return originOf(streamUrl) ? "anonymous" : undefined;
|
||||
}
|
||||
|
||||
// ===== Native (Android / ExoPlayer) path ====================================
|
||||
// ===== The native player =====================================================
|
||||
//
|
||||
// The HTML5 element gets `<track>` children; the native backend instead gets the
|
||||
// list *up front*, as part of the play request, because ExoPlayer sideloads
|
||||
// subtitles as `MediaItem.SubtitleConfiguration`s that must exist before
|
||||
// `prepare()`. There is no "add a subtitle later" — a track absent from the
|
||||
// MediaItem simply does not exist as far as the player is concerned.
|
||||
// The native backend gets the list *up front*, as part of the play request:
|
||||
// ExoPlayer sideloads subtitles as `MediaItem.SubtitleConfiguration`s that must
|
||||
// exist before `prepare()`, and mpv queues them as external files for the load.
|
||||
// There is no "add a subtitle later" — a track absent from the request simply
|
||||
// does not exist as far as the player is concerned.
|
||||
|
||||
/**
|
||||
* Map resolved tracks onto the wire shape `PlayItemRequest.subtitles` carries.
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { shouldApplyTimeUpdate } from "./timeTracking";
|
||||
|
||||
/**
|
||||
* TRACES: UT-245 | DR-265
|
||||
*/
|
||||
describe("shouldApplyTimeUpdate", () => {
|
||||
const base = { isPlaying: false, isSeeking: false, isDraggingSeekBar: false, readyState: 4 };
|
||||
|
||||
it("applies the update while the video is PLAYING", () => {
|
||||
// THE REPORTED BUG. `timeupdate` was the only position source that still
|
||||
// fires once requestAnimationFrame stops -- which is exactly what happens
|
||||
// when the activity is paused behind a picture-in-picture window. Gating it
|
||||
// on `!isPlaying` disabled it precisely when it was the only thing left,
|
||||
// so the component's `currentTime` froze at the moment PiP was entered
|
||||
// while the element played on. The background-audio handoff then resumed
|
||||
// the audio-only stream at that frozen position.
|
||||
expect(shouldApplyTimeUpdate({ ...base, isPlaying: true })).toBe(true);
|
||||
});
|
||||
|
||||
it("still applies the update while paused", () => {
|
||||
// The case it always handled: RAF is stopped, timeupdate carries the seek.
|
||||
expect(shouldApplyTimeUpdate(base)).toBe(true);
|
||||
});
|
||||
|
||||
it("yields to an in-flight seek", () => {
|
||||
// A seek owns the position until it settles; a stale element read landing
|
||||
// mid-seek is what makes a scrubbed video snap back.
|
||||
expect(shouldApplyTimeUpdate({ ...base, isSeeking: true })).toBe(false);
|
||||
expect(shouldApplyTimeUpdate({ ...base, isPlaying: true, isSeeking: true })).toBe(false);
|
||||
});
|
||||
|
||||
it("yields while the user is dragging the seek bar", () => {
|
||||
expect(shouldApplyTimeUpdate({ ...base, isDraggingSeekBar: true })).toBe(false);
|
||||
expect(shouldApplyTimeUpdate({ ...base, isPlaying: true, isDraggingSeekBar: true })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("ignores an element with no usable data yet", () => {
|
||||
// readyState < HAVE_CURRENT_DATA reads 0, which would rewind the position.
|
||||
expect(shouldApplyTimeUpdate({ ...base, readyState: 1 })).toBe(false);
|
||||
expect(shouldApplyTimeUpdate({ ...base, isPlaying: true, readyState: 0 })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,45 +0,0 @@
|
||||
/**
|
||||
* Pure helpers for keeping the player's position variable honest.
|
||||
*
|
||||
* TRACES: UR-004, UR-041 | DR-265 | UT-245
|
||||
*
|
||||
* `VideoPlayer.svelte` tracks the absolute playback position in its own
|
||||
* `currentTime` variable rather than reading `videoElement.currentTime` at the
|
||||
* point of use — transcoded HLS resets the element to 0 on every segment
|
||||
* rebuild, so only the component's running total is meaningful. Everything
|
||||
* downstream reads that variable: the seek bar, the progress reports, the
|
||||
* position mirrored into Rust, and the background-audio handoff.
|
||||
*
|
||||
* Which makes "who is allowed to write it" a correctness question, not a
|
||||
* rendering detail — hence a pure module with tests rather than a condition
|
||||
* buried in an event handler.
|
||||
*/
|
||||
|
||||
export interface TimeUpdateGate {
|
||||
/**
|
||||
* Deliberately does NOT gate the update, and is accepted only to say so.
|
||||
*
|
||||
* `timeupdate` was written as a fallback "for when RAF isn't running" and so
|
||||
* excluded itself whenever `isPlaying` was true. But RAF is driven by the
|
||||
* document being rendered, and an Android activity behind a picture-in-picture
|
||||
* window is paused: the loop stops while the element plays on, and the one
|
||||
* remaining position source had switched itself off. Both writing the same
|
||||
* derived value costs nothing — the element is the authority either way.
|
||||
*/
|
||||
isPlaying?: boolean;
|
||||
isSeeking: boolean;
|
||||
isDraggingSeekBar: boolean;
|
||||
readyState: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a `timeupdate` event may write the component's position.
|
||||
*
|
||||
* Kept free of Svelte/DOM so the rule is unit-testable without mounting the
|
||||
* player.
|
||||
*/
|
||||
export function shouldApplyTimeUpdate(opts: TimeUpdateGate): boolean {
|
||||
// An in-flight seek or a drag owns the position until it settles, and an
|
||||
// element with no current data reads 0, which would rewind it.
|
||||
return !opts.isSeeking && !opts.isDraggingSeekBar && opts.readyState >= 2;
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { videoFitClass } from "./videoFit";
|
||||
|
||||
describe("videoFitClass", () => {
|
||||
it("fills the container instead of capping at the source's intrinsic size", () => {
|
||||
const cls = videoFitClass();
|
||||
// max-w/max-h only shrink oversized media; a 480p source would stay a small
|
||||
// box in the middle of a large window.
|
||||
expect(cls).not.toContain("max-w-full");
|
||||
expect(cls).not.toContain("max-h-full");
|
||||
expect(cls).toContain("w-full");
|
||||
expect(cls).toContain("h-full");
|
||||
});
|
||||
|
||||
it("preserves aspect ratio while fitting (letterbox, never crop)", () => {
|
||||
const cls = videoFitClass();
|
||||
expect(cls).toContain("object-contain");
|
||||
expect(cls).not.toContain("object-cover");
|
||||
expect(cls).not.toContain("object-fill");
|
||||
});
|
||||
});
|
||||
@@ -1,17 +0,0 @@
|
||||
// Sizing rules for the HTML5 <video> element in the full-screen player.
|
||||
// Extracted from VideoPlayer.svelte so the fit behaviour is unit-testable.
|
||||
|
||||
/**
|
||||
* Classes applied to the <video> element so it fits the player viewport.
|
||||
*
|
||||
* TRACES: UR-005
|
||||
*
|
||||
* `max-w-full max-h-full` only ever *shrinks* oversized media, so a source
|
||||
* smaller than the window (e.g. 480p on a 1080p display) rendered at its
|
||||
* intrinsic size - a small box in the middle of a black screen. Filling the
|
||||
* container and letting `object-contain` do the scaling fits the picture to
|
||||
* whichever axis constrains it, in both directions, preserving aspect ratio.
|
||||
*/
|
||||
export function videoFitClass(): string {
|
||||
return "w-full h-full object-contain";
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
/**
|
||||
* Adapter-selection regression guards.
|
||||
*
|
||||
* TRACES: UR-003, UR-004 | DR-150 | UT-149
|
||||
*
|
||||
* The selection rule has two inputs and one hard safety property:
|
||||
*
|
||||
* - Rust says which backend the platform has (`backendKind`).
|
||||
* - The user opts in with `experimentalNativeVideo`.
|
||||
* - **The flag off must force HTML5 even when Rust says native.** That is the
|
||||
* regression guard: a broken spike must not be able to ship as the default.
|
||||
*
|
||||
* These are pure functions, so the whole matrix is testable without a device.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createAdapter } from "./index";
|
||||
import { Html5PlayerAdapter } from "./html5Adapter";
|
||||
import { NativePlayerAdapter } from "./nativeAdapter";
|
||||
import type { AdapterHost } from "./types";
|
||||
|
||||
const host: AdapterHost = {
|
||||
reportState: () => {},
|
||||
reportPosition: () => {},
|
||||
reportEnded: () => {},
|
||||
} as unknown as AdapterHost;
|
||||
|
||||
const bridge = {
|
||||
getElement: () => null,
|
||||
} as any;
|
||||
|
||||
describe("createAdapter", () => {
|
||||
it("returns the native adapter when Rust says native and the flag is on", () => {
|
||||
const adapter = createAdapter({
|
||||
backendKind: "native",
|
||||
host,
|
||||
bridge,
|
||||
experimentalNativeVideo: true,
|
||||
});
|
||||
expect(adapter).toBeInstanceOf(NativePlayerAdapter);
|
||||
expect(adapter.kind).toBe("native");
|
||||
});
|
||||
|
||||
// The regression guard: the flag is a suppressor, so off must beat Rust.
|
||||
it("forces HTML5 when the flag is off even though Rust says native", () => {
|
||||
const adapter = createAdapter({
|
||||
backendKind: "native",
|
||||
host,
|
||||
bridge,
|
||||
experimentalNativeVideo: false,
|
||||
});
|
||||
expect(adapter).toBeInstanceOf(Html5PlayerAdapter);
|
||||
expect(adapter.kind).toBe("html5");
|
||||
});
|
||||
|
||||
it("returns the HTML5 adapter when Rust says html5 and the flag is off", () => {
|
||||
const adapter = createAdapter({
|
||||
backendKind: "html5",
|
||||
host,
|
||||
bridge,
|
||||
experimentalNativeVideo: false,
|
||||
});
|
||||
expect(adapter).toBeInstanceOf(Html5PlayerAdapter);
|
||||
});
|
||||
|
||||
// The flag must never *promote* a platform Rust said has no native backend
|
||||
// (e.g. Linux, where WebKitGTK cannot composite a surface behind the webview).
|
||||
it("stays on HTML5 when Rust says html5 even with the flag on", () => {
|
||||
const adapter = createAdapter({
|
||||
backendKind: "html5",
|
||||
host,
|
||||
bridge,
|
||||
experimentalNativeVideo: true,
|
||||
});
|
||||
expect(adapter).toBeInstanceOf(Html5PlayerAdapter);
|
||||
});
|
||||
|
||||
it("defaults to HTML5 when the flag is omitted entirely", () => {
|
||||
const adapter = createAdapter({ backendKind: "native", host, bridge });
|
||||
expect(adapter).toBeInstanceOf(Html5PlayerAdapter);
|
||||
});
|
||||
|
||||
it("requires a bridge for the HTML5 adapter", () => {
|
||||
expect(() =>
|
||||
createAdapter({ backendKind: "html5", host, experimentalNativeVideo: false }),
|
||||
).toThrow(/bridge/i);
|
||||
});
|
||||
|
||||
// The native adapter owns no DOM element, so it must not demand a bridge.
|
||||
it("does not require a bridge for the native adapter", () => {
|
||||
expect(() =>
|
||||
createAdapter({ backendKind: "native", host, experimentalNativeVideo: true }),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -1,340 +0,0 @@
|
||||
/**
|
||||
* Unit tests for Html5PlayerAdapter.
|
||||
*
|
||||
* The Option-1 primitive design makes the adapter pure, decision-free mechanics
|
||||
* — it takes a mock <video> element + bridge + host, so we can assert each
|
||||
* primitive drives the element correctly without any real DOM or backend.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { Html5PlayerAdapter, type Html5ElementBridge } from "./html5Adapter";
|
||||
import type { AdapterHost } from "./types";
|
||||
|
||||
/**
|
||||
* A `StreamSelection` for tests that only care about the URL. Transcoded HLS is
|
||||
* what these paths exercised before the contract carried a transport.
|
||||
*/
|
||||
function testSelection(url: string, transport: "hls" | "progressive" | "localFile" = "hls") {
|
||||
return {
|
||||
url,
|
||||
transport: { type: transport },
|
||||
playbackKind: { type: transport === "hls" ? "transcode" : "directPlay" },
|
||||
rendition: null,
|
||||
available: [],
|
||||
mediaSourceId: null,
|
||||
playSessionId: null,
|
||||
needsTranscoding: transport === "hls",
|
||||
} as import("$lib/api/bindings").StreamSelection;
|
||||
}
|
||||
|
||||
/** A minimal fake <video> element that records mutations and fires events. */
|
||||
function makeFakeVideo() {
|
||||
const listeners: Record<string, Array<() => void>> = {};
|
||||
const el: any = {
|
||||
paused: true,
|
||||
currentTime: 0,
|
||||
volume: 1,
|
||||
muted: false,
|
||||
src: "blob:existing",
|
||||
play: vi.fn(async () => {
|
||||
el.paused = false;
|
||||
}),
|
||||
pause: vi.fn(() => {
|
||||
el.paused = true;
|
||||
}),
|
||||
load: vi.fn(),
|
||||
removeAttribute: vi.fn((attr: string) => {
|
||||
if (attr === "src") el.src = "";
|
||||
}),
|
||||
addEventListener: (event: string, cb: () => void) => {
|
||||
(listeners[event] ??= []).push(cb);
|
||||
},
|
||||
removeEventListener: (event: string, cb: () => void) => {
|
||||
listeners[event] = (listeners[event] ?? []).filter((f) => f !== cb);
|
||||
},
|
||||
// Test helper: fire an event so waitForEvent resolves immediately.
|
||||
_fire: (event: string) => {
|
||||
(listeners[event] ?? []).slice().forEach((f) => f());
|
||||
},
|
||||
querySelectorAll: () => [] as any,
|
||||
textTracks: [] as any,
|
||||
};
|
||||
return el;
|
||||
}
|
||||
type FakeVideo = ReturnType<typeof makeFakeVideo>;
|
||||
|
||||
function makeBridge(overrides: Partial<Html5ElementBridge> = {}): Html5ElementBridge {
|
||||
let offset = 0;
|
||||
return {
|
||||
getElement: () => null,
|
||||
getSeekOffset: () => offset,
|
||||
setSeekOffset: vi.fn((o: number) => {
|
||||
offset = o;
|
||||
}),
|
||||
setStreamSelection: vi.fn(),
|
||||
destroyHls: vi.fn(),
|
||||
getMediaSourceId: () => "msid-1",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeHost(): AdapterHost {
|
||||
return {
|
||||
onState: vi.fn(),
|
||||
onPosition: vi.fn(),
|
||||
onMediaLoaded: vi.fn(),
|
||||
onEnded: vi.fn(),
|
||||
onError: vi.fn(),
|
||||
onStreamUrlChanged: vi.fn(),
|
||||
onBuffering: vi.fn(),
|
||||
onReady: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
describe("Html5PlayerAdapter", () => {
|
||||
let host: AdapterHost;
|
||||
let bridge: Html5ElementBridge;
|
||||
let adapter: Html5PlayerAdapter;
|
||||
let video: ReturnType<typeof makeFakeVideo>;
|
||||
|
||||
beforeEach(() => {
|
||||
host = makeHost();
|
||||
bridge = makeBridge();
|
||||
adapter = new Html5PlayerAdapter(host, bridge);
|
||||
video = makeFakeVideo();
|
||||
adapter.attach(video);
|
||||
});
|
||||
|
||||
it("is an html5-kind adapter", () => {
|
||||
expect(adapter.kind).toBe("html5");
|
||||
});
|
||||
|
||||
it("play() calls element.play()", async () => {
|
||||
await adapter.play();
|
||||
expect(video.play).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// A stalling HLS stream makes hls.js' gap-controller nudge the element, which
|
||||
// aborts an in-flight play(). That AbortError is transient — the element is
|
||||
// still trying to play — so it must not be surfaced as a player error, or the
|
||||
// UI reports failure ~once a second for the whole stall.
|
||||
it("play() does not report an interrupted-by-pause AbortError as an error", async () => {
|
||||
const abort = new DOMException(
|
||||
"The play() request was interrupted by a call to pause().",
|
||||
"AbortError",
|
||||
);
|
||||
video.play = vi.fn(async () => {
|
||||
throw abort;
|
||||
});
|
||||
|
||||
await adapter.play();
|
||||
|
||||
expect(host.onError).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("play() still reports a genuine failure", async () => {
|
||||
video.play = vi.fn(async () => {
|
||||
throw new DOMException("no supported source", "NotSupportedError");
|
||||
});
|
||||
|
||||
await adapter.play();
|
||||
|
||||
expect(host.onError).toHaveBeenCalledTimes(1);
|
||||
expect(String((host.onError as any).mock.calls[0][0])).toContain("play() failed");
|
||||
});
|
||||
|
||||
it("play() coalesces concurrent attempts into one element.play() call", async () => {
|
||||
// During a stall the UI and recovery paths can both ask to play. Stacking
|
||||
// element.play() calls is what generates the AbortError storm.
|
||||
let resolvePlay: () => void = () => {};
|
||||
video.play = vi.fn(
|
||||
() =>
|
||||
new Promise<void>((r) => {
|
||||
resolvePlay = () => {
|
||||
video.paused = false;
|
||||
r();
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
const first = adapter.play();
|
||||
const second = adapter.play();
|
||||
resolvePlay();
|
||||
await Promise.all([first, second]);
|
||||
|
||||
expect(video.play).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("play() works again after a previous attempt settled", async () => {
|
||||
await adapter.play();
|
||||
await adapter.play();
|
||||
expect(video.play).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("pause() calls element.pause()", async () => {
|
||||
video.paused = false;
|
||||
await adapter.pause();
|
||||
expect(video.pause).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("toggle() plays when paused and reports the resulting state", async () => {
|
||||
video.paused = true;
|
||||
const playing = await adapter.toggle();
|
||||
expect(video.play).toHaveBeenCalled();
|
||||
expect(playing).toBe(true);
|
||||
});
|
||||
|
||||
it("toggle() pauses when playing", async () => {
|
||||
video.paused = false;
|
||||
const playing = await adapter.toggle();
|
||||
expect(video.pause).toHaveBeenCalled();
|
||||
expect(playing).toBe(false);
|
||||
});
|
||||
|
||||
it("seekElement() sets currentTime, offset, and waits for 'seeked'", async () => {
|
||||
const p = adapter.seekElement(42, 0);
|
||||
expect(video.currentTime).toBe(42);
|
||||
expect(bridge.setSeekOffset).toHaveBeenCalledWith(0);
|
||||
video._fire("seeked"); // resolve the wait
|
||||
await p;
|
||||
});
|
||||
|
||||
it("reloadSource() runs the invariant teardown->swap->resume sequence", async () => {
|
||||
video.paused = false; // was playing → should resume
|
||||
const p = adapter.reloadSource(testSelection("http://new/master.m3u8"), 120);
|
||||
|
||||
// Teardown happened synchronously before the awaited canplay wait.
|
||||
expect(video.pause).toHaveBeenCalled();
|
||||
expect(bridge.destroyHls).toHaveBeenCalledTimes(1);
|
||||
expect(video.removeAttribute).toHaveBeenCalledWith("src");
|
||||
expect(video.load).toHaveBeenCalled();
|
||||
|
||||
// Allow the internal 100ms settle delay, then fire canplay to resume.
|
||||
await new Promise((r) => setTimeout(r, 110));
|
||||
expect(bridge.setStreamSelection).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ url: "http://new/master.m3u8", transport: { type: "hls" } }),
|
||||
);
|
||||
video._fire("canplay");
|
||||
video._fire("seeked");
|
||||
await p;
|
||||
expect(video.play).toHaveBeenCalled(); // resumed because it was playing
|
||||
});
|
||||
|
||||
/**
|
||||
* The reload lands the viewer at the position they asked for — by *seeking*,
|
||||
* with no transcode offset left over.
|
||||
*
|
||||
* This used to be inverted: the offset was set to the position and nothing
|
||||
* seeked, which was right only while the reloaded URL itself began there via
|
||||
* `StartTimeTicks`. DR-181 removes that parameter, because on an HLS playlist
|
||||
* the server copies it onto every segment URI and then rejects each one with
|
||||
* `400`. With the URL starting at the item's zero, the old arithmetic leaves
|
||||
* `currentTime = offset + 0` — the scrubber reading 20:00 over the opening
|
||||
* titles, and the seek silently never happening.
|
||||
*
|
||||
* TRACES: UR-004, UR-005 | DR-181 | UT-183
|
||||
*/
|
||||
it("reloadSource() seeks to the position and clears the transcode offset", async () => {
|
||||
video.paused = false;
|
||||
const p = adapter.reloadSource(testSelection("http://new/master.m3u8"), 1200);
|
||||
|
||||
await new Promise((r) => setTimeout(r, 110));
|
||||
expect(bridge.setSeekOffset).toHaveBeenCalledWith(0);
|
||||
expect(bridge.setSeekOffset).not.toHaveBeenCalledWith(1200);
|
||||
|
||||
// Nothing may seek before the new source is playable — the element drops it.
|
||||
expect(video.currentTime).not.toBe(1200);
|
||||
|
||||
video._fire("canplay");
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect(video.currentTime).toBe(1200);
|
||||
|
||||
video._fire("seeked");
|
||||
await p;
|
||||
expect(video.play).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
/** A reload to the very start has nothing to seek to; it must not stall. */
|
||||
it("reloadSource() at position 0 does not wait for a seek", async () => {
|
||||
video.paused = false;
|
||||
const p = adapter.reloadSource(testSelection("http://new/master.m3u8"), 0);
|
||||
await new Promise((r) => setTimeout(r, 110));
|
||||
video._fire("canplay");
|
||||
await p; // resolves without any "seeked" event
|
||||
expect(video.play).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
/**
|
||||
* A reload that never becomes playable must be reported as a failure. It used
|
||||
* to resolve on the timeout, so a quality switch whose new stream the server
|
||||
* refused to serve (Jellyfin 400s the first segment when two transcode jobs
|
||||
* collide) looked like a success: the picker showed the new quality selected
|
||||
* over a stream that never played, and the caller had nothing to revert to.
|
||||
*
|
||||
* TRACES: UR-074 | DR-177 | UT-175
|
||||
*/
|
||||
it("reloadSource() rejects when the new stream never becomes playable", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
video.paused = false;
|
||||
const p = adapter.reloadSource(testSelection("http://new/master.m3u8"), 120);
|
||||
const assertion = expect(p).rejects.toThrow(/canplay/i);
|
||||
await vi.advanceTimersByTimeAsync(11_000); // past the 10s readiness budget
|
||||
await assertion;
|
||||
expect(video.play).not.toHaveBeenCalled(); // nothing to resume into
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("reloadSource() does not resume when it was paused", async () => {
|
||||
video.paused = true;
|
||||
const p = adapter.reloadSource(testSelection("http://new/master.m3u8"), 30);
|
||||
await new Promise((r) => setTimeout(r, 110));
|
||||
video._fire("canplay");
|
||||
video._fire("seeked");
|
||||
await p;
|
||||
expect(video.play).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("setVolume() clamps to 0..1", () => {
|
||||
adapter.setVolume(1.5);
|
||||
expect(video.volume).toBe(1);
|
||||
adapter.setVolume(-0.5);
|
||||
expect(video.volume).toBe(0);
|
||||
adapter.setVolume(0.4);
|
||||
expect(video.volume).toBeCloseTo(0.4);
|
||||
});
|
||||
|
||||
it("setMuted() sets the element muted flag", () => {
|
||||
adapter.setMuted(true);
|
||||
expect(video.muted).toBe(true);
|
||||
});
|
||||
|
||||
it("getPosition() returns element time plus the transcode offset", () => {
|
||||
video.currentTime = 10;
|
||||
(bridge.getSeekOffset as any) = () => 100;
|
||||
// Rebuild adapter with the offset-returning bridge.
|
||||
const a = new Html5PlayerAdapter(host, bridge);
|
||||
a.attach(video);
|
||||
expect(a.getPosition()).toBe(110);
|
||||
});
|
||||
|
||||
it("dispose() tears down hls and clears the element", async () => {
|
||||
await adapter.dispose();
|
||||
expect(bridge.destroyHls).toHaveBeenCalled();
|
||||
expect(video.pause).toHaveBeenCalled();
|
||||
// After dispose, primitives are no-ops (element detached).
|
||||
await adapter.play();
|
||||
// play was called once during dispose teardown? no — play only on reload/resume.
|
||||
expect(video.play).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("primitives are safe no-ops before an element is attached", async () => {
|
||||
const bare = new Html5PlayerAdapter(host, bridge);
|
||||
await expect(bare.play()).resolves.toBeUndefined();
|
||||
await expect(bare.pause()).resolves.toBeUndefined();
|
||||
await expect(bare.seekElement(5, 0)).resolves.toBeUndefined();
|
||||
expect(await bare.toggle()).toBe(false);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user