Compare commits

..
Author SHA1 Message Date
dtourolleandClaude Opus 5 adc460f35d fix(downloads): honor the selected bitrate (videoBitRate, capital R)
Downloading at a specific quality silently returned the full-size
original. The download URL builder spelled the transcode params
`videoBitrate`/`audioBitrate`, but Jellyfin binds `videoBitRate`/
`audioBitRate` — with a capital R.

Query-key binding is case-insensitive, so this is not a casing
preference: the lowercase-r form is a different token that fails to
bind. The server discards it without error and then stream-copies the
source, so picking "480p" produced an original-quality file with no
failure surfaced anywhere. `maxHeight`/`videoCodec` were unaffected
(case-insensitive binding covers them), which is why the height cap
applied while the bitrate cap vanished.

Also set `allowVideoStreamCopy=false` on the transcode presets to force
a real re-encode. Video stream-copy is gated by `allowVideoStreamCopy`,
not `enableAutoStreamCopy` — the latter governs audio only.

`original` is unchanged: it stays a deliberate direct static copy, now
pinned by a test.

The pre-existing unit tests asserted the broken lowercase-r spellings,
so they passed against broken code; corrected. Verified red -> green by
extracting the pre-fix and post-fix builder bodies into an isolated
harness: 15 assertion failures before, 0 after.

TRACES: UR-071 | DR-123

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 18:44:54 +02:00
dtourolleandClaude Opus 5 3619f71aba build: make the git tag the single source of truth for the version (DR-153)
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 6m55s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m21s
Traceability Validation / Check Requirement Traces (push) Successful in 15s
Build & Release / Run Tests (push) Successful in 7m36s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 2m57s
Build & Release / Build Linux (push) Successful in 20m4s
Build & Release / Build Windows (push) Successful in 8m42s
Build & Release / Build Android (push) Successful in 30m30s
Build & Release / Create Release (push) Successful in 17s
The version lived in four files — package.json, tauri.conf.json, Cargo.toml and
Cargo.lock — that had to be hand-edited in lockstep, and the release workflow
rewrote exactly one of them. A tagged build therefore produced an installer
named for the tag wrapped around package metadata naming the previous release,
and the Linux job, which had no version step at all, shipped whatever happened
to be committed.

scripts/set-version.sh now writes all four from one argument and is the only
thing that does. Every release job calls it with the tag, including the Linux
job that was missing one. The committed versions become a placeholder for dev
builds rather than something to maintain by hand.

The Android versionCode moves into the same script, unchanged in formula
(1000 + major*10000 + minor*100 + patch). It stays inline-documented because the
reasoning is not obvious: builds already in the field shipped code 1000, and
Android refuses an update whose code is lower than the installed one, so a
formula that can emit a smaller number for a newer release bricks updates
irreversibly. UT-150 asserts that property directly — monotonic across an
upgrade sequence, and always above the floor.

Two edge cases the previous inline version got wrong:

- A prerelease tag (v0.6.0-rc1) made $(( 0-rc1 )) abort the step under set -e.
  The suffix is stripped before the arithmetic; the manifests keep it.
- CI passes "${GITHUB_REF#refs/tags/}" unconditionally, which on a branch build
  is still a full ref. That reached the validator verbatim and would have failed
  every untagged Android build; a non-tag ref now falls back to git describe.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 21:21:58 +02:00
dtourolle 8e081845d0 Merge pull request 'Feat/android native video' (#13) from feat/android-native-video into master
Reviewed-on: #13
2026-08-11 19:03:45 +00:00
dtourolleandClaude Opus 5 5fa74d9e34 docs: renumber to DR-150/151/152 after rebase onto master
🏗️ Build and Test JellyTau / Run Tests (pull_request) Successful in 7m56s
Traceability Validation / Check Requirement Traces (pull_request) Successful in 24s
🏗️ Build and Test JellyTau / Android Compile Check (pull_request) Successful in 2m49s
master landed DR-148 and DR-149 for unrelated audio-decode work (0.4.7/0.4.8)
while this branch was in flight, and both sides claimed the same two IDs. The
native-video requirements move to DR-150 (native rendering behind the flag),
DR-151 (the severed SurfaceView attach chain) and DR-152 (capabilities reported
by Rust). UT-090 was likewise already taken by the seek-bar test, so the adapter
selection test moves to UT-149 and is registered in the table.

The spec header also cited DR-023/DR-024, which are the subtitle and audio-track
selection UI requirements — unrelated to this work. Corrected, with a note so the
wrong IDs are not reintroduced from the draft.

extract-traces.test.ts asserts the live requirement counts on purpose, so adding
three DRs moves DR 144→147 and total 282→285.

Subtitles on the native path are not a regression from this branch: master's
6a712c4 already fixed the root cause (MediaItem.subtitles was hardcoded to
vec![], so ExoPlayer always received zero SubtitleConfigurations) and that fix is
now underneath these commits.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 20:57:58 +02:00
dtourolleandClaude Opus 5 c480276a97 docs(spec): native video confirmed working on device
The spike's central question — can a SurfaceView be composited behind a
transparent Tauri WebView on Android — is answered yes, verified on a physical
device. No upstream issue blocked it and none demonstrated it; this appears to
be the first working instance.

Marks DR-148 done behind the flag and records what is confirmed versus what is
still open: playback and positioning are verified, but the individual native
controls (seek, audio-track, subtitle), the mini-player transition, and the
MediaCodec hardware-decode claim are not yet each measured. The mini-player
transition is called out as the known gap, since it is the one case where the
fullscreen assumption behind "no rect plumbing needed" does not hold.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 20:57:58 +02:00
dtourolleandClaude Opus 5 ca490c34ec docs(traceability): regenerate matrix for the native-video requirements
DR-148/149/150 now resolve; coverage 86% (243/283), no orphans.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 20:57:58 +02:00
dtourolleandClaude Opus 5 e144e62b31 feat(player): render Android video natively behind a transparent webview (DR-150, DR-151, DR-152)
Rust already reported `use_html5_element: false` on Android, but two frontend
overrides threw that answer away, so ExoPlayer's video path had never actually
run. Both are lifted behind an `experimentalNativeVideo` opt-in (default off).

The flag is a suppressor, never a promoter: off forces HTML5 even where Rust
says native, so an in-progress spike cannot ship as the default, but it can
never select native where Rust reported HTML5 — Linux cannot composite behind
WebKitGTK, and promoting there would be a black screen.

Two blockers the spec did not anticipate, both in code assumed to be merely
unreachable rather than broken:

- `JellyTauPlayer.setActivity()` had zero callers, so `currentActivity` was
  always null and `autoAttachSurface()` bailed. The SurfaceView was created and
  wired to ExoPlayer but never added to the view hierarchy — video would have
  decoded to a surface that was never on screen, whatever the webview did.
  This also revives PiP on the video path, which gated on the same flag.
- `createAdapter()` was not the real gate; it is never called in production.
  The actual override was in VideoPlayer.svelte, which forced HTML5 and stopped
  the native backend `player_play_item` had just started. Both sites now route
  through `createAdapter()`.

Compositing needs two independent opaque layers cleared, not one. Clearing only
the page leaves the WebView widget opaque — audio over a black picture, exactly
the symptom the old INTERIM comment described. `videoSurface.ts` toggles both:
the widget background and window drawable from Kotlin, the page backgrounds via
a `data-native-video` attribute keyed by app.css. Transparency lives in
`tauri.android.conf.json` so Linux keeps an opaque window, and is scoped to the
playback session so the launcher never shows through the rest of the app.

Phase 3's rect plumbing turned out to be unnecessary: video is fullscreen on the
player route, and `fitSurfaceToScreen()` already letterboxes and re-fits on
rotation. The mini-player transition remains unverified on device.

Also removes the `navigator.userAgent` sniffing in webviewAudio.ts, which was a
second copy of the Rust cfg gate free to drift from it. `player_get_capabilities`
now reports `usesWebviewAudio` and `supportsNativeVideo` from those same gates.

Tests: adapter selection covers the full matrix, including the regression guard
that the flag off beats Rust. Written first and confirmed failing (2 of 7) before
the fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 20:57:58 +02:00
23 changed files with 4262 additions and 3016 deletions
+22 -44
View File
@@ -96,6 +96,12 @@ jobs:
- name: Install dependencies
run: bun install
# The Linux job previously had no version step at all, so a tagged release
# built Linux packages from whatever version happened to be committed.
- name: Set app version from tag
run: ./scripts/set-version.sh "${GITHUB_REF#refs/tags/}"
if: startsWith(github.ref, 'refs/tags/v')
- name: Build for Linux
run: bun run tauri build
env:
@@ -156,15 +162,13 @@ jobs:
restore-keys: |
${{ runner.os }}-bun-
# The tag is the single source of truth for a release version; the script
# stamps every file that carries it (package.json, tauri.conf.json,
# Cargo.toml, Cargo.lock). This step used to sed only tauri.conf.json, so
# the other three shipped whatever was committed.
- name: Set app version from tag
run: |
# On a tag build the tag is the single source of truth for the version.
if echo "$GITHUB_REF" | grep -q '^refs/tags/v'; then
VERSION="${GITHUB_REF#refs/tags/v}"
echo "Setting version to $VERSION"
sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"$VERSION\"/" src-tauri/tauri.conf.json
fi
grep '"version"' src-tauri/tauri.conf.json
run: ./scripts/set-version.sh "${GITHUB_REF#refs/tags/}"
if: startsWith(github.ref, 'refs/tags/v')
- name: Build Windows (NSIS installer + exe)
run: OUTPUT_DIR="$PWD/dist/windows" WIN_BUNDLES=nsis ./scripts/build-windows-cross.sh
@@ -217,48 +221,22 @@ jobs:
- name: Install dependencies
run: bun install
# Stamp before `android init`: it derives its generated project (including
# the initial versionCode) from tauri.conf.json.
- name: Set app version from tag
run: |
# On a tag build, the tag is the single source of truth for the
# version name. On non-tag runs keep whatever is in tauri.conf.json.
if echo "$GITHUB_REF" | grep -q '^refs/tags/v'; then
VERSION="${GITHUB_REF#refs/tags/v}"
echo "Setting version to $VERSION"
sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"$VERSION\"/" src-tauri/tauri.conf.json
fi
grep '"version"' src-tauri/tauri.conf.json
run: ./scripts/set-version.sh "${GITHUB_REF#refs/tags/}"
if: startsWith(github.ref, 'refs/tags/v')
- name: Initialize Android project
run: bun run tauri android init
# Re-run after init: tauri.properties only exists now, and its
# autogenerated versionCode (0.0.15 -> 15) is both tiny and NOT monotonic
# against the 1000 floor already shipped in the field. The script rewrites
# it as 1000 + major*10000 + minor*100 + patch. Runs unconditionally so
# untagged builds get a sane code too, derived from git describe.
- name: Pin a monotonic Android versionCode
run: |
# `tauri android init` autogenerates src-tauri/gen/android/app/tauri.properties
# with a versionCode derived from the semver (e.g. 0.0.15 -> 15). That
# number is (a) tiny and (b) NOT monotonic across our history: earlier
# local/dev builds shipped versionCode 1000 (from a 0.1.0 config), so a
# plain 15 would be a *downgrade* and Android would refuse the update.
#
# Derive an explicit code that is both monotonic in semver order and
# always above the 1000 floor already in the field:
# code = 1000 + major*10000 + minor*100 + patch
# e.g. 0.0.14 -> 1014, 0.0.15 -> 1015, 0.1.0 -> 1100, 1.0.0 -> 11000.
# POSIX sh only (the runner uses dash): no here-strings, no \s in sed.
PROPS="src-tauri/gen/android/app/tauri.properties"
VERSION=$(grep '"version"' src-tauri/tauri.conf.json | head -1 | sed -E 's/.*"version"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/')
MAJ=$(echo "$VERSION" | cut -d. -f1)
MIN=$(echo "$VERSION" | cut -d. -f2)
PAT=$(echo "$VERSION" | cut -d. -f3)
# Guard against a malformed/missing component so we never emit code 0.
: "${MAJ:=0}" "${MIN:=0}" "${PAT:=0}"
CODE=$(( 1000 + MAJ*10000 + MIN*100 + PAT ))
echo "version=$VERSION -> versionCode=$CODE"
if grep -q '^tauri.android.versionCode=' "$PROPS"; then
sed -i "s/^tauri.android.versionCode=.*/tauri.android.versionCode=$CODE/" "$PROPS"
else
echo "tauri.android.versionCode=$CODE" >> "$PROPS"
fi
cat "$PROPS"
run: ./scripts/set-version.sh "${GITHUB_REF#refs/tags/}"
- name: Sync custom Android sources & gradle config
run: ./scripts/sync-android-sources.sh
+52
View File
@@ -6,6 +6,58 @@ Entries are grouped by the capability they change, not by commit. Requirement
IDs in parentheses point at [docs/requirements.md](docs/requirements.md); the
generated trace matrix lives in [docs/traceability.md](docs/traceability.md).
## v0.5.0
### ✨ Features
- **Android video can render on the device's own video surface.** Settings →
Video Playback → **Native Video** (experimental, off by default) hands
decoding to ExoPlayer, which draws into a surface composited *behind* a
transparent WebView, with the player controls layered on top of it.
The backend had reported "this platform has a native video surface" on Android
all along, but the frontend threw that answer away in two separate places, so
the path had never actually run. Both are lifted. The setting can only ever
*suppress* the backend's choice, never override it upward: turning it off
forces the web player even where native is available, and turning it on does
nothing on platforms whose backend never offered it — Linux cannot composite
behind its webview, so it stays on the web player either way.
Verified playing on a physical device. Still unverified: the mini-player
transition, audio-track switching on the native path, and whether hardware
decoding measurably improves battery or CPU — so the toggle stays off by
default. (UR-003, UR-004 → DR-150)
### 🐛 Fixes
- **The video surface now reaches the screen at all.** The player built its
video surface, handed it to ExoPlayer, and then never added it to the view
hierarchy, because the Activity reference it needed was never supplied — so
native video would have decoded to a surface nobody could see, whatever else
was fixed. This also silently disabled picture-in-picture for video, which
gated on that same never-attached surface. (UR-003, UR-041 → DR-151)
- **Platform playback support is no longer guessed from the browser user
agent.** The frontend re-derived "does this platform decode audio natively" by
string-matching `navigator.userAgent` — a second copy of a decision the
backend already makes, free to drift out of step with the backends it was
describing. The backend now reports its own capabilities and the frontend
consumes them. (UR-003, UR-005 → DR-152)
### 🔧 Internal
- **The git tag is now the single source of truth for a release version.** The
version lived in four files that had to be edited in lockstep, and the release
workflow rewrote exactly one of them — so a tagged build produced an installer
named for the tag wrapped around package metadata naming the *previous*
release, and the Linux job, which had no version step at all, shipped whatever
happened to be committed. `scripts/set-version.sh` now writes all four from
one argument and every release job calls it with the tag. The Android
`versionCode` is derived in the same place, guarded by tests for the property
that actually matters: it must increase monotonically and stay above the value
already installed in the field, or Android silently refuses the update.
(DR-153)
## v0.4.8
### 🐛 Fixes
+6
View File
@@ -312,6 +312,10 @@ Internal architecture, components, and application logic.
| DR-146 | The no-audio-track fallback picks a track the renderer can actually play. When ExoPlayer selected no audio track, the recovery forced group 0 / track 0 unconditionally — but the most likely reason nothing was selected is that this very track cannot be decoded on this device, so the override reinstated the silence it was meant to fix. It now scans the groups for the first `isTrackSupported` track and overrides to that, and clears `setTrackTypeDisabled(TRACK_TYPE_AUDIO)` because audio may equally have been off at the type level, which an override alone does not undo. When no group holds a supported track the condition is logged as an error — the server was expected to transcode — rather than leaving a silent video with no explanation in the log | Playback | UR-004 | Done |
| DR-148 | The video direct-play profile advertises only what the **webview** can decode. The audio codec list comes from `MediaCodecList`, which describes ExoPlayer — but video does not play through ExoPlayer on either platform: Android force-renders every video in the webview `<video>` element (the interim override in `VideoPlayer.svelte`, because the native SurfaceView sits behind an opaque webview) and Linux always has. Chromium and WebKit decode a far narrower set than the platform does, and the gap is widest on devices whose vendor licenses Dolby: a phone shipping `/vendor/etc/media_codecs_dolby_audio.xml` reports `ac3,eac3`, so Jellyfin direct-played an E-AC-3 track with `static=true` and the webview built a video decoder and no audio decoder at all — full picture, no sound. The defect is triggered by *capability*, not the lack of it, which is why it reproduced on one Motorola while a Fairphone and an Honor tablet played the same file on the same build: a device without the Dolby decoder never claims the codec, so the server transcodes to AAC and it plays. `video_audio_codecs` narrows the platform list to the webview-decodable set (`aac,mp3,opus,vorbis,flac`) for the video direct-play profile *only* — the audio-only profile keeps the full list, since that playback really is the native player's and narrowing it would transcode music that plays perfectly well. A list with nothing decodable still claims `aac` rather than going out empty, because a profile that claims nothing invites the server to give up instead of transcoding. The video codec list is deliberately untouched: HEVC direct-plays through the webview correctly, so the constraint is specific to audio | Playback | UR-004 | Done |
| DR-149 | The client decides whether its own renderer can decode the audio, rather than trusting the server's negotiation. Advertising a webview-shaped profile (DR-148) turned out to be necessary but not sufficient: Jellyfin 10.11.5 enforces a `DirectPlayProfile`'s `Container` and `VideoCodec` — excluding either returns `SupportsDirectPlay: false` with `TranscodeReasons=ContainerNotSupported` / `VideoCodecNotSupported` — but **ignores its `AudioCodec`**, offering an E-AC-3 track for direct play against a profile listing only `aac,flac,mp3,opus,vorbis`. Neither a `VideoAudio` `CodecProfile` forbidding the codec nor a `MaxAudioChannels: 2` against a 6-channel track changes the answer, so no profile the client can send fixes it and the picture plays silent. The negotiated source's audio is therefore checked locally against what the webview decodes, and an undecodable track forces the existing h264/aac HLS transcode URL regardless of the server saying direct play is fine — `direct_play` and `needs_transcoding` are corrected to match, so the frontend and the reporting path agree with the URL actually used. The track judged is the one the server would serve: the default, or the first when nothing is marked default, since a supported track further down the list is not the one that plays. A source with no audio streams, or a stream whose codec the server did not name, is left alone — forcing a transcode on a guess spends server CPU on files that already play | Playback | UR-004 | Done |
| DR-150 | Android video renders on the native ExoPlayer surface behind a transparent WebView, behind the `experimentalNativeVideo` opt-in. Rust already reported `use_html5_element: false` on Android, but two frontend overrides discarded it — `createAdapter()` hardcoded `"html5"`, and `VideoPlayer.svelte` forced `useHtml5Element = true` and stopped the native backend `player_play_item` had just started. The flag is a **suppressor, never a promoter**: off forces HTML5 even where Rust says native, so an in-progress spike cannot ship as the default, but it can never select native where Rust reported HTML5 (Linux cannot composite behind WebKitGTK, so promoting there is a black screen). Compositing requires clearing two independent opaque layers, and clearing only one leaves audio over a black picture — the WebView widget background and window drawable from Kotlin (`AndroidVideoSurface.setTransparent`), and the page's `html`/`body` and app-shell background from CSS (`data-native-video`). Transparency is declared in `tauri.android.conf.json` rather than the base config, because a transparent window on Linux has nothing behind it, and is toggled per playback session rather than set once, because a permanently transparent window shows the launcher through the rest of the app | Playback | UR-003, UR-004 | Done (behind `experimentalNativeVideo`, default off) |
| DR-151 | The player's video SurfaceView actually reaches the view hierarchy. `JellyTauPlayer.setActivity()` had zero callers, so `currentActivity` was always null and `autoAttachSurface()` returned at "Cannot attach surface - no Activity reference". The surface was created and handed to ExoPlayer but never added to the content view, so native video decoded to a surface that was never on screen — independent of any webview transparency. `MainActivity.onCreate` now supplies the reference, which also revives PiP on the video path: `canEnterPip()` gates on `isVideoSurfaceAttached()`, which had been permanently false | Playback | UR-003, UR-041 | Done |
| DR-152 | Platform playback facilities are reported by Rust, not sniffed from the user agent. `webviewAudio.ts` re-derived "does this platform have a native audio backend" by matching `navigator.userAgent` against `android`/`linux` — a second copy of the `cfg!` gate the backends are compiled under, free to drift from it. `player_get_capabilities` now returns `usesWebviewAudio` and `supportsNativeVideo` from the same cfg gates, and the frontend consumes them; the settings toggle for native video is hidden entirely where the platform cannot support it | Player | UR-003, UR-005 | Done |
| DR-153 | The git tag is the single source of truth for a release version. The version lived in four files (`package.json`, `tauri.conf.json`, `Cargo.toml`, `Cargo.lock`) that had to be hand-edited in lockstep, and CI's release job rewrote exactly one of them — so a tagged build produced an installer named for the tag wrapped around package metadata naming the previous release, while the Linux job had no version step at all and shipped whatever was committed. `scripts/set-version.sh` writes all four from one argument and is the only thing that does; every release job calls it with the tag. The Android `versionCode` is derived in the same place as `1000 + major*10000 + minor*100 + patch`, which is monotonic in semver order and clears the 1000 floor already installed in the field — a lower code than the installed one makes Android refuse the update. A prerelease suffix is stripped before that arithmetic, which would otherwise abort the script, and a non-tag ref (CI passes `${GITHUB_REF#refs/tags/}` unconditionally) falls back to `git describe` rather than failing a branch build | Build | - | Done |
| DR-143 | Flipping the offline downloaded-only gate actually re-queries the listing. The gate (DR-078) is a process-wide flag in Rust consulted only *while a query runs*, but no library surface re-queried when its inputs changed: `useServerReachabilityReload` fires only on the offline → **online** transition, and `GenericMediaListPage`, `GenericGenreBrowser` and the favourites page never even called its `checkServerReachability`. So going offline left the full server catalog on screen under a now-closed gate, and toggling "Show all server media" only greyed cards — `MediaCard.isServerOnly` is a pure frontend derivation that updates instantly — without adding or removing a single row. The filter therefore read as "shows everything until I filter, then greys some of it" while the backend gate was correct and simply never exercised. `catalogFilterVersion` is the refetch signal: `pushCatalogVisibility` now awaits `set_show_server_catalog` and bumps the version only **after** the backend accepts the new flag, since a reload racing the push would re-query under the old gate and undo itself. A failed push clears `lastIncludeCatalog` instead of latching it, so the next identical transition is retried rather than skipped as a no-op and left permanently disagreeing with the backend. `useOfflineFilterReload` subscribes pages to that signal, skipping the value they already loaded under; it is wired into both generic list components and the movies/music/tv/favourites landing pages and the `/library/[id]` detail page | UI | UR-052 | Done |
| DR-135 | A download's media type comes from the item, not a default. `download_item` — the path a media card uses to queue an item while offline — never records `media_type`, and the reconnect resolver read that NULL as `'audio'`, so a **movie** queued from a card had its URL resolved by `get_audio_stream_url`. The file that landed on disk was an audio-only transcode, which is why a "downloaded" film could never play offline no matter how the path or protocol was fixed. The resolver now falls back to the item's own `item_type` (`VIDEO_ITEM_TYPES` in Rust, so the frontend never learns which types are video) and only defaults to audio when the item is not cached locally. An explicit `media_type` on the row still wins | Downloads | UR-071, UR-052 | Done |
| DR-136 | Rows already downloaded under the audio default are repaired, not just prevented. They are identifiable after the fact — no `media_type`, but a video item — so on reconnect they are reset to `pending` with their audio URL cleared and re-resolved by DR-135's corrected logic, overwriting the audio file in place. Without this the fix is invisible to anyone who had already queued a film: the row still reads "downloaded" and still fails to play. Rows carrying an explicit `media_type` and genuine audio downloads are left untouched | Downloads | UR-071 | Done |
@@ -543,6 +547,8 @@ 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-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-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 |
+97 -11
View File
@@ -1,7 +1,19 @@
# Spec: Android native video — transparent-webview spike
**Status:** Proposed (spike — timeboxed, may conclude "not viable")
**Requirements:** IR-004, UR-003, UR-004 → DR-001, DR-023, DR-024
**Status:** Spike succeeded — native video confirmed working on a physical
device (2026-08-11) with `experimentalNativeVideo` on. Shipped behind that flag,
default off. Branch `feat/android-native-video`.
**The spike's central question is answered: yes.** A `SurfaceView` *can* be
composited behind a transparent Tauri WebView on Android. Nothing upstream
blocked it and nothing upstream demonstrated it — this is, as far as the issue
trackers show, the first working instance. The remaining flag is about test
coverage and the unverified cases below, not about viability.
**Requirements:** IR-004, UR-003, UR-004, UR-041 → DR-001, DR-004, DR-150, DR-151, DR-152
**Note:** the original draft cited DR-023/DR-024 here. Those are the *subtitle*
and *audio-track selection UI* requirements — unrelated to this work. The IDs
actually implemented are DR-150 (native rendering behind the flag), DR-151 (the
severed SurfaceView attach chain) and DR-152 (capabilities reported by Rust).
**UX spec:** n/a — no intended visual change; the video surface must land exactly where the `<video>` element is today
**Supersedes / revises:** acts on finding 2 of [playback-backend-unification.md](playback-backend-unification.md)
@@ -109,6 +121,46 @@ in here rather than leaving a second, subtler copy of the bug behind. If
`get_player_status` does not currently expose enough to cover the audio case, add
the field — that is backend work, and correct.
### Implementation findings (2026-08-11)
Two blockers existed that this spec did not anticipate. Both were in code the
spec assumed was merely *unreachable*; it was also *broken*.
**1. The Kotlin attach chain was severed.** `JellyTauPlayer.setActivity()` had
**zero callers** anywhere in the tree. `currentActivity` was therefore always
null, so `autoAttachSurface()` logged "Cannot attach surface - no Activity
reference" and returned. The `SurfaceView` was created and wired to ExoPlayer but
never added to the view hierarchy — video would have decoded to a surface that
was never on screen, *regardless* of webview transparency. Fixed by calling
`JellyTauPlayer.setActivity(this)` from `MainActivity.onCreate`.
Note the knock-on: `PictureInPictureManager.canEnterPip()` gates on
`VideoOverlayManager.isVideoSurfaceAttached()`, which was permanently false. PiP
on the video path was dead for the same reason.
**2. `createAdapter()` was not the real gate.** It is never called by production
code — `VideoPlayer.svelte` constructs `Html5PlayerAdapter` directly. The actual
override was `VideoPlayer.svelte`'s INTERIM block, which read Rust's
`useHtml5Element`, forced it to `true`, and called `playerStop()` to kill the
native backend `player_play_item` had just started. Both sites are now fixed;
`VideoPlayer.svelte` routes through `createAdapter()` so there is one gate.
**Transparency needs two independent layers cleared,** not one. The spec's
Phase 1 named only `html, body`. Clearing just the page leaves the WebView
widget's own background opaque, which is a black screen with audio — the exact
symptom the INTERIM comment described as "native surface not visible". Both are
now toggled together by `$lib/utils/videoSurface.ts`:
| Layer | Cleared by | Reachable from |
|-------|-----------|----------------|
| WebView widget background + window drawable | `AndroidVideoSurface.setTransparent()` (MainActivity) | Kotlin only |
| `html`/`body` + app-shell `--color-background` | `data-native-video` attribute → app.css | CSS only |
Transparency is scoped to `tauri.android.conf.json` rather than the base config:
a transparent window on Linux is a regression, since nothing renders behind it.
It is also toggled per-session rather than set once — a permanently transparent
window shows the launcher through the rest of the app.
### Phase 3 — surface positioning
The hard part, and where this most likely fails. The webview's `<video>` element
@@ -124,6 +176,32 @@ the video is effectively fullscreen on Android, which it is in the player route.
rotation or the mini-player transition without visible artefacts, the spike fails
and we keep HTML5. Do not ship a janky native path for a codec win.
**Update: no rect plumbing was needed.** The premise — that the surface must be
positioned to match a laid-out `<video>` box — does not hold on the player route,
where video is fullscreen. `VideoOverlayManager` adds the SurfaceView at index 0
of `android.R.id.content` with `MATCH_PARENT`, and `fitSurfaceToScreen()`
(`JellyTauPlayer.kt`) already letterboxes/pillarboxes to the real video aspect
ratio and re-centres via a `Gravity.CENTER` `FrameLayout.LayoutParams`. Rotation
is handled by an `OnLayoutChangeListener` that re-fits on any bounds change. The
frontend's native branch is a bare `flex-1` box, so there is no rect to report
and nothing to keep in sync.
Fullscreen playback is confirmed working on device. But this reasoning rests
entirely on the fullscreen assumption, so **the mini-player transition is the
known gap** — it is the one case where the surface is *not* fullscreen, and
therefore the one case where the "no rect plumbing needed" conclusion could
still turn out to be wrong. If artefacts appear there, the fix is the rect
reporting this section originally proposed, scoped to that transition alone.
### A trap for the next implementer
There is a **stale duplicate player** at
`src-tauri/android/app/src/main/java/com/dtourolle/jellytau/player/JellyTauPlayer.kt`
(only commit: `cfddc1e` "First working POC"). No `sourceSets` entry points at it,
so it is not compiled — but edits made there silently do nothing. The canonical
tree is `src-tauri/android/src`, synced into `gen/` by
`scripts/sync-android-sources.sh`.
### What we gain if it works
- **Hardware decode via MediaCodec**`CodecDetector.kt` already reports
@@ -145,12 +223,13 @@ and we keep HTML5. Do not ship a janky native path for a codec win.
The spike is **complete** when one of these is true:
**Success path**
- [ ] Transparent WebView confirmed working on a physical device.
- [ ] `experimentalNativeVideo` off → behaviour byte-identical to today.
- [ ] `webviewAudio.ts` no longer inspects `navigator.userAgent`; the platform's audio backend is read from Rust.
- [ ] `experimentalNativeVideo` on → video plays via ExoPlayer/MediaCodec, correctly positioned, with working seek, audio-track switch, and subtitle selection through the existing `PlayerAdapter` contract.
- [ ] No artefacts on rotation, background/foreground, or mini-player transition.
- [ ] `adb shell dumpsys media.metrics` (or logcat) confirms a hardware decoder is in use.
- [x] Transparent WebView confirmed working on a physical device (reported by the maintainer; the config that enables it is now committed in `tauri.android.conf.json`).
- [x] `experimentalNativeVideo` off → behaviour byte-identical to today. Guarded by `adapterSelection.test.ts`, which asserts the flag-off case forces HTML5 even when Rust reports native.
- [x] `webviewAudio.ts` no longer inspects `navigator.userAgent`; the platform's audio backend is read from Rust (`player_get_capabilities``usesWebviewAudio`).
- [x] `experimentalNativeVideo` on → video plays via ExoPlayer, correctly positioned, on a physical device (2026-08-11). The surface reaches the hierarchy and is visible through the transparent WebView — the whole point of the spike.
- [ ] Seek, audio-track switch and subtitle selection exercised through `NativePlayerAdapter`. Playback is confirmed; these individual controls are not yet each verified on the native path.
- [ ] No artefacts on rotation, background/foreground, or **mini-player transition** — the last is the one case the fullscreen assumption does not cover, so it is the likeliest place to find a problem.
- [ ] `adb shell dumpsys media.metrics` (or logcat) confirms a hardware decoder is in use. Plausible but unmeasured — do not claim the MediaCodec win until this is read.
- [ ] Measured battery/thermal or CPU improvement over the HTML5 path on the same clip.
**Failure path**
@@ -159,8 +238,15 @@ The spike is **complete** when one of these is true:
- [ ] `nativeAdapter.ts:11-14` no longer cites tauri#10152.
Either way:
- [ ] `bun run check`, `bun run test`, `bun run check:boundary` pass.
- [ ] `cargo fmt` / `cargo clippy` clean; `bun run test:rust` passes.
- [x] `bun run check` (0 errors), `bun run test` (892 passed), `bun run check:boundary` pass.
- [x] `cargo fmt` / `cargo clippy` clean (no new warnings); `cargo test` passes (603 lib + 7 doc).
> Note: this environment has no host WebKitGTK dev packages, no Android SDK and
> no `bun`, so all of the above were run inside the CI builder image
> (`gitea.tourolle.paris/dtourolle/jellytau-builder:latest`). On Fedora the bind
> mount needs `:z` for SELinux, and `scripts/build-android.sh` hardcodes
> `ANDROID_HOME="$HOME/Android/Sdk"`, so the image's SDK at `/opt/android-sdk`
> must be symlinked there rather than passed by env var.
## Testing
@@ -179,7 +265,7 @@ native adapter), write the failing test first.
## TRACES
- `createAdapter``// TRACES: UR-003, UR-004 | DR-023, DR-024`
- `createAdapter``// TRACES: UR-003, UR-004 | DR-004, DR-150 | UT-149`
- Adapter-selection tests → `UT-xxx`
- No new requirement IDs; this spike either satisfies existing IR-004 expectations or documents why it cannot.
+3054 -2901
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -175,8 +175,8 @@ describe("live requirements.md", () => {
expect(defined.UR).toBe(71);
expect(defined.IR).toBe(32);
expect(defined.DR).toBe(144);
expect(defined.DR).toBe(148);
expect(defined.JA).toBe(35);
expect(defined.total).toBe(282);
expect(defined.total).toBe(286);
});
});
+111
View File
@@ -0,0 +1,111 @@
#!/bin/bash
# Stamp the release version into every file that carries it.
#
# The git tag is the single source of truth for a release version. The versions
# committed in package.json / tauri.conf.json / Cargo.toml are a placeholder for
# dev builds; a tagged build overwrites all of them from the tag so they cannot
# disagree with each other or with the tag.
#
# Usage:
# ./scripts/set-version.sh 0.5.0 # explicit
# JELLYTAU_VERSION=0.5.0 ./scripts/set-version.sh
# ./scripts/set-version.sh # derive from git describe (dev builds)
#
# Accepts the version with or without a leading "v".
#
# Why a script and not four sed lines in CI: the version lived in four files and
# CI only ever rewrote one of them (tauri.conf.json), so a tagged release shipped
# a matching installer name and mismatched package metadata. Keeping the write in
# one place is what makes "the tag is authoritative" actually true.
set -euo pipefail
cd "$(dirname "$0")/.."
VERSION="${1:-${JELLYTAU_VERSION:-}}"
# CI passes "${GITHUB_REF#refs/tags/}" unconditionally, which on an untagged
# build is still a full ref ("refs/heads/master"). Treat anything that is not a
# bare version as "no version given" and fall through to git describe, so a
# branch build gets a sane dev version instead of failing the job.
case "$VERSION" in
refs/*) VERSION="" ;;
esac
if [ -z "$VERSION" ]; then
# No explicit version: derive from the most recent tag. Dev builds land on
# something like 0.5.0 (exact tag) or 0.5.0-3-gabc1234 (ahead of the tag).
VERSION="$(git describe --tags --always --match 'v*' 2>/dev/null || echo "0.0.0")"
fi
# Tags are written v0.5.0; the files carry a bare semver.
VERSION="${VERSION#v}"
# Validate before writing anything — a malformed version silently propagated
# into four files is far worse than a failed script.
if ! echo "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+([-+][0-9A-Za-z.-]+)?$'; then
echo "❌ Not a valid semver: '$VERSION'" >&2
echo " Expected MAJOR.MINOR.PATCH with an optional -prerelease/+build suffix." >&2
exit 1
fi
echo "📌 Setting version to $VERSION"
# --- The three committed manifests -----------------------------------------
# Anchored to the first "version" key so a dependency's version is never hit.
# package.json — the top-level "version", which sits in the first few lines.
perl -0pi -e 's/("version"\s*:\s*)"[^"]*"/$1"'"$VERSION"'"/' package.json
# tauri.conf.json — likewise; this is the one the bundler reads for installer
# names, and the one CI used to patch alone.
perl -0pi -e 's/("version"\s*:\s*)"[^"]*"/$1"'"$VERSION"'"/' src-tauri/tauri.conf.json
# Cargo.toml — only the [package] version, never a dependency's. Restricted to
# the first occurrence of a line-anchored `version = "..."`.
perl -0pi -e 's/^(version\s*=\s*)"[^"]*"/$1"'"$VERSION"'"/m' src-tauri/Cargo.toml
# Cargo.lock — the jellytau entry. Left alone if the lock has not been generated
# yet; the next cargo invocation writes it. Cargo would otherwise rewrite the
# lock mid-build and dirty the tree.
if [ -f src-tauri/Cargo.lock ]; then
perl -0pi -e 's/(name = "jellytau"\nversion = )"[^"]*"/$1"'"$VERSION"'"/' src-tauri/Cargo.lock
fi
# --- Android versionCode ----------------------------------------------------
# Only when the generated Android project exists (i.e. after `tauri android
# init`); on Linux/Windows jobs there is nothing to stamp.
#
# `tauri android init` derives a versionCode from the semver (0.0.15 -> 15).
# That is both tiny and NOT monotonic across our history: earlier local/dev
# builds shipped versionCode 1000 (from a 0.1.0 config), so a plain 15 is a
# *downgrade* and Android refuses the update.
#
# code = 1000 + major*10000 + minor*100 + patch
# e.g. 0.0.14 -> 1014, 0.0.15 -> 1015, 0.1.0 -> 1100, 1.0.0 -> 11000.
PROPS="src-tauri/gen/android/app/tauri.properties"
if [ -f "$PROPS" ]; then
# Strip any -rc1/+build suffix first: it is not numeric, and feeding it to
# $(( )) would abort the script under `set -e`.
CORE="${VERSION%%-*}"
CORE="${CORE%%+*}"
MAJ=$(echo "$CORE" | cut -d. -f1)
MIN=$(echo "$CORE" | cut -d. -f2)
PAT=$(echo "$CORE" | cut -d. -f3)
: "${MAJ:=0}" "${MIN:=0}" "${PAT:=0}"
CODE=$(( 1000 + MAJ*10000 + MIN*100 + PAT ))
echo " versionCode=$CODE (from $CORE)"
if grep -q '^tauri.android.versionCode=' "$PROPS"; then
sed -i "s/^tauri.android.versionCode=.*/tauri.android.versionCode=$CODE/" "$PROPS"
else
echo "tauri.android.versionCode=$CODE" >> "$PROPS"
fi
fi
# --- Report -----------------------------------------------------------------
echo "✅ Version stamped:"
grep -m1 '"version"' package.json | sed 's/^/ package.json: /'
grep -m1 '"version"' src-tauri/tauri.conf.json | sed 's/^/ tauri.conf.json: /'
grep -m1 '^version' src-tauri/Cargo.toml | sed 's/^/ Cargo.toml: /'
[ -f "$PROPS" ] && grep '^tauri.android.versionCode=' "$PROPS" | sed 's/^/ tauri.properties: /'
exit 0
+169
View File
@@ -0,0 +1,169 @@
/**
* Guards for scripts/set-version.sh the release version stamper.
*
* TRACES: DR-153 | UT-150
*
* These run the real script against a throwaway copy of the manifests, because
* the failure modes are all in the shell, not in any TS logic: a regex that also
* matches a dependency's version, arithmetic that aborts on a `-rc1` suffix, or
* a CI ref reaching the validator verbatim.
*
* The versionCode formula matters most. Android refuses an update whose code is
* lower than the installed one, and builds already in the field shipped code
* 1000 so any formula that can emit a smaller number for a *newer* release
* bricks updates for those users, silently and irreversibly.
*/
import { describe, expect, it, beforeEach, afterEach } from "vitest";
import { execFileSync } from "child_process";
import * as fs from "fs";
import * as path from "path";
import * as os from "os";
const repoRoot = path.resolve(path.dirname(new URL(import.meta.url).pathname), "..");
const script = path.join(repoRoot, "scripts", "set-version.sh");
let tmp: string;
/** A minimal repo skeleton: just the files the script rewrites. */
function seed(dir: string) {
fs.mkdirSync(path.join(dir, "src-tauri", "gen", "android", "app"), { recursive: true });
fs.mkdirSync(path.join(dir, "scripts"), { recursive: true });
fs.copyFileSync(script, path.join(dir, "scripts", "set-version.sh"));
fs.chmodSync(path.join(dir, "scripts", "set-version.sh"), 0o755);
fs.writeFileSync(
path.join(dir, "package.json"),
JSON.stringify({ name: "jellytau", version: "0.0.1", dependencies: { hls: "1.2.3" } }, null, 2)
);
fs.writeFileSync(
path.join(dir, "src-tauri", "tauri.conf.json"),
JSON.stringify({ productName: "jellytau", version: "0.0.1" }, null, 2)
);
// A dependency carrying its own `version =` is the trap: a greedy regex
// rewrites it too and the build then resolves the wrong crate.
fs.writeFileSync(
path.join(dir, "src-tauri", "Cargo.toml"),
['[package]', 'name = "jellytau"', 'version = "0.0.1"', '', '[dependencies]', 'serde = { version = "1.0.100" }', ''].join("\n")
);
fs.writeFileSync(
path.join(dir, "src-tauri", "Cargo.lock"),
['[[package]]', 'name = "serde"', 'version = "1.0.100"', '', '[[package]]', 'name = "jellytau"', 'version = "0.0.1"', ''].join("\n")
);
fs.writeFileSync(
path.join(dir, "src-tauri", "gen", "android", "app", "tauri.properties"),
"tauri.android.versionCode=1\n"
);
}
function run(version: string, dir = tmp) {
return execFileSync("bash", [path.join(dir, "scripts", "set-version.sh"), version], {
cwd: dir,
encoding: "utf-8",
});
}
function read(rel: string): string {
return fs.readFileSync(path.join(tmp, rel), "utf-8");
}
function versionCode(): number {
const m = read("src-tauri/gen/android/app/tauri.properties").match(
/^tauri\.android\.versionCode=(\d+)$/m
);
return m ? Number(m[1]) : NaN;
}
beforeEach(() => {
tmp = fs.mkdtempSync(path.join(os.tmpdir(), "setversion-"));
seed(tmp);
});
afterEach(() => {
fs.rmSync(tmp, { recursive: true, force: true });
});
describe("set-version.sh", () => {
it("stamps the version into all four manifests", () => {
run("0.5.0");
expect(JSON.parse(read("package.json")).version).toBe("0.5.0");
expect(JSON.parse(read("src-tauri/tauri.conf.json")).version).toBe("0.5.0");
expect(read("src-tauri/Cargo.toml")).toContain('version = "0.5.0"');
expect(read("src-tauri/Cargo.lock")).toMatch(/name = "jellytau"\nversion = "0\.5\.0"/);
});
it("accepts a leading v, as git tags are written", () => {
run("v0.5.0");
expect(JSON.parse(read("package.json")).version).toBe("0.5.0");
});
// The regression that motivates anchoring the patterns.
it("does not rewrite dependency versions", () => {
run("0.5.0");
expect(read("src-tauri/Cargo.toml")).toContain('serde = { version = "1.0.100" }');
expect(read("src-tauri/Cargo.lock")).toMatch(/name = "serde"\nversion = "1\.0\.100"/);
expect(JSON.parse(read("package.json")).dependencies.hls).toBe("1.2.3");
});
describe("Android versionCode", () => {
// Codes below 1000 are already in the field; a newer release must never
// produce a smaller number than an older one.
it("clears the 1000 floor shipped by earlier builds", () => {
run("0.0.1");
expect(versionCode()).toBeGreaterThan(1000);
});
it("uses 1000 + major*10000 + minor*100 + patch", () => {
const cases: Array<[string, number]> = [
["0.0.14", 1014],
["0.0.15", 1015],
["0.1.0", 1100],
["0.4.8", 1408],
["0.5.0", 1500],
["1.0.0", 11000],
];
for (const [version, code] of cases) {
seed(tmp);
run(version);
expect(versionCode(), `versionCode for ${version}`).toBe(code);
}
});
it("increases monotonically across an upgrade sequence", () => {
const ordered = ["0.0.14", "0.0.15", "0.1.0", "0.4.8", "0.5.0", "1.0.0"];
const codes = ordered.map((v) => {
seed(tmp);
run(v);
return versionCode();
});
const sorted = [...codes].sort((a, b) => a - b);
expect(codes).toEqual(sorted);
expect(new Set(codes).size).toBe(codes.length);
});
// `$(( 0-rc1 ))` aborts the script under `set -e`, so the suffix has to be
// stripped before the arithmetic.
it("derives the code from the numeric core of a prerelease", () => {
run("0.6.0-rc1");
expect(versionCode()).toBe(1600);
expect(JSON.parse(read("package.json")).version).toBe("0.6.0-rc1");
});
});
describe("input validation", () => {
it("rejects a malformed version without writing anything", () => {
expect(() => run("not-a-version")).toThrow();
// The manifests must be untouched, not half-written.
expect(JSON.parse(read("package.json")).version).toBe("0.0.1");
expect(JSON.parse(read("src-tauri/tauri.conf.json")).version).toBe("0.0.1");
});
// CI passes "${GITHUB_REF#refs/tags/}" unconditionally; on a branch build
// that is still a full ref, and must not fail the job.
it("falls back to a dev version when handed a non-tag ref", () => {
const out = run("refs/heads/master");
expect(out).not.toMatch(/refs\/heads/);
expect(JSON.parse(read("package.json")).version).not.toBe("0.0.1");
});
});
});
@@ -64,6 +64,20 @@ class MainActivity : TauriActivity() {
// so on devices with a tall opaque 3-button bar. (UR-066)
WindowInsetsBridge.install(this)
// Hand the player an Activity reference so it can attach its video
// SurfaceView to the content view behind the WebView.
//
// Without this, JellyTauPlayer.currentActivity stays null forever and
// autoAttachSurface() logs "Cannot attach surface - no Activity reference"
// and returns — so the SurfaceView is created, wired to ExoPlayer, and then
// never added to the view hierarchy. Native video decoded to a surface that
// was never on screen. setActivity() stores into a companion-object
// WeakReference, so calling it here (before Rust initializes the player over
// JNI) is safe and is the case it was written for.
//
// TRACES: UR-003, UR-041 | DR-151
com.dtourolle.jellytau.player.JellyTauPlayer.setActivity(this)
// Configure WebView for media playback after Tauri initialization
handler.postDelayed({
configureWebViewForMedia()
@@ -271,6 +285,46 @@ class MainActivity : TauriActivity() {
}, "AndroidNetworkType")
android.util.Log.d("MainActivity", "JavaScript interface 'AndroidNetworkType' added")
// Native video compositing: let the frontend make the WebView transparent
// so the ExoPlayer SurfaceView behind it is visible (UR-003, UR-004).
//
// Toggled rather than set once because a transparent WebView is only
// correct while a native video is on screen — every other screen needs its
// opaque background, and leaving the window transparent shows the
// launcher/wallpaper through the app.
//
// The CSS in app.css clears the *web* layer's backgrounds; this clears the
// WebView widget's own background, which CSS cannot reach. Both are
// required — an opaque WebView hides the surface no matter what the page
// paints.
//
// TRACES: UR-003, UR-004 | DR-150
webView.addJavascriptInterface(object : Any() {
/** Make the WebView background transparent (true) or opaque (false). */
@JavascriptInterface
fun setTransparent(transparent: Boolean) {
handler.post {
val color = if (transparent) {
android.graphics.Color.TRANSPARENT
} else {
android.graphics.Color.BLACK
}
mediaWebView?.setBackgroundColor(color)
// The WebView's window/surface must also stop painting opaque, or a
// hardware-accelerated WebView still composites its own background.
window.setBackgroundDrawable(
android.graphics.drawable.ColorDrawable(color)
)
android.util.Log.d("MainActivity", "WebView transparent = $transparent")
}
}
/** Whether native-video compositing is available on this platform. */
@JavascriptInterface
fun isSupported(): Boolean = true
}, "AndroidVideoSurface")
android.util.Log.d("MainActivity", "JavaScript interface 'AndroidVideoSurface' added")
// Window insets (safe areas). The push path above races the page load, so
// the frontend pulls the current values on mount through this bridge.
webView.addJavascriptInterface(WindowInsetsBridge.jsInterface(), "AndroidInsets")
+37
View File
@@ -1654,6 +1654,43 @@ pub async fn player_get_queue(
Ok(get_queue_status(&controller))
}
/// What playback facilities this platform's backend actually provides.
///
/// The frontend is presentation-only and must not re-derive backend facts from
/// `navigator.userAgent` — that sniffing was a second copy of the same platform
/// decision Rust already makes with `cfg!`, and it drifted. These flags are the
/// single source of truth; the frontend consumes them.
///
/// TRACES: UR-003, UR-005 | DR-004, DR-023, DR-024
#[derive(specta::Type, Debug, Serialize)]
#[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.
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.
///
/// TRACES: UR-003, UR-005 | DR-004, DR-023, DR-024
#[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"));
Ok(PlaybackCapabilities {
uses_webview_audio: !native_audio,
supports_native_video: cfg!(target_os = "android"),
})
}
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") {
+2
View File
@@ -123,6 +123,7 @@ use commands::{
player_get_audio_settings,
player_get_autoplay_settings,
player_get_cache_config,
player_get_capabilities,
player_get_eq_presets,
player_get_queue,
// Session management commands
@@ -682,6 +683,7 @@ fn specta_builder() -> Builder<tauri::Wry> {
player_cycle_repeat,
player_get_status,
player_get_queue,
player_get_capabilities,
player_add_to_queue,
player_add_track_by_id,
player_add_tracks_by_ids,
+85 -8
View File
@@ -1772,6 +1772,7 @@ impl MediaRepository for OnlineRepository {
)
}
/// TRACES: UR-071 | DR-123
fn get_video_download_url(
&self,
item_id: &str,
@@ -1789,27 +1790,43 @@ impl MediaRepository for OnlineRepository {
// Map the frontend quality preset to concrete transcode params. For
// "original" we request a direct static copy (no transcode) which is
// byte-range resumable; other presets ask the server to transcode.
//
// 🔴 It is `videoBitRate`/`audioBitRate` — **capital R**. Jellyfin binds
// query keys case-insensitively, so `maxHeight`/`videoCodec` casing is
// free, but `videoBitrate` (lowercase r) is a *different token*: it
// fails to bind, is silently dropped, and the requested cap vanishes
// with no error. That is why every "480p"/"720p" download came back at
// full original quality. See `Jellyfin.Api` BaseEncodingJobOptions.
//
// `allowVideoStreamCopy=false` forces a real re-encode. Without it the
// server may stream-copy the source when it already satisfies the cap —
// fine in itself, but it also means a mis-typed cap degrades silently.
// Note `enableAutoStreamCopy=false` alone does NOT stop a *video* copy;
// video copy is gated by `allowVideoStreamCopy`.
match quality {
"high" => {
params.push("videoBitrate=8000000".to_string());
params.push("videoBitRate=8000000".to_string());
params.push("maxHeight=1080".to_string());
params.push("audioBitrate=384000".to_string());
params.push("audioBitRate=384000".to_string());
params.push("videoCodec=h264".to_string());
params.push("audioCodec=aac".to_string());
params.push("allowVideoStreamCopy=false".to_string());
}
"medium" => {
params.push("videoBitrate=4000000".to_string());
params.push("videoBitRate=4000000".to_string());
params.push("maxHeight=720".to_string());
params.push("audioBitrate=256000".to_string());
params.push("audioBitRate=256000".to_string());
params.push("videoCodec=h264".to_string());
params.push("audioCodec=aac".to_string());
params.push("allowVideoStreamCopy=false".to_string());
}
"low" => {
params.push("videoBitrate=1500000".to_string());
params.push("videoBitRate=1500000".to_string());
params.push("maxHeight=480".to_string());
params.push("audioBitrate=128000".to_string());
params.push("audioBitRate=128000".to_string());
params.push("videoCodec=h264".to_string());
params.push("audioCodec=aac".to_string());
params.push("allowVideoStreamCopy=false".to_string());
}
// "original" (and any unknown value) → direct, resumable copy.
_ => {
@@ -2548,7 +2565,7 @@ mod tests {
// with no transcode params.
assert!(url.contains("Static=true"), "url: {url}");
assert!(
!url.contains("videoBitrate"),
!url.contains("videoBitRate"),
"original must not transcode: {url}"
);
assert!(
@@ -2568,7 +2585,7 @@ mod tests {
"{quality} must use stream.mp4: {url}"
);
assert!(
url.contains("videoBitrate="),
url.contains("videoBitRate="),
"{quality} must set bitrate: {url}"
);
assert!(
@@ -2584,6 +2601,66 @@ mod tests {
}
}
/// The bitrate params are spelled `videoBitRate`/`audioBitRate` — **capital
/// R**. Jellyfin binds query keys case-insensitively, so this is not a
/// casing preference: `videoBitrate` is a *different token* that fails to
/// bind and is silently discarded, taking the user's quality cap with it.
/// Nothing errors — the download just returns the full-size original, which
/// is exactly how this bug went unnoticed.
#[test]
fn test_video_download_url_bitrate_params_use_capital_r_spelling() {
let repo = create_test_repository();
for quality in ["high", "medium", "low"] {
let url = repo.get_video_download_url("item123", quality, None);
assert!(
url.contains("videoBitRate="),
"{quality} must spell it videoBitRate (capital R): {url}"
);
assert!(
url.contains("audioBitRate="),
"{quality} must spell it audioBitRate (capital R): {url}"
);
// The lowercase-r spellings never bind — they must not appear at
// all, or the cap is silently dropped by the server.
assert!(
!url.contains("videoBitrate="),
"{quality} emits the unbindable lowercase-r spelling: {url}"
);
assert!(
!url.contains("audioBitrate="),
"{quality} emits the unbindable lowercase-r spelling: {url}"
);
}
}
/// A correctly-spelled cap is still only *conditionally* honored: the server
/// may stream-copy the source when it already satisfies the cap. Video copy
/// is gated by `allowVideoStreamCopy` (NOT `enableAutoStreamCopy`, which
/// only governs audio), so the transcode presets must disable it to
/// guarantee a real re-encode at the requested bitrate.
#[test]
fn test_video_download_url_transcode_presets_forbid_video_stream_copy() {
let repo = create_test_repository();
for quality in ["high", "medium", "low"] {
let url = repo.get_video_download_url("item123", quality, None);
assert!(
url.contains("allowVideoStreamCopy=false"),
"{quality} must forbid video stream copy: {url}"
);
}
// "original" is a deliberate direct copy — it must NOT disable copying.
let original = repo.get_video_download_url("item123", "original", None);
assert!(
!original.contains("allowVideoStreamCopy=false"),
"original must remain a direct copy: {original}"
);
}
#[test]
fn test_video_download_url_passes_media_source_id() {
let repo = create_test_repository();
+10
View File
@@ -0,0 +1,10 @@
{
"$schema": "https://schema.tauri.app/config/2",
"app": {
"windows": [
{
"transparent": true
}
]
}
}
+24
View File
@@ -51,6 +51,30 @@ html, body {
background-color: var(--color-background);
}
/* Native-video compositing (Android).
*
* TRACES: UR-003, UR-004 | DR-150
*
* When ExoPlayer renders into a SurfaceView *behind* the WebView, every opaque
* layer between the viewport and that surface hides the video. The WebView
* itself is made transparent by `"transparent": true` in
* tauri.android.conf.json; these rules clear the app's own painted backgrounds.
*
* Scoped to `[data-native-video="active"]` set on <html> by
* $lib/stores/nativeVideo.ts only while a native video session is on screen
* because every other screen genuinely needs its opaque background. The app
* shell (+layout.svelte) also paints --color-background across the viewport, so
* it is cleared here too; the shell is the layer directly over the surface.
*
* `background: transparent` (not a colour) is required: an alpha-0 colour still
* composites in some WebView versions.
*/
html[data-native-video="active"],
html[data-native-video="active"] body,
html[data-native-video="active"] [data-app-shell] {
background: transparent !important;
}
body {
@apply text-white antialiased;
font-family: system-ui, -apple-system, sans-serif;
+32
View File
@@ -143,6 +143,14 @@ async playerGetStatus() : Promise<PlayerStatus> {
async playerGetQueue() : Promise<QueueStatus> {
return await TAURI_INVOKE("player_get_queue");
},
/**
* Report this platform's playback capabilities to the frontend.
*
* TRACES: UR-003, UR-005 | DR-004, DR-023, DR-024
*/
async playerGetCapabilities() : Promise<PlaybackCapabilities> {
return await TAURI_INVOKE("player_get_capabilities");
},
async playerAddToQueue(request: AddToQueueRequest) : Promise<QueueStatus> {
return await TAURI_INVOKE("player_add_to_queue", { request });
},
@@ -2293,6 +2301,30 @@ export type PlayTracksRequest = { trackIds: string[]; startIndex: number; shuffl
* over playback from a remote session so we don't restart from 0.
*/
startPosition?: number | null }
/**
* What playback facilities this platform's backend actually provides.
*
* The frontend is presentation-only and must not re-derive backend facts from
* `navigator.userAgent` that sniffing was a second copy of the same platform
* decision Rust already makes with `cfg!`, and it drifted. These flags are the
* single source of truth; the frontend consumes them.
*
* TRACES: UR-003, UR-005 | DR-004, DR-023, DR-024
*/
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.
*/
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 }
/**
* Playback information
*/
+55 -15
View File
@@ -26,8 +26,18 @@
import { playbackPosition, playerState } from "$lib/stores/player";
import * as html5Adapter from "$lib/player/html5Adapter";
import { playerController } from "$lib/player";
import { Html5PlayerAdapter, type Html5ElementBridge } from "$lib/player/adapters";
import {
createAdapter,
Html5PlayerAdapter,
type PlayerAdapter,
type Html5ElementBridge,
} from "$lib/player/adapters";
import { createRustReportHost } from "$lib/player/adapters/rustReportHost";
import { experimentalNativeVideo } from "$lib/stores/nativeVideo";
import {
enableNativeVideoCompositing,
disableNativeVideoCompositing,
} from "$lib/utils/videoSurface";
import { isPipSupported, enterPip, setAutoEnterEnabled } from "$lib/utils/pictureInPicture";
import {
createTapGestureState,
@@ -166,7 +176,10 @@
// VideoPlayer supplies a narrow bridge for the element/HLS-coupled parts and
// registers the adapter with the facade so control intents — from UI OR from a
// backend control event (lockscreen/remote/sleep) — reach this element.
let playerAdapter: Html5PlayerAdapter | null = null;
// Widened from Html5PlayerAdapter: the native path registers a
// NativePlayerAdapter here. Element-coupled work is guarded by
// `useHtml5Element`, not by narrowing this type.
let playerAdapter: PlayerAdapter | null = null;
function tearDownHls() {
if (hls) {
@@ -645,15 +658,16 @@
backendChosen = true;
console.log(`[VideoPlayer] Backend: ${response.backend}, useHtml5Element: ${useHtml5Element}`);
// INTERIM (until the video-player API refactor lands): always render
// through the webview HTML5 element, including Android. The native
// ExoPlayer SurfaceView sits behind an opaque webview and has never
// actually been visible (an init bug kept the app on the HTML5 path
// since the POC), so true native mode plays audio behind a frozen
// picture. Stop the native backend and let the webview own playback,
// matching Linux behavior and avoiding dual audio.
if (!useHtml5Element) {
console.warn("[VideoPlayer] Native video backend reported - overriding to HTML5 rendering (native surface not visible through webview)");
// Rust reported a native backend (Android/ExoPlayer). Honour it only if
// the user opted into the experimental native path; otherwise fall back
// to the webview element, which is what shipped by default.
//
// The flag is a suppressor, never a promoter — see createAdapter(). When
// it is off we must also stop the native backend that player_play_item
// just started, or ExoPlayer and the <video> element both decode the
// same stream and the audio doubles.
if (!useHtml5Element && !$experimentalNativeVideo) {
console.log("[VideoPlayer] Native backend available but experimentalNativeVideo is off - using HTML5");
useHtml5Element = true;
try {
await commands.playerStop();
@@ -661,6 +675,14 @@
} catch (err) {
console.warn("[VideoPlayer] Failed to stop native backend:", err);
}
} else if (!useHtml5Element) {
// Native path: clear the opaque layers between the viewport and the
// ExoPlayer SurfaceView (webview widget background + page background).
// Paired with disableNativeVideoCompositing() in the teardown path —
// leaving this on renders the rest of the app over a transparent
// window.
console.log("[VideoPlayer] Using native ExoPlayer video surface");
enableNativeVideoCompositing();
}
// If using HTML5 element for non-transcoded content, stop the backend player
@@ -679,14 +701,24 @@
didStartNativePlayback = true; // Track that we need to stop backend on unmount
}
// Register the HTML5 player adapter with the facade so control intents
// (UI or backend lockscreen/remote/sleep events) route to this element.
if (useHtml5Element) {
// Register the adapter with the facade so control intents (UI, or a
// backend lockscreen/remote/sleep event) route to whatever is actually
// rendering. Both paths need one: the native adapter forwards control
// intents to ExoPlayer over IPC.
{
const host = createRustReportHost(media.id, {
onEnded: () => notifyEnded(),
onStreamUrlChanged: (u) => { currentStreamUrl = u; },
});
playerAdapter = new Html5PlayerAdapter(host, adapterBridge);
playerAdapter = createAdapter({
backendKind: useHtml5Element ? "html5" : "native",
host,
bridge: adapterBridge,
// useHtml5Element is already the resolved decision above, so the
// flag has had its say; pass it through for the invariant check.
experimentalNativeVideo: $experimentalNativeVideo,
});
// No-op for the native adapter, which owns no DOM element.
playerAdapter.attach(videoElement);
playerController.setActiveAdapter(playerAdapter);
}
@@ -780,6 +812,14 @@
});
onDestroy(async () => {
// FIRST, and synchronously: restore the opaque webview/page backgrounds.
//
// This callback is async, so anything after an `await` may run a frame or
// more later. Leaving the window transparent for even that long shows the
// launcher/wallpaper through the app as the player unwinds. Unconditional
// and idempotent — a no-op when compositing was never enabled.
disableNativeVideoCompositing();
// Stop RAF loop
stopTimeUpdates();
@@ -0,0 +1,95 @@
/**
* 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();
});
});
+38 -20
View File
@@ -2,13 +2,22 @@
* Player adapter factory + public exports.
*
* `createAdapter` selects the concrete PlayerAdapter for the current platform.
* It is the single place that encodes the INTERIM Android override: the Rust
* backend may report a native ExoPlayer backend, but native Android video
* rendering is blocked upstream (tauri#10152 transparent webview / SurfaceView
* compositing), so we render Android video through the HTML5 adapter for now.
* When that upstream limitation is resolved, flip this to honor `backendKind`.
* Rust decides *which backend this platform has* (`useHtml5Element` from
* `player_play_item`); this factory consumes that decision rather than
* re-deriving it.
*
* TRACES: UR-003 | DR-004
* The `experimentalNativeVideo` flag is a **suppressor, never a promoter**: it
* can force the HTML5 path when Rust says native (so an in-progress spike cannot
* ship as a regression), but it can never select native on a platform whose Rust
* backend reported HTML5 Linux has no way to composite a surface behind a
* WebKitGTK webview, so promoting there would produce a black screen.
*
* The previous unconditional HTML5 override cited tauri#10152 as an upstream
* blocker. That was stale: #10152 is a dormant *feature request*, the capability
* shipped in tauri 27d01834, and the black-screen bug (tauri#8381, #9408) was a
* broken `setBackgroundColor` JNI signature fixed in wry 0.39.4 we ship 0.53.x.
*
* TRACES: UR-003, UR-004 | DR-004, DR-150 | UT-149
*/
import { Html5PlayerAdapter, type Html5ElementBridge } from "./html5Adapter";
@@ -29,27 +38,36 @@ export interface CreateAdapterArgs {
host: AdapterHost;
/** Required for the HTML5 adapter; ignored by the native adapter. */
bridge?: Html5ElementBridge;
/**
* User opt-in for the native video path. Defaults to **off**, so omitting it
* yields today's behaviour (HTML5 everywhere) rather than silently enabling
* the spike.
*/
experimentalNativeVideo?: boolean;
}
/**
* Build the adapter for this platform/stream.
*
* INTERIM: always returns the HTML5 adapter, because the native surface is not
* visible through the webview on current Tauri (see module docs). The bridge is
* therefore required.
* Native is chosen only when Rust reports a native backend AND the user has
* opted in. Every other combination is HTML5.
*/
export function createAdapter({ backendKind, host, bridge }: CreateAdapterArgs): PlayerAdapter {
// INTERIM OVERRIDE: force HTML5 rendering even when the backend reports native.
const effectiveKind: BackendKind = "html5";
export function createAdapter({
backendKind,
host,
bridge,
experimentalNativeVideo = false,
}: CreateAdapterArgs): PlayerAdapter {
const effectiveKind: BackendKind =
backendKind === "native" && experimentalNativeVideo ? "native" : "html5";
if (effectiveKind === "html5") {
if (!bridge) {
throw new Error("createAdapter: Html5ElementBridge is required for the HTML5 adapter");
}
return new Html5PlayerAdapter(host, bridge);
if (effectiveKind === "native") {
// The native surface is owned by the backend — no DOM element, no bridge.
return new NativePlayerAdapter(host);
}
// Reached only once the interim override is lifted (native Android unblocked).
void backendKind;
return new NativePlayerAdapter(host);
if (!bridge) {
throw new Error("createAdapter: Html5ElementBridge is required for the HTML5 adapter");
}
return new Html5PlayerAdapter(host, bridge);
}
+70
View File
@@ -0,0 +1,70 @@
/**
* Platform playback capabilities, read from Rust.
*
* TRACES: UR-003, UR-005 | DR-004, DR-152
*
* "Which backend does this platform have" is a *backend* fact, so Rust owns it
* (`player_get_capabilities`, gated on the same `cfg!` the backends are built
* under). This module is a thin cache over that command.
*
* It exists because the frontend used to re-derive the answer by sniffing
* `navigator.userAgent` for "android"/"linux" a second, silently drifting copy
* of a decision Rust already makes. Consume the value; never re-derive it.
*/
import { commands } from "$lib/api/bindings";
export interface PlaybackCapabilities {
/** Audio renders through a webview `<audio>` element, not a native backend. */
usesWebviewAudio: boolean;
/** Video can render on a native surface behind a transparent webview. */
supportsNativeVideo: boolean;
}
/**
* Conservative defaults for when the backend cannot be reached (very early
* startup, or a command failure). Both false = "assume no special platform
* facilities": no stray `<audio>` element is mounted, and video stays on the
* HTML5 path, which is the safe behaviour everywhere.
*/
const FALLBACK: PlaybackCapabilities = {
usesWebviewAudio: false,
supportsNativeVideo: false,
};
let cached: PlaybackCapabilities | null = null;
let inflight: Promise<PlaybackCapabilities> | null = null;
/**
* Fetch (and memoize) this platform's capabilities. Cached because the answer is
* compile-time constant in Rust it cannot change during a session.
*/
export async function getPlaybackCapabilities(): Promise<PlaybackCapabilities> {
if (cached) return cached;
if (inflight) return inflight;
inflight = (async () => {
try {
const caps = (await commands.playerGetCapabilities()) as PlaybackCapabilities;
cached = {
usesWebviewAudio: !!caps?.usesWebviewAudio,
supportsNativeVideo: !!caps?.supportsNativeVideo,
};
return cached;
} catch (err) {
console.warn("[capabilities] player_get_capabilities failed:", err);
// Do NOT cache the fallback — a later call should get the real answer.
return FALLBACK;
} finally {
inflight = null;
}
})();
return inflight;
}
/** Reset the cache. Test-only. */
export function __resetPlaybackCapabilitiesCache(): void {
cached = null;
inflight = null;
}
+8 -14
View File
@@ -23,31 +23,25 @@ import { events } from "$lib/api/bindings";
import { playerController } from "$lib/player";
import { createRustReportHost } from "$lib/player/adapters/rustReportHost";
import { WebviewAudioAdapter } from "$lib/player/adapters/webviewAudioAdapter";
import { getPlaybackCapabilities } from "$lib/services/playbackCapabilities";
let unlisten: UnlistenFn | null = null;
let audioEl: HTMLAudioElement | null = null;
let adapter: WebviewAudioAdapter | null = null;
/** Platforms whose Rust backend renders audio in the webview rather than natively. */
function usesWebviewAudio(): boolean {
// Native audio backends exist only for Linux (mpv) and Android (ExoPlayer).
// Everything else (Windows, and any future desktop) uses the webview element.
// We detect "not linux/android" rather than "is windows" so new desktop
// targets are covered automatically, matching the Rust cfg gate.
if (typeof navigator === "undefined") return false;
const ua = navigator.userAgent.toLowerCase();
const isAndroid = ua.includes("android");
const isLinux = ua.includes("linux") && !isAndroid;
return !isAndroid && !isLinux;
}
/**
* Initialize the webview audio controller. Safe to call unconditionally from the
* root layout; it self-gates on platform and is idempotent.
*/
export async function initWebviewAudio(): Promise<void> {
if (unlisten) return;
if (!usesWebviewAudio()) return;
// Whether this platform needs the webview element is a backend fact, so Rust
// answers it. This used to sniff `navigator.userAgent` for "android"/"linux"
// — a duplicate of the Rust cfg gate that could drift out of step with the
// backends it was trying to describe.
const { usesWebviewAudio } = await getPlaybackCapabilities();
if (!usesWebviewAudio) return;
audioEl = document.createElement("audio");
audioEl.hidden = true;
+91
View File
@@ -0,0 +1,91 @@
// Native-video compositing state.
//
// TRACES: UR-003, UR-004 | DR-150, DR-152
//
// Two separate concerns live here, deliberately:
//
// 1. `experimentalNativeVideo` — the user-facing opt-in flag. Rust already
// decides *which backend this platform has* (`useHtml5Element` from
// `player_play_item`); this flag only *suppresses* that decision so a
// half-working spike cannot ship as a regression. It never turns native on
// where Rust says HTML5.
//
// 2. `nativeVideoActive` — whether a native surface is on screen right now.
// Setting it toggles `data-native-video` on <html>, which is what the CSS in
// app.css keys off to clear the app's opaque backgrounds so the SurfaceView
// behind the WebView is visible. It is deliberately NOT derived from the
// flag: the backgrounds must come back the moment the player unmounts.
//
// Frontend-only preference, stored in localStorage per the `jellytau-view-mode`
// precedent in library.ts — no Rust settings command backs this.
import { writable } from "svelte/store";
const STORAGE_KEY = "jellytau-experimental-native-video";
/** The attribute app.css keys its transparency rules off. */
const NATIVE_VIDEO_ATTR = "data-native-video";
function load(): boolean {
if (typeof localStorage === "undefined") return false;
try {
return localStorage.getItem(STORAGE_KEY) === "true";
} catch {
// Private-mode / disabled storage — default to the safe (HTML5) path.
return false;
}
}
function persist(enabled: boolean) {
if (typeof localStorage === "undefined") return;
try {
localStorage.setItem(STORAGE_KEY, String(enabled));
} catch {
// Quota or private-mode failure — keep the in-memory value.
}
}
function createExperimentalNativeVideoStore() {
const { subscribe, set } = writable<boolean>(load());
return {
subscribe,
set(enabled: boolean) {
persist(enabled);
set(enabled);
},
/** Read the current value without subscribing (init-time decisions). */
current: load,
};
}
/** User opt-in for the native Android video path. Default off. */
export const experimentalNativeVideo = createExperimentalNativeVideoStore();
function createNativeVideoActiveStore() {
const { subscribe, set } = writable<boolean>(false);
return {
subscribe,
/**
* Mark a native video surface as visible (or gone) and sync the <html>
* attribute that app.css uses to clear opaque backgrounds.
*/
set(active: boolean) {
if (typeof document !== "undefined") {
if (active) {
document.documentElement.setAttribute(NATIVE_VIDEO_ATTR, "active");
} else {
document.documentElement.removeAttribute(NATIVE_VIDEO_ATTR);
}
}
set(active);
},
};
}
/**
* Whether a native video surface is currently on screen. Must be cleared on
* player teardown, or the rest of the app renders over a transparent window.
*/
export const nativeVideoActive = createNativeVideoActiveStore();
+86
View File
@@ -0,0 +1,86 @@
/**
* Native video surface compositing, Android only.
*
* TRACES: UR-003, UR-004 | DR-150, DR-151
*
* On Android, ExoPlayer renders video into a SurfaceView that sits *behind* the
* Tauri WebView (`setZOrderMediaOverlay(false)`, added at index 0 of the content
* view by VideoOverlayManager). For that video to be visible, two independent
* opaque layers have to be cleared:
*
* 1. The **WebView widget's own background** reachable only from Kotlin, via
* the `AndroidVideoSurface` @JavascriptInterface installed by MainActivity.
* 2. The **web page's backgrounds** the `html`/`body` colour in app.css and
* the app shell's `bg-[var(--color-background)]`. Handled by the
* `data-native-video` attribute, which $lib/stores/nativeVideo.ts sets and
* app.css keys its transparency rules off.
*
* Clearing only one leaves a black screen with audio, which is exactly the
* failure mode the old INTERIM override in VideoPlayer.svelte was working
* around. Both must be toggled together, so this module owns both halves.
*
* Everything here is a no-op off Android the bridge is simply absent.
*/
import { nativeVideoActive } from "$lib/stores/nativeVideo";
interface AndroidVideoSurfaceBridge {
setTransparent(transparent: boolean): void;
isSupported(): boolean;
}
declare global {
interface Window {
AndroidVideoSurface?: AndroidVideoSurfaceBridge;
}
}
function bridge(): AndroidVideoSurfaceBridge | undefined {
if (typeof window === "undefined") return undefined;
return window.AndroidVideoSurface;
}
/**
* Whether the native-surface bridge exists on this platform. This reports only
* that the *plumbing* is present; whether native video should actually be used
* is Rust's decision (`player_get_capabilities`) gated by the user's
* `experimentalNativeVideo` flag.
*/
export function isNativeSurfaceBridgeAvailable(): boolean {
try {
return bridge()?.isSupported() ?? false;
} catch (err) {
console.warn("[videoSurface] isSupported check failed:", err);
return false;
}
}
/**
* Make the webview transparent so the video surface behind it shows through.
*
* MUST be paired with {@link disableNativeVideoCompositing} on teardown a
* transparent window left behind shows the launcher through the whole app.
*/
export function enableNativeVideoCompositing(): void {
// Page layer first: if the Kotlin call succeeded but this threw, the user
// would see through the app to the home screen.
nativeVideoActive.set(true);
try {
bridge()?.setTransparent(true);
} catch (err) {
console.warn("[videoSurface] setTransparent(true) failed:", err);
nativeVideoActive.set(false);
}
}
/** Restore the opaque webview background. Safe to call unconditionally. */
export function disableNativeVideoCompositing(): void {
try {
bridge()?.setTransparent(false);
} catch (err) {
console.warn("[videoSurface] setTransparent(false) failed:", err);
}
// Always clear the page layer, even if the bridge call failed, so the app is
// never left rendering over a transparent window.
nativeVideoActive.set(false);
}
+62 -1
View File
@@ -1,6 +1,6 @@
<!-- TRACES: UR-023, UR-025, UR-027, UR-029, UR-057 | DR-030, DR-048, DR-077, DR-086, DR-132 -->
<script lang="ts">
import { onMount } from "svelte";
import { onDestroy, onMount } from "svelte";
import { commands } from "$lib/api/bindings";
import type {
AudioSettings,
@@ -26,6 +26,8 @@
isNetworkDetectionSupported,
reportNetworkState,
} from "$lib/services/networkType";
import { experimentalNativeVideo } from "$lib/stores/nativeVideo";
import { getPlaybackCapabilities } from "$lib/services/playbackCapabilities";
const episodeLimitOptions = [
{ value: 0, label: "Unlimited" },
@@ -97,8 +99,27 @@
{ label: "Unlimited", bytes: 0 },
];
// Native-video opt-in (Android). `supportsNativeVideo` comes from Rust, which
// owns the "does this platform have a native video surface" decision; the
// toggle is hidden entirely where it cannot apply.
let supportsNativeVideo = $state(false);
let nativeVideoEnabled = $state(false);
const unsubscribeNativeVideo = experimentalNativeVideo.subscribe((v) => {
nativeVideoEnabled = v;
});
function handleNativeVideoToggle() {
experimentalNativeVideo.set(!nativeVideoEnabled);
}
// Not returned from onMount: that callback is async, so its return value is a
// Promise and Svelte would never invoke it as a teardown.
onDestroy(unsubscribeNativeVideo);
onMount(async () => {
await loadSettings();
supportsNativeVideo = (await getPlaybackCapabilities()).supportsNativeVideo;
});
async function loadSettings() {
@@ -659,6 +680,46 @@
</div>
{/if}
</div>
<!-- Native video (experimental). Only rendered where the platform's Rust
backend actually has a native video surface (Android). -->
{#if supportsNativeVideo}
<div class="bg-[var(--color-surface)] rounded-lg p-6 mt-4">
<div class="flex items-center justify-between">
<div class="pr-4">
<h3 class="text-xl font-semibold text-white">
Native Video
<span
class="ml-2 align-middle text-xs font-medium uppercase tracking-wide text-amber-400 border border-amber-400/40 rounded px-1.5 py-0.5"
>
Experimental
</span>
</h3>
<p class="text-sm text-gray-400 mt-1">
Decode video with the device's hardware decoder instead of the
built-in web player. Better performance and battery life, but
less tested — turn this off if video fails to appear.
</p>
</div>
<button
onclick={handleNativeVideoToggle}
class="relative inline-flex h-8 w-14 shrink-0 items-center rounded-full transition-colors {nativeVideoEnabled
? 'bg-[var(--color-jellyfin)]'
: 'bg-gray-600'}"
aria-label="Toggle native video"
>
<span
class="inline-block h-6 w-6 transform rounded-full bg-white transition-transform {nativeVideoEnabled
? 'translate-x-7'
: 'translate-x-1'}"
></span>
</button>
</div>
<p class="text-xs text-gray-500 mt-3">
Takes effect the next time you start a video.
</p>
</div>
{/if}
</div>
<!-- Search Settings -->