Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
50934e2ac6 | ||
|
|
8fbc080733 | ||
|
|
ba5fd55204 | ||
|
|
fec4b7ae8c | ||
|
|
2ca2174cea | ||
|
|
0ca2857c3a | ||
|
|
1f32e4040b | ||
|
|
e4632bb2b2 | ||
|
|
2d50744320 | ||
|
|
adc460f35d | ||
|
|
9d7cb085e9 | ||
|
|
85bd227714 | ||
|
|
3619f71aba | ||
|
|
8e081845d0 | ||
|
|
5fa74d9e34 | ||
|
|
c480276a97 | ||
|
|
ca490c34ec | ||
|
|
e144e62b31 | ||
|
|
07d10dfed7 | ||
|
|
acddcdd6fa | ||
|
|
6a712c46cb | ||
|
|
211792947d | ||
|
|
2c3955914e |
@@ -96,6 +96,12 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: bun install
|
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
|
- name: Build for Linux
|
||||||
run: bun run tauri build
|
run: bun run tauri build
|
||||||
env:
|
env:
|
||||||
@@ -156,15 +162,13 @@ jobs:
|
|||||||
restore-keys: |
|
restore-keys: |
|
||||||
${{ runner.os }}-bun-
|
${{ 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
|
- name: Set app version from tag
|
||||||
run: |
|
run: ./scripts/set-version.sh "${GITHUB_REF#refs/tags/}"
|
||||||
# On a tag build the tag is the single source of truth for the version.
|
if: startsWith(github.ref, 'refs/tags/v')
|
||||||
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
|
|
||||||
|
|
||||||
- name: Build Windows (NSIS installer + exe)
|
- name: Build Windows (NSIS installer + exe)
|
||||||
run: OUTPUT_DIR="$PWD/dist/windows" WIN_BUNDLES=nsis ./scripts/build-windows-cross.sh
|
run: OUTPUT_DIR="$PWD/dist/windows" WIN_BUNDLES=nsis ./scripts/build-windows-cross.sh
|
||||||
@@ -217,48 +221,22 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: bun install
|
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
|
- name: Set app version from tag
|
||||||
run: |
|
run: ./scripts/set-version.sh "${GITHUB_REF#refs/tags/}"
|
||||||
# On a tag build, the tag is the single source of truth for the
|
if: startsWith(github.ref, 'refs/tags/v')
|
||||||
# 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
|
|
||||||
|
|
||||||
- name: Initialize Android project
|
- name: Initialize Android project
|
||||||
run: bun run tauri android init
|
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
|
- name: Pin a monotonic Android versionCode
|
||||||
run: |
|
run: ./scripts/set-version.sh "${GITHUB_REF#refs/tags/}"
|
||||||
# `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"
|
|
||||||
|
|
||||||
- name: Sync custom Android sources & gradle config
|
- name: Sync custom Android sources & gradle config
|
||||||
run: ./scripts/sync-android-sources.sh
|
run: ./scripts/sync-android-sources.sh
|
||||||
|
|||||||
@@ -6,6 +6,88 @@ Entries are grouped by the capability they change, not by commit. Requirement
|
|||||||
IDs in parentheses point at [docs/requirements.md](docs/requirements.md); the
|
IDs in parentheses point at [docs/requirements.md](docs/requirements.md); the
|
||||||
generated trace matrix lives in [docs/traceability.md](docs/traceability.md).
|
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
|
||||||
|
|
||||||
|
- **Video with an undecodable soundtrack now transcodes instead of playing
|
||||||
|
silent.** Advertising a webview-shaped profile (v0.4.7) turned out not to be
|
||||||
|
enough: Jellyfin 10.11.5 enforces a direct-play profile's container and video
|
||||||
|
codec but ignores its audio codec, offering an E-AC-3 track for direct play
|
||||||
|
against a profile listing only AAC — and no `CodecProfile` or channel limit
|
||||||
|
changes that. The client now checks the track it would actually be served
|
||||||
|
against what its renderer can decode and forces the h264/AAC HLS transcode
|
||||||
|
when it cannot, rather than trusting the negotiation.
|
||||||
|
(UR-004 → DR-149)
|
||||||
|
|
||||||
|
## v0.4.7
|
||||||
|
|
||||||
|
### 🐛 Fixes
|
||||||
|
|
||||||
|
- **Video plays with sound on devices that ship a Dolby decoder.** The audio
|
||||||
|
codec list sent to Jellyfin came from `MediaCodecList`, which describes
|
||||||
|
ExoPlayer — but video does not play through ExoPlayer: it renders in the
|
||||||
|
webview `<video>` element, which decodes far less. A phone whose vendor
|
||||||
|
licenses Dolby therefore advertised `ac3`/`eac3`, got a direct play, and
|
||||||
|
showed full picture with no audio, while a leaner device claimed neither
|
||||||
|
codec, received an AAC transcode, and played the same file correctly. The
|
||||||
|
video direct-play profile is now narrowed to what the webview can decode;
|
||||||
|
audio-only playback is genuinely the native player's and keeps the full list,
|
||||||
|
so music is not transcoded needlessly.
|
||||||
|
(UR-004 → DR-148)
|
||||||
|
|
||||||
## v0.4.1
|
## v0.4.1
|
||||||
|
|
||||||
### 🐛 Fixes
|
### 🐛 Fixes
|
||||||
|
|||||||
@@ -87,6 +87,22 @@ RUN $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --sdk_root=$ANDROID_HOME \
|
|||||||
# Set NDK environment variable
|
# Set NDK environment variable
|
||||||
ENV NDK_HOME=$ANDROID_HOME/ndk/$NDK_VERSION
|
ENV NDK_HOME=$ANDROID_HOME/ndk/$NDK_VERSION
|
||||||
|
|
||||||
|
# Gradle distribution. `tauri android init` regenerates gen/android with a
|
||||||
|
# wrapper pointing at services.gradle.org, so every Android job would otherwise
|
||||||
|
# download ~130MB of Gradle at build time — slow, and a hard failure when the
|
||||||
|
# CDN hiccups ("Unexpected end of file from server"). Ship the distribution in
|
||||||
|
# the image instead; scripts/sync-android-sources.sh repoints the regenerated
|
||||||
|
# wrapper at this local copy. Keep GRADLE_VERSION in sync with the version
|
||||||
|
# Tauri's generated wrapper requests.
|
||||||
|
ENV GRADLE_VERSION=8.14.3 \
|
||||||
|
GRADLE_HOME=/opt/gradle/gradle-8.14.3
|
||||||
|
RUN mkdir -p /opt/gradle/dist && \
|
||||||
|
wget -q "https://services.gradle.org/distributions/gradle-${GRADLE_VERSION}-bin.zip" \
|
||||||
|
-O "/opt/gradle/dist/gradle-${GRADLE_VERSION}-bin.zip" && \
|
||||||
|
unzip -q "/opt/gradle/dist/gradle-${GRADLE_VERSION}-bin.zip" -d /opt/gradle && \
|
||||||
|
"$GRADLE_HOME/bin/gradle" --version
|
||||||
|
ENV PATH="$GRADLE_HOME/bin:$PATH"
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Desktop packaging tools — kept in a trailing layer ON PURPOSE so that adding
|
# Desktop packaging tools — kept in a trailing layer ON PURPOSE so that adding
|
||||||
# or changing a packaging tool doesn't invalidate the expensive apt/rust/Android
|
# or changing a packaging tool doesn't invalidate the expensive apt/rust/Android
|
||||||
|
|||||||
@@ -310,6 +310,14 @@ Internal architecture, components, and application logic.
|
|||||||
| DR-141 | The device profile states how many channels the audio route can actually voice. `MediaCodecList` answers "can this device *decode* 5.1", which is not the question that decides whether the user hears anything — a phone decodes an AC-3 5.1 track happily and still has two channels to play it out of. With no `MaxAudioChannels` in the profile, Jellyfin was free to direct-play the multichannel track, and the result is device dependent: a failed `AudioSink` configuration (silence) or dialogue folded into surround channels that go nowhere. media3's `AudioCapabilities.maxChannelCount` for the current route is reported over JNI alongside the codec lists, and bounds both the direct-play profile and the transcoding profiles, so the server downmixes rather than shipping channels the sink cannot take. Codecs are never removed from the profile — a device with genuine surround output keeps direct-playing it. A missing or zero reading means "route not yet established", not "no audio", and falls back to stereo, the one capability every sink has | Playback | UR-004 | Done |
|
| DR-141 | The device profile states how many channels the audio route can actually voice. `MediaCodecList` answers "can this device *decode* 5.1", which is not the question that decides whether the user hears anything — a phone decodes an AC-3 5.1 track happily and still has two channels to play it out of. With no `MaxAudioChannels` in the profile, Jellyfin was free to direct-play the multichannel track, and the result is device dependent: a failed `AudioSink` configuration (silence) or dialogue folded into surround channels that go nowhere. media3's `AudioCapabilities.maxChannelCount` for the current route is reported over JNI alongside the codec lists, and bounds both the direct-play profile and the transcoding profiles, so the server downmixes rather than shipping channels the sink cannot take. Codecs are never removed from the profile — a device with genuine surround output keeps direct-playing it. A missing or zero reading means "route not yet established", not "no audio", and falls back to stereo, the one capability every sink has | Playback | UR-004 | Done |
|
||||||
| DR-145 | Video playback starts only once the app actually holds audio focus. Video manages focus by hand (`handleAudioFocus=false`, because ExoPlayer's automatic handling is reserved for the audio path), and the request's three outcomes were all treated as success: `AUDIOFOCUS_REQUEST_DELAYED` — which `setAcceptsDelayedFocusGain(true)` explicitly invites, and which means the system is *withholding our audio* until it calls back — and an outright `REQUEST_FAILED` were logged and then followed by `playWhenReady = true`. The picture rolled with no sound, indistinguishable to the user from a broken stream. Playback is now held when focus is not granted and started from the `AUDIOFOCUS_GAIN` callback; an explicit `play()` re-requests focus rather than resuming into a stream the system is still muting, guarded by a held-focus flag so repeated plays do not leak focus requests. A `LOSS` clears the pending flag, so an unrelated later `GAIN` cannot start playback the user never asked for | Playback | UR-004 | Done |
|
| DR-145 | Video playback starts only once the app actually holds audio focus. Video manages focus by hand (`handleAudioFocus=false`, because ExoPlayer's automatic handling is reserved for the audio path), and the request's three outcomes were all treated as success: `AUDIOFOCUS_REQUEST_DELAYED` — which `setAcceptsDelayedFocusGain(true)` explicitly invites, and which means the system is *withholding our audio* until it calls back — and an outright `REQUEST_FAILED` were logged and then followed by `playWhenReady = true`. The picture rolled with no sound, indistinguishable to the user from a broken stream. Playback is now held when focus is not granted and started from the `AUDIOFOCUS_GAIN` callback; an explicit `play()` re-requests focus rather than resuming into a stream the system is still muting, guarded by a held-focus flag so repeated plays do not leak focus requests. A `LOSS` clears the pending flag, so an unrelated later `GAIN` cannot start playback the user never asked for | Playback | UR-004 | Done |
|
||||||
| 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-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-154 | A watch position that cannot reach the server is queued, not dropped. `sync_queue` and its drain (DR-131) were built, tested and running, but the stop-report path never fed them: `HybridRepository::report_playback_stopped` is a bare pass-through to the online repository ("Playback reporting goes directly to server"), and on failure the error surfaced to a frontend `catch` whose own comment read "Server error - could queue, but for now just log". Both producers that *would* have queued it — `PlaybackReporter::queue_for_sync` in Rust and `syncService.queuePlaybackProgress` on the frontend — have no callers on the playback path, so closing a video while the server was unreachable lost the resume point outright even though `user_data.pending_sync` was dutifully set to 1 and nothing ever drains that flag for positions (unlike favourites, DR-120). The command layer now enqueues a `report_playback_stopped` row whenever the push fails, which the existing drain already knows how to parse and replay. The pending row for an item is **superseded in place** rather than appended to: progress is reported every 10s, so a server that stays down would otherwise add a row per tick, all of them obsoleted by the newest — the unbounded queue DR-131 exists to prevent. Only `pending`/`failed` rows are superseded, because an `abandoned` row has been given up on and reviving it would restore that same growing counter. Queueing is best-effort and never fails the command: the local position is already saved, so a failed *queue* write must not be reported as a lost position | Backend | UR-025, UR-002 | Done |
|
||||||
|
| DR-155 | A watch position set on another device reaches this one. The resume check reads the local `user_data` row and nothing else, but `mirror_user_data` — the only path by which server `UserData` lands in that table — mirrored `is_favorite` alone, and returned early whenever that field was absent, which is exactly the shape of an ordinary watched episode. So `playback_position_ticks` was write-only from this device's perspective: watch 40 minutes in a browser, open JellyTau, and it resumed from whatever *this* device last saw or offered no resume at all — the same user-visible symptom as DR-150's Android bug, from an unrelated cause, which is why resume read as broadly flaky. The mirror now carries the position alongside the favourite flag under the same `pending_sync = 0` conflict rule, so a local position still waiting to be pushed is never pulled *backwards* by a server that has not yet heard where we got to; `COALESCE(excluded.x, user_data.x)` means a field the server omitted keeps its stored value rather than being nulled, and a row with neither field is still skipped rather than fabricated as zeroes. Mirroring alone was not sufficient: `get_item` — the call the player route makes — returned the cached copy on a hit and never consulted the server, so for an already-cached item the mirror never ran. It now refreshes in the background on a cache hit (`race_with_refresh`, the reusable form of what `get_items` already did inline), which is why browsing a season picked up other devices' state while opening the episode directly did not. The refreshed value lands for the next read, the cache-first race still answering immediately | Backend | UR-025, UR-002 | 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-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-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 |
|
| 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 |
|
||||||
@@ -540,6 +548,17 @@ Internal architecture, components, and application logic.
|
|||||||
| UT-139 | A failed visibility push is retried on the next identical transition rather than latched | DR-143 | Done |
|
| UT-139 | A failed visibility push is retried on the next identical transition rather than latched | DR-143 | Done |
|
||||||
| UT-140 | `useOfflineFilterReload` skips the value a page already loaded under and reloads on each later change | DR-143 | Done |
|
| UT-140 | `useOfflineFilterReload` skips the value a page already loaded under and reloads on each later change | DR-143 | Done |
|
||||||
| UT-141 | The advertised channel cap: an unknown or zero reading falls back to stereo, a real route keeps its channels, an absurd driver reading is capped at 7.1, and mono is taken at its word | DR-141 | Done |
|
| UT-141 | The advertised channel cap: an unknown or zero reading falls back to stereo, a real route keeps its channels, an absurd driver reading is capped at 7.1, and mono is taken at its word | DR-141 | Done |
|
||||||
|
| UT-148 | Forcing a transcode from the client: an undecodable default track forces one, a decodable track does not, the default track decides rather than the first, the first decides when nothing is marked default, and neither an audio-less source nor an unnamed codec is second-guessed | DR-149 | Done |
|
||||||
|
| UT-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-151 | An unreportable stop lands in the queue and is pushed by the existing drain; re-queueing the same item supersedes the earlier position rather than adding a row, distinct items keep their own positions, and an abandoned row is not revived by a later report | DR-154 | Done |
|
||||||
|
| UT-152 | Caching a server result mirrors its watch position locally — including for an item carrying a position but no favourite flag — without inventing a row for an item the server reported no user data for, and without pulling a still-unsynced local position backwards | DR-155 | Done |
|
||||||
|
| UT-142 | The audio codecs offered for video direct play: a Dolby device's real `MediaCodecList` output drops `ac3`/`eac3`, AMR and raw PCM are dropped too, a fully-supported list is passed through untouched, a list with nothing decodable still claims `aac`, and stray spacing or casing does not decide whether the user gets sound | DR-148 | Done |
|
||||||
|
| UT-143 | Subtitle URLs resolve to plain strings before they reach the markup (never a Promise), unresolvable tracks are dropped, a stale selection collapses to "Off", and a server-default track is never auto-selected | UR-020, DR-023 | Done |
|
||||||
|
| UT-144 | VideoPlayer actually renders `<track kind="subtitles">` children carrying `data-stream-index`, with no `default` attribute and no async `getSubtitleUrl()` bound to `src` | UR-020, DR-023 | Done |
|
||||||
|
| UT-145 | The frontend's subtitle payload survives the IPC hop: a camelCase `PlayItemRequest` carrying `subtitles` deserializes, `create_media_item` lands them on `MediaItem.subtitles` in the order sent, and a request without the field still defaults to empty | UR-020, IR-016 | Done |
|
||||||
|
| UT-146 | The subtitle JSON serialized across the JNI boundary uses the keys `JellyTauPlayer.load()` reads — `url`, `language`, `label` and `mime_type`, never `mimeType` | UR-020, IR-016, JA-008 | Done |
|
||||||
|
| UT-147 | The native subtitle payload and the track-selection index come from the same resolved list: the wire shape keeps `mime_type` and stream order, `playerPlayItem` actually sends it, and the index is a position in the sent list (so a track whose URL failed to resolve cannot shift the others) rather than the menu's row number | UR-020, IR-016 | Done |
|
||||||
|
|
||||||
### Integration Tests
|
### Integration Tests
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,19 @@
|
|||||||
# Spec: Android native video — transparent-webview spike
|
# Spec: Android native video — transparent-webview spike
|
||||||
|
|
||||||
**Status:** Proposed (spike — timeboxed, may conclude "not viable")
|
**Status:** Spike succeeded — native video confirmed working on a physical
|
||||||
**Requirements:** IR-004, UR-003, UR-004 → DR-001, DR-023, DR-024
|
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
|
**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)
|
**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
|
`get_player_status` does not currently expose enough to cover the audio case, add
|
||||||
the field — that is backend work, and correct.
|
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
|
### Phase 3 — surface positioning
|
||||||
|
|
||||||
The hard part, and where this most likely fails. The webview's `<video>` element
|
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
|
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.
|
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
|
### What we gain if it works
|
||||||
|
|
||||||
- **Hardware decode via MediaCodec** — `CodecDetector.kt` already reports
|
- **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:
|
The spike is **complete** when one of these is true:
|
||||||
|
|
||||||
**Success path**
|
**Success path**
|
||||||
- [ ] Transparent WebView confirmed working on a physical device.
|
- [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`).
|
||||||
- [ ] `experimentalNativeVideo` off → behaviour byte-identical to today.
|
- [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.
|
||||||
- [ ] `webviewAudio.ts` no longer inspects `navigator.userAgent`; the platform's audio backend is read from Rust.
|
- [x] `webviewAudio.ts` no longer inspects `navigator.userAgent`; the platform's audio backend is read from Rust (`player_get_capabilities` → `usesWebviewAudio`).
|
||||||
- [ ] `experimentalNativeVideo` on → video plays via ExoPlayer/MediaCodec, correctly positioned, with working seek, audio-track switch, and subtitle selection through the existing `PlayerAdapter` contract.
|
- [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.
|
||||||
- [ ] No artefacts on rotation, background/foreground, or mini-player transition.
|
- [ ] 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.
|
||||||
- [ ] `adb shell dumpsys media.metrics` (or logcat) confirms a hardware decoder is in use.
|
- [ ] 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.
|
- [ ] Measured battery/thermal or CPU improvement over the HTML5 path on the same clip.
|
||||||
|
|
||||||
**Failure path**
|
**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.
|
- [ ] `nativeAdapter.ts:11-14` no longer cites tauri#10152.
|
||||||
|
|
||||||
Either way:
|
Either way:
|
||||||
- [ ] `bun run check`, `bun run test`, `bun run check:boundary` pass.
|
- [x] `bun run check` (0 errors), `bun run test` (892 passed), `bun run check:boundary` pass.
|
||||||
- [ ] `cargo fmt` / `cargo clippy` clean; `bun run test:rust` passes.
|
- [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
|
## Testing
|
||||||
|
|
||||||
@@ -179,7 +265,7 @@ native adapter), write the failing test first.
|
|||||||
|
|
||||||
## TRACES
|
## 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`
|
- Adapter-selection tests → `UT-xxx`
|
||||||
- No new requirement IDs; this spike either satisfies existing IR-004 expectations or documents why it cannot.
|
- No new requirement IDs; this spike either satisfies existing IR-004 expectations or documents why it cannot.
|
||||||
|
|
||||||
|
|||||||
+3443
-2998
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "jellytau",
|
"name": "jellytau",
|
||||||
"version": "0.4.6",
|
"version": "0.4.8",
|
||||||
"description": "",
|
"description": "",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"packageManager": "bun@1.3.5",
|
"packageManager": "bun@1.3.5",
|
||||||
|
|||||||
@@ -175,8 +175,8 @@ describe("live requirements.md", () => {
|
|||||||
|
|
||||||
expect(defined.UR).toBe(71);
|
expect(defined.UR).toBe(71);
|
||||||
expect(defined.IR).toBe(32);
|
expect(defined.IR).toBe(32);
|
||||||
expect(defined.DR).toBe(142);
|
expect(defined.DR).toBe(150);
|
||||||
expect(defined.JA).toBe(35);
|
expect(defined.JA).toBe(35);
|
||||||
expect(defined.total).toBe(280);
|
expect(defined.total).toBe(288);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Executable
+111
@@ -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
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -118,4 +118,26 @@ if [ -d "$RES_SRC" ]; then
|
|||||||
"$RES_DST"/drawable*/ic_launcher_background.xml
|
"$RES_DST"/drawable*/ic_launcher_background.xml
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Gradle wrapper distribution. `tauri android init` regenerates the wrapper
|
||||||
|
# pointing at services.gradle.org, so each build downloads ~130MB of Gradle —
|
||||||
|
# slow, and a hard failure when the CDN drops the connection mid-transfer
|
||||||
|
# ("Unexpected end of file from server"), which is what broke the release APK
|
||||||
|
# job. The builder image ships the matching distribution under /opt/gradle/dist,
|
||||||
|
# so when it's present repoint the wrapper at that local zip and build offline.
|
||||||
|
# Outside the image (dev machines) the properties file is left untouched and the
|
||||||
|
# wrapper downloads as usual.
|
||||||
|
WRAPPER_PROPS="$PROJECT_ROOT/src-tauri/gen/android/gradle/wrapper/gradle-wrapper.properties"
|
||||||
|
if [ -f "$WRAPPER_PROPS" ]; then
|
||||||
|
WANTED_VERSION="$(sed -n 's#.*/gradle-\([0-9.]*\)-\(bin\|all\)\.zip.*#\1#p' "$WRAPPER_PROPS")"
|
||||||
|
LOCAL_DIST="/opt/gradle/dist/gradle-${WANTED_VERSION}-bin.zip"
|
||||||
|
if [ -n "$WANTED_VERSION" ] && [ -f "$LOCAL_DIST" ]; then
|
||||||
|
# distributionUrl is a java.util.Properties value: ':' must stay escaped.
|
||||||
|
sed -i "s#^distributionUrl=.*#distributionUrl=file\\\\:///opt/gradle/dist/gradle-${WANTED_VERSION}-bin.zip#" \
|
||||||
|
"$WRAPPER_PROPS"
|
||||||
|
echo " Gradle wrapper -> local distribution ($WANTED_VERSION, offline)"
|
||||||
|
elif [ -n "$WANTED_VERSION" ]; then
|
||||||
|
echo " Gradle wrapper: $WANTED_VERSION not in image, will download"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
echo "✓ Android sources synced successfully"
|
echo "✓ Android sources synced successfully"
|
||||||
|
|||||||
Generated
+1
-1
@@ -2018,7 +2018,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "jellytau"
|
name = "jellytau"
|
||||||
version = "0.4.6"
|
version = "0.4.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "jellytau"
|
name = "jellytau"
|
||||||
version = "0.4.6"
|
version = "0.4.8"
|
||||||
description = "A Tauri App"
|
description = "A Tauri App"
|
||||||
authors = ["you"]
|
authors = ["you"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|||||||
@@ -64,6 +64,20 @@ class MainActivity : TauriActivity() {
|
|||||||
// so on devices with a tall opaque 3-button bar. (UR-066)
|
// so on devices with a tall opaque 3-button bar. (UR-066)
|
||||||
WindowInsetsBridge.install(this)
|
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
|
// Configure WebView for media playback after Tauri initialization
|
||||||
handler.postDelayed({
|
handler.postDelayed({
|
||||||
configureWebViewForMedia()
|
configureWebViewForMedia()
|
||||||
@@ -271,6 +285,46 @@ class MainActivity : TauriActivity() {
|
|||||||
}, "AndroidNetworkType")
|
}, "AndroidNetworkType")
|
||||||
android.util.Log.d("MainActivity", "JavaScript interface 'AndroidNetworkType' added")
|
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
|
// Window insets (safe areas). The push path above races the page load, so
|
||||||
// the frontend pulls the current values on mount through this bridge.
|
// the frontend pulls the current values on mount through this bridge.
|
||||||
webView.addJavascriptInterface(WindowInsetsBridge.jsInterface(), "AndroidInsets")
|
webView.addJavascriptInterface(WindowInsetsBridge.jsInterface(), "AndroidInsets")
|
||||||
|
|||||||
@@ -202,6 +202,27 @@ pub struct PlayItemRequest {
|
|||||||
/// look up the next episode when a background-audio track ends.
|
/// look up the next episode when a background-audio track ends.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub series_id: Option<String>,
|
pub series_id: Option<String>,
|
||||||
|
/// Subtitle tracks to sideload, with URLs the frontend has already resolved.
|
||||||
|
///
|
||||||
|
/// Only the native backends use these: on Android they become the
|
||||||
|
/// `MediaItem.SubtitleConfiguration`s ExoPlayer renders. The HTML5 path
|
||||||
|
/// builds its own `<track>` children instead and ignores this list.
|
||||||
|
///
|
||||||
|
/// **Order is the contract.** `player_set_subtitle_track(n)` reaches
|
||||||
|
/// `JellyTauPlayer.setSubtitleTrack(n)`, which indexes into ExoPlayer's
|
||||||
|
/// *text track groups* — i.e. the position of the sideloaded configuration,
|
||||||
|
/// not the Jellyfin stream index (which is kept on each entry for the UI's
|
||||||
|
/// benefit). So `n` must be a position in this very array, and the array
|
||||||
|
/// must not be reordered or filtered between building it and sending it.
|
||||||
|
/// `nativeSubtitleArrayIndex()` on the frontend computes `n` from the same
|
||||||
|
/// list that is sent here, for exactly this reason.
|
||||||
|
///
|
||||||
|
/// Defaulted so the background-audio handoff and the autoplay/next-episode
|
||||||
|
/// callers, which have no subtitles to offer, need not send the field.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-020 | IR-016, JA-008 | UT-145
|
||||||
|
#[serde(default)]
|
||||||
|
pub subtitles: Vec<crate::player::SubtitleTrack>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Queue context for remote transfer - what type of queue is this?
|
/// Queue context for remote transfer - what type of queue is this?
|
||||||
@@ -373,7 +394,10 @@ pub(super) async fn create_media_item(
|
|||||||
needs_transcoding: req.needs_transcoding,
|
needs_transcoding: req.needs_transcoding,
|
||||||
video_width: None, // Not available from video-only request
|
video_width: None, // Not available from video-only request
|
||||||
video_height: None, // Not available from video-only request
|
video_height: None, // Not available from video-only request
|
||||||
subtitles: vec![],
|
// Sideloaded subtitles, in the order the frontend sent them — that order
|
||||||
|
// is what `player_set_subtitle_track(n)` indexes into on Android.
|
||||||
|
// TRACES: UR-020 | IR-016 | UT-145
|
||||||
|
subtitles: req.subtitles,
|
||||||
series_id: None, // Not available from video-only request
|
series_id: None, // Not available from video-only request
|
||||||
server_id: None, // Not available from video-only request
|
server_id: None, // Not available from video-only request
|
||||||
})
|
})
|
||||||
@@ -979,6 +1003,16 @@ pub async fn player_stop(
|
|||||||
.clone()
|
.clone()
|
||||||
};
|
};
|
||||||
client.send_session_command(session_id, "Stop").await?;
|
client.send_session_command(session_id, "Stop").await?;
|
||||||
|
|
||||||
|
// Stopping the remote session ends the cast, so the manager returns to
|
||||||
|
// Idle — same as a local stop. This is also what hands OS volume control
|
||||||
|
// back to this device: set_mode releases the Android remote volume
|
||||||
|
// provider on any exit from remote mode. Without it the mode stayed
|
||||||
|
// Remote and the system volume slider remained stuck on the remote
|
||||||
|
// session with no way back to the local speaker.
|
||||||
|
playback_mode
|
||||||
|
.0
|
||||||
|
.set_mode(crate::playback_mode::PlaybackMode::Idle);
|
||||||
} else {
|
} else {
|
||||||
// Local playback
|
// Local playback
|
||||||
let controller = player.0.lock().await;
|
let controller = player.0.lock().await;
|
||||||
@@ -1630,6 +1664,43 @@ pub async fn player_get_queue(
|
|||||||
Ok(get_queue_status(&controller))
|
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 {
|
pub(super) fn get_player_status(controller: &PlayerController) -> PlayerStatus {
|
||||||
// Determine backend at compile time based on platform
|
// Determine backend at compile time based on platform
|
||||||
let (backend, use_html5_element) = if cfg!(target_os = "android") {
|
let (backend, use_html5_element) = if cfg!(target_os = "android") {
|
||||||
@@ -2459,6 +2530,141 @@ pub async fn player_disable_jellyfin(player: State<'_, PlayerStateWrapper>) -> R
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
/// The subtitle list the frontend resolved must survive the IPC hop and end
|
||||||
|
/// up on the `MediaItem` the native backend loads.
|
||||||
|
///
|
||||||
|
/// The bug: `VideoPlayer.svelte` built a fully-resolved subtitle array and
|
||||||
|
/// then dropped it on the floor — `PlayItemRequest` had no field to put it
|
||||||
|
/// in — so `create_media_item` always produced `subtitles: vec![]`,
|
||||||
|
/// `android/mod.rs` serialized `[]` across JNI, and ExoPlayer was handed a
|
||||||
|
/// `MediaItem` with zero `SubtitleConfiguration`s. Every later
|
||||||
|
/// `setSubtitleTrack(n)` then found no text track groups and logged
|
||||||
|
/// "Invalid subtitle track index".
|
||||||
|
///
|
||||||
|
/// The payload below is exactly what the frontend sends: camelCase for the
|
||||||
|
/// top-level command params (Tauri v2 converts them), and the subtitle
|
||||||
|
/// entries in the casing of `SubtitleTrack` itself — note `mime_type`.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-020 | IR-016 | UT-145
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_play_item_request_carries_subtitles_into_media_item() {
|
||||||
|
use super::{create_media_item, PlayItemRequest};
|
||||||
|
|
||||||
|
let payload = serde_json::json!({
|
||||||
|
"id": "ep-1",
|
||||||
|
"title": "Pilot",
|
||||||
|
"streamUrl": "https://jelly.example/Videos/ep-1/master.m3u8",
|
||||||
|
"videoCodec": "h264",
|
||||||
|
"needsTranscoding": false,
|
||||||
|
"subtitles": [
|
||||||
|
{
|
||||||
|
"index": 2,
|
||||||
|
"url": "https://jelly.example/Videos/ep-1/2/Subtitles/subtitles.vtt",
|
||||||
|
"language": "eng",
|
||||||
|
"label": "English (SRT)",
|
||||||
|
"mime_type": "text/vtt"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"index": 3,
|
||||||
|
"url": "https://jelly.example/Videos/ep-1/3/Subtitles/subtitles.vtt",
|
||||||
|
"language": null,
|
||||||
|
"label": null,
|
||||||
|
"mime_type": "text/vtt"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
let req: PlayItemRequest =
|
||||||
|
serde_json::from_value(payload).expect("frontend payload must deserialize");
|
||||||
|
assert_eq!(
|
||||||
|
req.subtitles.len(),
|
||||||
|
2,
|
||||||
|
"PlayItemRequest must carry the subtitle tracks, not silently ignore them"
|
||||||
|
);
|
||||||
|
|
||||||
|
let media = create_media_item(req, None).await.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
media.subtitles.len(),
|
||||||
|
2,
|
||||||
|
"create_media_item must thread the tracks onto the MediaItem the backend loads"
|
||||||
|
);
|
||||||
|
assert_eq!(media.subtitles[0].index, 2);
|
||||||
|
assert_eq!(media.subtitles[0].language.as_deref(), Some("eng"));
|
||||||
|
assert_eq!(media.subtitles[0].label.as_deref(), Some("English (SRT)"));
|
||||||
|
assert_eq!(media.subtitles[0].mime_type, "text/vtt");
|
||||||
|
// Order is the contract: `player_set_subtitle_track(n)` is a position in
|
||||||
|
// this list (see the note on `PlayItemRequest::subtitles`).
|
||||||
|
assert_eq!(media.subtitles[1].index, 3);
|
||||||
|
assert!(media.subtitles[1].language.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A request without subtitles must still deserialize — the field is
|
||||||
|
/// defaulted so the background-audio handoff and the autoplay/next-episode
|
||||||
|
/// callers keep compiling and sending what they always sent.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-020 | IR-016 | UT-145
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_play_item_request_without_subtitles_defaults_to_empty() {
|
||||||
|
use super::{create_media_item, PlayItemRequest};
|
||||||
|
|
||||||
|
let req: PlayItemRequest = serde_json::from_value(serde_json::json!({
|
||||||
|
"id": "movie-1",
|
||||||
|
"title": "Movie",
|
||||||
|
"streamUrl": "https://jelly.example/Videos/movie-1/stream.mp4",
|
||||||
|
"videoCodec": "h264",
|
||||||
|
"needsTranscoding": false
|
||||||
|
}))
|
||||||
|
.expect("a subtitle-less payload must still deserialize");
|
||||||
|
|
||||||
|
assert!(req.subtitles.is_empty());
|
||||||
|
assert!(create_media_item(req, None)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.subtitles
|
||||||
|
.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The JSON handed to Kotlin over JNI must use the keys
|
||||||
|
/// `JellyTauPlayer.load()` actually reads.
|
||||||
|
///
|
||||||
|
/// `MediaItem` is `rename_all = "camelCase"`, and the instinct (and the
|
||||||
|
/// house IPC rule) is to camelCase nested structs too — but
|
||||||
|
/// `JellyTauPlayer.kt` reads `subtitle.optString("mime_type", …)`. Renaming
|
||||||
|
/// the field would not fail to compile or fail the IPC; it would silently
|
||||||
|
/// fall back to the default MIME type for every track, so this is asserted
|
||||||
|
/// on the exact bytes `android/mod.rs` sends.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-020 | IR-016, JA-008 | UT-146
|
||||||
|
#[test]
|
||||||
|
fn test_subtitle_json_for_jni_uses_the_keys_kotlin_reads() {
|
||||||
|
use crate::player::media::SubtitleTrack;
|
||||||
|
|
||||||
|
let subtitles = vec![SubtitleTrack {
|
||||||
|
index: 2,
|
||||||
|
url: "https://jelly.example/subs.vtt".to_string(),
|
||||||
|
language: Some("eng".to_string()),
|
||||||
|
label: Some("English".to_string()),
|
||||||
|
mime_type: "text/vtt".to_string(),
|
||||||
|
}];
|
||||||
|
|
||||||
|
// Exactly what player/android/mod.rs passes to loadWithMetadata.
|
||||||
|
let json = serde_json::to_string(&subtitles).unwrap();
|
||||||
|
let parsed: Vec<serde_json::Value> = serde_json::from_str(&json).unwrap();
|
||||||
|
let obj = parsed[0].as_object().unwrap();
|
||||||
|
|
||||||
|
for key in ["url", "language", "label", "mime_type"] {
|
||||||
|
assert!(
|
||||||
|
obj.contains_key(key),
|
||||||
|
"JellyTauPlayer.load() reads `{key}`; serialized keys were {:?}",
|
||||||
|
obj.keys().collect::<Vec<_>>()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
!obj.contains_key("mimeType"),
|
||||||
|
"camelCasing mime_type silently drops every track's MIME type on Android"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// The audio-only handoff must play a downloaded file when there is one,
|
/// The audio-only handoff must play a downloaded file when there is one,
|
||||||
/// rather than fetching an audio-only stream for media already on disk.
|
/// rather than fetching an audio-only stream for media already on disk.
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -712,9 +712,19 @@ pub async fn repository_report_playback_progress(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Report playback stopped
|
/// Report playback stopped
|
||||||
|
///
|
||||||
|
/// A stop-report that cannot reach the server is queued rather than dropped:
|
||||||
|
/// this is the position the resume point is built from, and losing it is
|
||||||
|
/// exactly the "it forgot where I was" the sync queue exists to prevent. The
|
||||||
|
/// drain (DR-131) pushes it on the next reconnect. Queueing is best-effort —
|
||||||
|
/// failing the command because the *queue* write failed would tell the caller
|
||||||
|
/// the report was lost when the local position was already saved.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-025 | DR-154 | UT-151
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
pub async fn repository_report_playback_stopped(
|
pub async fn repository_report_playback_stopped(
|
||||||
|
db: State<'_, crate::commands::storage::DatabaseWrapper>,
|
||||||
manager: State<'_, RepositoryManagerWrapper>,
|
manager: State<'_, RepositoryManagerWrapper>,
|
||||||
handle: String,
|
handle: String,
|
||||||
item_id: String,
|
item_id: String,
|
||||||
@@ -723,10 +733,39 @@ pub async fn repository_report_playback_stopped(
|
|||||||
// Milliseconds across the boundary; the Jellyfin API wants ticks.
|
// Milliseconds across the boundary; the Jellyfin API wants ticks.
|
||||||
let position_ticks = position_ms * 10_000;
|
let position_ticks = position_ms * 10_000;
|
||||||
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||||
repo.as_ref()
|
|
||||||
|
let result = repo
|
||||||
|
.as_ref()
|
||||||
.report_playback_stopped(&item_id, position_ticks)
|
.report_playback_stopped(&item_id, position_ticks)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
if let Err(e) = &result {
|
||||||
|
let db_service = {
|
||||||
|
let database = db.0.lock().map_err(|err| err.to_string())?;
|
||||||
|
Arc::new(database.service())
|
||||||
|
};
|
||||||
|
let user_id = repo.user_id().to_string();
|
||||||
|
if let Err(queue_err) = crate::commands::sync_drain::enqueue_playback_stopped(
|
||||||
|
&db_service,
|
||||||
|
&user_id,
|
||||||
|
&item_id,
|
||||||
|
position_ticks,
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("{:?}", e))
|
{
|
||||||
|
warn!(
|
||||||
|
"[Repository] Stop-report for {} failed ({:?}) and could not be queued: {}",
|
||||||
|
item_id, e, queue_err
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
debug!(
|
||||||
|
"[Repository] Stop-report for {} failed ({:?}); queued for the next reconnect",
|
||||||
|
item_id, e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result.map_err(|e| format!("{:?}", e))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get image URL for an item
|
/// Get image URL for an item
|
||||||
|
|||||||
@@ -355,6 +355,65 @@ async fn mark_failed(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Queue a watch position that could not be reported to the server.
|
||||||
|
///
|
||||||
|
/// The stop-report path pushed straight to the server and, on failure, logged
|
||||||
|
/// and dropped the position — so closing a video while the server was
|
||||||
|
/// unreachable lost the resume point outright, even though the queue and its
|
||||||
|
/// drain (DR-131) were built and running. This is the missing producer.
|
||||||
|
///
|
||||||
|
/// The pending row for an item is *replaced* rather than appended to. Progress
|
||||||
|
/// is reported every 10s, so a server that stays down would otherwise grow one
|
||||||
|
/// row per tick, all of them superseded by the newest — the unbounded queue
|
||||||
|
/// DR-131 exists to prevent. Only `pending`/`failed` rows are superseded:
|
||||||
|
/// an `abandoned` row has been given up on and must not be revived, and a
|
||||||
|
/// `completed` one is history.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-025 | DR-154 | UT-151
|
||||||
|
pub async fn enqueue_playback_stopped(
|
||||||
|
db: &Arc<RusqliteService>,
|
||||||
|
user_id: &str,
|
||||||
|
item_id: &str,
|
||||||
|
position_ticks: i64,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let payload = format!(r#"{{"position_ticks": {}}}"#, position_ticks);
|
||||||
|
|
||||||
|
// Supersede an already-queued position for this item, keeping its place in
|
||||||
|
// the queue order (created_at) so a later item cannot overtake it.
|
||||||
|
let updated = db
|
||||||
|
.execute(Query::with_params(
|
||||||
|
"UPDATE sync_queue \
|
||||||
|
SET payload = ?, status = 'pending', error_message = NULL \
|
||||||
|
WHERE user_id = ? AND item_id = ? AND operation = 'report_playback_stopped' \
|
||||||
|
AND status IN ('pending', 'failed')",
|
||||||
|
vec![
|
||||||
|
QueryParam::String(payload.clone()),
|
||||||
|
QueryParam::String(user_id.to_string()),
|
||||||
|
QueryParam::String(item_id.to_string()),
|
||||||
|
],
|
||||||
|
))
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if updated == 0 {
|
||||||
|
db.execute(Query::with_params(
|
||||||
|
"INSERT INTO sync_queue (user_id, operation, item_id, payload, status, created_at) \
|
||||||
|
VALUES (?, 'report_playback_stopped', ?, ?, 'pending', CURRENT_TIMESTAMP)",
|
||||||
|
vec![
|
||||||
|
QueryParam::String(user_id.to_string()),
|
||||||
|
QueryParam::String(item_id.to_string()),
|
||||||
|
QueryParam::String(payload),
|
||||||
|
],
|
||||||
|
))
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
debug!(
|
||||||
|
"[SyncQueue] Queued unreported stop for {} at {} ticks",
|
||||||
|
item_id, position_ticks
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Drain on every offline→online transition.
|
/// Drain on every offline→online transition.
|
||||||
///
|
///
|
||||||
/// TRACES: UR-025 | DR-131
|
/// TRACES: UR-025 | DR-131
|
||||||
@@ -781,6 +840,143 @@ mod tests {
|
|||||||
assert_eq!(row_state(&db, "theirs").await.0, "pending");
|
assert_eq!(row_state(&db, "theirs").await.0, "pending");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The bug DR-154 fixes: a stop-report that could not reach the server was
|
||||||
|
/// logged and dropped, so the watch position was lost outright. It must
|
||||||
|
/// land in the queue the drain already knows how to push.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-025 | DR-154 | UT-151
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_failed_stop_report_is_queued_rather_than_dropped() {
|
||||||
|
let db = test_db();
|
||||||
|
|
||||||
|
enqueue_playback_stopped(&db, "u1", "ep1", 5_000_000_000)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// The very drain that already exists must be able to push it.
|
||||||
|
let sink = RecordingSink::new();
|
||||||
|
let report = drain_sync_queue(&db, &sink, "u1").await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
sink.calls(),
|
||||||
|
vec![QueuedOp::PlaybackStopped {
|
||||||
|
item_id: "ep1".to_string(),
|
||||||
|
position_ticks: 5_000_000_000,
|
||||||
|
}]
|
||||||
|
);
|
||||||
|
assert_eq!(report.pushed, 1);
|
||||||
|
assert_eq!(report.remaining, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Progress is reported every 10s, and a server that stays unreachable
|
||||||
|
/// would otherwise add a row per tick — an unbounded queue of positions
|
||||||
|
/// that are all superseded by the newest one. The pending row for an item
|
||||||
|
/// is replaced in place, so the queue holds the latest position only.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-025 | DR-154 | UT-151
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_requeueing_the_same_item_supersedes_the_earlier_position() {
|
||||||
|
let db = test_db();
|
||||||
|
|
||||||
|
enqueue_playback_stopped(&db, "u1", "ep1", 1_000)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
enqueue_playback_stopped(&db, "u1", "ep1", 2_000)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
enqueue_playback_stopped(&db, "u1", "ep1", 3_000)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let sink = RecordingSink::new();
|
||||||
|
drain_sync_queue(&db, &sink, "u1").await.unwrap();
|
||||||
|
|
||||||
|
// One row, carrying the newest position — not three.
|
||||||
|
assert_eq!(
|
||||||
|
sink.calls(),
|
||||||
|
vec![QueuedOp::PlaybackStopped {
|
||||||
|
item_id: "ep1".to_string(),
|
||||||
|
position_ticks: 3_000,
|
||||||
|
}]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Distinct items must not collide — superseding is per item, not global.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-025 | DR-154 | UT-151
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_requeueing_keeps_positions_for_different_items_apart() {
|
||||||
|
let db = test_db();
|
||||||
|
|
||||||
|
enqueue_playback_stopped(&db, "u1", "ep1", 1_000)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
enqueue_playback_stopped(&db, "u1", "ep2", 2_000)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let sink = RecordingSink::new();
|
||||||
|
drain_sync_queue(&db, &sink, "u1").await.unwrap();
|
||||||
|
|
||||||
|
let mut calls = sink.calls();
|
||||||
|
calls.sort_by_key(|op| match op {
|
||||||
|
QueuedOp::PlaybackStopped { item_id, .. } => item_id.clone(),
|
||||||
|
_ => String::new(),
|
||||||
|
});
|
||||||
|
assert_eq!(
|
||||||
|
calls,
|
||||||
|
vec![
|
||||||
|
QueuedOp::PlaybackStopped {
|
||||||
|
item_id: "ep1".to_string(),
|
||||||
|
position_ticks: 1_000,
|
||||||
|
},
|
||||||
|
QueuedOp::PlaybackStopped {
|
||||||
|
item_id: "ep2".to_string(),
|
||||||
|
position_ticks: 2_000,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A row already abandoned (DR-131 gave up on it) must not be resurrected
|
||||||
|
/// by a later report — that would restore the queue-that-only-grows this
|
||||||
|
/// whole area exists to prevent. The new report is queued as its own row.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-025 | DR-154 | UT-151
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_requeueing_does_not_revive_an_abandoned_row() {
|
||||||
|
let db = test_db();
|
||||||
|
seed(
|
||||||
|
&db,
|
||||||
|
&[(
|
||||||
|
"u1",
|
||||||
|
"report_playback_stopped",
|
||||||
|
"ep1",
|
||||||
|
Some(r#"{"position_ticks": 111}"#),
|
||||||
|
"abandoned",
|
||||||
|
MAX_SYNC_ATTEMPTS,
|
||||||
|
"2026-08-01T10:00:00Z",
|
||||||
|
)],
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
enqueue_playback_stopped(&db, "u1", "ep1", 999)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let sink = RecordingSink::new();
|
||||||
|
drain_sync_queue(&db, &sink, "u1").await.unwrap();
|
||||||
|
|
||||||
|
// Only the fresh row is pushed; the abandoned one stays abandoned.
|
||||||
|
assert_eq!(
|
||||||
|
sink.calls(),
|
||||||
|
vec![QueuedOp::PlaybackStopped {
|
||||||
|
item_id: "ep1".to_string(),
|
||||||
|
position_ticks: 999,
|
||||||
|
}]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Nothing queued means no server calls at all — a reconnect must not
|
/// Nothing queued means no server calls at all — a reconnect must not
|
||||||
/// generate traffic just because it happened.
|
/// generate traffic just because it happened.
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -123,6 +123,7 @@ use commands::{
|
|||||||
player_get_audio_settings,
|
player_get_audio_settings,
|
||||||
player_get_autoplay_settings,
|
player_get_autoplay_settings,
|
||||||
player_get_cache_config,
|
player_get_cache_config,
|
||||||
|
player_get_capabilities,
|
||||||
player_get_eq_presets,
|
player_get_eq_presets,
|
||||||
player_get_queue,
|
player_get_queue,
|
||||||
// Session management commands
|
// Session management commands
|
||||||
@@ -682,6 +683,7 @@ fn specta_builder() -> Builder<tauri::Wry> {
|
|||||||
player_cycle_repeat,
|
player_cycle_repeat,
|
||||||
player_get_status,
|
player_get_status,
|
||||||
player_get_queue,
|
player_get_queue,
|
||||||
|
player_get_capabilities,
|
||||||
player_add_to_queue,
|
player_add_to_queue,
|
||||||
player_add_track_by_id,
|
player_add_track_by_id,
|
||||||
player_add_tracks_by_ids,
|
player_add_tracks_by_ids,
|
||||||
|
|||||||
@@ -27,6 +27,10 @@ const TICKS_PER_SECOND: f64 = 10_000_000.0;
|
|||||||
/// send a resume position, so a fresh track casts from 0 rather than ~0.
|
/// send a resume position, so a fresh track casts from 0 rather than ~0.
|
||||||
const RESUME_THRESHOLD_SECONDS: f64 = 0.5;
|
const RESUME_THRESHOLD_SECONDS: f64 = 0.5;
|
||||||
|
|
||||||
|
/// Volume level (0-100) the remote volume slider starts at. The real level is
|
||||||
|
/// corrected by the session poller once the remote session reports its volume.
|
||||||
|
const DEFAULT_REMOTE_VOLUME: i32 = 50;
|
||||||
|
|
||||||
/// Convert a live playback position (seconds) into the `StartPositionTicks` to
|
/// Convert a live playback position (seconds) into the `StartPositionTicks` to
|
||||||
/// hand to a remote session, or `None` if we're effectively at the start.
|
/// hand to a remote session, or `None` if we're effectively at the start.
|
||||||
///
|
///
|
||||||
@@ -42,6 +46,50 @@ fn start_position_ticks_from_seconds(position_seconds: f64) -> Option<i64> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Platform hook for attaching/detaching the OS remote-volume control.
|
||||||
|
///
|
||||||
|
/// On Android, entering remote mode hands the `MediaSession` a
|
||||||
|
/// `VolumeProviderCompat` so hardware volume buttons and the system slider drive
|
||||||
|
/// the *remote* session; leaving remote mode must hand it back to the local
|
||||||
|
/// media stream. Behind a trait so the routing rule (see
|
||||||
|
/// [`PlaybackModeManager::set_mode`]) is unit-testable off-device — the real
|
||||||
|
/// implementation is JNI and only exists on Android.
|
||||||
|
pub trait RemoteVolumeControl: Send + Sync {
|
||||||
|
/// Attach remote-volume control (and, on Android, start the playback service).
|
||||||
|
fn enable(&self, initial_volume: i32);
|
||||||
|
/// Return volume control to the local device speaker.
|
||||||
|
fn disable(&self);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Production hook: forwards to the Android JNI bridge; no-op elsewhere.
|
||||||
|
struct PlatformRemoteVolumeControl;
|
||||||
|
|
||||||
|
impl RemoteVolumeControl for PlatformRemoteVolumeControl {
|
||||||
|
#[allow(unused_variables)]
|
||||||
|
fn enable(&self, initial_volume: i32) {
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
{
|
||||||
|
if let Err(e) = crate::player::enable_remote_volume(initial_volume) {
|
||||||
|
log::warn!(
|
||||||
|
"[PlaybackMode] Failed to enable remote volume/service: {}",
|
||||||
|
e
|
||||||
|
);
|
||||||
|
// Non-fatal - continue; the next poll tick will retry metadata.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn disable(&self) {
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
{
|
||||||
|
if let Err(e) = crate::player::disable_remote_volume() {
|
||||||
|
log::warn!("[PlaybackMode] Failed to disable remote volume: {}", e);
|
||||||
|
// Non-fatal - the mode change itself has already happened.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Manages playback mode transfers between local and remote sessions
|
/// Manages playback mode transfers between local and remote sessions
|
||||||
pub struct PlaybackModeManager {
|
pub struct PlaybackModeManager {
|
||||||
jellyfin_client: Arc<Mutex<Option<JellyfinClient>>>,
|
jellyfin_client: Arc<Mutex<Option<JellyfinClient>>>,
|
||||||
@@ -51,6 +99,8 @@ pub struct PlaybackModeManager {
|
|||||||
/// Optional emitter used to notify the frontend when the mode changes, so its
|
/// Optional emitter used to notify the frontend when the mode changes, so its
|
||||||
/// mirror store stays in sync with this authoritative one. `None` in tests.
|
/// mirror store stays in sync with this authoritative one. `None` in tests.
|
||||||
event_emitter: Arc<Mutex<Option<Arc<dyn PlayerEventEmitter>>>>,
|
event_emitter: Arc<Mutex<Option<Arc<dyn PlayerEventEmitter>>>>,
|
||||||
|
/// Platform hook for OS-level remote volume routing (swapped in tests).
|
||||||
|
remote_volume: Arc<dyn RemoteVolumeControl>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PlaybackModeManager {
|
impl PlaybackModeManager {
|
||||||
@@ -65,6 +115,24 @@ impl PlaybackModeManager {
|
|||||||
current_mode: Arc::new(RwLock::new(PlaybackMode::Idle)),
|
current_mode: Arc::new(RwLock::new(PlaybackMode::Idle)),
|
||||||
is_transferring: Arc::new(AtomicBool::new(false)),
|
is_transferring: Arc::new(AtomicBool::new(false)),
|
||||||
event_emitter: Arc::new(Mutex::new(None)),
|
event_emitter: Arc::new(Mutex::new(None)),
|
||||||
|
remote_volume: Arc::new(PlatformRemoteVolumeControl),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Construct with a custom remote-volume hook (tests).
|
||||||
|
#[cfg(test)]
|
||||||
|
fn with_remote_volume(
|
||||||
|
jellyfin_client: Arc<Mutex<Option<JellyfinClient>>>,
|
||||||
|
player_controller: Arc<TokioMutex<PlayerController>>,
|
||||||
|
remote_volume: Arc<dyn RemoteVolumeControl>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
jellyfin_client,
|
||||||
|
player_controller,
|
||||||
|
current_mode: Arc::new(RwLock::new(PlaybackMode::Idle)),
|
||||||
|
is_transferring: Arc::new(AtomicBool::new(false)),
|
||||||
|
event_emitter: Arc::new(Mutex::new(None)),
|
||||||
|
remote_volume,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,19 +154,39 @@ impl PlaybackModeManager {
|
|||||||
/// the frontend's mirror store reconciles to this authoritative value. The
|
/// the frontend's mirror store reconciles to this authoritative value. The
|
||||||
/// write lock is released before emitting to avoid holding it across the
|
/// write lock is released before emitting to avoid holding it across the
|
||||||
/// emitter call.
|
/// emitter call.
|
||||||
|
///
|
||||||
|
/// Also owns **OS volume routing**, which is derived from the transition
|
||||||
|
/// rather than from each call site: entering remote mode attaches the remote
|
||||||
|
/// volume control, and *any* exit from remote mode hands it back to the local
|
||||||
|
/// speaker. Doing this per-call-site is what caused the bug where stopping a
|
||||||
|
/// remote session (`player_stop` → Idle) left Android stuck on the remote
|
||||||
|
/// volume slider — only the transfer-to-local path tore it down.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-010 | DR-059, IR-021
|
||||||
pub fn set_mode(&self, mode: PlaybackMode) {
|
pub fn set_mode(&self, mode: PlaybackMode) {
|
||||||
log::info!("[PlaybackMode] Setting mode to: {:?}", mode);
|
log::info!("[PlaybackMode] Setting mode to: {:?}", mode);
|
||||||
let changed = {
|
let (changed, was_remote) = {
|
||||||
let mut current = self.current_mode.write_safe();
|
let mut current = self.current_mode.write_safe();
|
||||||
let changed = *current != mode;
|
let changed = *current != mode;
|
||||||
|
let was_remote = matches!(*current, PlaybackMode::Remote { .. });
|
||||||
*current = mode.clone();
|
*current = mode.clone();
|
||||||
changed
|
(changed, was_remote)
|
||||||
};
|
};
|
||||||
|
|
||||||
if !changed {
|
if !changed {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Volume routing follows the transition. Note remote->remote (switching
|
||||||
|
// target session) re-arms rather than releasing control.
|
||||||
|
let is_remote = matches!(mode, PlaybackMode::Remote { .. });
|
||||||
|
if is_remote {
|
||||||
|
self.remote_volume.enable(DEFAULT_REMOTE_VOLUME);
|
||||||
|
} else if was_remote {
|
||||||
|
log::info!("[PlaybackMode] Leaving remote mode - restoring local volume control");
|
||||||
|
self.remote_volume.disable();
|
||||||
|
}
|
||||||
|
|
||||||
let (mode_str, session_id) = match &mode {
|
let (mode_str, session_id) = match &mode {
|
||||||
PlaybackMode::Local => ("local".to_string(), None),
|
PlaybackMode::Local => ("local".to_string(), None),
|
||||||
PlaybackMode::Idle => ("idle".to_string(), None),
|
PlaybackMode::Idle => ("idle".to_string(), None),
|
||||||
@@ -122,18 +210,13 @@ impl PlaybackModeManager {
|
|||||||
/// Both symptoms share this one cause, so this must not be skipped on any
|
/// Both symptoms share this one cause, so this must not be skipped on any
|
||||||
/// remote-entry path (notably the empty-queue early return in
|
/// remote-entry path (notably the empty-queue early return in
|
||||||
/// `transfer_to_remote_inner`). No-op / non-Android builds do nothing.
|
/// `transfer_to_remote_inner`). No-op / non-Android builds do nothing.
|
||||||
#[allow(unused_variables)]
|
///
|
||||||
|
/// [`set_mode`](Self::set_mode) already arms this on entry into remote mode;
|
||||||
|
/// calling it again is harmless (the service start is idempotent) and keeps
|
||||||
|
/// the guarantee when the mode was already remote, which `set_mode` skips as
|
||||||
|
/// a no-op transition.
|
||||||
fn enable_remote_control(&self) {
|
fn enable_remote_control(&self) {
|
||||||
#[cfg(target_os = "android")]
|
self.remote_volume.enable(DEFAULT_REMOTE_VOLUME);
|
||||||
{
|
|
||||||
if let Err(e) = crate::player::enable_remote_volume(50) {
|
|
||||||
log::warn!(
|
|
||||||
"[PlaybackMode] Failed to enable remote volume/service: {}",
|
|
||||||
e
|
|
||||||
);
|
|
||||||
// Non-fatal - continue; the next poll tick will retry metadata.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if currently transferring
|
/// Check if currently transferring
|
||||||
@@ -766,18 +849,10 @@ impl PlaybackModeManager {
|
|||||||
// This will be improved in Phase 3 when repository is migrated to Rust.
|
// This will be improved in Phase 3 when repository is migrated to Rust.
|
||||||
log::debug!("[PlaybackMode] Cannot load media item in Rust yet - frontend handled it");
|
log::debug!("[PlaybackMode] Cannot load media item in Rust yet - frontend handled it");
|
||||||
|
|
||||||
// Update mode to local
|
// Update mode to local. This also returns volume control to the local
|
||||||
|
// device speaker — set_mode owns that for every exit from remote mode.
|
||||||
self.set_mode(PlaybackMode::Local);
|
self.set_mode(PlaybackMode::Local);
|
||||||
|
|
||||||
// Disable remote volume control on Android (return to system volume)
|
|
||||||
#[cfg(target_os = "android")]
|
|
||||||
{
|
|
||||||
if let Err(e) = crate::player::disable_remote_volume() {
|
|
||||||
log::warn!("[PlaybackMode] Failed to disable remote volume: {}", e);
|
|
||||||
// Non-fatal - continue with transfer
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
log::info!("[PlaybackMode] Successfully transferred to local");
|
log::info!("[PlaybackMode] Successfully transferred to local");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -893,6 +968,118 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Records enable/disable calls so tests can assert volume routing.
|
||||||
|
struct RecordingVolumeControl {
|
||||||
|
calls: Mutex<Vec<&'static str>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RemoteVolumeControl for RecordingVolumeControl {
|
||||||
|
fn enable(&self, _initial_volume: i32) {
|
||||||
|
self.calls.lock().unwrap().push("enable");
|
||||||
|
}
|
||||||
|
fn disable(&self) {
|
||||||
|
self.calls.lock().unwrap().push("disable");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn manager_with_volume_control() -> (PlaybackModeManager, Arc<RecordingVolumeControl>) {
|
||||||
|
let volume = Arc::new(RecordingVolumeControl {
|
||||||
|
calls: Mutex::new(Vec::new()),
|
||||||
|
});
|
||||||
|
let manager = PlaybackModeManager::with_remote_volume(
|
||||||
|
Arc::new(Mutex::new(None)),
|
||||||
|
Arc::new(TokioMutex::new(crate::player::PlayerController::default())),
|
||||||
|
volume.clone(),
|
||||||
|
);
|
||||||
|
(manager, volume)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Leaving remote mode must hand volume control back to the local device.
|
||||||
|
///
|
||||||
|
/// Stopping a remote session (`player_stop`) drives the manager
|
||||||
|
/// Remote -> Idle without going through `transfer_to_local`. Before this was
|
||||||
|
/// centralised in `set_mode`, only the transfer path tore the Android
|
||||||
|
/// `VolumeProviderCompat` down, so a plain stop left the system stuck on the
|
||||||
|
/// remote volume slider with no way back to the phone speaker.
|
||||||
|
///
|
||||||
|
/// @req-test: UR-010 - Control playback of Jellyfin remote sessions
|
||||||
|
#[test]
|
||||||
|
fn test_leaving_remote_mode_restores_local_volume() {
|
||||||
|
let (manager, volume) = manager_with_volume_control();
|
||||||
|
|
||||||
|
manager.set_mode(PlaybackMode::Remote {
|
||||||
|
session_id: "sess-1".to_string(),
|
||||||
|
});
|
||||||
|
// The stop path: remote -> idle, no transfer involved.
|
||||||
|
manager.set_mode(PlaybackMode::Idle);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
*volume.calls.lock().unwrap(),
|
||||||
|
vec!["enable", "disable"],
|
||||||
|
"remote->idle must return volume control to the local speaker"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The same must hold for remote -> local (transfer back to this device).
|
||||||
|
///
|
||||||
|
/// @req-test: UR-010 - Control playback of Jellyfin remote sessions
|
||||||
|
#[test]
|
||||||
|
fn test_remote_to_local_restores_local_volume() {
|
||||||
|
let (manager, volume) = manager_with_volume_control();
|
||||||
|
|
||||||
|
manager.set_mode(PlaybackMode::Remote {
|
||||||
|
session_id: "sess-1".to_string(),
|
||||||
|
});
|
||||||
|
manager.set_mode(PlaybackMode::Local);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
*volume.calls.lock().unwrap(),
|
||||||
|
vec!["enable", "disable"],
|
||||||
|
"remote->local must return volume control to the local speaker"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Volume routing must not be touched by transitions that never involve
|
||||||
|
/// remote mode — an idle->local start would otherwise issue a pointless
|
||||||
|
/// `setPlaybackToLocal` on every playback start.
|
||||||
|
///
|
||||||
|
/// @req-test: UR-010 - Control playback of Jellyfin remote sessions
|
||||||
|
#[test]
|
||||||
|
fn test_non_remote_transitions_leave_volume_routing_alone() {
|
||||||
|
let (manager, volume) = manager_with_volume_control();
|
||||||
|
|
||||||
|
manager.set_mode(PlaybackMode::Local);
|
||||||
|
manager.set_mode(PlaybackMode::Idle);
|
||||||
|
manager.set_mode(PlaybackMode::Local);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
volume.calls.lock().unwrap().is_empty(),
|
||||||
|
"local/idle transitions must not touch remote volume routing"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Switching directly between two remote sessions stays remote: control must
|
||||||
|
/// remain attached (re-armed for the new session), never handed back local.
|
||||||
|
///
|
||||||
|
/// @req-test: UR-010 - Control playback of Jellyfin remote sessions
|
||||||
|
#[test]
|
||||||
|
fn test_remote_to_remote_keeps_remote_volume() {
|
||||||
|
let (manager, volume) = manager_with_volume_control();
|
||||||
|
|
||||||
|
manager.set_mode(PlaybackMode::Remote {
|
||||||
|
session_id: "sess-1".to_string(),
|
||||||
|
});
|
||||||
|
manager.set_mode(PlaybackMode::Remote {
|
||||||
|
session_id: "sess-2".to_string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
*volume.calls.lock().unwrap(),
|
||||||
|
vec!["enable", "enable"],
|
||||||
|
"remote->remote re-arms control without releasing it to local"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Setting the same mode twice must not re-emit — the frontend reconciler
|
/// Setting the same mode twice must not re-emit — the frontend reconciler
|
||||||
/// (and the event channel) shouldn't be spammed on no-op transitions.
|
/// (and the event channel) shouldn't be spammed on no-op transitions.
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -23,6 +23,23 @@ pub enum QueueContext {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Represents a subtitle track
|
/// Represents a subtitle track
|
||||||
|
///
|
||||||
|
/// 🔴 **Do not add `#[serde(rename_all = "camelCase")]` here.** This is the one
|
||||||
|
/// struct in the player that deliberately keeps snake_case on the wire, because
|
||||||
|
/// the *same* serialization feeds two consumers that both spell `mime_type`:
|
||||||
|
///
|
||||||
|
/// * the JNI boundary — `player/android/mod.rs` serializes `MediaItem::subtitles`
|
||||||
|
/// with `serde_json` and hands the string to `JellyTauPlayer.loadWithMetadata`,
|
||||||
|
/// whose parser reads `url`, `language`, `label` and `optString("mime_type")`;
|
||||||
|
/// * the IPC boundary — `PlayItemRequest::subtitles` deserializes this same type
|
||||||
|
/// from the frontend, and the generated binding (`SubtitleTrack` in
|
||||||
|
/// `bindings.ts`) therefore also declares `mime_type`.
|
||||||
|
///
|
||||||
|
/// Renaming would not break the build and would not fail the IPC: Kotlin's
|
||||||
|
/// `optString` would just fall back to its default MIME type for every track, so
|
||||||
|
/// the failure would be silent. UT-146 asserts the serialized keys.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-020 | IR-016, JA-008 | UT-146
|
||||||
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
pub struct SubtitleTrack {
|
pub struct SubtitleTrack {
|
||||||
/// Stream index in the media source
|
/// Stream index in the media source
|
||||||
@@ -33,7 +50,8 @@ pub struct SubtitleTrack {
|
|||||||
pub language: Option<String>,
|
pub language: Option<String>,
|
||||||
/// Display title
|
/// Display title
|
||||||
pub label: Option<String>,
|
pub label: Option<String>,
|
||||||
/// MIME type (e.g., "text/vtt", "application/x-subrip")
|
/// MIME type (e.g., "text/vtt", "application/x-subrip").
|
||||||
|
/// Snake_case on purpose — see the note on the struct.
|
||||||
pub mime_type: String,
|
pub mime_type: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ pub mod webview_audio_backend;
|
|||||||
pub use autoplay::{AutoplayDecision, AutoplaySettings};
|
pub use autoplay::{AutoplayDecision, AutoplaySettings};
|
||||||
pub use backend::{NullBackend, PlayerBackend, PlayerError};
|
pub use backend::{NullBackend, PlayerBackend, PlayerError};
|
||||||
pub use events::{PlayerEventEmitter, PlayerStatusEvent, TauriEventEmitter};
|
pub use events::{PlayerEventEmitter, PlayerStatusEvent, TauriEventEmitter};
|
||||||
pub use media::{MediaItem, MediaSource, MediaType, QueueContext};
|
pub use media::{MediaItem, MediaSource, MediaType, QueueContext, SubtitleTrack};
|
||||||
pub use queue::{QueueManager, RepeatMode};
|
pub use queue::{QueueManager, RepeatMode};
|
||||||
pub use seek::{determine_video_seek_strategy, VideoSeekStrategy};
|
pub use seek::{determine_video_seek_strategy, VideoSeekStrategy};
|
||||||
pub use session::{MediaSessionManager, MediaSessionType};
|
pub use session::{MediaSessionManager, MediaSessionType};
|
||||||
|
|||||||
@@ -50,10 +50,189 @@ pub fn max_audio_channels() -> u32 {
|
|||||||
clamp_max_audio_channels(reported)
|
clamp_max_audio_channels(reported)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Audio codecs the webview's `<video>` element can decode.
|
||||||
|
///
|
||||||
|
/// Deliberately narrower than what the platform reports: see
|
||||||
|
/// [`video_audio_codecs`].
|
||||||
|
const WEBVIEW_AUDIO_CODECS: &[&str] = &["aac", "mp3", "opus", "vorbis", "flac"];
|
||||||
|
|
||||||
|
/// The codec claimed when a device reports nothing we can use. Every renderer
|
||||||
|
/// decodes AAC, and claiming *something* is what makes the server transcode to
|
||||||
|
/// it rather than give up.
|
||||||
|
const FALLBACK_AUDIO_CODEC: &str = "aac";
|
||||||
|
|
||||||
|
/// Narrow a detected audio-codec list to what the renderer that will actually
|
||||||
|
/// play the **video** can decode.
|
||||||
|
///
|
||||||
|
/// The platform list comes from `MediaCodecList`, which describes ExoPlayer —
|
||||||
|
/// but video does not play through ExoPlayer. Both Android and Linux render it
|
||||||
|
/// in a webview `<video>` element, and Chromium/WebKit decode a much smaller set
|
||||||
|
/// than the platform does. Advertising the raw list makes Jellyfin direct-play a
|
||||||
|
/// track the webview cannot decode, and the user gets picture with no sound.
|
||||||
|
///
|
||||||
|
/// The gap is widest on devices whose vendor licenses Dolby: a phone with
|
||||||
|
/// `c2.dolby.eac3.decoder` reports `eac3`, so it — and only it — gets a silent
|
||||||
|
/// direct play where a leaner device is transcoded to AAC and plays fine.
|
||||||
|
///
|
||||||
|
/// This applies to the *video* direct-play profile only. Audio-only playback
|
||||||
|
/// really is ExoPlayer's, so its profile keeps the full platform list.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-004 | DR-148 | UT-142
|
||||||
|
pub fn video_audio_codecs(detected: &str) -> String {
|
||||||
|
let kept: Vec<&str> = detected
|
||||||
|
.split(',')
|
||||||
|
.filter_map(|codec| {
|
||||||
|
let codec = codec.trim();
|
||||||
|
// Match case-insensitively but emit our own spelling: the platform
|
||||||
|
// list is assembled from MIME strings and its casing is not ours to
|
||||||
|
// forward to the server.
|
||||||
|
WEBVIEW_AUDIO_CODECS
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.find(|supported| supported.eq_ignore_ascii_case(codec))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if kept.is_empty() {
|
||||||
|
FALLBACK_AUDIO_CODEC.to_string()
|
||||||
|
} else {
|
||||||
|
kept.join(",")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the webview `<video>` element can decode this audio codec.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-004 | DR-149 | UT-148
|
||||||
|
pub fn webview_can_decode_audio(codec: &str) -> bool {
|
||||||
|
WEBVIEW_AUDIO_CODECS
|
||||||
|
.iter()
|
||||||
|
.any(|supported| supported.eq_ignore_ascii_case(codec.trim()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decide whether we must transcode *regardless of what the server negotiated*,
|
||||||
|
/// given the source's audio streams as `(codec, is_default)` in source order.
|
||||||
|
///
|
||||||
|
/// Advertising a narrow profile ([`video_audio_codecs`]) is necessary but not
|
||||||
|
/// sufficient: Jellyfin 10.11.5 enforces a `DirectPlayProfile`'s container and
|
||||||
|
/// video codec but **ignores its audio codec** — an E-AC-3 track is offered for
|
||||||
|
/// direct play even when the profile lists only AAC, and neither a `VideoAudio`
|
||||||
|
/// `CodecProfile` nor `MaxAudioChannels` changes that. So the client cannot
|
||||||
|
/// delegate this decision; it knows what its own renderer can decode and must
|
||||||
|
/// apply that itself.
|
||||||
|
///
|
||||||
|
/// The track that matters is the one the server will actually serve: the
|
||||||
|
/// default, or the first when none is marked. An unknown codec is left alone —
|
||||||
|
/// forcing a transcode on a guess would burn server CPU for files that play.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-004 | DR-149 | UT-148
|
||||||
|
pub fn audio_forces_transcode(streams: &[(Option<&str>, bool)]) -> bool {
|
||||||
|
let served = streams
|
||||||
|
.iter()
|
||||||
|
.find(|(_, is_default)| *is_default)
|
||||||
|
.or_else(|| streams.first());
|
||||||
|
|
||||||
|
match served {
|
||||||
|
Some((Some(codec), _)) => !webview_can_decode_audio(codec),
|
||||||
|
// No audio at all, or a codec the server did not name: leave it alone.
|
||||||
|
Some((None, _)) | None => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_undecodable_default_track_forces_a_transcode() {
|
||||||
|
// The reported bug: one E-AC-3 track, which the webview cannot decode.
|
||||||
|
assert!(audio_forces_transcode(&[(Some("eac3"), false)]));
|
||||||
|
assert!(audio_forces_transcode(&[(Some("ac3"), true)]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_decodable_track_is_left_to_direct_play() {
|
||||||
|
// Never spend server CPU on a file that already plays.
|
||||||
|
assert!(!audio_forces_transcode(&[(Some("aac"), true)]));
|
||||||
|
assert!(!audio_forces_transcode(&[(Some("mp3"), false)]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_default_track_decides_not_the_first() {
|
||||||
|
// The webview plays the default track, so that is the one that has to be
|
||||||
|
// decodable — a supported track further down does not save us.
|
||||||
|
assert!(audio_forces_transcode(&[
|
||||||
|
(Some("aac"), false),
|
||||||
|
(Some("eac3"), true)
|
||||||
|
]));
|
||||||
|
assert!(!audio_forces_transcode(&[
|
||||||
|
(Some("eac3"), false),
|
||||||
|
(Some("aac"), true)
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn with_no_default_marked_the_first_track_decides() {
|
||||||
|
// Jellyfin leaves IsDefault false on every stream for some files; the
|
||||||
|
// server then serves the first, so judge that one.
|
||||||
|
assert!(audio_forces_transcode(&[
|
||||||
|
(Some("eac3"), false),
|
||||||
|
(Some("aac"), false)
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_source_with_no_audio_is_not_transcoded() {
|
||||||
|
// Nothing to rescue, and a transcode would not create audio.
|
||||||
|
assert!(!audio_forces_transcode(&[]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_unknown_codec_is_not_second_guessed() {
|
||||||
|
// The server did not tell us the codec; assuming the worst would
|
||||||
|
// transcode files that play perfectly.
|
||||||
|
assert!(!audio_forces_transcode(&[(None, true)]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_dolby_device_does_not_advertise_dolby_for_video() {
|
||||||
|
// The bug: a Motorola reporting c2.dolby.eac3.decoder direct-played
|
||||||
|
// E-AC-3 into a webview that cannot decode it — silent video, on that
|
||||||
|
// device only.
|
||||||
|
let codecs = video_audio_codecs("aac,ac3,amrnb,amrwb,eac3,flac,mp3,opus,pcm,vorbis");
|
||||||
|
assert_eq!(codecs, "aac,flac,mp3,opus,vorbis");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn codecs_the_webview_cannot_decode_are_dropped() {
|
||||||
|
// AMR and raw PCM come from the AOSP set, so this is not a Dolby-only
|
||||||
|
// problem — it is just rarer content.
|
||||||
|
assert_eq!(video_audio_codecs("amrnb,amrwb,pcm,aac"), "aac");
|
||||||
|
assert_eq!(video_audio_codecs("dts,truehd,mp3"), "mp3");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_list_the_webview_fully_supports_is_untouched() {
|
||||||
|
assert_eq!(
|
||||||
|
video_audio_codecs("aac,mp3,opus,vorbis,flac"),
|
||||||
|
"aac,mp3,opus,vorbis,flac"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nothing_decodable_still_claims_aac() {
|
||||||
|
// Claiming an empty list invites the server to give up rather than
|
||||||
|
// transcode. AAC is universally decodable, so ask for it.
|
||||||
|
assert_eq!(video_audio_codecs("eac3,dts"), "aac");
|
||||||
|
assert_eq!(video_audio_codecs(""), "aac");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn spacing_and_case_in_the_platform_list_are_tolerated() {
|
||||||
|
// The list is assembled from MediaCodecList strings; do not let
|
||||||
|
// whitespace decide whether the user gets sound.
|
||||||
|
assert_eq!(video_audio_codecs("aac, EAC3 , Mp3"), "aac,mp3");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn an_unknown_route_falls_back_to_stereo() {
|
fn an_unknown_route_falls_back_to_stereo() {
|
||||||
// Codec detection has not run yet, or the platform has no answer. Never
|
// Codec detection has not run yet, or the platform has no answer. Never
|
||||||
|
|||||||
@@ -319,6 +319,49 @@ impl HybridRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// [`Self::parallel_race`], plus a callback fired on the fast path so the
|
||||||
|
/// caller can refresh the cache in the background.
|
||||||
|
///
|
||||||
|
/// A plain cache hit answers from data that may be arbitrarily old, which
|
||||||
|
/// is right for the *response* and wrong for what it leaves behind: per-user
|
||||||
|
/// state (watch positions, favourites) only reaches the local tables when a
|
||||||
|
/// server result is cached, so a surface that always hits cache never learns
|
||||||
|
/// what another device did. `get_items` had a bespoke version of this; this
|
||||||
|
/// is the same idea, reusable.
|
||||||
|
///
|
||||||
|
/// The callback runs only on a cache hit — on a miss the server result is
|
||||||
|
/// already being fetched and cached by the normal path.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-002, UR-025 | DR-155
|
||||||
|
async fn race_with_refresh<T, F1, F2, R>(
|
||||||
|
&self,
|
||||||
|
cache_future: F1,
|
||||||
|
server_future: F2,
|
||||||
|
on_cache_hit: R,
|
||||||
|
) -> Result<T, RepoError>
|
||||||
|
where
|
||||||
|
T: MeaningfulContent + Clone + Send + 'static,
|
||||||
|
F1: std::future::Future<Output = Result<T, RepoError>> + Send,
|
||||||
|
F2: std::future::Future<Output = Result<T, RepoError>> + Send,
|
||||||
|
R: FnOnce(),
|
||||||
|
{
|
||||||
|
let cache_result = cache_future.await;
|
||||||
|
|
||||||
|
if let Ok(data) = &cache_result {
|
||||||
|
if data.has_content() {
|
||||||
|
debug!("[HybridRepo] Cache hit, returning immediately (refreshing in background)");
|
||||||
|
on_cache_hit();
|
||||||
|
return Ok(data.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
debug!("[HybridRepo] Cache miss, querying server");
|
||||||
|
match server_future.await {
|
||||||
|
Ok(data) => Ok(data),
|
||||||
|
Err(e) => cache_result.or(Err(e)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Simple timeout wrapper for cache queries (100ms timeout)
|
/// Simple timeout wrapper for cache queries (100ms timeout)
|
||||||
///
|
///
|
||||||
/// @req: DR-013 - Repository pattern (cache-first with timeout)
|
/// @req: DR-013 - Repository pattern (cache-first with timeout)
|
||||||
@@ -489,6 +532,21 @@ impl MediaRepository for HybridRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A single item, cache-first — and, on a cache hit, refreshed in the
|
||||||
|
/// background so the stored copy keeps up with the server.
|
||||||
|
///
|
||||||
|
/// The background refresh is what carries per-user state home: caching an
|
||||||
|
/// item runs `mirror_user_data`, which is the only path by which a watch
|
||||||
|
/// position set on another device reaches the local `user_data` row the
|
||||||
|
/// resume check reads. Without it a cache hit returned this device's own
|
||||||
|
/// stale position forever and cross-device resume silently did nothing —
|
||||||
|
/// `get_items` already refreshes this way, so browsing a season worked
|
||||||
|
/// while opening the episode directly did not.
|
||||||
|
///
|
||||||
|
/// The refreshed value lands for the *next* read rather than this one: the
|
||||||
|
/// point of the cache-first race is to answer immediately.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-025, UR-002 | DR-155 | UT-152
|
||||||
async fn get_item(&self, item_id: &str) -> Result<MediaItem, RepoError> {
|
async fn get_item(&self, item_id: &str) -> Result<MediaItem, RepoError> {
|
||||||
let offline = Arc::clone(&self.offline);
|
let offline = Arc::clone(&self.offline);
|
||||||
let online = Arc::clone(&self.online);
|
let online = Arc::clone(&self.online);
|
||||||
@@ -497,9 +555,32 @@ impl MediaRepository for HybridRepository {
|
|||||||
|
|
||||||
let cache_future = self.cache_with_timeout(async move { offline.get_item(&item_id).await });
|
let cache_future = self.cache_with_timeout(async move { offline.get_item(&item_id).await });
|
||||||
|
|
||||||
|
let online_for_refresh = Arc::clone(&self.online);
|
||||||
|
let offline_for_save = Arc::clone(&self.offline);
|
||||||
|
let refresh_id = item_id_clone.clone();
|
||||||
|
let on_cache_hit = move || {
|
||||||
|
tokio::spawn(async move {
|
||||||
|
match online_for_refresh.get_item(&refresh_id).await {
|
||||||
|
Ok(fresh) => {
|
||||||
|
// `save_to_cache` files the row under a parent; the item's
|
||||||
|
// own parent keeps it where a later listing expects it.
|
||||||
|
let parent = fresh
|
||||||
|
.parent_id
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| "item".to_string());
|
||||||
|
if let Err(e) = offline_for_save.save_to_cache(&parent, &[fresh]).await {
|
||||||
|
debug!("[HybridRepo] Background item refresh failed: {:?}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => debug!("[HybridRepo] Background item refresh unavailable: {:?}", e),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
let server_future = async move { online.get_item(&item_id_clone).await };
|
let server_future = async move { online.get_item(&item_id_clone).await };
|
||||||
|
|
||||||
self.parallel_race(cache_future, server_future).await
|
self.race_with_refresh(cache_future, server_future, on_cache_hit)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_latest_items(
|
async fn get_latest_items(
|
||||||
|
|||||||
@@ -665,40 +665,63 @@ impl OfflineRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Mirror the server's per-user state for an item into the local
|
/// Mirror the server's per-user state for an item into the local
|
||||||
/// `user_data` table, so favourites marked on any other client are visible
|
/// `user_data` table, so favourites marked — and positions watched — on any
|
||||||
/// here — including offline, where the local table is the only source.
|
/// other client are visible here, including offline, where the local table
|
||||||
|
/// is the only source.
|
||||||
///
|
///
|
||||||
/// The `WHERE user_data.pending_sync = 0` on the conflict clause is the
|
/// The `WHERE user_data.pending_sync = 0` on the conflict clause is the
|
||||||
/// conflict rule: a toggle made while the server was unreachable is still
|
/// conflict rule: a change made while the server was unreachable is still
|
||||||
/// waiting to be pushed, and must not be clobbered by the stale value the
|
/// waiting to be pushed, and must not be clobbered by the stale value the
|
||||||
/// server is still reporting. Rows carrying no favourite state are skipped
|
/// server is still reporting. For a position that means it is never pulled
|
||||||
/// entirely rather than written as `0`, which would fabricate an
|
/// *backwards* by a server that has not yet heard where we got to.
|
||||||
/// "unfavourited" record from an endpoint that simply omits `UserData`.
|
|
||||||
///
|
///
|
||||||
/// TRACES: UR-069 | DR-114 | UT-102
|
/// Each field is mirrored only when the server actually reported it —
|
||||||
|
/// `COALESCE(excluded.x, user_data.x)` keeps the stored value for anything
|
||||||
|
/// absent, and a row with neither field is skipped outright rather than
|
||||||
|
/// written as zeroes, which would fabricate an "unfavourited, unwatched"
|
||||||
|
/// record from an endpoint that simply omits `UserData`.
|
||||||
|
///
|
||||||
|
/// The position half is what makes cross-device resume work: the resume
|
||||||
|
/// check reads this table alone, so before it was mirrored an item watched
|
||||||
|
/// elsewhere resumed from whatever *this* device last saw, or not at all.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-025, UR-069 | DR-114, DR-155 | UT-102, UT-152
|
||||||
async fn mirror_user_data(&self, item: &MediaItem, now: &str) -> Result<(), RepoError> {
|
async fn mirror_user_data(&self, item: &MediaItem, now: &str) -> Result<(), RepoError> {
|
||||||
let Some(is_favorite) = item.user_data.as_ref().and_then(|ud| ud.is_favorite) else {
|
let user_data = item.user_data.as_ref();
|
||||||
|
let is_favorite = user_data.and_then(|ud| ud.is_favorite);
|
||||||
|
let position_ticks = user_data.and_then(|ud| ud.playback_position_ticks);
|
||||||
|
|
||||||
|
// Nothing the server actually told us about — do not invent a row.
|
||||||
|
if is_favorite.is_none() && position_ticks.is_none() {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
}
|
||||||
|
|
||||||
let query = Query::with_params(
|
let query = Query::with_params(
|
||||||
"INSERT INTO user_data (user_id, item_id, is_favorite, synced_at, pending_sync)
|
"INSERT INTO user_data
|
||||||
VALUES (?1, ?2, ?3, ?4, 0)
|
(user_id, item_id, is_favorite, playback_position_ticks, synced_at, pending_sync)
|
||||||
|
VALUES (?1, ?2, ?3, ?4, ?5, 0)
|
||||||
ON CONFLICT(user_id, item_id) DO UPDATE SET
|
ON CONFLICT(user_id, item_id) DO UPDATE SET
|
||||||
is_favorite = excluded.is_favorite,
|
is_favorite = COALESCE(excluded.is_favorite, user_data.is_favorite),
|
||||||
|
playback_position_ticks = COALESCE(
|
||||||
|
excluded.playback_position_ticks, user_data.playback_position_ticks),
|
||||||
synced_at = excluded.synced_at
|
synced_at = excluded.synced_at
|
||||||
WHERE user_data.pending_sync = 0",
|
WHERE user_data.pending_sync = 0",
|
||||||
vec![
|
vec![
|
||||||
QueryParam::String(self.user_id.clone()),
|
QueryParam::String(self.user_id.clone()),
|
||||||
QueryParam::String(item.id.clone()),
|
QueryParam::String(item.id.clone()),
|
||||||
QueryParam::Int(if is_favorite { 1 } else { 0 }),
|
is_favorite
|
||||||
|
.map(|f| QueryParam::Int(if f { 1 } else { 0 }))
|
||||||
|
.unwrap_or(QueryParam::Null),
|
||||||
|
position_ticks
|
||||||
|
.map(QueryParam::Int64)
|
||||||
|
.unwrap_or(QueryParam::Null),
|
||||||
QueryParam::String(now.to_string()),
|
QueryParam::String(now.to_string()),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
// A missing item row (FK) is not fatal here — the favourite mirror is
|
// A missing item row (FK) is not fatal here — the mirror is best-effort
|
||||||
// best-effort metadata, and failing the whole cache write over it would
|
// metadata, and failing the whole cache write over it would break
|
||||||
// break browsing.
|
// browsing.
|
||||||
if let Err(e) = self.db_service.execute(query).await {
|
if let Err(e) = self.db_service.execute(query).await {
|
||||||
debug!(
|
debug!(
|
||||||
"[OfflineRepo] user_data mirror skipped for {}: {}",
|
"[OfflineRepo] user_data mirror skipped for {}: {}",
|
||||||
@@ -1426,6 +1449,14 @@ impl MediaRepository for OfflineRepository {
|
|||||||
FROM items i
|
FROM items i
|
||||||
INNER JOIN downloaded_items di ON i.id = di.id
|
INNER JOIN downloaded_items di ON i.id = di.id
|
||||||
WHERE i.server_id = ? AND i.library_id = ?
|
WHERE i.server_id = ? AND i.library_id = ?
|
||||||
|
-- Collapse leaves into the container that was added: a new
|
||||||
|
-- 14-track album should read as one album, not 14 songs. Only
|
||||||
|
-- drops a leaf when its own container is present in the same
|
||||||
|
-- result, so a standalone track or movie still appears.
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM downloaded_items parent
|
||||||
|
WHERE parent.id IN (i.album_id, i.season_id, i.series_id, i.parent_id)
|
||||||
|
)
|
||||||
ORDER BY i.synced_at DESC
|
ORDER BY i.synced_at DESC
|
||||||
LIMIT {}", limit_val
|
LIMIT {}", limit_val
|
||||||
),
|
),
|
||||||
@@ -3544,6 +3575,32 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Like `insert_item`, but sets `library_id` — which `get_latest_items`
|
||||||
|
/// filters on, so rows without it are invisible to that query.
|
||||||
|
async fn insert_library_item(
|
||||||
|
db: &Arc<RusqliteService>,
|
||||||
|
id: &str,
|
||||||
|
item_type: &str,
|
||||||
|
library_id: &str,
|
||||||
|
album_id: Option<&str>,
|
||||||
|
) {
|
||||||
|
db.execute(Query::with_params(
|
||||||
|
"INSERT INTO items (id, server_id, library_id, name, item_type, album_id, synced_at)
|
||||||
|
VALUES (?1, 'test-server', ?2, ?3, ?4, ?5, '2024-01-01')",
|
||||||
|
vec![
|
||||||
|
QueryParam::String(id.to_string()),
|
||||||
|
QueryParam::String(library_id.to_string()),
|
||||||
|
QueryParam::String(format!("Name {id}")),
|
||||||
|
QueryParam::String(item_type.to_string()),
|
||||||
|
album_id
|
||||||
|
.map(|s| QueryParam::String(s.to_string()))
|
||||||
|
.unwrap_or(QueryParam::Null),
|
||||||
|
],
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
async fn seed_completed_download(db: &Arc<RusqliteService>, item_id: &str, file_size: i64) {
|
async fn seed_completed_download(db: &Arc<RusqliteService>, item_id: &str, file_size: i64) {
|
||||||
db.execute(Query::with_params(
|
db.execute(Query::with_params(
|
||||||
"INSERT INTO downloads (item_id, status, file_size) VALUES (?1, 'completed', ?2)",
|
"INSERT INTO downloads (item_id, status, file_size) VALUES (?1, 'completed', ?2)",
|
||||||
@@ -3578,6 +3635,36 @@ mod tests {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A newly-synced album appears once in "recently added", not once per track.
|
||||||
|
///
|
||||||
|
/// The downloaded-items CTE deliberately matches both the leaves and their
|
||||||
|
/// container, which is right for browsing but wrong here: it made a 3-track
|
||||||
|
/// album occupy 4 slots in the row. Tracks whose album is itself in the
|
||||||
|
/// result are now collapsed into it.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_get_latest_items_collapses_tracks_into_their_album() {
|
||||||
|
let db = create_test_db();
|
||||||
|
insert_library_item(&db, "album-1", "MusicAlbum", "lib-1", None).await;
|
||||||
|
for track in ["track-1", "track-2", "track-3"] {
|
||||||
|
insert_library_item(&db, track, "Audio", "lib-1", Some("album-1")).await;
|
||||||
|
seed_completed_download(&db, track, 1000).await;
|
||||||
|
}
|
||||||
|
// A movie has no container, so it must still show up on its own.
|
||||||
|
insert_library_item(&db, "movie-1", "Movie", "lib-1", None).await;
|
||||||
|
seed_completed_download(&db, "movie-1", 2000).await;
|
||||||
|
|
||||||
|
let repo = make_repo(&db);
|
||||||
|
let latest = repo.get_latest_items("lib-1", Some(16)).await.unwrap();
|
||||||
|
let ids: Vec<&str> = latest.iter().map(|i| i.id.as_str()).collect();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
!ids.iter().any(|id| id.starts_with("track-")),
|
||||||
|
"individual tracks must collapse into their album, got: {ids:?}"
|
||||||
|
);
|
||||||
|
assert!(ids.contains(&"album-1"), "the album itself is listed");
|
||||||
|
assert!(ids.contains(&"movie-1"), "containerless items still listed");
|
||||||
|
}
|
||||||
|
|
||||||
/// UT: downloaded-only browse returns a downloaded leaf AND its container,
|
/// UT: downloaded-only browse returns a downloaded leaf AND its container,
|
||||||
/// filtered to the requested album parent. A non-downloaded sibling is omitted.
|
/// filtered to the requested album parent. A non-downloaded sibling is omitted.
|
||||||
///
|
///
|
||||||
@@ -4374,4 +4461,142 @@ mod tests {
|
|||||||
"an unsynced local toggle must survive a cache write"
|
"an unsynced local toggle must survive a cache write"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// UT-152 — the server's watch position is mirrored locally, so an item
|
||||||
|
/// watched on another device resumes here.
|
||||||
|
///
|
||||||
|
/// The resume check reads only the local `user_data` row, and the mirror
|
||||||
|
/// previously carried `is_favorite` alone — so a position set on any other
|
||||||
|
/// client never reached this device and cross-device resume silently did
|
||||||
|
/// nothing. The `pending_sync` guard is the same conflict rule favourites
|
||||||
|
/// use: a local position still waiting to be pushed must not be pulled
|
||||||
|
/// backwards by the stale value the server is still reporting.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-025, UR-069 | DR-155 | UT-152
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_save_to_cache_mirrors_playback_position_without_clobbering_pending() {
|
||||||
|
use crate::storage::db_service::DatabaseService;
|
||||||
|
let db_service = create_test_db();
|
||||||
|
let repo = OfflineRepository::new(
|
||||||
|
db_service.clone(),
|
||||||
|
"test-server".to_string(),
|
||||||
|
"test-user".to_string(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let position = |id: &'static str| {
|
||||||
|
let db = db_service.clone();
|
||||||
|
async move {
|
||||||
|
db.query_optional(
|
||||||
|
Query::with_params(
|
||||||
|
"SELECT playback_position_ticks, pending_sync FROM user_data \
|
||||||
|
WHERE user_id = ? AND item_id = ?",
|
||||||
|
vec![
|
||||||
|
QueryParam::String("test-user".to_string()),
|
||||||
|
QueryParam::String(id.to_string()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|row| Ok((row.get::<_, Option<i64>>(0)?, row.get::<_, Option<i32>>(1)?)),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Watched 20 minutes into this episode on another device.
|
||||||
|
let mut watched = create_test_item("ep-1", "Watched Elsewhere", None);
|
||||||
|
watched.user_data = Some(UserData {
|
||||||
|
playback_position_ticks: Some(12_000_000_000),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
// No user data at all — must not fabricate a position of 0.
|
||||||
|
let untouched = create_test_item("ep-2", "No User Data", None);
|
||||||
|
|
||||||
|
repo.save_to_cache("parent-1", &[watched.clone(), untouched])
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
position("ep-1").await,
|
||||||
|
Some((Some(12_000_000_000), Some(0))),
|
||||||
|
"the server's position should be mirrored as synced"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
position("ep-2").await,
|
||||||
|
None,
|
||||||
|
"an item without UserData should not get an invented position"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Watched further here while the server was unreachable: pending_sync = 1.
|
||||||
|
db_service
|
||||||
|
.execute(Query::with_params(
|
||||||
|
"UPDATE user_data SET playback_position_ticks = ?, pending_sync = 1 \
|
||||||
|
WHERE user_id = ? AND item_id = ?",
|
||||||
|
vec![
|
||||||
|
QueryParam::Int64(30_000_000_000),
|
||||||
|
QueryParam::String("test-user".to_string()),
|
||||||
|
QueryParam::String("ep-1".to_string()),
|
||||||
|
],
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// The server still reports the older position; caching must not win.
|
||||||
|
repo.save_to_cache("parent-1", &[watched]).await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
position("ep-1").await,
|
||||||
|
Some((Some(30_000_000_000), Some(1))),
|
||||||
|
"an unsynced local position must not be pulled backwards"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// UT-152 — a server item carrying *only* a position (no favourite flag)
|
||||||
|
/// still gets mirrored.
|
||||||
|
///
|
||||||
|
/// The mirror used to return early whenever `is_favorite` was absent, which
|
||||||
|
/// is exactly the shape of an ordinary watched episode: Jellyfin reports
|
||||||
|
/// `PlaybackPositionTicks` with no favourite state. That early return is why
|
||||||
|
/// the position never landed.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-025 | DR-155 | UT-152
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_position_is_mirrored_even_when_no_favourite_flag_is_present() {
|
||||||
|
use crate::storage::db_service::DatabaseService;
|
||||||
|
let db_service = create_test_db();
|
||||||
|
let repo = OfflineRepository::new(
|
||||||
|
db_service.clone(),
|
||||||
|
"test-server".to_string(),
|
||||||
|
"test-user".to_string(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut watched = create_test_item("ep-3", "Position Only", None);
|
||||||
|
watched.user_data = Some(UserData {
|
||||||
|
is_favorite: None,
|
||||||
|
playback_position_ticks: Some(9_000_000_000),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
|
||||||
|
repo.save_to_cache("parent-1", &[watched]).await.unwrap();
|
||||||
|
|
||||||
|
let stored = db_service
|
||||||
|
.query_optional(
|
||||||
|
Query::with_params(
|
||||||
|
"SELECT playback_position_ticks FROM user_data \
|
||||||
|
WHERE user_id = ? AND item_id = ?",
|
||||||
|
vec![
|
||||||
|
QueryParam::String("test-user".to_string()),
|
||||||
|
QueryParam::String("ep-3".to_string()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|row| row.get::<_, Option<i64>>(0),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
stored,
|
||||||
|
Some(Some(9_000_000_000)),
|
||||||
|
"a position with no favourite flag must still be mirrored"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
//! TRACES: UR-002, UR-007 | DR-013 | IR-010
|
//! TRACES: UR-002, UR-007 | DR-013 | IR-010
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
#[cfg(target_os = "android")]
|
use log::{debug, error, info, warn};
|
||||||
use log::warn;
|
|
||||||
use log::{debug, error, info};
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
@@ -672,6 +670,26 @@ fn build_get_items_endpoint(
|
|||||||
endpoint
|
endpoint
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Build the Jellyfin endpoint for a "recently added" listing.
|
||||||
|
///
|
||||||
|
/// `GroupItems=true` is the load-bearing parameter: Jellyfin defaults it to
|
||||||
|
/// `false`, which returns each newly-added *leaf* separately, so importing one
|
||||||
|
/// 14-track album pushed 14 rows into "recently added" and buried everything
|
||||||
|
/// else. With grouping on, the server collapses children into the container
|
||||||
|
/// that was added — an album appears once, while movies (which have no such
|
||||||
|
/// container) are unaffected.
|
||||||
|
///
|
||||||
|
/// Pulled out of `get_latest_items` so the query can be asserted without an
|
||||||
|
/// HTTP server, matching `build_favorites_endpoint`.
|
||||||
|
fn build_latest_items_endpoint(user_id: &str, parent_id: &str, limit: Option<usize>) -> String {
|
||||||
|
format!(
|
||||||
|
"/Users/{}/Items/Latest?ParentId={}&Limit={}&GroupItems=true&Fields=BackdropImageTags,ParentBackdropImageTags,UserData",
|
||||||
|
user_id,
|
||||||
|
parent_id,
|
||||||
|
limit.unwrap_or(16)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/// Build the Jellyfin endpoint for a favourites listing.
|
/// Build the Jellyfin endpoint for a favourites listing.
|
||||||
///
|
///
|
||||||
/// Pulled out of `get_favorites` so the query can be asserted without an HTTP
|
/// Pulled out of `get_favorites` so the query can be asserted without an HTTP
|
||||||
@@ -910,11 +928,7 @@ impl MediaRepository for OnlineRepository {
|
|||||||
parent_id: &str,
|
parent_id: &str,
|
||||||
limit: Option<usize>,
|
limit: Option<usize>,
|
||||||
) -> Result<Vec<MediaItem>, RepoError> {
|
) -> Result<Vec<MediaItem>, RepoError> {
|
||||||
let limit_str = limit.unwrap_or(16);
|
let endpoint = build_latest_items_endpoint(&self.user_id, parent_id, limit);
|
||||||
let endpoint = format!(
|
|
||||||
"/Users/{}/Items/Latest?ParentId={}&Limit={}&Fields=BackdropImageTags,ParentBackdropImageTags,UserData",
|
|
||||||
self.user_id, parent_id, limit_str
|
|
||||||
);
|
|
||||||
|
|
||||||
let items: Vec<JellyfinItem> = self.get_json(&endpoint).await?;
|
let items: Vec<JellyfinItem> = self.get_json(&endpoint).await?;
|
||||||
Ok(items
|
Ok(items
|
||||||
@@ -1342,6 +1356,9 @@ impl MediaRepository for OnlineRepository {
|
|||||||
index: i32,
|
index: i32,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
codec: Option<String>,
|
codec: Option<String>,
|
||||||
|
/// The track the server serves when the client pins none.
|
||||||
|
#[serde(default)]
|
||||||
|
is_default: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get detected codecs from Android MediaCodecList or use platform defaults
|
// Get detected codecs from Android MediaCodecList or use platform defaults
|
||||||
@@ -1356,8 +1373,9 @@ impl MediaRepository for OnlineRepository {
|
|||||||
// Linux desktop plays video through the WebKitGTK HTML5 <video> element,
|
// Linux desktop plays video through the WebKitGTK HTML5 <video> element,
|
||||||
// which cannot reliably decode HEVC/AV1/VP9. Advertise only codecs the
|
// which cannot reliably decode HEVC/AV1/VP9. Advertise only codecs the
|
||||||
// WebView can decode so Jellyfin transcodes anything else to h264 HLS.
|
// WebView can decode so Jellyfin transcodes anything else to h264 HLS.
|
||||||
// (Audio-only files still direct-play via MPV, but the PlaybackInfo
|
// (Audio-only files still direct-play via MPV; these codecs are what
|
||||||
// profile is shared, so we keep the broadly-supported audio codecs.)
|
// both renderers handle, and the audio profile keeps them in full while
|
||||||
|
// the video profile is narrowed below.)
|
||||||
#[cfg(all(not(target_os = "android"), target_os = "linux"))]
|
#[cfg(all(not(target_os = "android"), target_os = "linux"))]
|
||||||
let (video_codecs, audio_codecs) =
|
let (video_codecs, audio_codecs) =
|
||||||
("h264".to_string(), "aac,mp3,opus,vorbis,flac".to_string());
|
("h264".to_string(), "aac,mp3,opus,vorbis,flac".to_string());
|
||||||
@@ -1368,8 +1386,19 @@ impl MediaRepository for OnlineRepository {
|
|||||||
"aac,mp3,opus,vorbis,flac".to_string(),
|
"aac,mp3,opus,vorbis,flac".to_string(),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Video plays in a webview <video> element on every platform, which
|
||||||
|
// decodes a narrower audio set than the platform does — so the video
|
||||||
|
// profile must claim less than the audio-only profile. Without this a
|
||||||
|
// Dolby-licensed device advertises eac3, gets a direct play, and shows
|
||||||
|
// picture with no sound.
|
||||||
|
let video_audio_codecs = super::device_profile::video_audio_codecs(&audio_codecs);
|
||||||
|
|
||||||
info!("[DeviceProfile] Using video codecs: {}", video_codecs);
|
info!("[DeviceProfile] Using video codecs: {}", video_codecs);
|
||||||
info!("[DeviceProfile] Using audio codecs: {}", audio_codecs);
|
info!("[DeviceProfile] Using audio codecs: {}", audio_codecs);
|
||||||
|
info!(
|
||||||
|
"[DeviceProfile] Audio codecs for video direct play: {}",
|
||||||
|
video_audio_codecs
|
||||||
|
);
|
||||||
|
|
||||||
// Bound every profile by what the audio route can actually voice, so a
|
// Bound every profile by what the audio route can actually voice, so a
|
||||||
// multichannel track is downmixed by the server rather than direct-played
|
// multichannel track is downmixed by the server rather than direct-played
|
||||||
@@ -1388,12 +1417,16 @@ impl MediaRepository for OnlineRepository {
|
|||||||
profile_type: "Video".to_string(),
|
profile_type: "Video".to_string(),
|
||||||
container: "mp4,mkv,avi,mov,flv,ts,m2ts,webm,ogv,3gp".to_string(),
|
container: "mp4,mkv,avi,mov,flv,ts,m2ts,webm,ogv,3gp".to_string(),
|
||||||
video_codec: Some(video_codecs.clone()),
|
video_codec: Some(video_codecs.clone()),
|
||||||
audio_codec: audio_codecs.clone(),
|
// The webview decodes this stream, not ExoPlayer/MPV.
|
||||||
|
audio_codec: video_audio_codecs.clone(),
|
||||||
},
|
},
|
||||||
DirectPlayProfile {
|
DirectPlayProfile {
|
||||||
profile_type: "Audio".to_string(),
|
profile_type: "Audio".to_string(),
|
||||||
container: "mp3,aac,flac,alac,wav,ogg,wma,opus".to_string(),
|
container: "mp3,aac,flac,alac,wav,ogg,wma,opus".to_string(),
|
||||||
video_codec: None,
|
video_codec: None,
|
||||||
|
// Audio-only really is the native player's, so it keeps the
|
||||||
|
// full platform list — narrowing it would transcode music
|
||||||
|
// that plays perfectly well.
|
||||||
audio_codec: audio_codecs.clone(),
|
audio_codec: audio_codecs.clone(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -1459,9 +1492,29 @@ impl MediaRepository for OnlineRepository {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Jellyfin 10.11.5 honours a DirectPlayProfile's container and video codec
|
||||||
|
// but ignores its audio codec, so it offers an E-AC-3 track for direct
|
||||||
|
// play even though DR-148 advertises only AAC — and the webview renders
|
||||||
|
// the picture in silence. Judge the track we would actually be served
|
||||||
|
// against what the webview can decode, and override the server's answer.
|
||||||
|
let audio_streams: Vec<(Option<&str>, bool)> = source
|
||||||
|
.media_streams
|
||||||
|
.iter()
|
||||||
|
.filter(|stream| stream.stream_type == "Audio")
|
||||||
|
.map(|stream| (stream.codec.as_deref(), stream.is_default))
|
||||||
|
.collect();
|
||||||
|
let audio_forces_transcode = super::device_profile::audio_forces_transcode(&audio_streams);
|
||||||
|
|
||||||
// Use TranscodingUrl from response if available (Streamyfin pattern)
|
// Use TranscodingUrl from response if available (Streamyfin pattern)
|
||||||
let stream_url = if let Some(transcoding_url) = &source.transcoding_url {
|
let stream_url = if let Some(transcoding_url) = &source.transcoding_url {
|
||||||
format!("{}{}", self.server_url, transcoding_url)
|
format!("{}{}", self.server_url, transcoding_url)
|
||||||
|
} else if audio_forces_transcode {
|
||||||
|
warn!(
|
||||||
|
"[PlaybackInfo] Server offered direct play for audio the webview cannot decode ({:?}) — forcing an HLS transcode",
|
||||||
|
audio_streams.first().and_then(|(codec, _)| *codec)
|
||||||
|
);
|
||||||
|
self.get_video_stream_url(item_id, Some(&source.id), None, None)
|
||||||
|
.await?
|
||||||
} else {
|
} else {
|
||||||
// Fall back to direct stream URL. No audioStreamIndex: static=true
|
// Fall back to direct stream URL. No audioStreamIndex: static=true
|
||||||
// serves the original file untouched, and pinning index 0 (the video
|
// serves the original file untouched, and pinning index 0 (the video
|
||||||
@@ -1482,8 +1535,9 @@ impl MediaRepository for OnlineRepository {
|
|||||||
media_source_id: source.id.clone(),
|
media_source_id: source.id.clone(),
|
||||||
play_session_id: response.play_session_id,
|
play_session_id: response.play_session_id,
|
||||||
stream_url,
|
stream_url,
|
||||||
direct_play: source.supports_direct_play,
|
direct_play: source.supports_direct_play && !audio_forces_transcode,
|
||||||
needs_transcoding: !source.supports_direct_play && source.supports_transcoding,
|
needs_transcoding: audio_forces_transcode
|
||||||
|
|| (!source.supports_direct_play && source.supports_transcoding),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1734,6 +1788,7 @@ impl MediaRepository for OnlineRepository {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// TRACES: UR-071 | DR-123
|
||||||
fn get_video_download_url(
|
fn get_video_download_url(
|
||||||
&self,
|
&self,
|
||||||
item_id: &str,
|
item_id: &str,
|
||||||
@@ -1751,27 +1806,43 @@ impl MediaRepository for OnlineRepository {
|
|||||||
// Map the frontend quality preset to concrete transcode params. For
|
// Map the frontend quality preset to concrete transcode params. For
|
||||||
// "original" we request a direct static copy (no transcode) which is
|
// "original" we request a direct static copy (no transcode) which is
|
||||||
// byte-range resumable; other presets ask the server to transcode.
|
// 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 {
|
match quality {
|
||||||
"high" => {
|
"high" => {
|
||||||
params.push("videoBitrate=8000000".to_string());
|
params.push("videoBitRate=8000000".to_string());
|
||||||
params.push("maxHeight=1080".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("videoCodec=h264".to_string());
|
||||||
params.push("audioCodec=aac".to_string());
|
params.push("audioCodec=aac".to_string());
|
||||||
|
params.push("allowVideoStreamCopy=false".to_string());
|
||||||
}
|
}
|
||||||
"medium" => {
|
"medium" => {
|
||||||
params.push("videoBitrate=4000000".to_string());
|
params.push("videoBitRate=4000000".to_string());
|
||||||
params.push("maxHeight=720".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("videoCodec=h264".to_string());
|
||||||
params.push("audioCodec=aac".to_string());
|
params.push("audioCodec=aac".to_string());
|
||||||
|
params.push("allowVideoStreamCopy=false".to_string());
|
||||||
}
|
}
|
||||||
"low" => {
|
"low" => {
|
||||||
params.push("videoBitrate=1500000".to_string());
|
params.push("videoBitRate=1500000".to_string());
|
||||||
params.push("maxHeight=480".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("videoCodec=h264".to_string());
|
||||||
params.push("audioCodec=aac".to_string());
|
params.push("audioCodec=aac".to_string());
|
||||||
|
params.push("allowVideoStreamCopy=false".to_string());
|
||||||
}
|
}
|
||||||
// "original" (and any unknown value) → direct, resumable copy.
|
// "original" (and any unknown value) → direct, resumable copy.
|
||||||
_ => {
|
_ => {
|
||||||
@@ -2510,7 +2581,7 @@ mod tests {
|
|||||||
// with no transcode params.
|
// with no transcode params.
|
||||||
assert!(url.contains("Static=true"), "url: {url}");
|
assert!(url.contains("Static=true"), "url: {url}");
|
||||||
assert!(
|
assert!(
|
||||||
!url.contains("videoBitrate"),
|
!url.contains("videoBitRate"),
|
||||||
"original must not transcode: {url}"
|
"original must not transcode: {url}"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
@@ -2530,7 +2601,7 @@ mod tests {
|
|||||||
"{quality} must use stream.mp4: {url}"
|
"{quality} must use stream.mp4: {url}"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
url.contains("videoBitrate="),
|
url.contains("videoBitRate="),
|
||||||
"{quality} must set bitrate: {url}"
|
"{quality} must set bitrate: {url}"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
@@ -2546,6 +2617,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]
|
#[test]
|
||||||
fn test_video_download_url_passes_media_source_id() {
|
fn test_video_download_url_passes_media_source_id() {
|
||||||
let repo = create_test_repository();
|
let repo = create_test_repository();
|
||||||
@@ -2668,6 +2799,25 @@ mod tests {
|
|||||||
assert!(!off.contains("Filters=IsFavorite"));
|
assert!(!off.contains("Filters=IsFavorite"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A newly-added album must arrive as one entry, not one per track.
|
||||||
|
///
|
||||||
|
/// Jellyfin's `/Items/Latest` defaults to `GroupItems=false`, which returns
|
||||||
|
/// every new Audio track individually — so ripping a 14-track album filled
|
||||||
|
/// the whole "recently added" row with that one album. `GroupItems=true`
|
||||||
|
/// makes the server collapse children into their parent container.
|
||||||
|
#[test]
|
||||||
|
fn test_latest_items_endpoint_groups_children_into_containers() {
|
||||||
|
let endpoint = build_latest_items_endpoint("u1", "lib-1", Some(16));
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
endpoint.contains("GroupItems=true"),
|
||||||
|
"latest items must be grouped so an album counts once, got: {}",
|
||||||
|
endpoint
|
||||||
|
);
|
||||||
|
assert!(endpoint.contains("ParentId=lib-1"));
|
||||||
|
assert!(endpoint.contains("Limit=16"));
|
||||||
|
}
|
||||||
|
|
||||||
/// UT-099 — a Jellyfin item's `UserData` reaches `MediaItem.user_data`.
|
/// UT-099 — a Jellyfin item's `UserData` reaches `MediaItem.user_data`.
|
||||||
///
|
///
|
||||||
/// Before DR-113 this mapping was hardcoded to `None`, so nothing outside
|
/// Before DR-113 this mapping was hardcoded to `None`, so nothing outside
|
||||||
|
|||||||
@@ -97,9 +97,14 @@ fn belongs_to_series(item: &MediaItem, series_id: &str) -> bool {
|
|||||||
/// working through.
|
/// working through.
|
||||||
/// 2. **The server's Next Up** for this series — it accounts for watch history
|
/// 2. **The server's Next Up** for this series — it accounts for watch history
|
||||||
/// we do not cache locally.
|
/// we do not cache locally.
|
||||||
/// 3. **The first unwatched episode** in series order. This is the offline path:
|
/// 3. **The episode after the furthest-watched one**, falling back to the first
|
||||||
/// `OfflineRepository::get_next_up_episodes` returns an empty vec, so without
|
/// unwatched episode when nothing has been watched or the series is finished.
|
||||||
/// this rung the whole feature would be online-only.
|
/// This is the offline path: `OfflineRepository::get_next_up_episodes`
|
||||||
|
/// returns an empty vec, so without this rung the whole feature would be
|
||||||
|
/// online-only. It deliberately does *not* return the first unwatched
|
||||||
|
/// episode outright — an unwatched episode behind the viewer's furthest
|
||||||
|
/// point was skipped on purpose, and sending them back to it is the bug
|
||||||
|
/// DR-101 was reopened for.
|
||||||
/// 4. **The first episode**, so a never-watched series opens on its premiere
|
/// 4. **The first episode**, so a never-watched series opens on its premiere
|
||||||
/// rather than on nothing.
|
/// rather than on nothing.
|
||||||
///
|
///
|
||||||
@@ -136,7 +141,18 @@ pub fn pick_current_episode(
|
|||||||
return Some(matched.unwrap_or(found).clone());
|
return Some(matched.unwrap_or(found).clone());
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. First unwatched in series order.
|
// 3. The episode after the furthest-watched one. Not simply the first
|
||||||
|
// unwatched: a viewer who skipped the pilot but is deep into season 3
|
||||||
|
// must not be dragged back to S1E1. An earlier gap is a deliberate skip;
|
||||||
|
// where they stopped is the *last* thing they watched.
|
||||||
|
if let Some(furthest) = episodes.iter().rposition(is_played) {
|
||||||
|
if let Some(found) = episodes.get(furthest + 1) {
|
||||||
|
return Some(found.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nothing watched yet (or the furthest-watched episode is the finale):
|
||||||
|
// the first unwatched episode in series order.
|
||||||
if let Some(found) = episodes.iter().find(|e| !is_played(e)) {
|
if let Some(found) = episodes.iter().find(|e| !is_played(e)) {
|
||||||
return Some(found.clone());
|
return Some(found.clone());
|
||||||
}
|
}
|
||||||
@@ -352,6 +368,54 @@ mod tests {
|
|||||||
assert_eq!(current.id, "s2e2");
|
assert_eq!(current.id, "s2e2");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A viewer deep in season 3 who never watched the pilot must not be sent
|
||||||
|
/// back to it: the gap was a skip, not the place they stopped.
|
||||||
|
#[test]
|
||||||
|
fn resumes_after_the_furthest_watched_episode_not_the_first_gap() {
|
||||||
|
let mut eps = [season(1, 4), season(2, 4), season(3, 4)].concat();
|
||||||
|
for ep in eps.iter_mut() {
|
||||||
|
// Everything through S3E3 watched, except the never-watched pilot.
|
||||||
|
let watched_through = ep.parent_index_number < Some(3) || ep.index_number <= Some(3);
|
||||||
|
if watched_through && ep.id != "s1e1" {
|
||||||
|
*ep = watched(ep.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let current = pick_current_episode(SERIES, &eps, &[], &[]).unwrap();
|
||||||
|
assert_eq!(current.id, "s3e4");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The furthest-watched episode being a finale must still roll into the
|
||||||
|
/// next season rather than stopping the series.
|
||||||
|
#[test]
|
||||||
|
fn resumes_into_the_next_season_after_a_skipped_earlier_episode() {
|
||||||
|
let mut eps = [season(1, 3), season(2, 3)].concat();
|
||||||
|
for ep in eps.iter_mut() {
|
||||||
|
if ep.parent_index_number == Some(1) && ep.id != "s1e1" {
|
||||||
|
*ep = watched(ep.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let current = pick_current_episode(SERIES, &eps, &[], &[]).unwrap();
|
||||||
|
assert_eq!(current.id, "s2e1");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Specials sort last, so watching one must not mark the series finished
|
||||||
|
/// while numbered episodes remain.
|
||||||
|
#[test]
|
||||||
|
fn a_watched_special_does_not_end_the_series() {
|
||||||
|
let mut eps = [season(1, 3), vec![episode("s0e1", 0, 1)]].concat();
|
||||||
|
sort_series_order(&mut eps);
|
||||||
|
for ep in eps.iter_mut() {
|
||||||
|
if ep.id == "s1e1" || ep.id == "s0e1" {
|
||||||
|
*ep = watched(ep.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let current = pick_current_episode(SERIES, &eps, &[], &[]).unwrap();
|
||||||
|
assert_eq!(current.id, "s1e2");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn crosses_a_season_boundary_when_a_season_is_finished() {
|
fn crosses_a_season_boundary_when_a_season_is_finished() {
|
||||||
let mut eps = [season(1, 3), season(2, 3)].concat();
|
let mut eps = [season(1, 3), season(2, 3)].concat();
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
|
"app": {
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"transparent": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "jellytau",
|
"productName": "jellytau",
|
||||||
"version": "0.4.6",
|
"version": "0.4.8",
|
||||||
"identifier": "com.dtourolle.jellytau",
|
"identifier": "com.dtourolle.jellytau",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "bun run dev",
|
"beforeDevCommand": "bun run dev",
|
||||||
|
|||||||
+24
@@ -51,6 +51,30 @@ html, body {
|
|||||||
background-color: var(--color-background);
|
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 {
|
body {
|
||||||
@apply text-white antialiased;
|
@apply text-white antialiased;
|
||||||
font-family: system-ui, -apple-system, sans-serif;
|
font-family: system-ui, -apple-system, sans-serif;
|
||||||
|
|||||||
+83
-2
@@ -143,6 +143,14 @@ async playerGetStatus() : Promise<PlayerStatus> {
|
|||||||
async playerGetQueue() : Promise<QueueStatus> {
|
async playerGetQueue() : Promise<QueueStatus> {
|
||||||
return await TAURI_INVOKE("player_get_queue");
|
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> {
|
async playerAddToQueue(request: AddToQueueRequest) : Promise<QueueStatus> {
|
||||||
return await TAURI_INVOKE("player_add_to_queue", { request });
|
return await TAURI_INVOKE("player_add_to_queue", { request });
|
||||||
},
|
},
|
||||||
@@ -1464,6 +1472,15 @@ async repositoryReportPlaybackProgress(handle: string, itemId: string, positionM
|
|||||||
},
|
},
|
||||||
/**
|
/**
|
||||||
* Report playback stopped
|
* Report playback stopped
|
||||||
|
*
|
||||||
|
* A stop-report that cannot reach the server is queued rather than dropped:
|
||||||
|
* this is the position the resume point is built from, and losing it is
|
||||||
|
* exactly the "it forgot where I was" the sync queue exists to prevent. The
|
||||||
|
* drain (DR-131) pushes it on the next reconnect. Queueing is best-effort —
|
||||||
|
* failing the command because the *queue* write failed would tell the caller
|
||||||
|
* the report was lost when the local position was already saved.
|
||||||
|
*
|
||||||
|
* TRACES: UR-025 | DR-154 | UT-151
|
||||||
*/
|
*/
|
||||||
async repositoryReportPlaybackStopped(handle: string, itemId: string, positionMs: number) : Promise<null> {
|
async repositoryReportPlaybackStopped(handle: string, itemId: string, positionMs: number) : Promise<null> {
|
||||||
return await TAURI_INVOKE("repository_report_playback_stopped", { handle, itemId, positionMs });
|
return await TAURI_INVOKE("repository_report_playback_stopped", { handle, itemId, positionMs });
|
||||||
@@ -2231,7 +2248,29 @@ itemType?: string | null;
|
|||||||
* Series ID for TV episodes. Needed alongside `item_type` so the backend can
|
* Series ID for TV episodes. Needed alongside `item_type` so the backend can
|
||||||
* look up the next episode when a background-audio track ends.
|
* look up the next episode when a background-audio track ends.
|
||||||
*/
|
*/
|
||||||
seriesId?: string | null }
|
seriesId?: string | null;
|
||||||
|
/**
|
||||||
|
* Subtitle tracks to sideload, with URLs the frontend has already resolved.
|
||||||
|
*
|
||||||
|
* Only the native backends use these: on Android they become the
|
||||||
|
* `MediaItem.SubtitleConfiguration`s ExoPlayer renders. The HTML5 path
|
||||||
|
* builds its own `<track>` children instead and ignores this list.
|
||||||
|
*
|
||||||
|
* **Order is the contract.** `player_set_subtitle_track(n)` reaches
|
||||||
|
* `JellyTauPlayer.setSubtitleTrack(n)`, which indexes into ExoPlayer's
|
||||||
|
* *text track groups* — i.e. the position of the sideloaded configuration,
|
||||||
|
* not the Jellyfin stream index (which is kept on each entry for the UI's
|
||||||
|
* benefit). So `n` must be a position in this very array, and the array
|
||||||
|
* must not be reordered or filtered between building it and sending it.
|
||||||
|
* `nativeSubtitleArrayIndex()` on the frontend computes `n` from the same
|
||||||
|
* list that is sent here, for exactly this reason.
|
||||||
|
*
|
||||||
|
* Defaulted so the background-audio handoff and the autoplay/next-episode
|
||||||
|
* callers, which have no subtitles to offer, need not send the field.
|
||||||
|
*
|
||||||
|
* TRACES: UR-020 | IR-016, JA-008 | UT-145
|
||||||
|
*/
|
||||||
|
subtitles?: SubtitleTrack[] }
|
||||||
/**
|
/**
|
||||||
* Queue context for remote transfer - what type of queue is this?
|
* Queue context for remote transfer - what type of queue is this?
|
||||||
*/
|
*/
|
||||||
@@ -2271,6 +2310,30 @@ export type PlayTracksRequest = { trackIds: string[]; startIndex: number; shuffl
|
|||||||
* over playback from a remote session so we don't restart from 0.
|
* over playback from a remote session so we don't restart from 0.
|
||||||
*/
|
*/
|
||||||
startPosition?: number | null }
|
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
|
* Playback information
|
||||||
*/
|
*/
|
||||||
@@ -2769,6 +2832,23 @@ export type StreamKind = "audio" | "video" | "subtitle" |
|
|||||||
"other"
|
"other"
|
||||||
/**
|
/**
|
||||||
* Represents a subtitle track
|
* Represents a subtitle track
|
||||||
|
*
|
||||||
|
* 🔴 **Do not add `#[serde(rename_all = "camelCase")]` here.** This is the one
|
||||||
|
* struct in the player that deliberately keeps snake_case on the wire, because
|
||||||
|
* the *same* serialization feeds two consumers that both spell `mime_type`:
|
||||||
|
*
|
||||||
|
* * the JNI boundary — `player/android/mod.rs` serializes `MediaItem::subtitles`
|
||||||
|
* with `serde_json` and hands the string to `JellyTauPlayer.loadWithMetadata`,
|
||||||
|
* whose parser reads `url`, `language`, `label` and `optString("mime_type")`;
|
||||||
|
* * the IPC boundary — `PlayItemRequest::subtitles` deserializes this same type
|
||||||
|
* from the frontend, and the generated binding (`SubtitleTrack` in
|
||||||
|
* `bindings.ts`) therefore also declares `mime_type`.
|
||||||
|
*
|
||||||
|
* Renaming would not break the build and would not fail the IPC: Kotlin's
|
||||||
|
* `optString` would just fall back to its default MIME type for every track, so
|
||||||
|
* the failure would be silent. UT-146 asserts the serialized keys.
|
||||||
|
*
|
||||||
|
* TRACES: UR-020 | IR-016, JA-008 | UT-146
|
||||||
*/
|
*/
|
||||||
export type SubtitleTrack = {
|
export type SubtitleTrack = {
|
||||||
/**
|
/**
|
||||||
@@ -2788,7 +2868,8 @@ language: string | null;
|
|||||||
*/
|
*/
|
||||||
label: string | null;
|
label: string | null;
|
||||||
/**
|
/**
|
||||||
* MIME type (e.g., "text/vtt", "application/x-subrip")
|
* MIME type (e.g., "text/vtt", "application/x-subrip").
|
||||||
|
* Snake_case on purpose — see the note on the struct.
|
||||||
*/
|
*/
|
||||||
mime_type: string }
|
mime_type: string }
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -45,9 +45,16 @@
|
|||||||
* TRACES: UR-068 | DR-119
|
* TRACES: UR-068 | DR-119
|
||||||
*/
|
*/
|
||||||
showFavorite?: boolean;
|
showFavorite?: boolean;
|
||||||
|
/**
|
||||||
|
* Force the artwork box to a fixed aspect ratio instead of deriving one from
|
||||||
|
* the item. Use on rows that mix item kinds (e.g. the home "Your Libraries"
|
||||||
|
* strip, where square music art next to 16:9 video art would otherwise give
|
||||||
|
* the cards different heights). Artwork still fills the box via object-cover.
|
||||||
|
*/
|
||||||
|
aspect?: "square" | "video" | "poster";
|
||||||
}
|
}
|
||||||
|
|
||||||
let { item, size = "medium", showProgress = false, showDownloadStatus = true, sizeLabel, downloadedBadge, onRemove, onclick, onLongPress, showFavorite = true }: Props = $props();
|
let { item, size = "medium", showProgress = false, showDownloadStatus = true, sizeLabel, downloadedBadge, onRemove, onclick, onLongPress, showFavorite = true, aspect }: Props = $props();
|
||||||
|
|
||||||
// Long-press detection. We arm a timer on pointerdown; if it fires before the
|
// Long-press detection. We arm a timer on pointerdown; if it fires before the
|
||||||
// pointer is released (or moves too far), we treat it as a long press and set a
|
// pointer is released (or moves too far), we treat it as a long press and set a
|
||||||
@@ -179,7 +186,14 @@
|
|||||||
"kind" in item && (item.kind === "track" || item.kind === "album" || item.kind === "artist" || item.kind === "playlist")
|
"kind" in item && (item.kind === "track" || item.kind === "album" || item.kind === "artist" || item.kind === "playlist")
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const FIXED_ASPECT = {
|
||||||
|
square: "aspect-square",
|
||||||
|
video: "aspect-video",
|
||||||
|
poster: "aspect-[2/3]",
|
||||||
|
} as const;
|
||||||
|
|
||||||
const aspectRatio = $derived(() => {
|
const aspectRatio = $derived(() => {
|
||||||
|
if (aspect) return FIXED_ASPECT[aspect];
|
||||||
if ("kind" in item) {
|
if ("kind" in item) {
|
||||||
return isMusicType ? "aspect-square" : "aspect-[2/3]";
|
return isMusicType ? "aspect-square" : "aspect-[2/3]";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<!-- TRACES: UR-003, UR-005, UR-020, UR-021, UR-026, UR-040, UR-061 | DR-010, DR-023, DR-024, DR-051, DR-052, DR-092, DR-098, DR-099 -->
|
<!-- TRACES: UR-003, UR-005, UR-020, UR-021, UR-026, UR-040, UR-061 | DR-010, DR-023, DR-024, DR-051, DR-052, DR-092, DR-098, DR-099 -->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, onDestroy, untrack } from "svelte";
|
import { onMount, onDestroy, tick, untrack } from "svelte";
|
||||||
import { get } from "svelte/store";
|
import { get } from "svelte/store";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
import { commands } from "$lib/api/bindings";
|
import { commands } from "$lib/api/bindings";
|
||||||
@@ -14,12 +14,30 @@
|
|||||||
import SleepTimerIndicator from "./SleepTimerIndicator.svelte";
|
import SleepTimerIndicator from "./SleepTimerIndicator.svelte";
|
||||||
import CachedImage from "../common/CachedImage.svelte";
|
import CachedImage from "../common/CachedImage.svelte";
|
||||||
import { videoFitClass } from "./videoFit";
|
import { videoFitClass } from "./videoFit";
|
||||||
|
import {
|
||||||
|
resolveSubtitleTracks,
|
||||||
|
reconcileSelectedSubtitle,
|
||||||
|
videoCrossOriginMode,
|
||||||
|
nativeSubtitleTracks,
|
||||||
|
nativeSubtitleArrayIndex,
|
||||||
|
type RenderableSubtitleTrack,
|
||||||
|
} from "./subtitleTracks";
|
||||||
import { sleepTimerActive, sleepTimerExpiredSignal } from "$lib/stores/sleepTimer";
|
import { sleepTimerActive, sleepTimerExpiredSignal } from "$lib/stores/sleepTimer";
|
||||||
import { playbackPosition, playerState } from "$lib/stores/player";
|
import { playbackPosition, playerState } from "$lib/stores/player";
|
||||||
import * as html5Adapter from "$lib/player/html5Adapter";
|
import * as html5Adapter from "$lib/player/html5Adapter";
|
||||||
import { playerController } from "$lib/player";
|
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 { 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 { isPipSupported, enterPip, setAutoEnterEnabled } from "$lib/utils/pictureInPicture";
|
||||||
import {
|
import {
|
||||||
createTapGestureState,
|
createTapGestureState,
|
||||||
@@ -158,7 +176,10 @@
|
|||||||
// VideoPlayer supplies a narrow bridge for the element/HLS-coupled parts and
|
// 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
|
// registers the adapter with the facade so control intents — from UI OR from a
|
||||||
// backend control event (lockscreen/remote/sleep) — reach this element.
|
// 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() {
|
function tearDownHls() {
|
||||||
if (hls) {
|
if (hls) {
|
||||||
@@ -275,6 +296,61 @@
|
|||||||
return tracks;
|
return tracks;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ===== Subtitle <track> sources for the HTML5 element (Linux/WebKitGTK) =====
|
||||||
|
// Resolved asynchronously into state and only then rendered. The URLs come
|
||||||
|
// from an async command, so they must never be bound to `src` directly — the
|
||||||
|
// original markup did exactly that and put "[object Promise]" on every track,
|
||||||
|
// which is why the whole block ended up commented out (and why selecting a
|
||||||
|
// subtitle did nothing: with no <track> children the element has no
|
||||||
|
// textTracks for the adapter to switch on).
|
||||||
|
// TRACES: UR-020 | DR-023 | UT-143, UT-144
|
||||||
|
let renderedSubtitleTracks = $state<RenderableSubtitleTrack[]>([]);
|
||||||
|
|
||||||
|
// The subtitle list actually handed to the native backend at load time
|
||||||
|
// (Android/ExoPlayer). Kept because `player_set_subtitle_track` takes a
|
||||||
|
// *position in this list*, not a Jellyfin stream index — see
|
||||||
|
// nativeSubtitleArrayIndex. It is written once, from onMount, before the
|
||||||
|
// play request; it is not derived, because the request is what fixed the
|
||||||
|
// backend's idea of the track order.
|
||||||
|
// TRACES: UR-020 | IR-016 | UT-147
|
||||||
|
let sentSubtitleTracks = $state<RenderableSubtitleTrack[]>([]);
|
||||||
|
|
||||||
|
// Cross-origin <track> fetches use the media element's CORS setting; see
|
||||||
|
// videoCrossOriginMode for why this is opt-in and same-origin-only.
|
||||||
|
const videoCrossOrigin = $derived(
|
||||||
|
videoCrossOriginMode(currentStreamUrl, subtitleTracks().length)
|
||||||
|
);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const streams = media?.mediaStreams ?? null;
|
||||||
|
const itemId = media?.id;
|
||||||
|
const sourceId = mediaSourceId;
|
||||||
|
// Native (ExoPlayer) mode renders subtitles itself; the element has none.
|
||||||
|
if (!useHtml5Element || !itemId || !sourceId) {
|
||||||
|
renderedSubtitleTracks = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
void (async () => {
|
||||||
|
const tracks = await resolveSubtitleTracks(streams, (index) => getSubtitleUrl(index));
|
||||||
|
if (cancelled) return;
|
||||||
|
renderedSubtitleTracks = tracks;
|
||||||
|
// Keep the menu's checkmark and the element's text tracks in agreement:
|
||||||
|
// a selection that no longer resolves collapses to "Off".
|
||||||
|
const selected = reconcileSelectedSubtitle(tracks, untrack(() => selectedSubtitleIndex));
|
||||||
|
selectedSubtitleIndex = selected;
|
||||||
|
// The <track> children were just (re)created, so re-apply the selection to
|
||||||
|
// the new TextTrack objects — otherwise a surviving selection shows nothing.
|
||||||
|
await tick();
|
||||||
|
if (!cancelled) applySubtitleToElement(selected);
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
// Track the last prop value to detect when parent changes the URL (vs internal seeks)
|
// Track the last prop value to detect when parent changes the URL (vs internal seeks)
|
||||||
let lastStreamUrlProp = $state("");
|
let lastStreamUrlProp = $state("");
|
||||||
|
|
||||||
@@ -544,28 +620,23 @@
|
|||||||
console.log("[VideoPlayer] Initializing player for:", media.name);
|
console.log("[VideoPlayer] Initializing player for:", media.name);
|
||||||
console.log("[VideoPlayer] Stream URL:", currentStreamUrl);
|
console.log("[VideoPlayer] Stream URL:", currentStreamUrl);
|
||||||
|
|
||||||
// Build subtitle tracks for native player
|
// Resolve subtitle URLs for the native (ExoPlayer) path. These must be
|
||||||
const subtitleTracks = [];
|
// in hand *before* the play request: ExoPlayer sideloads subtitles as
|
||||||
if (media.mediaStreams && mediaSourceId) {
|
// MediaItem.SubtitleConfigurations, which have to exist before
|
||||||
const subtitles = media.mediaStreams.filter(s => s.kind === "subtitle");
|
// prepare() — there is no way to add one to a loaded item afterwards.
|
||||||
for (const sub of subtitles) {
|
//
|
||||||
try {
|
// Awaiting here is safe despite the native-mode pitfall: that rule is
|
||||||
const url = await getSubtitleUrl(sub.index);
|
// about Svelte *lifecycle* calls (onMount/onDestroy) after an await,
|
||||||
if (url) {
|
// which throw lifecycle_outside_component and used to be misread as an
|
||||||
subtitleTracks.push({
|
// init failure. Nothing is registered here, and the background-audio
|
||||||
index: sub.index,
|
// subscriptions above already ran synchronously. resolveSubtitleTracks
|
||||||
url: url,
|
// fans the requests out in parallel, so this costs one round trip, not
|
||||||
language: sub.language || null,
|
// one per subtitle stream as the old serial loop did.
|
||||||
label: sub.displayTitle || sub.language || `Track ${sub.index}`,
|
// TRACES: UR-020 | IR-016, JA-008 | UT-147
|
||||||
mime_type: "text/vtt" // Jellyfin converts to WebVTT
|
sentSubtitleTracks = mediaSourceId
|
||||||
});
|
? await resolveSubtitleTracks(media.mediaStreams, (index) => getSubtitleUrl(index))
|
||||||
}
|
: [];
|
||||||
} catch (err) {
|
console.log(`[VideoPlayer] Sending ${sentSubtitleTracks.length} subtitle tracks to the backend`);
|
||||||
console.warn(`[VideoPlayer] Failed to build subtitle URL for track ${sub.index}:`, err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
console.log(`[VideoPlayer] Built ${subtitleTracks.length} subtitle tracks for native player`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Call Rust backend to start playback
|
// Call Rust backend to start playback
|
||||||
// Rust will choose ExoPlayer (Android), libmpv (Linux), or tell us to use HTML5
|
// Rust will choose ExoPlayer (Android), libmpv (Linux), or tell us to use HTML5
|
||||||
@@ -576,6 +647,10 @@
|
|||||||
id: media.id,
|
id: media.id,
|
||||||
videoCodec: needsTranscoding ? "hevc" : "h264",
|
videoCodec: needsTranscoding ? "hevc" : "h264",
|
||||||
needsTranscoding: needsTranscoding,
|
needsTranscoding: needsTranscoding,
|
||||||
|
// Order matters: player_set_subtitle_track(n) is a position in this
|
||||||
|
// array. Previously this array was built and then dropped, so
|
||||||
|
// ExoPlayer got a MediaItem with no subtitles at all.
|
||||||
|
subtitles: nativeSubtitleTracks(sentSubtitleTracks),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Rust tells us which backend it's using
|
// Rust tells us which backend it's using
|
||||||
@@ -583,15 +658,16 @@
|
|||||||
backendChosen = true;
|
backendChosen = true;
|
||||||
console.log(`[VideoPlayer] Backend: ${response.backend}, useHtml5Element: ${useHtml5Element}`);
|
console.log(`[VideoPlayer] Backend: ${response.backend}, useHtml5Element: ${useHtml5Element}`);
|
||||||
|
|
||||||
// INTERIM (until the video-player API refactor lands): always render
|
// Rust reported a native backend (Android/ExoPlayer). Honour it only if
|
||||||
// through the webview HTML5 element, including Android. The native
|
// the user opted into the experimental native path; otherwise fall back
|
||||||
// ExoPlayer SurfaceView sits behind an opaque webview and has never
|
// to the webview element, which is what shipped by default.
|
||||||
// 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
|
// The flag is a suppressor, never a promoter — see createAdapter(). When
|
||||||
// picture. Stop the native backend and let the webview own playback,
|
// it is off we must also stop the native backend that player_play_item
|
||||||
// matching Linux behavior and avoiding dual audio.
|
// just started, or ExoPlayer and the <video> element both decode the
|
||||||
if (!useHtml5Element) {
|
// same stream and the audio doubles.
|
||||||
console.warn("[VideoPlayer] Native video backend reported - overriding to HTML5 rendering (native surface not visible through webview)");
|
if (!useHtml5Element && !$experimentalNativeVideo) {
|
||||||
|
console.log("[VideoPlayer] Native backend available but experimentalNativeVideo is off - using HTML5");
|
||||||
useHtml5Element = true;
|
useHtml5Element = true;
|
||||||
try {
|
try {
|
||||||
await commands.playerStop();
|
await commands.playerStop();
|
||||||
@@ -599,6 +675,14 @@
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn("[VideoPlayer] Failed to stop native backend:", 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
|
// If using HTML5 element for non-transcoded content, stop the backend player
|
||||||
@@ -617,16 +701,61 @@
|
|||||||
didStartNativePlayback = true; // Track that we need to stop backend on unmount
|
didStartNativePlayback = true; // Track that we need to stop backend on unmount
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register the HTML5 player adapter with the facade so control intents
|
// Register the adapter with the facade so control intents (UI, or a
|
||||||
// (UI or backend lockscreen/remote/sleep events) route to this element.
|
// backend lockscreen/remote/sleep event) route to whatever is actually
|
||||||
if (useHtml5Element) {
|
// rendering. Both paths need one: the native adapter forwards control
|
||||||
|
// intents to ExoPlayer over IPC.
|
||||||
|
{
|
||||||
const host = createRustReportHost(media.id, {
|
const host = createRustReportHost(media.id, {
|
||||||
onEnded: () => notifyEnded(),
|
onEnded: () => notifyEnded(),
|
||||||
onStreamUrlChanged: (u) => { currentStreamUrl = u; },
|
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);
|
playerAdapter.attach(videoElement);
|
||||||
playerController.setActiveAdapter(playerAdapter);
|
playerController.setActiveAdapter(playerAdapter);
|
||||||
|
|
||||||
|
// The native (ExoPlayer) path has no <video> element, so `canplay`
|
||||||
|
// never fires and the handleCanPlay initial-seek below never runs —
|
||||||
|
// resume-at-position played from the beginning on Android. Hand the
|
||||||
|
// resume point to the adapter, which issues the backend seek.
|
||||||
|
//
|
||||||
|
// HTML5 keeps its existing element-driven seek: seeking before the
|
||||||
|
// element has metadata is clamped back to 0, which is precisely what
|
||||||
|
// handleCanPlay waits for.
|
||||||
|
// TRACES: UR-005 | DR-004, DR-028
|
||||||
|
if (!useHtml5Element) {
|
||||||
|
hasPerformedInitialSeek = true; // native path owns the initial seek
|
||||||
|
lastAppliedInitialPosition = initialPosition;
|
||||||
|
await playerAdapter.load(currentStreamUrl, {
|
||||||
|
mediaId: media.id,
|
||||||
|
mediaSourceId: mediaSourceId ?? null,
|
||||||
|
needsTranscoding,
|
||||||
|
initialPosition: initialPosition ?? 0,
|
||||||
|
isLive,
|
||||||
|
audioTrackIndex: null,
|
||||||
|
knownDuration: media.durationMs ? media.durationMs / 1000 : 0,
|
||||||
|
// ExoPlayer already received these as SubtitleConfigurations via
|
||||||
|
// player_play_item; mapped to the adapter shape for the contract.
|
||||||
|
subtitleTracks: sentSubtitleTracks.map((t) => ({
|
||||||
|
index: t.streamIndex,
|
||||||
|
url: t.url,
|
||||||
|
language: t.srclang,
|
||||||
|
label: t.label,
|
||||||
|
mimeType: "text/vtt",
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
if (initialPosition && initialPosition > 0 && !isLive) {
|
||||||
|
currentTime = initialPosition;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!useHtml5Element) {
|
if (!useHtml5Element) {
|
||||||
@@ -718,6 +847,14 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
onDestroy(async () => {
|
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
|
// Stop RAF loop
|
||||||
stopTimeUpdates();
|
stopTimeUpdates();
|
||||||
|
|
||||||
@@ -1670,41 +1807,59 @@
|
|||||||
showSubtitleMenu = !showSubtitleMenu;
|
showSubtitleMenu = !showSubtitleMenu;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function selectSubtitle(streamIndex: number | null, arrayIndex?: number) {
|
/**
|
||||||
console.log("[VideoPlayer] Selecting subtitle - streamIndex:", streamIndex, "arrayIndex:", arrayIndex);
|
* Show exactly one (or no) text track on the HTML5 element. `null` disables
|
||||||
|
* every track, which is what the menu's "Off" entry means.
|
||||||
|
*
|
||||||
|
* TRACES: UR-020 | DR-023
|
||||||
|
*/
|
||||||
|
function applySubtitleToElement(streamIndex: number | null) {
|
||||||
|
if (!useHtml5Element || !videoElement || !videoElement.textTracks) return;
|
||||||
|
|
||||||
|
// Disable all text tracks first, so "Off" genuinely turns subtitles off.
|
||||||
|
for (let i = 0; i < videoElement.textTracks.length; i++) {
|
||||||
|
videoElement.textTracks[i].mode = "disabled";
|
||||||
|
}
|
||||||
|
if (streamIndex === null) return;
|
||||||
|
|
||||||
|
// Find the corresponding track element by stream index.
|
||||||
|
videoElement.querySelectorAll("track").forEach((track) => {
|
||||||
|
const trackStreamIndex = parseInt(track.getAttribute("data-stream-index") || "-1");
|
||||||
|
if (trackStreamIndex === streamIndex && track.track) {
|
||||||
|
track.track.mode = "showing";
|
||||||
|
console.log("[VideoPlayer] Enabled subtitle track:", streamIndex);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply the menu's choice. `streamIndex` is always the Jellyfin media-stream
|
||||||
|
* index (or `null` for "Off") — the UI speaks stream indices throughout.
|
||||||
|
*
|
||||||
|
* The native backend does not: `player_set_subtitle_track(n)` reaches
|
||||||
|
* `JellyTauPlayer.setSubtitleTrack(n)`, which indexes ExoPlayer's text track
|
||||||
|
* groups, i.e. the position of the sideloaded subtitle configuration. That
|
||||||
|
* position is derived from `sentSubtitleTracks` — the exact array sent with
|
||||||
|
* the play request — and not from the menu's row number, which counts every
|
||||||
|
* subtitle *stream* including ones whose URL never resolved and so were never
|
||||||
|
* sideloaded.
|
||||||
|
*
|
||||||
|
* TRACES: UR-020 | DR-023, IR-016 | UT-147
|
||||||
|
*/
|
||||||
|
async function selectSubtitle(streamIndex: number | null) {
|
||||||
|
console.log("[VideoPlayer] Selecting subtitle - streamIndex:", streamIndex);
|
||||||
selectedSubtitleIndex = streamIndex;
|
selectedSubtitleIndex = streamIndex;
|
||||||
showSubtitleMenu = false;
|
showSubtitleMenu = false;
|
||||||
|
|
||||||
// For HTML5 video element, update the text tracks
|
// For HTML5 video element, update the text tracks
|
||||||
if (useHtml5Element && videoElement && videoElement.textTracks) {
|
if (useHtml5Element) {
|
||||||
// Disable all text tracks first
|
applySubtitleToElement(streamIndex);
|
||||||
for (let i = 0; i < videoElement.textTracks.length; i++) {
|
} else {
|
||||||
videoElement.textTracks[i].mode = "disabled";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Enable the selected track if not null
|
|
||||||
if (streamIndex !== null) {
|
|
||||||
// Find the corresponding track element by stream index
|
|
||||||
const tracks = videoElement.querySelectorAll("track");
|
|
||||||
tracks.forEach((track) => {
|
|
||||||
const trackStreamIndex = parseInt(track.getAttribute("data-stream-index") || "-1");
|
|
||||||
if (trackStreamIndex === streamIndex) {
|
|
||||||
const textTrack = track.track;
|
|
||||||
if (textTrack) {
|
|
||||||
textTrack.mode = "showing";
|
|
||||||
console.log("[VideoPlayer] Enabled subtitle track:", streamIndex);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else if (!useHtml5Element) {
|
|
||||||
// For native backend (Android), send command to change subtitle track
|
// For native backend (Android), send command to change subtitle track
|
||||||
try {
|
try {
|
||||||
// Use array index for ExoPlayer (0-based position in subtitle tracks array)
|
const indexToUse = nativeSubtitleArrayIndex(sentSubtitleTracks, streamIndex);
|
||||||
// If streamIndex is null (disable), pass null; otherwise use arrayIndex
|
|
||||||
const indexToUse = streamIndex === null ? null : (arrayIndex !== undefined ? arrayIndex : streamIndex);
|
|
||||||
await commands.playerSetSubtitleTrack(indexToUse);
|
await commands.playerSetSubtitleTrack(indexToUse);
|
||||||
console.log("[VideoPlayer] Native backend subtitle track changed - arrayIndex:", arrayIndex, "used:", indexToUse);
|
console.log("[VideoPlayer] Native backend subtitle track changed - streamIndex:", streamIndex, "position:", indexToUse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[VideoPlayer] Failed to set subtitle track:", error);
|
console.error("[VideoPlayer] Failed to set subtitle track:", error);
|
||||||
}
|
}
|
||||||
@@ -1743,6 +1898,7 @@
|
|||||||
<video
|
<video
|
||||||
bind:this={videoElement}
|
bind:this={videoElement}
|
||||||
src={currentStreamUrl.includes('.m3u8') && Hls.isSupported() ? '' : currentStreamUrl}
|
src={currentStreamUrl.includes('.m3u8') && Hls.isSupported() ? '' : currentStreamUrl}
|
||||||
|
crossorigin={videoCrossOrigin}
|
||||||
class={videoFitClass()}
|
class={videoFitClass()}
|
||||||
class:invisible={!isMediaReady}
|
class:invisible={!isMediaReady}
|
||||||
style="filter: brightness({brightness})"
|
style="filter: brightness({brightness})"
|
||||||
@@ -1761,18 +1917,22 @@
|
|||||||
onloadstart={handleLoadStart}
|
onloadstart={handleLoadStart}
|
||||||
onclick={handleSurfaceClick}
|
onclick={handleSurfaceClick}
|
||||||
>
|
>
|
||||||
<!-- Temporarily disabled to debug playback issues
|
<!--
|
||||||
{#each subtitleTracks() as track}
|
Subtitles for the HTML5 path. `src` is a resolved string (see
|
||||||
|
renderedSubtitleTracks); `data-stream-index` is what
|
||||||
|
Html5PlayerAdapter.selectSubtitle() matches on. No `default`
|
||||||
|
attribute: a default track auto-shows, which would contradict the
|
||||||
|
menu opening on "Off".
|
||||||
|
-->
|
||||||
|
{#each renderedSubtitleTracks as track (track.streamIndex)}
|
||||||
<track
|
<track
|
||||||
kind="subtitles"
|
kind="subtitles"
|
||||||
src={getSubtitleUrl(track.index)}
|
src={track.url}
|
||||||
srclang={track.language || "unknown"}
|
srclang={track.srclang}
|
||||||
label={track.displayTitle || track.language || `Track ${track.index}`}
|
label={track.label}
|
||||||
data-stream-index={track.index}
|
data-stream-index={track.streamIndex}
|
||||||
default={track.isDefault}
|
|
||||||
/>
|
/>
|
||||||
{/each}
|
{/each}
|
||||||
-->
|
|
||||||
</video>
|
</video>
|
||||||
{:else}
|
{:else}
|
||||||
<!-- Android ExoPlayer - video rendered natively in SurfaceView behind WebView -->
|
<!-- Android ExoPlayer - video rendered natively in SurfaceView behind WebView -->
|
||||||
@@ -2072,9 +2232,9 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
<!-- Subtitle tracks -->
|
<!-- Subtitle tracks -->
|
||||||
{#each subtitleTracks() as track, i}
|
{#each subtitleTracks() as track}
|
||||||
<button
|
<button
|
||||||
onclick={() => selectSubtitle(track.index, i)}
|
onclick={() => selectSubtitle(track.index)}
|
||||||
class="w-full text-left px-3 py-2 text-white hover:bg-white/10 rounded transition-colors flex items-center justify-between {selectedSubtitleIndex === track.index ? 'bg-white/20' : ''}"
|
class="w-full text-left px-3 py-2 text-white hover:bg-white/10 rounded transition-colors flex items-center justify-between {selectedSubtitleIndex === track.index ? 'bg-white/20' : ''}"
|
||||||
>
|
>
|
||||||
<div class="flex flex-col">
|
<div class="flex flex-col">
|
||||||
|
|||||||
@@ -0,0 +1,303 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
import {
|
||||||
|
subtitleStreamsOf,
|
||||||
|
subtitleTrackLabel,
|
||||||
|
resolveSubtitleTracks,
|
||||||
|
reconcileSelectedSubtitle,
|
||||||
|
videoCrossOriginMode,
|
||||||
|
nativeSubtitleTracks,
|
||||||
|
nativeSubtitleArrayIndex,
|
||||||
|
type SubtitleStreamLike,
|
||||||
|
} from "./subtitleTracks";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subtitles on the Linux / WebKitGTK HTML5 `<video>` path.
|
||||||
|
*
|
||||||
|
* TRACES: UR-020 | DR-023 | UT-143, UT-144
|
||||||
|
*
|
||||||
|
* The bug this guards: VideoPlayer rendered no `<track>` children at all (the
|
||||||
|
* block was commented out "to debug playback issues"), so
|
||||||
|
* `Html5PlayerAdapter.selectSubtitle()` walked an empty `textTracks` list and
|
||||||
|
* the subtitle menu was inert on Linux. The reason it had to be disabled is
|
||||||
|
* visible in the original markup — `src={getSubtitleUrl(track.index)}` bound the
|
||||||
|
* *Promise* returned by an async function to the attribute, so every track's src
|
||||||
|
* stringified to "[object Promise]", an unloadable resource hanging off the
|
||||||
|
* media element.
|
||||||
|
*
|
||||||
|
* So the fix has two halves and both are tested here: URLs must be resolved into
|
||||||
|
* plain strings *before* they reach the markup, and the markup must actually
|
||||||
|
* render the tracks (with the `data-stream-index` the adapter matches on).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const SUBS: SubtitleStreamLike[] = [
|
||||||
|
{ index: 2, kind: "subtitle", language: "eng", displayTitle: "English (SRT)", isDefault: true },
|
||||||
|
{ index: 3, kind: "subtitle", language: "fre", displayTitle: "French", isDefault: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
const STREAMS: SubtitleStreamLike[] = [
|
||||||
|
{ index: 0, kind: "video", language: null, displayTitle: "1080p" },
|
||||||
|
{ index: 1, kind: "audio", language: "eng", displayTitle: "English AAC" },
|
||||||
|
...SUBS,
|
||||||
|
];
|
||||||
|
|
||||||
|
const url = (i: number) => `http://jelly.example/Videos/x/Subtitles/${i}/0/subtitles.vtt?api_key=k`;
|
||||||
|
|
||||||
|
describe("subtitleStreamsOf", () => {
|
||||||
|
it("keeps only subtitle streams, in stream order", () => {
|
||||||
|
expect(subtitleStreamsOf(STREAMS).map((s) => s.index)).toEqual([2, 3]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tolerates missing media streams", () => {
|
||||||
|
expect(subtitleStreamsOf(null)).toEqual([]);
|
||||||
|
expect(subtitleStreamsOf(undefined)).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("subtitleTrackLabel", () => {
|
||||||
|
it("prefers the display title, then language, then the index", () => {
|
||||||
|
expect(subtitleTrackLabel({ index: 2, displayTitle: "English (SRT)", language: "eng" })).toBe("English (SRT)");
|
||||||
|
expect(subtitleTrackLabel({ index: 2, displayTitle: null, language: "eng" })).toBe("eng");
|
||||||
|
expect(subtitleTrackLabel({ index: 2 })).toBe("Track 2");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveSubtitleTracks", () => {
|
||||||
|
it("resolves real string URLs — never a Promise — for every subtitle stream", async () => {
|
||||||
|
const tracks = await resolveSubtitleTracks(STREAMS, async (i) => url(i));
|
||||||
|
|
||||||
|
expect(tracks).toHaveLength(2);
|
||||||
|
for (const track of tracks) {
|
||||||
|
expect(typeof track.url).toBe("string");
|
||||||
|
// The exact regression: a Promise bound to src stringifies to this.
|
||||||
|
expect(String(track.url)).not.toContain("[object Promise]");
|
||||||
|
expect(track.url).toContain("subtitles.vtt");
|
||||||
|
}
|
||||||
|
// The adapter matches <track> elements by data-stream-index, so the stream
|
||||||
|
// index has to survive resolution.
|
||||||
|
expect(tracks.map((t) => t.streamIndex)).toEqual([2, 3]);
|
||||||
|
expect(tracks.map((t) => t.label)).toEqual(["English (SRT)", "French"]);
|
||||||
|
expect(tracks.map((t) => t.srclang)).toEqual(["eng", "fre"]);
|
||||||
|
expect(tracks[0].isDefault).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops tracks whose URL cannot be built instead of rendering a dead src", async () => {
|
||||||
|
const tracks = await resolveSubtitleTracks(SUBS, async (i) => {
|
||||||
|
if (i === 2) throw new Error("no repository");
|
||||||
|
return url(i);
|
||||||
|
});
|
||||||
|
expect(tracks.map((t) => t.streamIndex)).toEqual([3]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops empty and non-string URLs", async () => {
|
||||||
|
const tracks = await resolveSubtitleTracks(SUBS, async (i) =>
|
||||||
|
i === 2 ? " " : (undefined as unknown as string),
|
||||||
|
);
|
||||||
|
expect(tracks).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns nothing when there are no subtitle streams", async () => {
|
||||||
|
expect(await resolveSubtitleTracks([STREAMS[0]], async (i) => url(i))).toEqual([]);
|
||||||
|
expect(await resolveSubtitleTracks(null, async (i) => url(i))).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("reconcileSelectedSubtitle", () => {
|
||||||
|
it("starts off (null) and keeps 'off' selectable", async () => {
|
||||||
|
const tracks = await resolveSubtitleTracks(SUBS, async (i) => url(i));
|
||||||
|
expect(reconcileSelectedSubtitle(tracks, null)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a selection that is still renderable", async () => {
|
||||||
|
const tracks = await resolveSubtitleTracks(SUBS, async (i) => url(i));
|
||||||
|
expect(reconcileSelectedSubtitle(tracks, 3)).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to off when the selected track is gone (new item / failed URL)", async () => {
|
||||||
|
const tracks = await resolveSubtitleTracks(SUBS, async (i) => url(i));
|
||||||
|
expect(reconcileSelectedSubtitle(tracks, 9)).toBeNull();
|
||||||
|
expect(reconcileSelectedSubtitle([], 3)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never auto-selects the server's default track", async () => {
|
||||||
|
// The menu opens on "Off" and a <track default> would auto-show, so the UI
|
||||||
|
// would claim subtitles are off while they are burned over the picture.
|
||||||
|
const tracks = await resolveSubtitleTracks(SUBS, async (i) => url(i));
|
||||||
|
expect(tracks[0].isDefault).toBe(true);
|
||||||
|
expect(reconcileSelectedSubtitle(tracks, null)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("videoCrossOriginMode", () => {
|
||||||
|
it("opts into CORS for a server stream that has subtitles", () => {
|
||||||
|
expect(videoCrossOriginMode("http://jelly.example/Videos/x/master.m3u8", 2)).toBe("anonymous");
|
||||||
|
expect(videoCrossOriginMode("https://jelly.example/Videos/x/stream.mp4", 1)).toBe("anonymous");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves a local/offline source alone so playback cannot regress", () => {
|
||||||
|
expect(videoCrossOriginMode("asset://localhost/movie.mkv", 2)).toBeUndefined();
|
||||||
|
expect(videoCrossOriginMode("file:///home/u/movie.mkv", 2)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stays out of the way when there is nothing to load", () => {
|
||||||
|
expect(videoCrossOriginMode("http://jelly.example/x.m3u8", 0)).toBeUndefined();
|
||||||
|
expect(videoCrossOriginMode("", 0)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is decided by inputs known at first render, so it cannot flip mid-load", () => {
|
||||||
|
// Same answer before and after the async URL resolution completes.
|
||||||
|
const before = videoCrossOriginMode("http://jelly.example/x.m3u8", SUBS.length);
|
||||||
|
const after = videoCrossOriginMode("http://jelly.example/x.m3u8", SUBS.length);
|
||||||
|
expect(before).toBe(after);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subtitles on the Android / ExoPlayer native path.
|
||||||
|
*
|
||||||
|
* TRACES: UR-020 | IR-016, JA-008 | UT-147
|
||||||
|
*
|
||||||
|
* The bug this guards: VideoPlayer built a fully-resolved subtitle array in
|
||||||
|
* onMount and then never sent it — `commands.playerPlayItem({...})` passed only
|
||||||
|
* streamUrl/title/id/videoCodec/needsTranscoding — so every MediaItem reached
|
||||||
|
* ExoPlayer with zero SubtitleConfigurations and `setSubtitleTrack(n)` logged
|
||||||
|
* "Invalid subtitle track index".
|
||||||
|
*
|
||||||
|
* And the second half: `setSubtitleTrack(n)` indexes ExoPlayer's *text track
|
||||||
|
* groups*, i.e. the position of the sideloaded configuration — not the Jellyfin
|
||||||
|
* stream index. The menu used to pass its own row position, which is a position
|
||||||
|
* in the *unresolved* stream list; the moment one subtitle URL failed to
|
||||||
|
* resolve, the two lists diverged and every track below the gap selected the
|
||||||
|
* wrong subtitle.
|
||||||
|
*/
|
||||||
|
describe("nativeSubtitleTracks", () => {
|
||||||
|
it("maps to the wire shape Rust deserializes and Kotlin parses", async () => {
|
||||||
|
const resolved = await resolveSubtitleTracks(SUBS, async (i) => url(i));
|
||||||
|
const payload = nativeSubtitleTracks(resolved);
|
||||||
|
|
||||||
|
expect(payload).toHaveLength(2);
|
||||||
|
// Kotlin reads url/language/label/mime_type; Rust's SubtitleTrack keeps
|
||||||
|
// snake_case for exactly that reason, and so does the generated binding.
|
||||||
|
for (const track of payload) {
|
||||||
|
expect(Object.keys(track).sort()).toEqual(
|
||||||
|
["index", "label", "language", "mime_type", "url"].sort(),
|
||||||
|
);
|
||||||
|
expect(track).not.toHaveProperty("mimeType");
|
||||||
|
expect(track.mime_type).toBe("text/vtt");
|
||||||
|
}
|
||||||
|
// Jellyfin serves every subtitle stream as WebVTT here, and the stream index
|
||||||
|
// rides along so the UI can keep talking in stream indices.
|
||||||
|
expect(payload.map((t) => t.index)).toEqual([2, 3]);
|
||||||
|
expect(payload[0].url).toContain("subtitles.vtt");
|
||||||
|
expect(payload[0].language).toBe("eng");
|
||||||
|
expect(payload[0].label).toBe("English (SRT)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves stream order, because that order is the selection index", async () => {
|
||||||
|
const resolved = await resolveSubtitleTracks(STREAMS, async (i) => url(i));
|
||||||
|
expect(nativeSubtitleTracks(resolved).map((t) => t.index)).toEqual(
|
||||||
|
resolved.map((t) => t.streamIndex),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has nothing to send when no subtitle URL resolved", async () => {
|
||||||
|
expect(nativeSubtitleTracks(await resolveSubtitleTracks(SUBS, async () => ""))).toEqual([]);
|
||||||
|
expect(nativeSubtitleTracks([])).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("carries a null language/label through rather than inventing one", () => {
|
||||||
|
const payload = nativeSubtitleTracks([
|
||||||
|
{ streamIndex: 5, url: "u.vtt", srclang: "und", label: "Track 5", isDefault: false },
|
||||||
|
]);
|
||||||
|
expect(payload[0].language).toBeNull();
|
||||||
|
expect(payload[0].label).toBe("Track 5");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("nativeSubtitleArrayIndex", () => {
|
||||||
|
it("returns the position in the list that was actually sent, not the stream index", async () => {
|
||||||
|
const resolved = await resolveSubtitleTracks(SUBS, async (i) => url(i));
|
||||||
|
expect(nativeSubtitleArrayIndex(resolved, 2)).toBe(0);
|
||||||
|
expect(nativeSubtitleArrayIndex(resolved, 3)).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stays aligned when a subtitle URL failed to resolve (the mis-selection bug)", async () => {
|
||||||
|
// Stream 2 has no URL, so it is not among the sideloaded configurations.
|
||||||
|
// The menu's own row for stream 3 is position 1, but ExoPlayer only has one
|
||||||
|
// text track group — position 0. Sending 1 would select nothing.
|
||||||
|
const resolved = await resolveSubtitleTracks(SUBS, async (i) => {
|
||||||
|
if (i === 2) throw new Error("no repository");
|
||||||
|
return url(i);
|
||||||
|
});
|
||||||
|
expect(resolved).toHaveLength(1);
|
||||||
|
expect(nativeSubtitleArrayIndex(resolved, 3)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps 'Off' to null so the backend disables text instead of selecting track 0", async () => {
|
||||||
|
const resolved = await resolveSubtitleTracks(SUBS, async (i) => url(i));
|
||||||
|
expect(nativeSubtitleArrayIndex(resolved, null)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps a track that was never sent to null rather than to a wrong position", async () => {
|
||||||
|
const resolved = await resolveSubtitleTracks(SUBS, async (i) => url(i));
|
||||||
|
expect(nativeSubtitleArrayIndex(resolved, 99)).toBeNull();
|
||||||
|
expect(nativeSubtitleArrayIndex([], 3)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("VideoPlayer markup (the regression that made the menu inert)", () => {
|
||||||
|
const source = readFileSync(
|
||||||
|
resolve(__dirname, "VideoPlayer.svelte"),
|
||||||
|
"utf-8",
|
||||||
|
);
|
||||||
|
|
||||||
|
it("renders <track> elements instead of leaving them commented out", () => {
|
||||||
|
expect(source).not.toContain("Temporarily disabled to debug playback issues");
|
||||||
|
expect(source).toMatch(/<track\b/);
|
||||||
|
expect(source).toContain('kind="subtitles"');
|
||||||
|
});
|
||||||
|
|
||||||
|
/** The rendered element, not a `<track>` mentioned in prose. */
|
||||||
|
const trackElement = source.slice(source.search(/<track\s/), source.search(/<track\s/) + 400);
|
||||||
|
|
||||||
|
it("keeps data-stream-index — Html5PlayerAdapter.selectSubtitle matches on it", () => {
|
||||||
|
expect(trackElement).toContain("data-stream-index");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never binds the async getSubtitleUrl() Promise to src", () => {
|
||||||
|
expect(source).not.toMatch(/src=\{\s*getSubtitleUrl\(/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not mark any track default (a default track auto-shows)", () => {
|
||||||
|
expect(trackElement).not.toMatch(/\bdefault=/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The half of the Android fix that lives in the component: the resolved list has
|
||||||
|
* to actually be handed to `playerPlayItem`, and the index sent to the backend
|
||||||
|
* has to be computed from that same list.
|
||||||
|
*
|
||||||
|
* TRACES: UR-020 | IR-016 | UT-147
|
||||||
|
*/
|
||||||
|
describe("VideoPlayer -> playerPlayItem (the tracks that were built and thrown away)", () => {
|
||||||
|
const source = readFileSync(resolve(__dirname, "VideoPlayer.svelte"), "utf-8");
|
||||||
|
|
||||||
|
/** The playerPlayItem({...}) argument object. */
|
||||||
|
const playItemCall = (() => {
|
||||||
|
const start = source.indexOf("commands.playerPlayItem(");
|
||||||
|
expect(start).toBeGreaterThan(-1);
|
||||||
|
return source.slice(start, source.indexOf("});", start) + 3);
|
||||||
|
})();
|
||||||
|
|
||||||
|
it("sends the subtitle tracks it resolved", () => {
|
||||||
|
expect(playItemCall).toMatch(/\bsubtitles:/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("selects by position in the sent list, not by the menu's row number", () => {
|
||||||
|
expect(source).toContain("nativeSubtitleArrayIndex");
|
||||||
|
// The old code forwarded the `{#each}` index straight to the backend.
|
||||||
|
expect(source).not.toMatch(/playerSetSubtitleTrack\(\s*arrayIndex\s*\)/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
// Subtitle plumbing for the Linux / WebKitGTK HTML5 `<video>` playback path.
|
||||||
|
//
|
||||||
|
// Extracted from VideoPlayer.svelte so it is unit-testable, and because the
|
||||||
|
// original inline version hid a fatal mistake in plain sight: `getSubtitleUrl()`
|
||||||
|
// is async, so `src={getSubtitleUrl(track.index)}` bound a *Promise* to the
|
||||||
|
// attribute and every `<track>` pointed at "[object Promise]". The whole block
|
||||||
|
// was commented out rather than fixed, which left `<video>` with no text tracks
|
||||||
|
// at all — `Html5PlayerAdapter.selectSubtitle()` then iterated an empty
|
||||||
|
// `textTracks` list and the subtitle menu silently did nothing.
|
||||||
|
//
|
||||||
|
// The rule this module enforces: URLs are resolved to plain strings *here*, off
|
||||||
|
// the render path, and only tracks that actually resolved are handed to the
|
||||||
|
// markup.
|
||||||
|
//
|
||||||
|
// The Android / ExoPlayer native path shares this module (see
|
||||||
|
// nativeSubtitleTracks / nativeSubtitleArrayIndex at the bottom): it needs the
|
||||||
|
// exact same "resolve the URLs first, keep only what resolved" list, just handed
|
||||||
|
// to Rust instead of to `<track>` elements.
|
||||||
|
//
|
||||||
|
// TRACES: UR-020 | DR-023, IR-016 | UT-143, UT-144, UT-147
|
||||||
|
|
||||||
|
import type { SubtitleTrack } from "$lib/api/bindings";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The subset of `MediaStream` (from the generated bindings) this module needs.
|
||||||
|
* Kept structural so tests do not have to build full binding objects.
|
||||||
|
*/
|
||||||
|
export interface SubtitleStreamLike {
|
||||||
|
index: number;
|
||||||
|
kind?: string | null;
|
||||||
|
language?: string | null;
|
||||||
|
displayTitle?: string | null;
|
||||||
|
isDefault?: boolean;
|
||||||
|
isForced?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A subtitle stream whose URL resolved — i.e. one we can actually render. */
|
||||||
|
export interface RenderableSubtitleTrack {
|
||||||
|
/** Jellyfin media-stream index; the adapter matches `data-stream-index`. */
|
||||||
|
streamIndex: number;
|
||||||
|
/** Fully resolved WebVTT URL. Always a string, never a Promise. */
|
||||||
|
url: string;
|
||||||
|
srclang: string;
|
||||||
|
label: string;
|
||||||
|
/** Server's "default" flag — shown in the menu, never auto-enabled. */
|
||||||
|
isDefault: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Subtitle streams of a media item, in stream order. */
|
||||||
|
export function subtitleStreamsOf(
|
||||||
|
streams: readonly SubtitleStreamLike[] | null | undefined,
|
||||||
|
): SubtitleStreamLike[] {
|
||||||
|
if (!streams) return [];
|
||||||
|
return streams.filter((s) => s.kind === "subtitle");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Human label for a subtitle stream, matching the menu's own fallback chain. */
|
||||||
|
export function subtitleTrackLabel(stream: SubtitleStreamLike): string {
|
||||||
|
return stream.displayTitle || stream.language || `Track ${stream.index}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A src we are willing to put on a `<track>`: a non-blank plain string. */
|
||||||
|
function isRenderableUrl(url: unknown): url is string {
|
||||||
|
return typeof url === "string" && url.trim().length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve every subtitle stream's URL and return only the tracks that can be
|
||||||
|
* rendered. `resolveUrl` failures are swallowed per track: one unavailable
|
||||||
|
* subtitle must not cost the user the others, and a dead `src` on a media
|
||||||
|
* element is exactly what made this block get disabled in the first place.
|
||||||
|
*/
|
||||||
|
export async function resolveSubtitleTracks(
|
||||||
|
streams: readonly SubtitleStreamLike[] | null | undefined,
|
||||||
|
resolveUrl: (streamIndex: number) => Promise<string>,
|
||||||
|
): Promise<RenderableSubtitleTrack[]> {
|
||||||
|
const subtitles = subtitleStreamsOf(streams);
|
||||||
|
if (subtitles.length === 0) return [];
|
||||||
|
|
||||||
|
const resolved = await Promise.all(
|
||||||
|
subtitles.map(async (stream) => {
|
||||||
|
try {
|
||||||
|
const url = await resolveUrl(stream.index);
|
||||||
|
if (!isRenderableUrl(url)) return null;
|
||||||
|
return {
|
||||||
|
streamIndex: stream.index,
|
||||||
|
url,
|
||||||
|
srclang: stream.language || "und",
|
||||||
|
label: subtitleTrackLabel(stream),
|
||||||
|
isDefault: stream.isDefault === true,
|
||||||
|
} satisfies RenderableSubtitleTrack;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return resolved.filter((t): t is RenderableSubtitleTrack => t !== null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The selection to keep once the rendered track list changes.
|
||||||
|
*
|
||||||
|
* Subtitles are OFF unless the user turns them on: `null` in, `null` out. The
|
||||||
|
* server's `isDefault` flag is deliberately NOT promoted to a selection (and the
|
||||||
|
* markup deliberately omits the `default` attribute, which would auto-show the
|
||||||
|
* track) — the menu opens on "Off", so auto-enabling would make the UI lie about
|
||||||
|
* what is on screen, and it would change behaviour for every user who has never
|
||||||
|
* asked for subtitles.
|
||||||
|
*
|
||||||
|
* A selection that is no longer renderable (new item, or a URL that failed to
|
||||||
|
* resolve) collapses to off, so the menu's checkmark can never point at a track
|
||||||
|
* that does not exist on the element.
|
||||||
|
*/
|
||||||
|
export function reconcileSelectedSubtitle(
|
||||||
|
tracks: readonly RenderableSubtitleTrack[],
|
||||||
|
selected: number | null,
|
||||||
|
): number | null {
|
||||||
|
if (selected === null) return null;
|
||||||
|
return tracks.some((t) => t.streamIndex === selected) ? selected : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function originOf(url: string): string | null {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null;
|
||||||
|
return parsed.origin;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `crossorigin` value for the `<video>` element, or undefined for none.
|
||||||
|
*
|
||||||
|
* Text-track fetches are CORS-enabled per the HTML spec and use the *media
|
||||||
|
* element's* CORS setting, so a cross-origin `<track>` never loads unless the
|
||||||
|
* element opts in. The webview page's origin is `tauri://localhost`, so every
|
||||||
|
* subtitle served by Jellyfin is cross-origin.
|
||||||
|
*
|
||||||
|
* Opting in is only safe when the media itself comes from an http(s) server —
|
||||||
|
* the same Jellyfin that already answers hls.js' cross-origin XHRs, so we know
|
||||||
|
* it sends the headers. For a local/offline source (`file:`/`asset:`) we leave
|
||||||
|
* the attribute off: subtitles staying dark there is the status quo, whereas
|
||||||
|
* forcing CORS onto the video fetch could break playback outright.
|
||||||
|
*
|
||||||
|
* Deliberately keyed on the *count of subtitle streams* rather than on the
|
||||||
|
* resolved tracks: both inputs are known at first render, so the attribute is
|
||||||
|
* decided before the element starts loading and never flips underneath an
|
||||||
|
* in-flight media fetch.
|
||||||
|
*/
|
||||||
|
export function videoCrossOriginMode(
|
||||||
|
streamUrl: string,
|
||||||
|
subtitleStreamCount: number,
|
||||||
|
): "anonymous" | undefined {
|
||||||
|
if (subtitleStreamCount <= 0) return undefined;
|
||||||
|
return originOf(streamUrl) ? "anonymous" : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Native (Android / ExoPlayer) path ====================================
|
||||||
|
//
|
||||||
|
// The HTML5 element gets `<track>` children; the native backend instead gets the
|
||||||
|
// list *up front*, as part of the play request, because ExoPlayer sideloads
|
||||||
|
// subtitles as `MediaItem.SubtitleConfiguration`s that must exist before
|
||||||
|
// `prepare()`. There is no "add a subtitle later" — a track absent from the
|
||||||
|
// MediaItem simply does not exist as far as the player is concerned.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map resolved tracks onto the wire shape `PlayItemRequest.subtitles` carries.
|
||||||
|
*
|
||||||
|
* The element type is the *generated* `SubtitleTrack` binding on purpose, so
|
||||||
|
* `bun run check` fails if the Rust struct's field names ever move. In
|
||||||
|
* particular `mime_type` is snake_case and must stay that way: the very same
|
||||||
|
* bytes are re-serialized across JNI in `player/android/mod.rs`, and
|
||||||
|
* `JellyTauPlayer.load()` reads `optString("mime_type")`. Renaming it to
|
||||||
|
* `mimeType` would not error anywhere — Kotlin would just silently fall back to
|
||||||
|
* its default MIME type for every track.
|
||||||
|
*
|
||||||
|
* Jellyfin is asked for every subtitle stream as WebVTT (see
|
||||||
|
* `getSubtitleUrl(..., "vtt")`), so the MIME type is fixed rather than derived
|
||||||
|
* from the source subtitle codec.
|
||||||
|
*
|
||||||
|
* TRACES: UR-020 | IR-016, JA-008 | UT-147
|
||||||
|
*/
|
||||||
|
export function nativeSubtitleTracks(
|
||||||
|
tracks: readonly RenderableSubtitleTrack[],
|
||||||
|
): SubtitleTrack[] {
|
||||||
|
return tracks.map((track) => ({
|
||||||
|
index: track.streamIndex,
|
||||||
|
url: track.url,
|
||||||
|
// `srclang` carries "und" for a stream with no language, which is the right
|
||||||
|
// value for a `<track>` but is not a language the native side should claim.
|
||||||
|
language: track.srclang === "und" ? null : track.srclang,
|
||||||
|
label: track.label,
|
||||||
|
mime_type: "text/vtt",
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The argument for `player_set_subtitle_track` on the native backend.
|
||||||
|
*
|
||||||
|
* 🔴 This is **not** the Jellyfin stream index.
|
||||||
|
* `JellyTauPlayer.setSubtitleTrack(n)` filters ExoPlayer's track groups down to
|
||||||
|
* `C.TRACK_TYPE_TEXT` and indexes that list with `n`, so `n` is the *position of
|
||||||
|
* the sideloaded subtitle configuration* — which is the position in the array
|
||||||
|
* that `nativeSubtitleTracks()` produced and `playerPlayItem` sent.
|
||||||
|
*
|
||||||
|
* The menu's own row number is not that position: the menu lists every subtitle
|
||||||
|
* *stream*, while only the streams whose URL resolved are sent. One failed URL
|
||||||
|
* and everything below it selects the wrong subtitle. So the index is looked up
|
||||||
|
* in the sent list instead of being passed down from the `{#each}`.
|
||||||
|
*
|
||||||
|
* `null` (the menu's "Off") stays `null`, which the backend turns into -1 and
|
||||||
|
* Kotlin turns into "disable text tracks". A stream that was never sent also
|
||||||
|
* maps to `null`: disabling subtitles is a truthful outcome, whereas guessing a
|
||||||
|
* position would show the user a different language than the one they clicked.
|
||||||
|
*
|
||||||
|
* TRACES: UR-020 | IR-016 | UT-147
|
||||||
|
*/
|
||||||
|
export function nativeSubtitleArrayIndex(
|
||||||
|
tracks: readonly RenderableSubtitleTrack[],
|
||||||
|
streamIndex: number | null,
|
||||||
|
): number | null {
|
||||||
|
if (streamIndex === null) return null;
|
||||||
|
const position = tracks.findIndex((t) => t.streamIndex === streamIndex);
|
||||||
|
return position === -1 ? null : position;
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,13 +2,22 @@
|
|||||||
* Player adapter factory + public exports.
|
* Player adapter factory + public exports.
|
||||||
*
|
*
|
||||||
* `createAdapter` selects the concrete PlayerAdapter for the current platform.
|
* `createAdapter` selects the concrete PlayerAdapter for the current platform.
|
||||||
* It is the single place that encodes the INTERIM Android override: the Rust
|
* Rust decides *which backend this platform has* (`useHtml5Element` from
|
||||||
* backend may report a native ExoPlayer backend, but native Android video
|
* `player_play_item`); this factory consumes that decision rather than
|
||||||
* rendering is blocked upstream (tauri#10152 — transparent webview / SurfaceView
|
* re-deriving it.
|
||||||
* compositing), so we render Android video through the HTML5 adapter for now.
|
|
||||||
* When that upstream limitation is resolved, flip this to honor `backendKind`.
|
|
||||||
*
|
*
|
||||||
* 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";
|
import { Html5PlayerAdapter, type Html5ElementBridge } from "./html5Adapter";
|
||||||
@@ -29,27 +38,36 @@ export interface CreateAdapterArgs {
|
|||||||
host: AdapterHost;
|
host: AdapterHost;
|
||||||
/** Required for the HTML5 adapter; ignored by the native adapter. */
|
/** Required for the HTML5 adapter; ignored by the native adapter. */
|
||||||
bridge?: Html5ElementBridge;
|
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.
|
* Build the adapter for this platform/stream.
|
||||||
*
|
*
|
||||||
* INTERIM: always returns the HTML5 adapter, because the native surface is not
|
* Native is chosen only when Rust reports a native backend AND the user has
|
||||||
* visible through the webview on current Tauri (see module docs). The bridge is
|
* opted in. Every other combination is HTML5.
|
||||||
* therefore required.
|
|
||||||
*/
|
*/
|
||||||
export function createAdapter({ backendKind, host, bridge }: CreateAdapterArgs): PlayerAdapter {
|
export function createAdapter({
|
||||||
// INTERIM OVERRIDE: force HTML5 rendering even when the backend reports native.
|
backendKind,
|
||||||
const effectiveKind: BackendKind = "html5";
|
host,
|
||||||
|
bridge,
|
||||||
|
experimentalNativeVideo = false,
|
||||||
|
}: CreateAdapterArgs): PlayerAdapter {
|
||||||
|
const effectiveKind: BackendKind =
|
||||||
|
backendKind === "native" && experimentalNativeVideo ? "native" : "html5";
|
||||||
|
|
||||||
|
if (effectiveKind === "native") {
|
||||||
|
// The native surface is owned by the backend — no DOM element, no bridge.
|
||||||
|
return new NativePlayerAdapter(host);
|
||||||
|
}
|
||||||
|
|
||||||
if (effectiveKind === "html5") {
|
|
||||||
if (!bridge) {
|
if (!bridge) {
|
||||||
throw new Error("createAdapter: Html5ElementBridge is required for the HTML5 adapter");
|
throw new Error("createAdapter: Html5ElementBridge is required for the HTML5 adapter");
|
||||||
}
|
}
|
||||||
return new Html5PlayerAdapter(host, bridge);
|
return new Html5PlayerAdapter(host, bridge);
|
||||||
}
|
|
||||||
|
|
||||||
// Reached only once the interim override is lifted (native Android unblocked).
|
|
||||||
void backendKind;
|
|
||||||
return new NativePlayerAdapter(host);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const playerToggle = vi.fn((..._a: any[]): any => ({ state: "playing" }));
|
|||||||
const playerSetVolume = vi.fn((..._a: any[]): any => ({}));
|
const playerSetVolume = vi.fn((..._a: any[]): any => ({}));
|
||||||
const playerToggleMute = vi.fn((..._a: any[]): any => ({}));
|
const playerToggleMute = vi.fn((..._a: any[]): any => ({}));
|
||||||
const playerSetSubtitleTrack = vi.fn((..._a: any[]): any => ({}));
|
const playerSetSubtitleTrack = vi.fn((..._a: any[]): any => ({}));
|
||||||
|
const playerSeek = vi.fn((..._a: any[]): any => ({}));
|
||||||
|
|
||||||
vi.mock("$lib/api/bindings", () => ({
|
vi.mock("$lib/api/bindings", () => ({
|
||||||
commands: {
|
commands: {
|
||||||
@@ -20,6 +21,7 @@ vi.mock("$lib/api/bindings", () => ({
|
|||||||
playerSetVolume: (...a: any[]) => playerSetVolume(...a),
|
playerSetVolume: (...a: any[]) => playerSetVolume(...a),
|
||||||
playerToggleMute: (...a: any[]) => playerToggleMute(...a),
|
playerToggleMute: (...a: any[]) => playerToggleMute(...a),
|
||||||
playerSetSubtitleTrack: (...a: any[]) => playerSetSubtitleTrack(...a),
|
playerSetSubtitleTrack: (...a: any[]) => playerSetSubtitleTrack(...a),
|
||||||
|
playerSeek: (...a: any[]) => playerSeek(...a),
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -72,6 +74,39 @@ describe("NativePlayerAdapter", () => {
|
|||||||
expect(adapter.getPosition()).toBe(90);
|
expect(adapter.getPosition()).toBe(90);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Regression: resume-at-position was broken on Android. player_play_item
|
||||||
|
// carries no start position, and ExoPlayer always begins at 0, so recording
|
||||||
|
// the number frontend-side left the backend playing from the beginning. The
|
||||||
|
// adapter must actually *issue* the seek.
|
||||||
|
it("load() issues the resume seek to the backend, not just records it", async () => {
|
||||||
|
await adapter.load("url", {
|
||||||
|
mediaId: "m", mediaSourceId: null, needsTranscoding: false,
|
||||||
|
initialPosition: 90, isLive: false, audioTrackIndex: null,
|
||||||
|
knownDuration: 0, subtitleTracks: [],
|
||||||
|
});
|
||||||
|
expect(playerSeek).toHaveBeenCalledWith(90);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("load() does not seek when starting from the beginning", async () => {
|
||||||
|
await adapter.load("url", {
|
||||||
|
mediaId: "m", mediaSourceId: null, needsTranscoding: false,
|
||||||
|
initialPosition: 0, isLive: false, audioTrackIndex: null,
|
||||||
|
knownDuration: 0, subtitleTracks: [],
|
||||||
|
});
|
||||||
|
expect(playerSeek).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
// A live stream has no meaningful resume point; seeking one is at best a
|
||||||
|
// no-op and at worst knocks the HLS window off its live edge.
|
||||||
|
it("load() never seeks a live stream", async () => {
|
||||||
|
await adapter.load("url", {
|
||||||
|
mediaId: "m", mediaSourceId: null, needsTranscoding: false,
|
||||||
|
initialPosition: 90, isLive: true, audioTrackIndex: null,
|
||||||
|
knownDuration: 0, subtitleTracks: [],
|
||||||
|
});
|
||||||
|
expect(playerSeek).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it("setVolume clamps and delegates; setMuted toggles mute", () => {
|
it("setVolume clamps and delegates; setMuted toggles mute", () => {
|
||||||
adapter.setVolume(2);
|
adapter.setVolume(2);
|
||||||
expect(playerSetVolume).toHaveBeenCalledWith(1);
|
expect(playerSetVolume).toHaveBeenCalledWith(1);
|
||||||
|
|||||||
@@ -43,10 +43,21 @@ export class NativePlayerAdapter implements PlayerAdapter {
|
|||||||
|
|
||||||
async load(_streamUrl: string, options: PlayerLoadOptions): Promise<void> {
|
async load(_streamUrl: string, options: PlayerLoadOptions): Promise<void> {
|
||||||
// player_play_item already initiated native playback before this adapter is
|
// player_play_item already initiated native playback before this adapter is
|
||||||
// created; nothing further to do. Seed a resume position if requested (the
|
// created, so there is no stream to load here — but it carries no start
|
||||||
// native backend performs the actual seek internally).
|
// position, and ExoPlayer always begins at 0. The resume seek must be
|
||||||
if (options.initialPosition > 0) {
|
// issued explicitly or "resume at position" silently plays from the top.
|
||||||
|
//
|
||||||
|
// Recording the position without seeking (what this used to do) is what
|
||||||
|
// broke Android resume: the frontend believed it had resumed while
|
||||||
|
// ExoPlayer played from the beginning.
|
||||||
|
//
|
||||||
|
// Live streams have no resume point — seeking one knocks the HLS window off
|
||||||
|
// its live edge, so they are excluded.
|
||||||
|
//
|
||||||
|
// TRACES: UR-005 | DR-004, DR-028
|
||||||
|
if (options.initialPosition > 0 && !options.isLive) {
|
||||||
this.position = options.initialPosition;
|
this.position = options.initialPosition;
|
||||||
|
await commands.playerSeek(options.initialPosition);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -103,15 +103,15 @@ export async function reportPlaybackStopped(itemId: string, positionSeconds: num
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Queue for sync to server (the sync service will handle retry logic)
|
// Report to the server. Rust queues the position for the next reconnect if
|
||||||
|
// the server cannot be reached (DR-154), so a throw here means the report
|
||||||
|
// did not land *this time* — not that the position was lost.
|
||||||
if (userId && positionSeconds > 0) {
|
if (userId && positionSeconds > 0) {
|
||||||
try {
|
try {
|
||||||
// Get the repository to check if we should queue
|
|
||||||
const repo = auth.getRepository();
|
const repo = auth.getRepository();
|
||||||
await repo.reportPlaybackStopped(itemId, positionMs);
|
await repo.reportPlaybackStopped(itemId, positionMs);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("[PlaybackReporting] Failed to report to server:", e);
|
console.warn("[PlaybackReporting] Stop-report did not reach the server; queued for sync:", e);
|
||||||
// Server error - could queue, but for now just log
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,31 +23,25 @@ import { events } from "$lib/api/bindings";
|
|||||||
import { playerController } from "$lib/player";
|
import { playerController } from "$lib/player";
|
||||||
import { createRustReportHost } from "$lib/player/adapters/rustReportHost";
|
import { createRustReportHost } from "$lib/player/adapters/rustReportHost";
|
||||||
import { WebviewAudioAdapter } from "$lib/player/adapters/webviewAudioAdapter";
|
import { WebviewAudioAdapter } from "$lib/player/adapters/webviewAudioAdapter";
|
||||||
|
import { getPlaybackCapabilities } from "$lib/services/playbackCapabilities";
|
||||||
|
|
||||||
let unlisten: UnlistenFn | null = null;
|
let unlisten: UnlistenFn | null = null;
|
||||||
let audioEl: HTMLAudioElement | null = null;
|
let audioEl: HTMLAudioElement | null = null;
|
||||||
let adapter: WebviewAudioAdapter | 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
|
* Initialize the webview audio controller. Safe to call unconditionally from the
|
||||||
* root layout; it self-gates on platform and is idempotent.
|
* root layout; it self-gates on platform and is idempotent.
|
||||||
*/
|
*/
|
||||||
export async function initWebviewAudio(): Promise<void> {
|
export async function initWebviewAudio(): Promise<void> {
|
||||||
if (unlisten) return;
|
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 = document.createElement("audio");
|
||||||
audioEl.hidden = true;
|
audioEl.hidden = true;
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -159,12 +159,15 @@
|
|||||||
{#if shortcutLibraries.length > 0}
|
{#if shortcutLibraries.length > 0}
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-xl font-bold text-white mb-4 px-4">Your Libraries</h2>
|
<h2 class="text-xl font-bold text-white mb-4 px-4">Your Libraries</h2>
|
||||||
<div class="flex gap-4 overflow-x-auto px-4 pb-2">
|
<div class="flex gap-4 overflow-x-auto px-4 pb-2 items-start">
|
||||||
{#each shortcutLibraries as lib (lib.id)}
|
{#each shortcutLibraries as lib (lib.id)}
|
||||||
<div class="flex-shrink-0">
|
<div class="flex-shrink-0">
|
||||||
|
<!-- Uniform 16:9 artwork so music (square) and video libraries
|
||||||
|
line up at the same height in this mixed row. -->
|
||||||
<MediaCard
|
<MediaCard
|
||||||
item={lib}
|
item={lib}
|
||||||
size="medium"
|
size="medium"
|
||||||
|
aspect="video"
|
||||||
onclick={() => handleLibraryClick(lib)}
|
onclick={() => handleLibraryClick(lib)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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 -->
|
<!-- TRACES: UR-023, UR-025, UR-027, UR-029, UR-057 | DR-030, DR-048, DR-077, DR-086, DR-132 -->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from "svelte";
|
import { onDestroy, onMount } from "svelte";
|
||||||
import { commands } from "$lib/api/bindings";
|
import { commands } from "$lib/api/bindings";
|
||||||
import type {
|
import type {
|
||||||
AudioSettings,
|
AudioSettings,
|
||||||
@@ -26,6 +26,8 @@
|
|||||||
isNetworkDetectionSupported,
|
isNetworkDetectionSupported,
|
||||||
reportNetworkState,
|
reportNetworkState,
|
||||||
} from "$lib/services/networkType";
|
} from "$lib/services/networkType";
|
||||||
|
import { experimentalNativeVideo } from "$lib/stores/nativeVideo";
|
||||||
|
import { getPlaybackCapabilities } from "$lib/services/playbackCapabilities";
|
||||||
|
|
||||||
const episodeLimitOptions = [
|
const episodeLimitOptions = [
|
||||||
{ value: 0, label: "Unlimited" },
|
{ value: 0, label: "Unlimited" },
|
||||||
@@ -97,8 +99,27 @@
|
|||||||
{ label: "Unlimited", bytes: 0 },
|
{ 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 () => {
|
onMount(async () => {
|
||||||
await loadSettings();
|
await loadSettings();
|
||||||
|
supportsNativeVideo = (await getPlaybackCapabilities()).supportsNativeVideo;
|
||||||
});
|
});
|
||||||
|
|
||||||
async function loadSettings() {
|
async function loadSettings() {
|
||||||
@@ -659,6 +680,46 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Search Settings -->
|
<!-- Search Settings -->
|
||||||
|
|||||||
Reference in New Issue
Block a user