Compare commits

...
Author SHA1 Message Date
dtourolle 5fede123e7 fix(deps): take the patched quick-xml via plist 1.10
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 18m44s
🏗️ Build and Test JellyTau / Supply Chain (push) Successful in 31s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m28s
Traceability Validation / Check Requirement Traces (push) Successful in 11s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 4m18s
cargo-deny went red on master with two quick-xml DoS advisories
(RUSTSEC-2026-0194, RUSTSEC-2026-0195).

They were absent before, and correctly so: quick-xml reached the graph
only through plist on Apple targets, and deny.toml scopes the graph to
the targets this project actually ships. The Tauri 2.11 upgrade changed
that. plist is now pulled in by tauri-utils, which is a build-dependency
of tauri-build, so it compiles on every target including Linux and the
advisory became genuinely in scope.

That is the gate behaving as designed -- silent while the crate was
unreachable, loud the moment a dependency upgrade brought it into a build
we ship.

Fixed rather than ignored. plist 1.10.0 requires quick-xml ^0.41.0, which
carries both patches, and tauri-utils accepts plist ^1, so the upgrade is
a lockfile change with nothing else moving:

  plist      1.8.0  -> 1.10.0
  quick-xml  0.38.4 -> 0.41.0

An ignore entry would have been easy to justify here -- build-time only,
parsing files we generate, absent from every shipped binary -- and that
is exactly why it would have been wrong: the justification would have
outlived the reason for it, and the entry would still be sitting in
deny.toml long after the upgrade became available.

No release. quick-xml is a build dependency, so it is not inside any
v0.10.1 artifact; this only restores master to green.

Verified: cargo deny (advisories, bans, licences, sources all ok),
cargo check, 765 tests, cargo fmt --check, clippy -D warnings.
2026-08-22 11:49:16 +02:00
dtourolle edff6eedc9 fix(player): let the background-audio toggle govern backgrounding again
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 18m44s
🏗️ Build and Test JellyTau / Supply Chain (push) Failing after 49s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m27s
Traceability Validation / Check Requirement Traces (push) Successful in 10s
Build & Release / Run Tests (push) Successful in 14m48s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 4m19s
Build & Release / Build Linux (push) Successful in 20m20s
Build & Release / Build Windows (push) Successful in 15m36s
Build & Release / Build Android (push) Successful in 30m46s
Build & Release / Create Release (push) Successful in 38s
Locking the screen kept a video's audio playing whether or not the
background-audio button was on. Reported as "audio only mode is always
active even if not selected".

The button (UR-040) was built for the WebView <video> path, where losing
visibility kills the decode: it chose between handing off to a native
audio stream and letting playback stop. Native video then became the
default renderer (DR-188), and on that path playback runs through
ExoPlayer inside a MediaSessionService -- a foreground media service
whose entire purpose is to keep playing while the app is hidden. Nothing
stopped it, and nothing in the codebase paused on background.

So the button governed a handoff that no longer had a gap to bridge.
There was no interruption to paper over, and a user who never touched it
got background playback anyway.

The gating made it self-concealing: MainActivity.onStop only dispatched
'jellytau-background' when backgroundAudioEnabled was already true. The
one notification that the app had gone away was itself conditional on the
setting, so with the button OFF nothing could react even in principle.
onStop and onStart now fire unconditionally and carry the two facts only
the activity knows -- whether the toggle is armed, and whether Android
put the window into picture-in-picture.

What to do about it is decided in Rust (player/background_policy.rs),
because it depends on whether the item has a picture to lose:

  video + toggle off  -> Pause
  video + toggle on   -> HandOffToAudio
  music, either       -> KeepPlaying   (no picture to give up)
  picture-in-picture  -> KeepPlaying   (the window is still on screen)

It takes no renderer parameter on purpose. Two renderers with two
behaviours and one toggle reaching only one of them is what produced the
defect; a rule that cannot see the renderer cannot reproduce it.

Two failure modes are deliberate. A decision call that fails leaves
playback alone rather than risking silence mid-listen. An event with no
detail -- older Kotlin against newer JS -- reads as "armed, not PiP",
degrading to the previous behaviour instead of pausing unexpectedly.

Foregrounding resumes only what backgrounding paused: a video the user
paused themselves before locking stays paused.

Written test-first per CLAUDE.md. The stub encoded today's behaviour
(nothing ever pauses) and failed exactly as reported --
`left: KeepPlaying, right: Pause` -- before the rule was implemented.

Verified on a device, R8-minified, both directions:

  [player_background_action] video=true armed=false pip=false -> Pause
  [player_background_action] video=true armed=true  pip=false -> HandOffToAudio

UR-040 / DR-224 / UT-211.
2026-08-22 10:09:46 +02:00
dtourolle 9c75e74ea3 fix(ci): give the builder image what linuxdeploy needs for the AppImage
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 15m52s
🏗️ Build and Test JellyTau / Supply Chain (push) Failing after 29s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m35s
Traceability Validation / Check Requirement Traces (push) Successful in 11s
Build & Release / Run Tests (push) Successful in 14m53s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 4m22s
Build & Release / Build Linux (push) Successful in 20m53s
Build & Release / Build Windows (push) Successful in 15m41s
Build & Release / Build Android (push) Successful in 30m46s
Build & Release / Create Release (push) Successful in 38s
The v0.10.0 release build failed in Build Linux after 16 minutes:

  failed to bundle project: xdg-open binary not found
  /usr/bin/xdg-open: No such file or directory

linuxdeploy embeds xdg-open into the AppImage and aborts the whole bundle
when it is absent. deb and rpm had already bundled fine; only AppImage
was affected.

This is the one failure tonight that building locally could not have
caught, and the reason is worth writing down: a developer machine is a
desktop and always has xdg-utils, so the AppImage builds there and fails
on a minimal server image. The asymmetry is the bug. Every other release
defect this evening was found by building locally first; this one needed
the runner.

xdg-utils, desktop-file-utils and zsync are added together rather than
one at a time. Each round trip costs an image rebuild plus a failed
release build, and those three are what linuxdeploy commonly reaches for
(xdg-open, desktop-file-validate, and zsync for delta updates).

Workflows move to jellytau-builder:2026.08.1, built and pushed with all
three verified present inside it before this commit.

ci-operations.md gains two things learned here: that an apt addition
invalidates the layer above the cargo-install steps, so it is a ~20 minute
rebuild rather than the ~2 minutes the trailing layer normally gives; and
that Tauri's AppImage bundler downloads linuxdeploy, AppRun and two plugin
scripts from GitHub during the build, so an AppImage build depends on
GitHub being reachable from the runner.
2026-08-22 02:52:32 +02:00
dtourolle 76a2d9609b fix(release): produce updater artifacts, and point the manifest at them
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 15m32s
🏗️ Build and Test JellyTau / Supply Chain (push) Failing after 29s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m34s
Traceability Validation / Check Requirement Traces (push) Successful in 11s
Build & Release / Run Tests (push) Successful in 14m49s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 4m22s
Build & Release / Build Linux (push) Failing after 17m42s
Build & Release / Build Windows (push) Successful in 15m46s
Build & Release / Build Android (push) Successful in 30m54s
Build & Release / Create Release (push) Skipped
Two defects on the release path, both of which would have failed the
v0.10.0 build after all three platforms had already compiled -- caught by
running a real signed build locally instead of waiting for the tag.

**createUpdaterArtifacts was never set.** Without it Tauri emits only the
plain .AppImage and .exe: no signatures at all. The manifest step then
finds none and aborts by design, so the release dies at Create Release
having spent ~40 minutes building artifacts it cannot publish.

**The manifest looked for the wrong filename.** Tauri v2 signs the
.AppImage *itself* and writes <name>.AppImage.sig beside it. The
.AppImage.tar.gz form this workflow globbed for only exists under
createUpdaterArtifacts: "v1Compatible". A real signed build produced:

  154M JellyTau_0.10.0_amd64.AppImage
  420  JellyTau_0.10.0_amd64.AppImage.sig

so the glob would have matched nothing and the step would have aborted
for a second, entirely different reason. Both the artifact collection and
the manifest now use the v2 names, and the AppImage and its .sig ship
together -- a manifest referencing a signature that was never uploaded
fails only on the user's machine.

Verified before tagging rather than after: the manifest logic was run
against the real artifacts (420-char minisign signature read correctly)
and the resulting latest.json checked for validity and shape.

The Windows side already used the correct pattern (<installer>.exe.sig),
which is why only Linux needed the change.
2026-08-21 23:14:16 +02:00
22 changed files with 435 additions and 44 deletions
+3 -3
View File
@@ -28,7 +28,7 @@ jobs:
if: "!startsWith(github.event.head_commit.message, 'chore(release)')"
runs-on: linux/amd64
container:
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.08
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.08.1
steps:
- name: Checkout repository
@@ -187,7 +187,7 @@ jobs:
runs-on: linux/amd64
needs: test
container:
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.08
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.08.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
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.08.1
steps:
- name: Checkout repository
+18 -10
View File
@@ -21,7 +21,7 @@ jobs:
name: Run Tests
runs-on: linux/amd64
container:
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.08
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.08.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
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.08.1
steps:
- name: Checkout repository
uses: actions/checkout@v4
@@ -190,12 +190,15 @@ jobs:
# Without nullglob an unmatched pattern stays literal, so test each
# candidate instead. Same POSIX-only rule as traceability-check.yml.
#
# The .AppImage.tar.gz + .sig pair is what the updater downloads and
# verifies; the plain .AppImage is what a human downloads. Both ship.
# Tauri v2 signs the .AppImage ITSELF and writes <name>.AppImage.sig
# beside it -- there is no .AppImage.tar.gz unless
# bundle.createUpdaterArtifacts is set to "v1Compatible". The updater
# downloads the same AppImage a human does and verifies that .sig, so
# both files must ship or the manifest points at a signature nobody
# can fetch.
for bundle in \
src-tauri/target/release/bundle/appimage/*.AppImage \
src-tauri/target/release/bundle/appimage/*.AppImage.tar.gz \
src-tauri/target/release/bundle/appimage/*.AppImage.tar.gz.sig \
src-tauri/target/release/bundle/appimage/*.AppImage.sig \
src-tauri/target/release/bundle/deb/*.deb \
src-tauri/target/release/bundle/rpm/*.rpm; do
[ -e "$bundle" ] || continue
@@ -232,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
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.08.1
steps:
- name: Checkout repository
uses: actions/checkout@v4
@@ -305,7 +308,7 @@ jobs:
runs-on: linux/amd64
needs: test
container:
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.08
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.08.1
env:
ANDROID_HOME: /opt/android-sdk
ANDROID_SDK_ROOT: /opt/android-sdk
@@ -408,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
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.08.1
steps:
- name: Checkout repository
uses: actions/checkout@v4
@@ -499,8 +502,13 @@ jobs:
APPIMAGE_URL=""
NSIS_URL=""
for f in artifacts/linux/*.AppImage.tar.gz; do
# Tauri v2 signs the AppImage itself; <name>.AppImage.sig sits beside
# it. Verified against a real signed build before tagging -- the
# v1-style .AppImage.tar.gz is never produced with
# createUpdaterArtifacts: true.
for f in artifacts/linux/*.AppImage; do
[ -e "$f" ] || continue
case "$f" in *.sig) continue;; esac
APPIMAGE_URL="${BASE}/$(basename "$f")"
[ -e "$f.sig" ] && APPIMAGE_SIG="$(cat "$f.sig")"
done
+1 -1
View File
@@ -21,7 +21,7 @@ jobs:
name: Build & publish docs to gitea-pages
runs-on: linux/amd64
container:
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.08
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.08.1
steps:
- name: Checkout code
+1 -1
View File
@@ -17,7 +17,7 @@ jobs:
runs-on: linux/amd64
name: Check Requirement Traces
container:
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.08
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.08.1
steps:
- name: Checkout repository
+20
View File
@@ -9,6 +9,26 @@ 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.10.1
A single fix, for something that had been quietly overriding a choice you made.
### 🐛 Fixes
- **Locking the screen no longer keeps playing a video's audio unless you asked
it to.** The player has a background-audio button: turn it on and the sound
carries on when you lock the screen or leave the app, turn it off and playback
stops. It stopped working when video moved to the native renderer — which plays
through a media service designed to keep going while the app is hidden — and
nothing was left to stop it. So the audio continued whether the button was on
or off, and there was no way to make it behave otherwise. The button governs it
again: with it off, locking the screen pauses the video and unlocking resumes
where you were; with it on, the audio continues as before. Music is untouched —
it keeps playing when backgrounded, as a music player should — and a video in a
picture-in-picture window keeps playing too, because the window is still on
screen. If you had already paused before locking, it stays paused.
(UR-040 → DR-224)
## v0.10.0
Two things you can see, and a great deal of work on how this project builds and
+11
View File
@@ -141,6 +141,17 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
lld \
llvm \
nsis \
# AppImage bundling. linuxdeploy embeds xdg-open into the AppImage and
# aborts the whole bundle if it is missing:
# failed to bundle project: xdg-open binary not found
# It is present on most desktop distros, which is why the AppImage built on
# a developer machine and failed here. desktop-file-utils and zsync are the
# other two linuxdeploy commonly wants (desktop-file-validate, and zsync for
# delta updates), added together so a missing one does not cost another
# image rebuild and another failed release build.
xdg-utils \
desktop-file-utils \
zsync \
&& rm -rf /var/lib/apt/lists/* \
# Ubuntu's clang package ships clang but NOT the clang-cl alias that cc-rs
# invokes for MSVC targets. clang-cl is the same binary in MSVC-compat mode,
+24
View File
@@ -61,6 +61,11 @@ filled. Keep a couple of dated tags live and prune the rest.
The order matters — CI breaks if the workflow lands before the image exists.
A caveat learned the hard way: the *trailing* layer is only fast for `cargo
install` tools. Adding an **apt** package invalidates the packaging layer, which
sits above the `cargo-xwin`/`cargo-deny` installs, so those recompile too — a
~20 minute rebuild rather than ~2.
```bash
# 1. Edit Dockerfile.builder. Put new tools in the TRAILING layer: it exists so
# a tool change is a ~2 min rebuild instead of ~15.
@@ -103,6 +108,25 @@ transitive upgrade (bumping `tauri-plugin-log` to 2.9.0 also moved `wry`,
therefore video playback. That is a change to make deliberately, with a full
build and a playback check — not one to slip into a release.
## AppImage needs more than the Rust toolchain
`linuxdeploy` (which Tauri downloads at build time to assemble the AppImage)
shells out to distro tools that a minimal server image does not have. It aborts
the whole bundle on the first one missing:
```
failed to bundle project: xdg-open binary not found
```
The image therefore carries `xdg-utils`, `desktop-file-utils` and `zsync`. This
is a class of failure that **cannot be caught by building locally**: a developer
machine is a desktop and has all three, so the AppImage builds there and fails in
CI. It cost one release build to find.
Tauri's AppImage bundler also downloads `linuxdeploy`, `AppRun` and two plugin
scripts from GitHub during the build. That is Tauri's behaviour, not ours, but it
means an AppImage build depends on GitHub being reachable from the runner.
## Secrets
Managed with the `tea` CLI (`tea actions secrets list`) or the repo settings UI.
+2
View File
@@ -415,6 +415,7 @@ Internal architecture, components, and application logic.
| DR-221 | The release path is exercised before a tag exists. Nothing in `build-and-test.yml` runs `tauri build` — only a tag does — so a whole class of breakage was invisible until release day, and two instances of it were sitting on master at once. Tauri refuses to build when a plugin's Rust crate and npm package differ by minor version, which the updater and logging work had introduced (`tauri-plugin-log 2.8.0` against `@tauri-apps/plugin-log 2.9.0`) while `cargo check`, clippy, the tests and `svelte-check` all passed; both sides are now pinned exactly rather than by caret, since a caret is what let them separate, and CI runs `tauri info` to compare them without building. The AppImage target had never once been built: linuxdeploy carries a `strip` too old to parse the `.relr.dyn` section modern toolchains emit, so bundling failed on every library — and Ubuntu 23.10+ links with `-z pack-relative-relocs` by default, so the builder image fails the same way a modern Arch host does. `NO_STRIP=true` is linuxdeploy's documented escape hatch; the cost is a larger, unstripped bundle. Both were found by building the target locally before tagging rather than by publishing a release that could not build | Tooling | - | Done |
| DR-222 | Build tooling matches the package manager the project declares. `scripts/build-android.sh` ran `npm install` on its clean-build path — in a bun project, where `packageManager` says bun and `bun.lock` is the committed lockfile. npm ignores that lockfile, re-resolves the whole tree from package.json, and writes a `package-lock.json` that `.gitignore` then hides. That is not a style preference: the JS halves of the Tauri plugins are pinned exactly against Cargo.lock because the CLI refuses to build when a plugin's crate and package differ by minor version, and a silent re-resolve is precisely how they drift apart. It survived because clean builds are rare — the shape shared by nearly every defect found preparing v0.10.0, where the code running on every commit was healthy and the code running on a release, a tag or a clean build had no guard at all. `scripts/check-tooling.sh` fails on any npm/yarn/pnpm invocation or foreign lockfile | Tooling | - | Done |
| DR-223 | The Android JavaVM and Application are published into `ndk_context` by this crate, not by a transitive dependency. Seven call sites (five in credentials.rs, two in lib.rs) read that process-global to reach JNI, and nothing here ever set it — `tao` did, three levels below anything this project names in Cargo.toml. tao 0.35.3 moved those pointers into a private struct and stopped publishing them, so the Tauri 2.11 upgrade made the first credential read abort the process on every launch: `PANIC ... android context was not initialized`. Our code had not changed; an undocumented side effect of the windowing layer had gone. The invariant is now owned here rather than assumed: `JNI_OnLoad` captures the JavaVM as the shared library loads, and the Application is resolved lazily via `ActivityThread.currentApplication()` and pinned as a global reference for the process lifetime — the Application rather than the Activity, since that is what `SecureStorage.initialize()` immediately reduces its argument to. Failure degrades to the encrypted-file credential path and is logged, rather than aborting. Found only by installing on a device: nothing in CI runs the app | Security | UR-012 | Done |
| DR-224 | Backgrounding the app obeys the background-audio toggle on every renderer. The toggle (UR-040) was built for the WebView `<video>` path, where losing visibility kills the decode: it chose between handing off to a native audio stream and letting playback stop. Native video then became the default renderer (DR-188), and on that path playback runs through ExoPlayer inside a `MediaSessionService` — a foreground media service whose purpose is to keep playing while the app is hidden. Nothing paused it and nothing in the codebase paused on background, so locking the screen kept the audio going whether or not the toggle was on: the toggle governed a handoff that no longer had a gap to bridge, and users got background playback they never asked for. The decision now lives in Rust (`player/background_policy.rs`) and both renderers obey it: a video with the toggle off pauses, with the toggle on hands off to audio, music is never paused by backgrounding, and picture-in-picture keeps playing because the window is still on screen (UR-041). It takes no renderer parameter on purpose — the split between the two paths is what produced the defect | Player | UR-040 | Done |
| DR-198 | The webview runs under a real Content-Security-Policy, and the asset protocol is scoped to the one directory it still serves. `csp` was `null`, which disables CSP entirely: any script that reached the web layer — through a future `{@html}`, a dependency, or a devtools paste — would have inherited the whole IPC surface, and with it the user's session. `script-src 'self'` (Tauri injects a nonce for SvelteKit's inline bootstrap script at build time, so no `'unsafe-inline'` is needed) plus `object-src`/`frame-src 'none'` and `base-uri 'self'` is the part that is genuinely restrictive. `img-src`/`media-src`/`connect-src` cannot be: the Jellyfin origin is typed in by the user at run time and is commonly plain `http` on a LAN, so they allow `http:`/`https:` — a wide grant for *data*, but one that still bars `file:`, `filesystem:` and scripting schemes, and leaves `script-src` untouched. `style-src` keeps `'unsafe-inline'` because Svelte compiles `style="…"` attributes (including `app.html`'s `display: contents` wrapper) into markup; this is safe only while no `<style>` element survives into `index.html`, since a nonce there would make Tauri's injection outrank — and therefore void — `'unsafe-inline'`. `worker-src blob:` and `media-src blob:` are hls.js: it demuxes in a worker built from a blob and attaches MSE through `URL.createObjectURL`. `asset:` and `http://asset.localhost` are the same protocol under the two naming schemes `convertFileSrc` emits (custom scheme on Linux/macOS, `http` host on Windows/Android); `ipc:`/`http://ipc.localhost` is the invoke transport, which would otherwise be blocked by `connect-src`. A run-time CSP naming the server origin exactly was rejected: Tauri computes the header from immutable config when it serves the HTML, so it would mean rebuilding config and reloading the webview on every server change, for a policy the user can already point anywhere. The asset-protocol scope narrows from `$APPDATA/**` to `$APPDATA/thumbnails/**` — since DR-137 moved downloaded media to the loopback server, `imageCache` is the only `convertFileSrc` caller left, so the database and the encrypted-token fallback file no longer sit inside the grant | Security | UR-012, UR-071 | Done |
---
@@ -715,6 +716,7 @@ Internal architecture, components, and application logic.
| UT-208 | The update decision: each numeric version field is compared in order, the installed version is not offered to itself, a leading `v` is tolerated because that is how the tags are written, a pre-release sorts below the release of the same number so 0.9.2-rc1 is not offered to somebody on 0.9.2, a missing patch field reads as zero rather than NaN, mobile reports link-only while desktop reports install, and absent release notes normalise to null rather than undefined | DR-217 | Done |
| UT-209 | Redaction and forwarding. Rust: every credential shape reduces to `[REDACTED]` while the host, username and neighbouring parameters survive; redaction is idempotent, leaves ordinary lines alone, does not fire on the word "token" in prose, and does not panic on multi-byte input; a server URL keeps only scheme and host and drops an embedded `user:pass@`; an unparseable level falls back to info rather than failing at startup. Frontend: info and above forward while debug does not, a message the level filter suppressed is not forwarded, a throwing forwarder neither propagates nor prevents the console write, and an `Error` renders as name and message rather than the `{}` that `JSON.stringify` produces | DR-218 | Done |
| UT-210 | Cosmetic-commit detection for release notes: a `chore(format)`, `chore(deps)` or `style` subject is skipped when deriving a range's changed files, while `fix`, `feat`, `ci`, `docs`, a bare `chore:` and `chore(release):` are kept; and the word "format" appearing later in a subject ("fix(duration): format times over 24 hours") does not make a real fix look cosmetic | DR-219 | Done |
| UT-211 | The background decision: a video with the toggle off pauses (the reported defect, where the media service kept playing regardless), a video with it on hands off to audio, music keeps playing whatever the toggle says because it has no picture to lose, picture-in-picture keeps playing in every combination since the window is still visible, and the answer does not vary by renderer | DR-224 | Done |
### Integration Tests
+1 -1
View File
@@ -28,7 +28,7 @@ know how something *works*, read
**Next free requirement ids** (always re-check
[requirements.md](../requirements.md) before allocating): **UR-079**,
**IR-033**, **DR-224**. Three specs below suggested ids that have since been
**IR-033**, **DR-225**. Three specs below suggested ids that have since been
taken by other work; each carries a ⚠️ note at the top.
## Partially implemented
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "jellytau",
"version": "0.10.0",
"version": "0.10.1",
"description": "A cross-platform Jellyfin client built with Tauri, SvelteKit and Rust.",
"author": "Duncan Tourolle <duncan@tourolle.paris>",
"license": "MIT",
+1 -1
View File
@@ -8,7 +8,7 @@
# tarball/VCS URL and drop the local-copy prepare() step.
pkgname=jellytau
pkgver=0.10.0
pkgver=0.10.1
pkgrel=1
pkgdesc="A cross-platform Jellyfin client"
arch=('x86_64')
+5 -5
View File
@@ -2181,7 +2181,7 @@ dependencies = [
[[package]]
name = "jellytau"
version = "0.10.0"
version = "0.10.1"
dependencies = [
"aes-gcm",
"async-trait",
@@ -3285,9 +3285,9 @@ checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
[[package]]
name = "plist"
version = "1.8.0"
version = "1.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07"
checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85"
dependencies = [
"base64 0.22.1",
"indexmap 2.12.1",
@@ -3463,9 +3463,9 @@ dependencies = [
[[package]]
name = "quick-xml"
version = "0.38.4"
version = "0.41.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c"
checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1"
dependencies = [
"memchr",
]
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "jellytau"
version = "0.10.0"
version = "0.10.1"
description = "A cross-platform Jellyfin client"
authors = ["Duncan Tourolle <duncan@tourolle.paris>"]
license = "MIT"
@@ -164,32 +164,48 @@ class MainActivity : TauriActivity() {
*/
override fun onStop() {
super.onStop()
if (backgroundAudioEnabled) {
dispatchWebEvent("jellytau-background")
// Fires unconditionally now. It used to be gated on backgroundAudioEnabled,
// which meant the frontend was never told the app had gone away unless the
// toggle was already on -- so with the toggle OFF nothing could react, and
// on the native path ExoPlayer's media service simply kept playing. That is
// the whole defect: the toggle appeared to do nothing because the only
// notification of backgrounding was itself gated on the toggle (DR-224).
//
// What to DO about it is decided in Rust (player_background_action); this
// only reports the two facts the activity alone knows.
val inPip = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.N) {
isInPictureInPictureMode
} else {
false
}
dispatchWebEvent(
"jellytau-background",
"{\"backgroundAudioArmed\": $backgroundAudioEnabled, \"inPictureInPicture\": $inPip}"
)
}
/** The app is visible again — tell the frontend to resume WebView video. */
override fun onStart() {
super.onStart()
if (backgroundAudioEnabled) {
// Also unconditional, for the same reason: a video paused on background has
// to be told it is visible again, and that pause happens with the toggle
// OFF. The frontend ignores this when it has nothing to restore.
dispatchWebEvent("jellytau-foreground")
}
}
/**
* Dispatch a DOM CustomEvent into the WebView (native frontend). Mirrors the
* evaluateJavascript pattern already used to unmute video elements. Posted to
* the WebView thread; safe no-op if the WebView isn't found yet.
*/
private fun dispatchWebEvent(name: String) {
private fun dispatchWebEvent(name: String, detailJson: String = "null") {
val webView = mediaWebView ?: run {
android.util.Log.w("MainActivity", "dispatchWebEvent('$name'): no WebView")
return
}
webView.post {
webView.evaluateJavascript(
"window.dispatchEvent(new CustomEvent('$name'));",
"window.dispatchEvent(new CustomEvent('$name', { detail: $detailJson }));",
null
)
android.util.Log.d("MainActivity", "Dispatched web event: $name")
+40
View File
@@ -840,6 +840,46 @@ pub async fn player_enter_background_audio(
/// untouched — if it fired while backgrounded, playback is already stopped and
/// this simply reports the last position.
///
/// What playback should do now that the app is no longer visible.
///
/// The caller supplies only what it alone knows -- whether the per-player
/// toggle is armed, and whether Android put the window into picture-in-picture.
/// Everything else (what is playing, and therefore whether there is a picture to
/// lose) is read here, because it is domain state.
///
/// The rule itself is in `player::background_policy`; this command is the wire.
/// Returning `KeepPlaying` for an empty queue is deliberate: with nothing
/// playing there is nothing to pause, and an error would make the frontend
/// handle a case that is not a failure.
///
/// TRACES: UR-040, UR-041 | DR-224 | UT-211
#[tauri::command]
#[specta::specta]
pub async fn player_background_action(
player: State<'_, PlayerStateWrapper>,
background_audio_armed: bool,
in_picture_in_picture: bool,
) -> Result<crate::player::background_policy::BackgroundAction, String> {
use crate::player::background_policy::{background_action, is_video_media, BackgroundAction};
let is_video = {
let controller = player.0.lock().await;
let queue_arc = controller.queue();
let queue = queue_arc.lock().map_err(|e| e.to_string())?;
match queue.current() {
Some(item) => is_video_media(item.media_type),
None => return Ok(BackgroundAction::KeepPlaying),
}
};
let action = background_action(is_video, background_audio_armed, in_picture_in_picture);
info!(
"[player_background_action] video={} armed={} pip={} -> {:?}",
is_video, background_audio_armed, in_picture_in_picture, action
);
Ok(action)
}
/// TRACES: UR-040 | DR-052 | UT-061, IT-013
#[tauri::command]
#[specta::specta]
+2
View File
@@ -121,6 +121,7 @@ use commands::{
player_add_to_queue,
player_add_track_by_id,
player_add_tracks_by_ids,
player_background_action,
player_cancel_autoplay_countdown,
player_cancel_sleep_timer,
// Jellyfin reporting commands
@@ -742,6 +743,7 @@ fn specta_builder() -> Builder<tauri::Wry> {
.commands(tauri_specta::collect_commands![
// Player commands
player_play_item,
player_background_action,
player_enter_background_audio,
player_exit_background_audio,
player_play_queue,
+155
View File
@@ -0,0 +1,155 @@
//! What playback should do when the app stops being visible.
//!
//! TRACES: UR-040 | DR-224 | UT-211
//!
//! # The defect this exists for
//!
//! The per-player background-audio toggle (UR-040) was built for the WebView
//! `<video>` path, where backgrounding the app kills the decode: the toggle
//! decided whether to *hand off* to a native audio stream or let playback die.
//!
//! Native video then became the default renderer (DR-188). On that path playback
//! runs through ExoPlayer inside a `MediaSessionService` — a foreground media
//! service whose entire purpose is to keep playing when the app is not visible.
//! Nothing stops it, and nothing in the codebase paused playback on background.
//!
//! So locking the screen kept the audio playing **whether or not the toggle was
//! on**. The toggle governed a handoff that no longer had anything to hand off
//! *from*: there was no gap in playback to bridge. A user who had never touched
//! it got background audio anyway, which is the bug as reported.
//!
//! # Why the decision lives in Rust
//!
//! It depends on what the item *is* (a video keeps its picture; music has none
//! to lose) and on a user setting — domain questions, not presentation ones, and
//! the answer must be identical for both renderers. The frontend and the Android
//! activity carry it out; neither decides it. Putting the rule in either would
//! have reproduced exactly the split that caused this: two renderers, two
//! behaviours, one toggle that only reached one of them.
use crate::player::media::MediaType;
/// What the player should do when the app is backgrounded.
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub enum BackgroundAction {
/// Carry on. Music, and video the user explicitly asked to keep hearing
/// while it is in a picture-in-picture window.
KeepPlaying,
/// Swap the video stream for an audio-only one and keep playing.
HandOffToAudio,
/// Stop making sound. The user did not ask for background playback.
Pause,
}
/// Decide what backgrounding should do.
///
/// * `is_video` — whether the current item has a picture to lose. Music is
/// never paused by backgrounding; that is what a music player is for.
/// * `background_audio_armed` — the per-player toggle (UR-040).
/// * `in_picture_in_picture` — the app is not "gone", it is in a floating
/// window and still visible. Pausing here would break PiP (UR-041).
///
/// TRACES: UR-040, UR-041 | DR-224 | UT-211
pub fn background_action(
is_video: bool,
background_audio_armed: bool,
in_picture_in_picture: bool,
) -> BackgroundAction {
// PiP first: the window is still on screen, so this is not backgrounding in
// any sense the user would recognise.
if in_picture_in_picture {
return BackgroundAction::KeepPlaying;
}
// Music has no picture to lose; a music player that stopped when the screen
// locked would be broken in an obvious way.
if !is_video {
return BackgroundAction::KeepPlaying;
}
if background_audio_armed {
BackgroundAction::HandOffToAudio
} else {
BackgroundAction::Pause
}
}
/// Whether a media type has a picture that backgrounding would throw away.
///
/// TRACES: UR-040 | DR-224
pub fn is_video_media(media_type: MediaType) -> bool {
matches!(media_type, MediaType::Video)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn video_without_the_toggle_pauses() {
// THE REPORTED BUG. Native video runs in a foreground media service that
// keeps playing when the app is hidden, and nothing paused it -- so
// locking the screen gave background audio to a user who never asked
// for it.
assert_eq!(
background_action(true, false, false),
BackgroundAction::Pause
);
}
#[test]
fn video_with_the_toggle_hands_off_to_audio() {
assert_eq!(
background_action(true, true, false),
BackgroundAction::HandOffToAudio
);
}
#[test]
fn music_always_keeps_playing() {
// Backgrounding a music player and having it stop would be absurd. The
// toggle is irrelevant here: there is no picture to give up.
assert_eq!(
background_action(false, false, false),
BackgroundAction::KeepPlaying
);
assert_eq!(
background_action(false, true, false),
BackgroundAction::KeepPlaying
);
}
#[test]
fn picture_in_picture_is_not_backgrounding() {
// The video is in a floating window and still on screen. Pausing would
// break PiP (UR-041), which is a separate feature reached through
// onUserLeaveHint rather than onStop.
assert_eq!(
background_action(true, false, true),
BackgroundAction::KeepPlaying
);
assert_eq!(
background_action(true, true, true),
BackgroundAction::KeepPlaying
);
}
#[test]
fn the_rule_does_not_depend_on_the_renderer() {
// There is deliberately no renderer parameter. The WebView path and the
// native path must answer identically -- the split between them is what
// produced the defect, because the toggle only ever reached one.
for armed in [true, false] {
let once = background_action(true, armed, false);
let again = background_action(true, armed, false);
assert_eq!(once, again);
}
}
#[test]
fn only_video_counts_as_having_a_picture() {
assert!(is_video_media(MediaType::Video));
assert!(!is_video_media(MediaType::Audio));
}
}
+1
View File
@@ -4,6 +4,7 @@
// DR-001, DR-004, DR-005, DR-009, DR-028, DR-029, DR-047
pub mod autoplay;
pub mod backend;
pub mod background_policy;
pub mod events;
pub mod media;
pub mod queue;
+2 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "JellyTau",
"version": "0.10.0",
"version": "0.10.1",
"identifier": "com.dtourolle.jellytau",
"build": {
"beforeDevCommand": "bun run dev",
@@ -44,6 +44,7 @@
},
"bundle": {
"active": true,
"createUpdaterArtifacts": true,
"targets": [
"deb",
"rpm",
+42 -7
View File
@@ -19,6 +19,31 @@ export const commands = {
async playerPlayItem(item: PlayItemRequest) : Promise<PlayerStatus> {
return await TAURI_INVOKE("player_play_item", { item });
},
/**
* 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).
*
* Returns the position in seconds. The sleep timer is intentionally left
* untouched if it fired while backgrounded, playback is already stopped and
* this simply reports the last position.
*
* What playback should do now that the app is no longer visible.
*
* The caller supplies only what it alone knows -- whether the per-player
* toggle is armed, and whether Android put the window into picture-in-picture.
* Everything else (what is playing, and therefore whether there is a picture to
* lose) is read here, because it is domain state.
*
* The rule itself is in `player::background_policy`; this command is the wire.
* Returning `KeepPlaying` for an empty queue is deliberate: with nothing
* playing there is nothing to pause, and an error would make the frontend
* handle a case that is not a failure.
*
* TRACES: UR-040, UR-041 | DR-224 | UT-211
*/
async playerBackgroundAction(backgroundAudioArmed: boolean, inPictureInPicture: boolean) : Promise<BackgroundAction> {
return await TAURI_INVOKE("player_background_action", { backgroundAudioArmed, inPictureInPicture });
},
/**
* Enter background-audio mode: hand playback of the currently-watched video off
* to the native ExoPlayer *audio* path so the audio keeps playing while the app
@@ -41,13 +66,6 @@ async playerEnterBackgroundAudio(item: PlayItemRequest, positionSeconds: number)
return await TAURI_INVOKE("player_enter_background_audio", { item, positionSeconds });
},
/**
* 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).
*
* Returns the position in seconds. The sleep timer is intentionally left
* untouched if it fired while backgrounded, playback is already stopped and
* this simply reports the last position.
*
* TRACES: UR-040 | DR-052 | UT-061, IT-013
*/
async playerExitBackgroundAudio() : Promise<number> {
@@ -1976,6 +1994,23 @@ countdownSeconds: number;
* Maximum number of episodes to auto-play consecutively (0 = unlimited)
*/
maxEpisodes?: number }
/**
* What the player should do when the app is backgrounded.
*/
export type BackgroundAction =
/**
* Carry on. Music, and video the user explicitly asked to keep hearing
* while it is in a picture-in-picture window.
*/
"keepPlaying" |
/**
* Swap the video stream for an audio-only one and keep playing.
*/
"handOffToAudio" |
/**
* Stop making sound. The user did not ask for background playback.
*/
"pause"
/**
* Smart caching configuration
*/
+57 -2
View File
@@ -4,7 +4,7 @@
import { get } from "svelte/store";
import { goto } from "$app/navigation";
import { commands } from "$lib/api/bindings";
import type { JRayActor, StreamingQuality } from "$lib/api/bindings";
import type { JRayActor, StreamingQuality, BackgroundAction } from "$lib/api/bindings";
import { listen } from "@tauri-apps/api/event";
import Hls from "hls.js";
import type { MediaItem } from "$lib/api/types";
@@ -63,6 +63,7 @@
import {
setBackgroundAudioEnabled,
subscribeAppBackgrounded,
type BackgroundSignal,
subscribeAppForegrounded,
} from "$lib/utils/backgroundAudio";
import { platform } from "@tauri-apps/plugin-os";
@@ -825,7 +826,7 @@
// flip the component into HTML5 mode). Unsubscribers go into nativeUnlisteners
// so onDestroy tears them down.
if (backgroundAudioSupported) {
nativeUnlisteners.push(subscribeAppBackgrounded(enterBackgroundAudioHandoff));
nativeUnlisteners.push(subscribeAppBackgrounded(onAppBackgrounded));
nativeUnlisteners.push(subscribeAppForegrounded(exitBackgroundAudioHandoff));
}
@@ -1722,6 +1723,9 @@
const backgroundAudioSupported = platform() === "android";
let backgroundAudioOn = $state(false); // v1: default OFF each session
let handoffState: BackgroundAudioState = { ...initialHandoffState };
// Set when backgrounding paused playback, so foregrounding resumes only
// what we stopped -- never something the user paused themselves.
let pausedByBackgrounding = false;
function toggleBackgroundAudio() {
backgroundAudioOn = !backgroundAudioOn;
@@ -1737,6 +1741,43 @@
// App went to background/locked while background-audio is armed: hand off to
// native audio and stop the WebView video decode.
async function onAppBackgrounded(signal: BackgroundSignal) {
// What to do is a domain decision, not a presentation one: it depends on
// whether the item has a picture to lose, which is Rust's to know. This used
// to be decided implicitly by Kotlin gating the event on the toggle, which
// is why the native path -- whose media service keeps playing regardless --
// ignored the toggle entirely (DR-224).
let action: BackgroundAction;
try {
action = await commands.playerBackgroundAction(
signal.backgroundAudioArmed,
signal.inPictureInPicture,
);
} catch (e) {
// Never leave playback in an undefined state because a decision call
// failed. Continuing is the old behaviour and the safer default: it
// cannot silently stop something the user is listening to.
log.warn("Background action lookup failed; leaving playback alone:", e);
return;
}
log.debug("Background action:", action);
switch (action) {
case "keepPlaying":
return;
case "pause":
// The user did not ask for background playback. Remember that WE paused
// it, so returning to the foreground can resume rather than leaving a
// video mysteriously stopped.
pausedByBackgrounding = isPlaying;
if (isPlaying) await playerController.pause();
return;
case "handOffToAudio":
await enterBackgroundAudioHandoff();
return;
}
}
async function enterBackgroundAudioHandoff() {
if (!shouldEnterBackgroundAudio(backgroundAudioOn, handoffState)) return;
// `currentTime` is the component's authoritative ABSOLUTE position (the RAF
@@ -1797,6 +1838,20 @@
// App returned to foreground: stop native audio, reload the WebView <video> at
// the position native reached, and restore play/pause.
async function exitBackgroundAudioHandoff() {
// Resume what backgrounding paused, before the handoff check: the pause path
// and the handoff path are mutually exclusive, and this one leaves no
// handoff state to unwind. Only resumes if WE paused it -- a user who
// paused before locking the screen stays paused.
if (pausedByBackgrounding) {
pausedByBackgrounding = false;
try {
await playerController.play();
} catch (e) {
log.warn("Failed to resume after backgrounding:", e);
}
return;
}
if (!shouldExitBackgroundAudio(handoffState)) return;
// Read the native player's state BEFORE exiting — the exit stops it. If the
// user hit pause on the lockscreen while backgrounded, that pause must
+24 -3
View File
@@ -66,10 +66,31 @@ export function setBackgroundAudioEnabled(enabled: boolean): boolean {
* Returns an unsubscribe function. No-op where unsupported (the event never
* fires on non-Android platforms).
*/
export function subscribeAppBackgrounded(handler: () => void): () => void {
export interface BackgroundSignal {
/** Whether the per-player background-audio toggle was armed (UR-040). */
backgroundAudioArmed: boolean;
/** Whether Android put the window into picture-in-picture (UR-041). */
inPictureInPicture: boolean;
}
/**
* Older builds dispatched this event with no detail, and only when the toggle
* was already armed. Treat a missing detail as "armed, not PiP" so a mismatched
* pair degrades to the previous behaviour rather than pausing unexpectedly.
*/
function readSignal(event: Event): BackgroundSignal {
const detail = (event as CustomEvent).detail as Partial<BackgroundSignal> | null | undefined;
return {
backgroundAudioArmed: detail?.backgroundAudioArmed ?? true,
inPictureInPicture: detail?.inPictureInPicture ?? false,
};
}
export function subscribeAppBackgrounded(handler: (signal: BackgroundSignal) => void): () => void {
if (typeof window === "undefined") return () => {};
window.addEventListener("jellytau-background", handler);
return () => window.removeEventListener("jellytau-background", handler);
const listener = (event: Event) => handler(readSignal(event));
window.addEventListener("jellytau-background", listener);
return () => window.removeEventListener("jellytau-background", listener);
}
/** Subscribe to the native "app foregrounded" signal. Returns an unsubscribe fn. */