Compare commits

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

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

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

Two edge cases the previous inline version got wrong:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 20:57:58 +02:00
dtourolle 07d10dfed7 docs(traceability): land the DR-149 requirement rows and settle a UT collision
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 19m31s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m28s
Traceability Validation / Check Requirement Traces (push) Successful in 22s
Build & Release / Run Tests (push) Successful in 6m47s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 10m6s
Build & Release / Build Linux (push) Successful in 19m57s
Build & Release / Build Windows (push) Successful in 14m14s
Build & Release / Build Android (push) Successful in 30m26s
Build & Release / Create Release (push) Successful in 19s
The DR-149 row lost an index race with a parallel session's edit of the same
file, so the previous commit carried the count assertion (DR 144, total 282)
without the requirement it counts — a clean checkout of that commit failed
`bun run test` against its own requirements.md.

The parallel session also reached UT-143 and UT-147 for subtitle work, which
collided with the UT-143 used for the client-side transcode tests. Those move
to UT-148, in the table and in the device_profile TRACES comments, so no two
requirements share an ID.
2026-08-11 20:11:22 +02:00
dtourolle acddcdd6fa fix(playback): force a transcode when the webview cannot decode the audio (DR-149, 0.4.8)
Advertising a webview-shaped profile (DR-148) was necessary but not
sufficient. Probing the server directly showed Jellyfin 10.11.5 enforces a
DirectPlayProfile's Container and VideoCodec — excluding either returns
SupportsDirectPlay:false with TranscodeReasons=ContainerNotSupported /
VideoCodecNotSupported — but ignores its AudioCodec entirely: an E-AC-3
track is still offered for direct play against a profile listing only
aac,flac,mp3,opus,vorbis. Neither a VideoAudio CodecProfile forbidding the
codec nor MaxAudioChannels:2 against a 6-channel track changes the answer,
so no profile the client can send fixes this and the picture plays silent.

The client therefore stops delegating a question it can answer itself. The
negotiated source's audio is checked against what the webview decodes, and
an undecodable track forces the existing h264/aac HLS transcode regardless
of the server calling direct play 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 that would be served —
the default, else the first — since a supported track further down is not
the one that plays. A source with no audio, or a codec the server did not
name, is left alone rather than transcoded on a guess.

Test-first: the new tests failed against the old behaviour before the
decision existed. Verified on a motorola edge 30 by the audio HAL, not by
ear — the same E-AC-3 episode logged isMusicActive=true once and 58
ACDB-LOADER lines under this build, against 0 and 0 on 0.4.6, where an AAC
file in the same session produced 16 and 116. No FATAL EXCEPTION, so R8 on
the signed release build is unaffected.

Also carries in-flight subtitle-track work authored in a parallel session
(subtitleTracks, VideoPlayer, player/media, bindings) at the user's
request, so the tag matches the APK verified on device.
2026-08-11 20:07:11 +02:00
dtourolle 6a712c46cb fix(player): send subtitle tracks to ExoPlayer on Android (UR-020)
Selecting a subtitle on Android did nothing. The Kotlin side has been
complete for a long time — JellyTauPlayer.load() parses a subtitles JSON
array into MediaItem.SubtitleConfigurations and setSubtitleTrack() drives a
TrackSelectionOverride — but nothing ever reached it.

VideoPlayer built the list and then threw it away: it resolved every
subtitle stream's URL into a subtitleTracks array and the
commands.playerPlayItem({...}) call two lines below passed only streamUrl,
title, id, videoCodec and needsTranscoding. PlayItemRequest had no subtitle
field to put them in, so create_media_item hardcoded subtitles: vec![],
android/mod.rs serialized "[]" across JNI, and every MediaItem reached
ExoPlayer with zero SubtitleConfigurations. A later set_subtitle_track then
found no text track groups and logged "Invalid subtitle track index".

PlayItemRequest now carries the tracks (defaulted, so the background-audio
handoff and next-episode callers are unchanged) and create_media_item
threads them onto the MediaItem.

Serialization: SubtitleTrack is reused verbatim rather than given an
IPC-specific twin, and deliberately keeps snake_case. The same struct feeds
two consumers that both spell mime_type — the JNI JSON that
JellyTauPlayer.load() reads with optString("mime_type"), and the generated
binding the frontend types against. camelCasing it would not fail the build
or the IPC; Kotlin would silently fall back to its default MIME type for
every track. UT-146 asserts the exact serialized keys so a future
rename_all cannot pass unnoticed.

The index mapping was NOT already correct. setSubtitleTrack(n) indexes
ExoPlayer's filtered text track groups, i.e. the position of the sideloaded
configuration — but the menu passed its own {#each} row number, which counts
every subtitle *stream*, including ones whose URL failed to resolve and were
therefore never sideloaded. One failed URL and every track below it selected
the wrong subtitle. The position is now looked up in the exact array that
was sent (nativeSubtitleArrayIndex), and a stream that was never sent maps
to "off" rather than to a guessed position.

The resolution loop also reuses resolveSubtitleTracks() from the Linux fix
instead of duplicating it, which fans the URL requests out in parallel
rather than awaiting them one per stream before playback can start. The
awaits are safe where they sit: the native-mode pitfall is about Svelte
lifecycle calls after an await, and nothing is registered here — the
background-audio subscriptions above still run synchronously.

No Kotlin change was needed.

Tests (UT-145, UT-146, UT-147) were written first and failed: PlayItemRequest
had no subtitles field to compile against, nativeSubtitleTracks and
nativeSubtitleArrayIndex did not exist, and the playerPlayItem call carried
no subtitles key.

TRACES: UR-020 | IR-016, JA-008 | UT-145, UT-146, UT-147
2026-08-11 20:03:19 +02:00
dtourolle 211792947d fix(player): render subtitle tracks on the Linux HTML5 path (UR-020)
Selecting a subtitle on Linux did nothing. VideoPlayer rendered no <track>
children at all — the block was commented out as "temporarily disabled to
debug playback issues" (it has been that way since the POC) — so
Html5PlayerAdapter.selectSubtitle() walked an empty textTracks list and the
menu, which is built from media.mediaStreams, was purely decorative.

The reason it had to be disabled is still visible in the dead markup:
getSubtitleUrl() is async, so src={getSubtitleUrl(track.index)} bound a
Promise to the attribute and every track pointed at "[object Promise]" — an
unloadable resource hanging off the media element.

Subtitle URLs are now resolved off the render path into component state
(subtitleTracks.ts), and only streams whose URL actually resolved are
rendered; a per-track failure drops that track instead of emitting a dead
src. data-stream-index is kept, since that is what the adapter matches on.

Subtitles stay OFF unless the user asks for them: the server's isDefault flag
is shown in the menu but is never promoted to a selection, and the `default`
attribute is deliberately not emitted. A <track default> auto-shows, so the
menu would open on "Off" while subtitles were burned over the picture, and
every user who never wanted subtitles would suddenly get them. That matches
the existing initial state (selectedSubtitleIndex = null).

Selection and rendered tracks are reconciled whenever the list changes: a
selection that no longer resolves collapses to "Off", and a surviving one is
re-applied after the new <track> elements exist. "Off" disables every text
track, as before.

Cross-origin text-track fetches use the media element's CORS setting, so the
element opts in with crossorigin="anonymous" — but only for an http(s)
stream, never for a local/offline file:/asset: source, where forcing CORS
onto the video fetch could break playback. It is keyed on the subtitle stream
count, known at first render, so the attribute cannot flip under an in-flight
media load.

Android/native is untouched: the ExoPlayer branch still goes through
player_set_subtitle_track.

Tests (UT-143, UT-144) were written first and failed against the old markup:
the commented-out block, the Promise bound to src, and the default attribute.

TRACES: UR-020 | DR-023 | UT-143, UT-144
2026-08-11 19:25:39 +02:00
dtourolle 2c3955914e fix(playback): advertise only webview-decodable audio for video (DR-148, 0.4.7)
The audio codec list sent to Jellyfin comes from MediaCodecList, which
describes ExoPlayer — but video does not play through ExoPlayer. Android
force-renders every video in the webview <video> element (the interim
override in VideoPlayer.svelte) and Linux always has, and Chromium/WebKit
decode a far narrower set than the platform does.

A motorola edge 30 ships /vendor/etc/media_codecs_dolby_audio.xml, so it
reported ac3,eac3; the server 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 rather
than the lack of it, which is why a Fairphone and an Honor tablet play the
same file on the same build: without the Dolby decoder they never claim the
codec, so the server transcodes to AAC. Confirmed by A/B on the failing
device — hevc+eac3 silent, hevc+aac audible, same session, same profile,
same direct-play path, audio codec the only variable.

video_audio_codecs narrows the platform list to the webview-decodable set
for the video direct-play profile only. Audio-only playback really is the
native player's, so that profile keeps the full list rather than
transcoding music that plays perfectly well. A list with nothing decodable
still claims aac, since a profile claiming 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.

Test-first: the tests failed against the old behaviour before the filter
existed, including the case built from the phone's real codec list. The
requirement-count assertion in extract-traces.test.ts moves 280 -> 281 for
the added DR, which is the deliberate edit that test exists to force.

Not yet verified on device — the 0.4.7 APK was still building.
2026-08-11 19:13:46 +02:00
dtourolle 1b70926c36 feat(offline): play downloaded video, and drain the offline sync queue (0.4.6)
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 20m34s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 6m6s
Traceability Validation / Check Requirement Traces (push) Successful in 18s
Build & Release / Run Tests (push) Successful in 20m26s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 10m3s
Build & Release / Build Linux (push) Successful in 37m59s
Build & Release / Build Windows (push) Successful in 23m0s
Build & Release / Build Android (push) Successful in 40m26s
Build & Release / Create Release (push) Successful in 1m20s
Bundles this session's work plus the concurrent search/offline/player changes.
Every gate passes on the combined tree: 885 frontend tests, 610 Rust tests,
clippy clean, boundary clean, trace coverage 86%.

Offline video playback — four separate defects, each of which alone stopped it:

  DR-133  A completed download's file_path is already absolute (the worker
          rewrites it on completion), but the player rooted it a second time and
          handed the webview /data/user/0/app//data/user/0/app/videos/x.mp4.
  DR-134  The asset protocol was never enabled: no protocol-asset feature and no
          assetProtocol config, so convertFileSrc produced URLs nothing answered.
          Also silently defeated the cached-thumbnail path, which fails soft to
          the server copy and hid it whenever the server was reachable.
  DR-137  Tauri's asset protocol answers a range-less request by reading the
          whole file into memory, and only advertises Accept-Ranges from inside
          its range branch, so the first request never learns ranges exist.
          Chromium gave up with PIPELINE_ERROR_READ after ~31s. Local media is
          now served by a loopback HTTP server: bounded 4 MiB chunks streamed
          from the file handle, every response length-delimited, and a range-less
          request answered with one chunk rather than the file. Confined by a
          per-session token and to the app data directory, because loopback is
          shared between apps on Android.
  DR-138  Release builds set usesCleartextTraffic=false, so Android rejected the
          request to that server before any I/O. A network-security-config
          exempts 127.0.0.1 only; a remote server must still be HTTPS.

Downloads:

  DR-135  download_item never records media_type and the reconnect resolver read
          that NULL as 'audio', so a movie queued from a media card had its URL
          resolved by get_audio_stream_url and completed as an audio-only
          transcode. The item's own type now decides.
  DR-136  Rows already downloaded that way are requeued on reconnect, since
          prevention alone leaves them reading "downloaded" and still unplayable.

Known limitation: a download taken at `original` quality is a byte copy of the
source, so it can be any container. One such file is an AVI holding XVID, which
the webview cannot play in any case — the media server serves it correctly and
Chromium refuses it. That needs either a transcoded download preset or the
native ExoPlayer surface work, and is not addressed here.

Also fixes two ID collisions between concurrent work: DR-143 defined twice
(search vs offline gate) and UT-131 defined twice (Episode Focus hero vs channel
cap). The search requirement is now DR-147 and the channel-cap test UT-141, with
their code references and matrix rows updated.
2026-08-09 16:38:07 +02:00
dtourolle 7b531a40be fix(player): pick a decodable track in the no-audio fallback (DR-146)
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.

Scan the groups for the first isTrackSupported track and override to
that. Also clear setTrackTypeDisabled(TRACK_TYPE_AUDIO), since audio may
equally have been off at the type level, which an override alone does not
undo. When no group holds a supported track, log it as an error — the
server was expected to transcode — instead of leaving a silent video with
no explanation in the log.

Verified by compiling :app:compileArm64DebugKotlin. Not unit-tested: this
tree has no Kotlin test source set, as noted in the previous commit.
2026-08-09 15:07:18 +02:00
dtourolle 19bc265a8d fix(player): do not start a video before audio focus is granted (DR-145)
Video manages audio focus by hand (handleAudioFocus=false, since
ExoPlayer's automatic handling is reserved for the audio path), and all
three outcomes of the request were 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, which to the
user is indistinguishable from a broken stream.

Hold playback when focus is not granted and start it from the
AUDIOFOCUS_GAIN callback. An explicit play() re-requests focus instead of
resuming into a stream the system is still muting, guarded by a
held-focus flag so repeated plays do not leak focus requests. LOSS clears
the pending flag so an unrelated later GAIN cannot start playback the
user never asked for.

Verified by compiling :app:compileArm64DebugKotlin. Not unit-tested: this
tree has no Kotlin test source set (the Gradle project lives in the
generated, gitignored gen/ tree), so the logic cannot be exercised off
device without restructuring the Android build.
2026-08-09 15:06:49 +02:00
dtourolle cc7f1cece0 fix(player): play downloaded video offline (DR-133, DR-134)
Offline video never started: the <video> element reported NETWORK_NO_SOURCE
one millisecond after loadstart, which the UI mislabelled as "may need
transcoding" even though nothing had been fetched. Two independent causes,
both required for playback.

The path was doubled. `downloads.file_path` is stored relative to the storage
root while a download is queued, but the worker rewrites it to the absolute
path it actually wrote once the transfer completes — so a completed row is
already rooted. The player's offline branch rooted it a second time, producing
/data/user/0/app//data/user/0/app/videos/x.mp4. Audio was unaffected because it
resolves the same column through Rust's resolve_local_media_path, which does
not re-root. The join is now absolute-aware (POSIX, Windows drive letters, UNC)
so rows written before completion still resolve.

The asset protocol was never enabled. convertFileSrc rewrites a path to
http://asset.localhost/… unconditionally, but Tauri only answers that origin
when the protocol-asset cargo feature is compiled in *and*
app.security.assetProtocol.enable is set — neither was, so even a correct path
resolved to nothing. This also silently defeated the cached-thumbnail path in
imageCache, which fails soft to the server copy and so hid the breakage
whenever the server was reachable. Scoped to $APPDATA/** — the storage root
holding the database, downloads/ and the thumbnail cache — rather than an
unrestricted grant.

Diagnosed from logcat on device; UT-124 reproduces the doubled path.
2026-08-09 15:05:16 +02:00
dtourolle a53042fe80 fix(playback): bound the device profile by the audio route's channels (DR-141)
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. The
DeviceProfile carried no MaxAudioChannels, so Jellyfin was free to
direct-play the multichannel track to a two-channel sink — silence or
dialogue folded into surround channels that go nowhere, depending on the
device.

Report media3 AudioCapabilities.maxChannelCount for the current route over
JNI alongside the codec lists, and bound the direct-play and transcoding
profiles (and the HLS URL's TranscodingMaxAudioChannels, previously
hardcoded to 2) by it. No codec is ever removed, so 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.
2026-08-09 15:01:57 +02:00
dtourolle db520c6551 fix(playback): stop pinning the video stream as the audio track (DR-140)
Jellyfin's MediaStream.Index is global across every stream in a media
source, so index 0 is the video stream on virtually all files. We sent
AudioStreamIndex=0 as "the first audio track" on the HLS transcode URL,
the background audio-only handoff URL, the direct-play fallback URL and
the PlaybackInfo negotiation body — asking the server to use the video
stream as audio. Servers that honour it produce a picture with no sound;
only those that silently correct the index hid the bug, which is why it
surfaced as "some videos have no audio".

Omit the parameter unless a track was actually chosen, so the server
resolves the source's DefaultAudioStreamIndex. An explicit selection from
player_switch_audio_track still passes through unchanged. Dropped
outright from the static=true direct-play URL, which serves the original
file untouched.
2026-08-09 14:47:03 +02:00
82 changed files with 11571 additions and 2841 deletions
+22 -44
View File
@@ -96,6 +96,12 @@ jobs:
- name: Install dependencies
run: bun install
# The Linux job previously had no version step at all, so a tagged release
# built Linux packages from whatever version happened to be committed.
- name: Set app version from tag
run: ./scripts/set-version.sh "${GITHUB_REF#refs/tags/}"
if: startsWith(github.ref, 'refs/tags/v')
- name: Build for Linux
run: bun run tauri build
env:
@@ -156,15 +162,13 @@ jobs:
restore-keys: |
${{ runner.os }}-bun-
# The tag is the single source of truth for a release version; the script
# stamps every file that carries it (package.json, tauri.conf.json,
# Cargo.toml, Cargo.lock). This step used to sed only tauri.conf.json, so
# the other three shipped whatever was committed.
- name: Set app version from tag
run: |
# On a tag build the tag is the single source of truth for the version.
if echo "$GITHUB_REF" | grep -q '^refs/tags/v'; then
VERSION="${GITHUB_REF#refs/tags/v}"
echo "Setting version to $VERSION"
sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"$VERSION\"/" src-tauri/tauri.conf.json
fi
grep '"version"' src-tauri/tauri.conf.json
run: ./scripts/set-version.sh "${GITHUB_REF#refs/tags/}"
if: startsWith(github.ref, 'refs/tags/v')
- name: Build Windows (NSIS installer + exe)
run: OUTPUT_DIR="$PWD/dist/windows" WIN_BUNDLES=nsis ./scripts/build-windows-cross.sh
@@ -217,48 +221,22 @@ jobs:
- name: Install dependencies
run: bun install
# Stamp before `android init`: it derives its generated project (including
# the initial versionCode) from tauri.conf.json.
- name: Set app version from tag
run: |
# On a tag build, the tag is the single source of truth for the
# version name. On non-tag runs keep whatever is in tauri.conf.json.
if echo "$GITHUB_REF" | grep -q '^refs/tags/v'; then
VERSION="${GITHUB_REF#refs/tags/v}"
echo "Setting version to $VERSION"
sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"$VERSION\"/" src-tauri/tauri.conf.json
fi
grep '"version"' src-tauri/tauri.conf.json
run: ./scripts/set-version.sh "${GITHUB_REF#refs/tags/}"
if: startsWith(github.ref, 'refs/tags/v')
- name: Initialize Android project
run: bun run tauri android init
# Re-run after init: tauri.properties only exists now, and its
# autogenerated versionCode (0.0.15 -> 15) is both tiny and NOT monotonic
# against the 1000 floor already shipped in the field. The script rewrites
# it as 1000 + major*10000 + minor*100 + patch. Runs unconditionally so
# untagged builds get a sane code too, derived from git describe.
- name: Pin a monotonic Android versionCode
run: |
# `tauri android init` autogenerates src-tauri/gen/android/app/tauri.properties
# with a versionCode derived from the semver (e.g. 0.0.15 -> 15). That
# number is (a) tiny and (b) NOT monotonic across our history: earlier
# local/dev builds shipped versionCode 1000 (from a 0.1.0 config), so a
# plain 15 would be a *downgrade* and Android would refuse the update.
#
# Derive an explicit code that is both monotonic in semver order and
# always above the 1000 floor already in the field:
# code = 1000 + major*10000 + minor*100 + patch
# e.g. 0.0.14 -> 1014, 0.0.15 -> 1015, 0.1.0 -> 1100, 1.0.0 -> 11000.
# POSIX sh only (the runner uses dash): no here-strings, no \s in sed.
PROPS="src-tauri/gen/android/app/tauri.properties"
VERSION=$(grep '"version"' src-tauri/tauri.conf.json | head -1 | sed -E 's/.*"version"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/')
MAJ=$(echo "$VERSION" | cut -d. -f1)
MIN=$(echo "$VERSION" | cut -d. -f2)
PAT=$(echo "$VERSION" | cut -d. -f3)
# Guard against a malformed/missing component so we never emit code 0.
: "${MAJ:=0}" "${MIN:=0}" "${PAT:=0}"
CODE=$(( 1000 + MAJ*10000 + MIN*100 + PAT ))
echo "version=$VERSION -> versionCode=$CODE"
if grep -q '^tauri.android.versionCode=' "$PROPS"; then
sed -i "s/^tauri.android.versionCode=.*/tauri.android.versionCode=$CODE/" "$PROPS"
else
echo "tauri.android.versionCode=$CODE" >> "$PROPS"
fi
cat "$PROPS"
run: ./scripts/set-version.sh "${GITHUB_REF#refs/tags/}"
- name: Sync custom Android sources & gradle config
run: ./scripts/sync-android-sources.sh
+82
View File
@@ -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
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
### 🐛 Fixes
+56 -7
View File
@@ -166,6 +166,7 @@ API endpoints and data contracts required for Jellyfin integration.
| JA-032 | Get audio-only stream URL for a video item (selected audio-stream index) | MediaInfo | UR-040 | Done |
| JA-033 | Query favourite items (`Filters=IsFavorite`, recursive, scoped by item type) | Items | UR-067 | Done |
| JA-034 | Read `UserData` (favourite, played, resume position) from item responses | UserData | UR-069 | Done |
| JA-035 | Mark item played (`POST /Users/{userId}/PlayedItems/{itemId}`) | UserData | UR-025 | Done |
### 2.3 Development Requirements
@@ -299,6 +300,27 @@ Internal architecture, components, and application logic.
| DR-128 | Audio-only playback of *downloaded* media reads the local file rather than fetching an audio-only stream. No transcode is involved or wanted: the Linux backend already runs MPV with `video: no`, so handing it the downloaded video file decodes the audio track and ignores the video, and ExoPlayer disables its video renderer equivalently. Transcoding to a separate audio artifact would cost CPU and battery, need an encoder the project does not ship, and produce a second file to keep in step — for no gain over simply not decoding the video | Playback | UR-071 | Done |
| DR-129 | A stream that stops delivering is recovered, not treated as terminal. Two failure shapes, because the streams differ. (a) *Phantom end* — the background audio-only handoff uses a progressive mp3 transcode over plain HTTP, chunked and therefore length-less, so a dropped connection reaches the player as end-of-input and ExoPlayer reports `STATE_ENDED` indistinguishably from the real end. The item's runtime is the only thing that can tell them apart: an end reported more than a tolerance short of it (comparing the *absolute* position — handoff base plus the player's relative position) is a truncation. Left unhandled, playback parked in `STATE_ENDED` and the next play intent from the lockscreen, notification or a Bluetooth reconnect seeks an ended player to position 0 — the user-visible "the episode randomly restarted". (b) *Recoverable error* — music (`/Audio/{id}/stream?Static=true`) and video (`/Videos/{id}/master.m3u8`) declare their length, so the player detects the truncation itself and raises an error; the frontend's handler stopped playback outright, turning a hiccup into silence. Both resume the current item **in place** (never via `play_item`, which would replace the queue with a single item and lose the album), the error path after a per-attempt backoff. Seekable streams are re-prepared at the URL they already have and seeked; the length-less transcode, which cannot be seeked, has `StartTimeTicks` rewritten into its existing URL so the user's audio-track selection survives and recovery needs no network round-trip. Only `Remote` sources qualify — a local file cannot fail from the network. A shared budget of consecutive attempts at the same position, refilled whenever playback progresses, stops an unreachable server from looping | Playback | UR-040, UR-004 | Done |
| DR-130 | A backend's position and duration must survive the end of the file they describe. MPV exposes `time-pos`/`duration` as properties of the *loaded* file, so at EOF it unloads and both stop resolving — the accessors reported `0.0`/unknown at exactly the moment end-of-file handling asks where playback reached, and any position-versus-runtime check would have read every natural end as a truncation. The poll thread records the last reading and the accessors fall back to it. Linux resilience is layered on the same principle that the stream, not the player, is what failed: MPV is configured with ffmpeg reconnection (`stream-lavf-o`, `network-timeout`) so ordinary blips never surface, and `EndFile(ERROR)` — previously a bare log, which left playback halted while the UI still showed "playing" — is emitted as a *recoverable* error. Because MpvBackend is constructed before `PlayerController` exists, it cannot decide in-process like the Android JNI callback: the frontend echoes the error into `player_recover_stream`, which keeps the decision in Rust (the same shape as `PlaybackEnded``player_on_playback_ended`). Android reports errors it has already declined as *unrecoverable*, so the echo never asks twice | Playback | UR-004, UR-040 | Done |
| DR-131 | The offline mutation queue is drained. `sync_queue` had producers and no consumer: `PlaybackReporter::queue_for_sync` writes a row for every start/stop/mark-played that cannot reach the server, `sync_mark_processing`/`_completed`/`_failed` were registered commands with no callers, and no Rust task processed the table — so queued watch positions never reached Jellyfin and the offline banner's count only ever grew. A drain hangs off the same `connectivity:reconnected` transition as DR-120 (in Rust, because a drain started by a component dies with it) and replays rows oldest-first, so a stale start cannot move the server's resume position backwards after a later stop. `update_progress` replays as *stopped at N* rather than as progress — replaying a mid-playback report hours later would claim the item is still playing — and payloads are read in both dialects that exist in users' databases (`position_ticks` from Rust, camelCase `positionMs` from the frontend helper). A failed row stays queued for the next reconnect; after `MAX_SYNC_ATTEMPTS` it is `abandoned` and stops counting, because a row nothing can ever push is what turns the queue into a counter that only grows. An *unreachable* server is not counted as an attempt at all — the row goes back to `pending` untouched — so opening the app offline a few times cannot abandon good rows; only a server that answers and refuses spends the budget. The drain also runs once at startup, because a queue built in a previous session would otherwise sit untouched for a whole run whenever the server was reachable the entire time and no offline→online transition ever fired. Requires `MediaRepository::mark_played` (JA-035) — the previous stand-in reported a stop at `i64::MAX` | Backend | UR-025, UR-002 | Done |
| DR-132 | The pending-sync count is answerable. The offline banner's badge read "N pending sync(s)" and led nowhere, so it was taken for pending *transfers* and looked for on the Downloads page — which lists the `downloads` table and structurally cannot show `sync_queue` rows. The badge becomes a button opening the queue it counts: each row's operation, the item's title (resolved by a `LEFT JOIN items` in `sync_get_pending`, not a per-row frontend fetch), when it was queued, and the error of anything failing, plus a "Sync now" that runs the DR-131 drain on demand. The same list is a Settings section, because a row that keeps failing is still queued when the server is reachable and no banner is on screen. The drain emits `sync-queue-changed` so the badge updates on reconnect instead of lagging by up to one 10s poll | UI | UR-025 | Done |
| DR-133 | A downloaded file has exactly one on-disk path, and the row that names it is authoritative. `downloads.file_path` starts relative to the storage root, but the worker rewrites it to the absolute path it actually wrote when the transfer completes — so a *completed* row is already rooted. The video player's offline branch rooted it a second time, handing the asset protocol `/data/user/0/app//data/user/0/app/videos/x.mp4`; the webview reported `MEDIA_ERR_SRC_NOT_SUPPORTED` with `NETWORK_NO_SOURCE`, so every downloaded video failed to play while audio — which resolves the same column through Rust's `resolve_local_media_path`, without re-rooting — played fine. The join is absolute-aware (POSIX, Windows drive letters and UNC) so rows written before completion still resolve | Playback | UR-071 | Done |
| DR-134 | The webview can actually fetch the local files it is handed. `convertFileSrc` rewrites a path to `http://asset.localhost/…` unconditionally, but Tauri only answers that origin when the `protocol-asset` cargo feature is compiled in *and* `app.security.assetProtocol.enable` is set — neither was, so every such URL reached a protocol with no handler and the webview reported `NETWORK_NO_SOURCE`. This silently defeated both offline video (`<video src>`) and the cached-thumbnail path in `imageCache`, which fails soft to the server copy and so hid the breakage whenever the server was reachable. The scope is `$APPDATA/**` — the storage root under which the database, `downloads/` and the thumbnail cache all live — rather than an unrestricted grant, so the webview can read the app's own media and nothing else | Security | UR-071 | Done |
| DR-140 | An audio track is pinned only when the user picked one. Jellyfin's `MediaStream.Index` is global across every stream in a media source, so index 0 is the *video* stream on virtually all files — yet `AudioStreamIndex=0` was sent as "the first audio track" on the HLS transcode URL, the background audio-only handoff URL, the direct-play fallback URL, and the `PlaybackInfo` negotiation body. A server that honours the request literally then transcodes the video stream into the audio slot and the result plays as a picture with no sound; only servers that silently correct the index hid the bug, which is why it presented as "some videos have no audio". The parameter is now omitted whenever no track has been chosen, so the server resolves the source's `DefaultAudioStreamIndex`; an explicit selection from `player_switch_audio_track` is still carried through unchanged. On the `static=true` direct-play URL it is dropped outright — the original file is served untouched, so the parameter could only mislead | Playback | UR-004, UR-040 | Done |
| DR-147 | One search input per screen, and the URL is the search's single source of truth. The header bar rendered only under `/library/**` and merely *navigated* to `/search` (DR-063), so a desktop search handed the user to a screen whose input was a different element — the header box cleared itself and vanished, and the page's own box took over mid-word. That page then re-derived its input from `?q=` against `library.searchQuery` on every store write, so the next keystroke re-ran the effect and snapped the text back to the query the header had sent (and a scope chip back to the URL's scope); entering from the bottom-nav Search tab skipped it only because the effect early-returned on an empty query. The bar now renders on `/search` too (`showHeaderSearch`) and is the sole md+ input — the page's own input is `md:hidden` — and on that route it republishes the query into the URL with `replaceState`, so a whole session of typing costs one history entry. The page *consumes* that URL once per distinct value (`seedFromSearchUrl` against a non-reactive `applied` marker) instead of continuously reconciling it, and the scope chips publish through the same URL so the bar and the chips cannot disagree. Landing on `/search` with a seeded query focuses the bar and puts the caret at the end, because the box the user was typing in belonged to the unmounted route | UI | UR-049, UR-054 | Done |
| DR-142 | An episode has exactly **one** surface, and it is complete. Two divergent renderings existed: `EpisodeFocusView` (reached from Continue Watching, the series episode list, the TV landing page and Downloads — i.e. every real entry point) offered only Play and Favourite, while the bare `/library/<episodeId>` page nobody routed to carried the download button, the series/season breadcrumbs and the cast section. Opening an episode the normal way therefore silently lost the ability to download it. The Focus View is now the single surface and carries the full §5B.2 composition — hero action row `Play / Download / Favourite`, series name and `SxEy` badge as links back to the series and to that season's anchor, then genres → cast → similar shows *below* the episode strip, never above it (DR-062). `/library/<episodeId>` redirects into it (`episodeRedirectTarget`, the same rule seasons follow under DR-103), and an episode with no `seriesId` renders the same component series-less rather than falling back to a second, lesser page. The focused episode is fetched in full rather than reused from the season fan-out, because that is a *list* query and carries neither cast nor genres — the sections would have rendered empty. The strip hides itself when the episode has no siblings, a card that only shows the episode you are already on being noise | UI | UR-048, UR-058 | 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-146 | The no-audio-track fallback picks a track the renderer can actually play. When ExoPlayer selected no audio track, the recovery forced group 0 / track 0 unconditionally — but the most likely reason nothing was selected is that this very track cannot be decoded on this device, so the override reinstated the silence it was meant to fix. It now scans the groups for the first `isTrackSupported` track and overrides to that, and clears `setTrackTypeDisabled(TRACK_TYPE_AUDIO)` because audio may equally have been off at the type level, which an override alone does not undo. When no group holds a supported track the condition is logged as an error — the server was expected to transcode — rather than leaving a silent video with no explanation in the log | Playback | UR-004 | Done |
| DR-148 | The video direct-play profile advertises only what the **webview** can decode. The audio codec list comes from `MediaCodecList`, which describes ExoPlayer — but video does not play through ExoPlayer on either platform: Android force-renders every video in the webview `<video>` element (the interim override in `VideoPlayer.svelte`, because the native SurfaceView sits behind an opaque webview) and Linux always has. Chromium and WebKit decode a far narrower set than the platform does, and the gap is widest on devices whose vendor licenses Dolby: a phone shipping `/vendor/etc/media_codecs_dolby_audio.xml` reports `ac3,eac3`, so Jellyfin direct-played an E-AC-3 track with `static=true` and the webview built a video decoder and no audio decoder at all — full picture, no sound. The defect is triggered by *capability*, not the lack of it, which is why it reproduced on one Motorola while a Fairphone and an Honor tablet played the same file on the same build: a device without the Dolby decoder never claims the codec, so the server transcodes to AAC and it plays. `video_audio_codecs` narrows the platform list to the webview-decodable set (`aac,mp3,opus,vorbis,flac`) for the video direct-play profile *only* — the audio-only profile keeps the full list, since that playback really is the native player's and narrowing it would transcode music that plays perfectly well. A list with nothing decodable still claims `aac` rather than going out empty, because a profile that claims nothing invites the server to give up instead of transcoding. The video codec list is deliberately untouched: HEVC direct-plays through the webview correctly, so the constraint is specific to audio | Playback | UR-004 | Done |
| DR-149 | The client decides whether its own renderer can decode the audio, rather than trusting the server's negotiation. Advertising a webview-shaped profile (DR-148) turned out to be necessary but not sufficient: Jellyfin 10.11.5 enforces a `DirectPlayProfile`'s `Container` and `VideoCodec` — excluding either returns `SupportsDirectPlay: false` with `TranscodeReasons=ContainerNotSupported` / `VideoCodecNotSupported` — but **ignores its `AudioCodec`**, offering an E-AC-3 track for direct play against a profile listing only `aac,flac,mp3,opus,vorbis`. Neither a `VideoAudio` `CodecProfile` forbidding the codec nor a `MaxAudioChannels: 2` against a 6-channel track changes the answer, so no profile the client can send fixes it and the picture plays silent. The negotiated source's audio is therefore checked locally against what the webview decodes, and an undecodable track forces the existing h264/aac HLS transcode URL regardless of the server saying direct play is fine — `direct_play` and `needs_transcoding` are corrected to match, so the frontend and the reporting path agree with the URL actually used. The track judged is the one the server would serve: the default, or the first when nothing is marked default, since a supported track further down the list is not the one that plays. A source with no audio streams, or a stream whose codec the server did not name, is left alone — forcing a transcode on a guess spends server CPU on files that already play | Playback | UR-004 | Done |
| DR-150 | Android video renders on the native ExoPlayer surface behind a transparent WebView, behind the `experimentalNativeVideo` opt-in. Rust already reported `use_html5_element: false` on Android, but two frontend overrides discarded it — `createAdapter()` hardcoded `"html5"`, and `VideoPlayer.svelte` forced `useHtml5Element = true` and stopped the native backend `player_play_item` had just started. The flag is a **suppressor, never a promoter**: off forces HTML5 even where Rust says native, so an in-progress spike cannot ship as the default, but it can never select native where Rust reported HTML5 (Linux cannot composite behind WebKitGTK, so promoting there is a black screen). Compositing requires clearing two independent opaque layers, and clearing only one leaves audio over a black picture — the WebView widget background and window drawable from Kotlin (`AndroidVideoSurface.setTransparent`), and the page's `html`/`body` and app-shell background from CSS (`data-native-video`). Transparency is declared in `tauri.android.conf.json` rather than the base config, because a transparent window on Linux has nothing behind it, and is toggled per playback session rather than set once, because a permanently transparent window shows the launcher through the rest of the app | Playback | UR-003, UR-004 | Done (behind `experimentalNativeVideo`, default off) |
| DR-151 | The player's video SurfaceView actually reaches the view hierarchy. `JellyTauPlayer.setActivity()` had zero callers, so `currentActivity` was always null and `autoAttachSurface()` returned at "Cannot attach surface - no Activity reference". The surface was created and handed to ExoPlayer but never added to the content view, so native video decoded to a surface that was never on screen — independent of any webview transparency. `MainActivity.onCreate` now supplies the reference, which also revives PiP on the video path: `canEnterPip()` gates on `isVideoSurfaceAttached()`, which had been permanently false | Playback | UR-003, UR-041 | Done |
| DR-152 | Platform playback facilities are reported by Rust, not sniffed from the user agent. `webviewAudio.ts` re-derived "does this platform have a native audio backend" by matching `navigator.userAgent` against `android`/`linux` — a second copy of the `cfg!` gate the backends are compiled under, free to drift from it. `player_get_capabilities` now returns `usesWebviewAudio` and `supportsNativeVideo` from the same cfg gates, and the frontend consumes them; the settings toggle for native video is hidden entirely where the platform cannot support it | Player | UR-003, UR-005 | Done |
| DR-153 | The git tag is the single source of truth for a release version. The version lived in four files (`package.json`, `tauri.conf.json`, `Cargo.toml`, `Cargo.lock`) that had to be hand-edited in lockstep, and CI's release job rewrote exactly one of them — so a tagged build produced an installer named for the tag wrapped around package metadata naming the previous release, while the Linux job had no version step at all and shipped whatever was committed. `scripts/set-version.sh` writes all four from one argument and is the only thing that does; every release job calls it with the tag. The Android `versionCode` is derived in the same place as `1000 + major*10000 + minor*100 + patch`, which is monotonic in semver order and clears the 1000 floor already installed in the field — a lower code than the installed one makes Android refuse the update. A prerelease suffix is stripped before that arithmetic, which would otherwise abort the script, and a non-tag ref (CI passes `${GITHUB_REF#refs/tags/}` unconditionally) falls back to `git describe` rather than failing a branch build | Build | - | Done |
| DR-143 | Flipping the offline downloaded-only gate actually re-queries the listing. The gate (DR-078) is a process-wide flag in Rust consulted only *while a query runs*, but no library surface re-queried when its inputs changed: `useServerReachabilityReload` fires only on the offline → **online** transition, and `GenericMediaListPage`, `GenericGenreBrowser` and the favourites page never even called its `checkServerReachability`. So going offline left the full server catalog on screen under a now-closed gate, and toggling "Show all server media" only greyed cards — `MediaCard.isServerOnly` is a pure frontend derivation that updates instantly — without adding or removing a single row. The filter therefore read as "shows everything until I filter, then greys some of it" while the backend gate was correct and simply never exercised. `catalogFilterVersion` is the refetch signal: `pushCatalogVisibility` now awaits `set_show_server_catalog` and bumps the version only **after** the backend accepts the new flag, since a reload racing the push would re-query under the old gate and undo itself. A failed push clears `lastIncludeCatalog` instead of latching it, so the next identical transition is retried rather than skipped as a no-op and left permanently disagreeing with the backend. `useOfflineFilterReload` subscribes pages to that signal, skipping the value they already loaded under; it is wired into both generic list components and the movies/music/tv/favourites landing pages and the `/library/[id]` detail page | UI | UR-052 | Done |
| DR-135 | A download's media type comes from the item, not a default. `download_item` — the path a media card uses to queue an item while offline — never records `media_type`, and the reconnect resolver read that NULL as `'audio'`, so a **movie** queued from a card had its URL resolved by `get_audio_stream_url`. The file that landed on disk was an audio-only transcode, which is why a "downloaded" film could never play offline no matter how the path or protocol was fixed. The resolver now falls back to the item's own `item_type` (`VIDEO_ITEM_TYPES` in Rust, so the frontend never learns which types are video) and only defaults to audio when the item is not cached locally. An explicit `media_type` on the row still wins | Downloads | UR-071, UR-052 | Done |
| DR-136 | Rows already downloaded under the audio default are repaired, not just prevented. They are identifiable after the fact — no `media_type`, but a video item — so on reconnect they are reset to `pending` with their audio URL cleared and re-resolved by DR-135's corrected logic, overwriting the audio file in place. Without this the fix is invisible to anyone who had already queued a film: the row still reads "downloaded" and still fails to play. Rows carrying an explicit `media_type` and genuine audio downloads are left untouched | Downloads | UR-071 | Done |
| DR-137 | Local media is served to the player over a loopback HTTP server, not the asset protocol. Tauri's `asset` protocol answers a request carrying no `Range` header by reading the whole file into memory, and only advertises `Accept-Ranges: bytes` from *inside* its range branch — so the first request never learns ranges exist and a multi-gigabyte body is attempted instead. Chromium abandoned it with `PIPELINE_ERROR_READ` after ~31s, which reached the user as "downloaded video does not play offline". Real HTTP on `127.0.0.1` is chosen over a custom URI scheme deliberately: range support becomes a property of the transport rather than depending on whether a platform's webview forwards `Range` to a custom scheme. No response ever exceeds a 4 MiB chunk and bodies stream from the file handle, so memory is bounded regardless of file size. Because **loopback is shared between apps on Android**, the server binds `127.0.0.1` only and every URL carries a random per-session token; paths are additionally confined to the app data directory, so a leaked URL cannot read outside it. This is stage 1 of making the server the single media origin — remote passthrough and download-while-watching are deliberately out of scope here | Playback | UR-071 | Done |
| DR-138 | Loopback is exempted from Android's cleartext ban, and nothing else is. Release builds set `usesCleartextTraffic="false"`, so the webview's request to the local media server (DR-137) was rejected by network security policy before any I/O — `<video>` failed in the same millisecond as `loadstart`, with `NETWORK_NO_SOURCE` and no server-side log at all, which is why it looked identical to a missing file. A `network-security-config` resource permits cleartext for `127.0.0.1` only and keeps `base-config cleartextTrafficPermitted="false"`, so a remote server must still be HTTPS; this is deliberately not a blanket opt-in. The manifest attribute is ignored once the config is present, so the config is the single authority. `sync-android-sources.sh` also had to learn to copy `res/xml`, which it skipped — the manifest references the resource, so a missed copy fails the resource link rather than degrading quietly | Security | UR-071 | Done |
| DR-093 | Traceability coverage gate derives its requirement denominators from `requirements.md` at run time rather than hardcoded literals: `countDefinedRequirements` counts an ID only where it leads a markdown table row (ignoring the "Traces To" column and prose) and deduplicates IDs listed both in the definition tables and in the §3 traceability matrix; `computeCoverage` reports the *intersection* of traced and defined IDs so an ID traced in code but absent from `requirements.md` is surfaced as `orphaned` instead of inflating the ratio past 100%. UT/IT test identifiers are excluded as a separate taxonomy. CI and `bun run traces:coverage` share this computation and fail on both a sub-threshold and an impossible >100% result | Tooling | - | Done |
---
@@ -333,7 +355,7 @@ Internal architecture, components, and application logic.
| UR-022 | IR-017 | DR-025 |
| UR-023 | IR-010 | DR-026, DR-047, DR-048, DR-049 |
| UR-024 | IR-010 | DR-027 |
| UR-025 | IR-015 | DR-028 |
| UR-025 | IR-015 | DR-028, DR-131, DR-132 |
| UR-026 | - | DR-029, DR-048, DR-050 |
| UR-027 | IR-020 | DR-030 |
| UR-028 | - | DR-031 |
@@ -356,17 +378,17 @@ Internal architecture, components, and application logic.
| UR-045 | - | DR-057 |
| UR-046 | IR-028 | DR-058 |
| UR-047 | IR-013 | DR-060 |
| UR-048 | - | DR-061, DR-062 |
| UR-049 | IR-010 | DR-063, DR-064, DR-065 |
| UR-048 | - | DR-061, DR-062, DR-142 |
| UR-049 | IR-010 | DR-063, DR-064, DR-065, DR-147 |
| UR-050 | - | DR-066, DR-067 |
| UR-051 | - | DR-068, DR-069, DR-070 |
| UR-052 | IR-027 | DR-078, DR-079, DR-080 |
| UR-052 | IR-027 | DR-078, DR-079, DR-080, DR-143 |
| UR-053 | IR-029 | DR-074 |
| UR-054 | - | DR-075, DR-076, DR-077 |
| UR-054 | - | DR-075, DR-076, DR-077, DR-147 |
| UR-055 | - | DR-081, DR-082, DR-083, DR-084 |
| UR-056 | - | DR-085 |
| UR-057 | - | DR-086 |
| UR-058 | - | DR-087 |
| UR-058 | - | DR-087, DR-142 |
| UR-060 | - | DR-090, DR-091, DR-111 |
| UR-061 | - | DR-092 |
| UR-062 | - | DR-101, DR-102, DR-103, DR-104, DR-107 |
@@ -378,7 +400,7 @@ Internal architecture, components, and application logic.
| UR-068 | - | DR-119 |
| UR-069 | - | DR-113, DR-114, DR-120 |
| UR-070 | - | DR-121, DR-122 |
| UR-071 | IR-032 | DR-123, DR-124, DR-125, DR-126, DR-127, DR-128 |
| UR-071 | IR-032 | DR-123, DR-124, DR-125, DR-126, DR-127, DR-128, DR-133, DR-134, DR-135, DR-136, DR-137, DR-138 |
---
@@ -505,7 +527,34 @@ Internal architecture, components, and application logic.
| UT-120 | Expiry reclaim takes only expired temporary entries: derived from `completed_at`+TTL, honouring an `expires_at` override, never a user download, and disabled by a zero TTL | DR-127 | Done |
| UT-108 | LRU eviction reclaims only `'auto'` downloads and never a user's own, even when the user's is the oldest | DR-126 | Done |
| UT-117 | A background audio-only stream cut short resumes where it died instead of ending the episode; a real end still advances; the absolute position is compared against the runtime; retries at a stuck position give up. A recoverable error resumes music and video too, with growing backoff, leaving the rest of the queue intact and the seekable stream's URL untouched; local and DirectUrl sources are excluded | DR-129 | Done |
| UT-124 | `downloadedFilePath` leaves a completed download's absolute path alone (POSIX and Windows) and only roots one that is still relative | DR-133 | Done |
| UT-125 | A NULL `media_type` resolves from the item type — Movie and Episode as video, a track as audio — an uncached item still defaults to audio, and an explicit `media_type` overrides the item | DR-135 | Done |
| UT-126 | Requeueing takes only video rows downloaded under the audio default, clearing their URL, and leaves correctly-typed video rows and real audio downloads alone | DR-136 | Done |
| UT-127 | The media server bounds and confines every response: a range-less request yields one chunk rather than the whole file, no range exceeds the chunk cap, explicit/open-ended/suffix ranges resolve correctly, a range past the end is unsatisfiable rather than clamped, a malformed header falls back to the first chunk, path traversal and unrelated absolute paths are refused, a wrong or absent token is rejected, and content type comes from the extension then the magic bytes | DR-137 | Done |
| UT-121 | An EOF reads as the last observed timestamp, not zero: live readings win while the file is loaded, a not-yet-established duration is not recorded as a real zero, a seek updates the position before the next poll, and loading a new file clears the previous one's | DR-130 | Done |
| UT-122 | The sync-queue drain pushes queued playback reports oldest-first, defers failures for the next reconnect, abandons a row after `MAX_SYNC_ATTEMPTS`, ignores other users' rows, and parses both payload dialects | DR-131 | Done |
| UT-123 | Pending-sync rows describe themselves: every queueable operation has a label, an unknown one still renders, the item title falls back to its id, and rows list oldest-first | DR-132 | Done |
| UT-130 | Video and background-audio stream URLs omit `AudioStreamIndex` when no track was chosen, and carry the exact index when one was | DR-140 | Done |
| UT-131 | The Episode Focus View hero offers a download control | DR-142 | Done |
| UT-132 | The series name links to the series and the `SxEy` badge to that season's anchor | DR-142 | Done |
| UT-133 | Cast renders below the "More Episodes" strip, never above it | DR-062, DR-142 | Done |
| UT-134 | The episode strip is hidden when the episode has no siblings | DR-142 | Done |
| UT-135 | An episode with no `seriesId` still renders the Focus View, with title, Play and download | DR-142 | Done |
| UT-136 | `episodeRedirectTarget` sends a bare episode page into its series' Focus View, and returns null with no series | DR-142 | Done |
| UT-137 | Going offline with the toggle off pushes the closed gate and bumps `catalogFilterVersion` | DR-143 | Done |
| UT-138 | The version bumps only after `set_show_server_catalog` resolves, never before | 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-141 | The advertised channel cap: an unknown or zero reading falls back to stereo, a real route keeps its channels, an absurd driver reading is capped at 7.1, and mono is taken at its word | DR-141 | Done |
| UT-148 | Forcing a transcode from the client: an undecodable default track forces one, a decodable track does not, the default track decides rather than the first, the first decides when nothing is marked default, and neither an audio-less source nor an unnamed codec is second-guessed | DR-149 | Done |
| UT-149 | `createAdapter` returns the native adapter only when Rust reports native AND `experimentalNativeVideo` is on; the flag off forces HTML5 even when Rust says native, and the flag on never promotes a platform Rust reported as HTML5 | DR-150 | Done |
| UT-150 | `set-version.sh` stamps all four manifests without touching dependency versions, and the Android versionCode is monotonic across an upgrade sequence, clears the 1000 floor, and survives a prerelease suffix | DR-153 | Done |
| UT-142 | The audio codecs offered for video direct play: a Dolby device's real `MediaCodecList` output drops `ac3`/`eac3`, AMR and raw PCM are dropped too, a fully-supported list is passed through untouched, a list with nothing decodable still claims `aac`, and stray spacing or casing does not decide whether the user gets sound | DR-148 | Done |
| UT-143 | Subtitle URLs resolve to plain strings before they reach the markup (never a Promise), unresolvable tracks are dropped, a stale selection collapses to "Off", and a server-default track is never auto-selected | UR-020, DR-023 | Done |
| UT-144 | VideoPlayer actually renders `<track kind="subtitles">` children carrying `data-stream-index`, with no `default` attribute and no async `getSubtitleUrl()` bound to `src` | UR-020, DR-023 | Done |
| 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
+97 -11
View File
@@ -1,7 +1,19 @@
# Spec: Android native video — transparent-webview spike
**Status:** Proposed (spike — timeboxed, may conclude "not viable")
**Requirements:** IR-004, UR-003, UR-004 → DR-001, DR-023, DR-024
**Status:** Spike succeeded — native video confirmed working on a physical
device (2026-08-11) with `experimentalNativeVideo` on. Shipped behind that flag,
default off. Branch `feat/android-native-video`.
**The spike's central question is answered: yes.** A `SurfaceView` *can* be
composited behind a transparent Tauri WebView on Android. Nothing upstream
blocked it and nothing upstream demonstrated it — this is, as far as the issue
trackers show, the first working instance. The remaining flag is about test
coverage and the unverified cases below, not about viability.
**Requirements:** IR-004, UR-003, UR-004, UR-041 → DR-001, DR-004, DR-150, DR-151, DR-152
**Note:** the original draft cited DR-023/DR-024 here. Those are the *subtitle*
and *audio-track selection UI* requirements — unrelated to this work. The IDs
actually implemented are DR-150 (native rendering behind the flag), DR-151 (the
severed SurfaceView attach chain) and DR-152 (capabilities reported by Rust).
**UX spec:** n/a — no intended visual change; the video surface must land exactly where the `<video>` element is today
**Supersedes / revises:** acts on finding 2 of [playback-backend-unification.md](playback-backend-unification.md)
@@ -109,6 +121,46 @@ in here rather than leaving a second, subtler copy of the bug behind. If
`get_player_status` does not currently expose enough to cover the audio case, add
the field — that is backend work, and correct.
### Implementation findings (2026-08-11)
Two blockers existed that this spec did not anticipate. Both were in code the
spec assumed was merely *unreachable*; it was also *broken*.
**1. The Kotlin attach chain was severed.** `JellyTauPlayer.setActivity()` had
**zero callers** anywhere in the tree. `currentActivity` was therefore always
null, so `autoAttachSurface()` logged "Cannot attach surface - no Activity
reference" and returned. The `SurfaceView` was created and wired to ExoPlayer but
never added to the view hierarchy — video would have decoded to a surface that
was never on screen, *regardless* of webview transparency. Fixed by calling
`JellyTauPlayer.setActivity(this)` from `MainActivity.onCreate`.
Note the knock-on: `PictureInPictureManager.canEnterPip()` gates on
`VideoOverlayManager.isVideoSurfaceAttached()`, which was permanently false. PiP
on the video path was dead for the same reason.
**2. `createAdapter()` was not the real gate.** It is never called by production
code — `VideoPlayer.svelte` constructs `Html5PlayerAdapter` directly. The actual
override was `VideoPlayer.svelte`'s INTERIM block, which read Rust's
`useHtml5Element`, forced it to `true`, and called `playerStop()` to kill the
native backend `player_play_item` had just started. Both sites are now fixed;
`VideoPlayer.svelte` routes through `createAdapter()` so there is one gate.
**Transparency needs two independent layers cleared,** not one. The spec's
Phase 1 named only `html, body`. Clearing just the page leaves the WebView
widget's own background opaque, which is a black screen with audio — the exact
symptom the INTERIM comment described as "native surface not visible". Both are
now toggled together by `$lib/utils/videoSurface.ts`:
| Layer | Cleared by | Reachable from |
|-------|-----------|----------------|
| WebView widget background + window drawable | `AndroidVideoSurface.setTransparent()` (MainActivity) | Kotlin only |
| `html`/`body` + app-shell `--color-background` | `data-native-video` attribute → app.css | CSS only |
Transparency is scoped to `tauri.android.conf.json` rather than the base config:
a transparent window on Linux is a regression, since nothing renders behind it.
It is also toggled per-session rather than set once — a permanently transparent
window shows the launcher through the rest of the app.
### Phase 3 — surface positioning
The hard part, and where this most likely fails. The webview's `<video>` element
@@ -124,6 +176,32 @@ the video is effectively fullscreen on Android, which it is in the player route.
rotation or the mini-player transition without visible artefacts, the spike fails
and we keep HTML5. Do not ship a janky native path for a codec win.
**Update: no rect plumbing was needed.** The premise — that the surface must be
positioned to match a laid-out `<video>` box — does not hold on the player route,
where video is fullscreen. `VideoOverlayManager` adds the SurfaceView at index 0
of `android.R.id.content` with `MATCH_PARENT`, and `fitSurfaceToScreen()`
(`JellyTauPlayer.kt`) already letterboxes/pillarboxes to the real video aspect
ratio and re-centres via a `Gravity.CENTER` `FrameLayout.LayoutParams`. Rotation
is handled by an `OnLayoutChangeListener` that re-fits on any bounds change. The
frontend's native branch is a bare `flex-1` box, so there is no rect to report
and nothing to keep in sync.
Fullscreen playback is confirmed working on device. But this reasoning rests
entirely on the fullscreen assumption, so **the mini-player transition is the
known gap** — it is the one case where the surface is *not* fullscreen, and
therefore the one case where the "no rect plumbing needed" conclusion could
still turn out to be wrong. If artefacts appear there, the fix is the rect
reporting this section originally proposed, scoped to that transition alone.
### A trap for the next implementer
There is a **stale duplicate player** at
`src-tauri/android/app/src/main/java/com/dtourolle/jellytau/player/JellyTauPlayer.kt`
(only commit: `cfddc1e` "First working POC"). No `sourceSets` entry points at it,
so it is not compiled — but edits made there silently do nothing. The canonical
tree is `src-tauri/android/src`, synced into `gen/` by
`scripts/sync-android-sources.sh`.
### What we gain if it works
- **Hardware decode via MediaCodec**`CodecDetector.kt` already reports
@@ -145,12 +223,13 @@ and we keep HTML5. Do not ship a janky native path for a codec win.
The spike is **complete** when one of these is true:
**Success path**
- [ ] Transparent WebView confirmed working on a physical device.
- [ ] `experimentalNativeVideo` off → behaviour byte-identical to today.
- [ ] `webviewAudio.ts` no longer inspects `navigator.userAgent`; the platform's audio backend is read from Rust.
- [ ] `experimentalNativeVideo` on → video plays via ExoPlayer/MediaCodec, correctly positioned, with working seek, audio-track switch, and subtitle selection through the existing `PlayerAdapter` contract.
- [ ] No artefacts on rotation, background/foreground, or mini-player transition.
- [ ] `adb shell dumpsys media.metrics` (or logcat) confirms a hardware decoder is in use.
- [x] Transparent WebView confirmed working on a physical device (reported by the maintainer; the config that enables it is now committed in `tauri.android.conf.json`).
- [x] `experimentalNativeVideo` off → behaviour byte-identical to today. Guarded by `adapterSelection.test.ts`, which asserts the flag-off case forces HTML5 even when Rust reports native.
- [x] `webviewAudio.ts` no longer inspects `navigator.userAgent`; the platform's audio backend is read from Rust (`player_get_capabilities``usesWebviewAudio`).
- [x] `experimentalNativeVideo` on → video plays via ExoPlayer, correctly positioned, on a physical device (2026-08-11). The surface reaches the hierarchy and is visible through the transparent WebView — the whole point of the spike.
- [ ] Seek, audio-track switch and subtitle selection exercised through `NativePlayerAdapter`. Playback is confirmed; these individual controls are not yet each verified on the native path.
- [ ] No artefacts on rotation, background/foreground, or **mini-player transition** — the last is the one case the fullscreen assumption does not cover, so it is the likeliest place to find a problem.
- [ ] `adb shell dumpsys media.metrics` (or logcat) confirms a hardware decoder is in use. Plausible but unmeasured — do not claim the MediaCodec win until this is read.
- [ ] Measured battery/thermal or CPU improvement over the HTML5 path on the same clip.
**Failure path**
@@ -159,8 +238,15 @@ The spike is **complete** when one of these is true:
- [ ] `nativeAdapter.ts:11-14` no longer cites tauri#10152.
Either way:
- [ ] `bun run check`, `bun run test`, `bun run check:boundary` pass.
- [ ] `cargo fmt` / `cargo clippy` clean; `bun run test:rust` passes.
- [x] `bun run check` (0 errors), `bun run test` (892 passed), `bun run check:boundary` pass.
- [x] `cargo fmt` / `cargo clippy` clean (no new warnings); `cargo test` passes (603 lib + 7 doc).
> Note: this environment has no host WebKitGTK dev packages, no Android SDK and
> no `bun`, so all of the above were run inside the CI builder image
> (`gitea.tourolle.paris/dtourolle/jellytau-builder:latest`). On Fedora the bind
> mount needs `:z` for SELinux, and `scripts/build-android.sh` hardcodes
> `ANDROID_HOME="$HOME/Android/Sdk"`, so the image's SDK at `/opt/android-sdk`
> must be symlinked there rather than passed by env var.
## Testing
@@ -179,7 +265,7 @@ native adapter), write the failing test first.
## TRACES
- `createAdapter``// TRACES: UR-003, UR-004 | DR-023, DR-024`
- `createAdapter``// TRACES: UR-003, UR-004 | DR-004, DR-150 | UT-149`
- Adapter-selection tests → `UT-xxx`
- No new requirement IDs; this spike either satisfies existing IR-004 expectations or documents why it cannot.
+5142 -2366
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "jellytau",
"version": "0.4.1",
"version": "0.4.8",
"description": "",
"type": "module",
"packageManager": "bun@1.3.5",
+3 -3
View File
@@ -175,8 +175,8 @@ describe("live requirements.md", () => {
expect(defined.UR).toBe(71);
expect(defined.IR).toBe(32);
expect(defined.DR).toBe(127);
expect(defined.JA).toBe(34);
expect(defined.total).toBe(264);
expect(defined.DR).toBe(148);
expect(defined.JA).toBe(35);
expect(defined.total).toBe(286);
});
});
+111
View File
@@ -0,0 +1,111 @@
#!/bin/bash
# Stamp the release version into every file that carries it.
#
# The git tag is the single source of truth for a release version. The versions
# committed in package.json / tauri.conf.json / Cargo.toml are a placeholder for
# dev builds; a tagged build overwrites all of them from the tag so they cannot
# disagree with each other or with the tag.
#
# Usage:
# ./scripts/set-version.sh 0.5.0 # explicit
# JELLYTAU_VERSION=0.5.0 ./scripts/set-version.sh
# ./scripts/set-version.sh # derive from git describe (dev builds)
#
# Accepts the version with or without a leading "v".
#
# Why a script and not four sed lines in CI: the version lived in four files and
# CI only ever rewrote one of them (tauri.conf.json), so a tagged release shipped
# a matching installer name and mismatched package metadata. Keeping the write in
# one place is what makes "the tag is authoritative" actually true.
set -euo pipefail
cd "$(dirname "$0")/.."
VERSION="${1:-${JELLYTAU_VERSION:-}}"
# CI passes "${GITHUB_REF#refs/tags/}" unconditionally, which on an untagged
# build is still a full ref ("refs/heads/master"). Treat anything that is not a
# bare version as "no version given" and fall through to git describe, so a
# branch build gets a sane dev version instead of failing the job.
case "$VERSION" in
refs/*) VERSION="" ;;
esac
if [ -z "$VERSION" ]; then
# No explicit version: derive from the most recent tag. Dev builds land on
# something like 0.5.0 (exact tag) or 0.5.0-3-gabc1234 (ahead of the tag).
VERSION="$(git describe --tags --always --match 'v*' 2>/dev/null || echo "0.0.0")"
fi
# Tags are written v0.5.0; the files carry a bare semver.
VERSION="${VERSION#v}"
# Validate before writing anything — a malformed version silently propagated
# into four files is far worse than a failed script.
if ! echo "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+([-+][0-9A-Za-z.-]+)?$'; then
echo "❌ Not a valid semver: '$VERSION'" >&2
echo " Expected MAJOR.MINOR.PATCH with an optional -prerelease/+build suffix." >&2
exit 1
fi
echo "📌 Setting version to $VERSION"
# --- The three committed manifests -----------------------------------------
# Anchored to the first "version" key so a dependency's version is never hit.
# package.json — the top-level "version", which sits in the first few lines.
perl -0pi -e 's/("version"\s*:\s*)"[^"]*"/$1"'"$VERSION"'"/' package.json
# tauri.conf.json — likewise; this is the one the bundler reads for installer
# names, and the one CI used to patch alone.
perl -0pi -e 's/("version"\s*:\s*)"[^"]*"/$1"'"$VERSION"'"/' src-tauri/tauri.conf.json
# Cargo.toml — only the [package] version, never a dependency's. Restricted to
# the first occurrence of a line-anchored `version = "..."`.
perl -0pi -e 's/^(version\s*=\s*)"[^"]*"/$1"'"$VERSION"'"/m' src-tauri/Cargo.toml
# Cargo.lock — the jellytau entry. Left alone if the lock has not been generated
# yet; the next cargo invocation writes it. Cargo would otherwise rewrite the
# lock mid-build and dirty the tree.
if [ -f src-tauri/Cargo.lock ]; then
perl -0pi -e 's/(name = "jellytau"\nversion = )"[^"]*"/$1"'"$VERSION"'"/' src-tauri/Cargo.lock
fi
# --- Android versionCode ----------------------------------------------------
# Only when the generated Android project exists (i.e. after `tauri android
# init`); on Linux/Windows jobs there is nothing to stamp.
#
# `tauri android init` derives a versionCode from the semver (0.0.15 -> 15).
# That is both tiny and NOT monotonic across our history: earlier local/dev
# builds shipped versionCode 1000 (from a 0.1.0 config), so a plain 15 is a
# *downgrade* and Android refuses the update.
#
# code = 1000 + major*10000 + minor*100 + patch
# e.g. 0.0.14 -> 1014, 0.0.15 -> 1015, 0.1.0 -> 1100, 1.0.0 -> 11000.
PROPS="src-tauri/gen/android/app/tauri.properties"
if [ -f "$PROPS" ]; then
# Strip any -rc1/+build suffix first: it is not numeric, and feeding it to
# $(( )) would abort the script under `set -e`.
CORE="${VERSION%%-*}"
CORE="${CORE%%+*}"
MAJ=$(echo "$CORE" | cut -d. -f1)
MIN=$(echo "$CORE" | cut -d. -f2)
PAT=$(echo "$CORE" | cut -d. -f3)
: "${MAJ:=0}" "${MIN:=0}" "${PAT:=0}"
CODE=$(( 1000 + MAJ*10000 + MIN*100 + PAT ))
echo " versionCode=$CODE (from $CORE)"
if grep -q '^tauri.android.versionCode=' "$PROPS"; then
sed -i "s/^tauri.android.versionCode=.*/tauri.android.versionCode=$CODE/" "$PROPS"
else
echo "tauri.android.versionCode=$CODE" >> "$PROPS"
fi
fi
# --- Report -----------------------------------------------------------------
echo "✅ Version stamped:"
grep -m1 '"version"' package.json | sed 's/^/ package.json: /'
grep -m1 '"version"' src-tauri/tauri.conf.json | sed 's/^/ tauri.conf.json: /'
grep -m1 '^version' src-tauri/Cargo.toml | sed 's/^/ Cargo.toml: /'
[ -f "$PROPS" ] && grep '^tauri.android.versionCode=' "$PROPS" | sed 's/^/ tauri.properties: /'
exit 0
+169
View File
@@ -0,0 +1,169 @@
/**
* Guards for scripts/set-version.sh the release version stamper.
*
* TRACES: DR-153 | UT-150
*
* These run the real script against a throwaway copy of the manifests, because
* the failure modes are all in the shell, not in any TS logic: a regex that also
* matches a dependency's version, arithmetic that aborts on a `-rc1` suffix, or
* a CI ref reaching the validator verbatim.
*
* The versionCode formula matters most. Android refuses an update whose code is
* lower than the installed one, and builds already in the field shipped code
* 1000 so any formula that can emit a smaller number for a *newer* release
* bricks updates for those users, silently and irreversibly.
*/
import { describe, expect, it, beforeEach, afterEach } from "vitest";
import { execFileSync } from "child_process";
import * as fs from "fs";
import * as path from "path";
import * as os from "os";
const repoRoot = path.resolve(path.dirname(new URL(import.meta.url).pathname), "..");
const script = path.join(repoRoot, "scripts", "set-version.sh");
let tmp: string;
/** A minimal repo skeleton: just the files the script rewrites. */
function seed(dir: string) {
fs.mkdirSync(path.join(dir, "src-tauri", "gen", "android", "app"), { recursive: true });
fs.mkdirSync(path.join(dir, "scripts"), { recursive: true });
fs.copyFileSync(script, path.join(dir, "scripts", "set-version.sh"));
fs.chmodSync(path.join(dir, "scripts", "set-version.sh"), 0o755);
fs.writeFileSync(
path.join(dir, "package.json"),
JSON.stringify({ name: "jellytau", version: "0.0.1", dependencies: { hls: "1.2.3" } }, null, 2)
);
fs.writeFileSync(
path.join(dir, "src-tauri", "tauri.conf.json"),
JSON.stringify({ productName: "jellytau", version: "0.0.1" }, null, 2)
);
// A dependency carrying its own `version =` is the trap: a greedy regex
// rewrites it too and the build then resolves the wrong crate.
fs.writeFileSync(
path.join(dir, "src-tauri", "Cargo.toml"),
['[package]', 'name = "jellytau"', 'version = "0.0.1"', '', '[dependencies]', 'serde = { version = "1.0.100" }', ''].join("\n")
);
fs.writeFileSync(
path.join(dir, "src-tauri", "Cargo.lock"),
['[[package]]', 'name = "serde"', 'version = "1.0.100"', '', '[[package]]', 'name = "jellytau"', 'version = "0.0.1"', ''].join("\n")
);
fs.writeFileSync(
path.join(dir, "src-tauri", "gen", "android", "app", "tauri.properties"),
"tauri.android.versionCode=1\n"
);
}
function run(version: string, dir = tmp) {
return execFileSync("bash", [path.join(dir, "scripts", "set-version.sh"), version], {
cwd: dir,
encoding: "utf-8",
});
}
function read(rel: string): string {
return fs.readFileSync(path.join(tmp, rel), "utf-8");
}
function versionCode(): number {
const m = read("src-tauri/gen/android/app/tauri.properties").match(
/^tauri\.android\.versionCode=(\d+)$/m
);
return m ? Number(m[1]) : NaN;
}
beforeEach(() => {
tmp = fs.mkdtempSync(path.join(os.tmpdir(), "setversion-"));
seed(tmp);
});
afterEach(() => {
fs.rmSync(tmp, { recursive: true, force: true });
});
describe("set-version.sh", () => {
it("stamps the version into all four manifests", () => {
run("0.5.0");
expect(JSON.parse(read("package.json")).version).toBe("0.5.0");
expect(JSON.parse(read("src-tauri/tauri.conf.json")).version).toBe("0.5.0");
expect(read("src-tauri/Cargo.toml")).toContain('version = "0.5.0"');
expect(read("src-tauri/Cargo.lock")).toMatch(/name = "jellytau"\nversion = "0\.5\.0"/);
});
it("accepts a leading v, as git tags are written", () => {
run("v0.5.0");
expect(JSON.parse(read("package.json")).version).toBe("0.5.0");
});
// The regression that motivates anchoring the patterns.
it("does not rewrite dependency versions", () => {
run("0.5.0");
expect(read("src-tauri/Cargo.toml")).toContain('serde = { version = "1.0.100" }');
expect(read("src-tauri/Cargo.lock")).toMatch(/name = "serde"\nversion = "1\.0\.100"/);
expect(JSON.parse(read("package.json")).dependencies.hls).toBe("1.2.3");
});
describe("Android versionCode", () => {
// Codes below 1000 are already in the field; a newer release must never
// produce a smaller number than an older one.
it("clears the 1000 floor shipped by earlier builds", () => {
run("0.0.1");
expect(versionCode()).toBeGreaterThan(1000);
});
it("uses 1000 + major*10000 + minor*100 + patch", () => {
const cases: Array<[string, number]> = [
["0.0.14", 1014],
["0.0.15", 1015],
["0.1.0", 1100],
["0.4.8", 1408],
["0.5.0", 1500],
["1.0.0", 11000],
];
for (const [version, code] of cases) {
seed(tmp);
run(version);
expect(versionCode(), `versionCode for ${version}`).toBe(code);
}
});
it("increases monotonically across an upgrade sequence", () => {
const ordered = ["0.0.14", "0.0.15", "0.1.0", "0.4.8", "0.5.0", "1.0.0"];
const codes = ordered.map((v) => {
seed(tmp);
run(v);
return versionCode();
});
const sorted = [...codes].sort((a, b) => a - b);
expect(codes).toEqual(sorted);
expect(new Set(codes).size).toBe(codes.length);
});
// `$(( 0-rc1 ))` aborts the script under `set -e`, so the suffix has to be
// stripped before the arithmetic.
it("derives the code from the numeric core of a prerelease", () => {
run("0.6.0-rc1");
expect(versionCode()).toBe(1600);
expect(JSON.parse(read("package.json")).version).toBe("0.6.0-rc1");
});
});
describe("input validation", () => {
it("rejects a malformed version without writing anything", () => {
expect(() => run("not-a-version")).toThrow();
// The manifests must be untouched, not half-written.
expect(JSON.parse(read("package.json")).version).toBe("0.0.1");
expect(JSON.parse(read("src-tauri/tauri.conf.json")).version).toBe("0.0.1");
});
// CI passes "${GITHUB_REF#refs/tags/}" unconditionally; on a branch build
// that is still a full ref, and must not fail the job.
it("falls back to a dev version when handed a non-tag ref", () => {
const out = run("refs/heads/master");
expect(out).not.toMatch(/refs\/heads/);
expect(JSON.parse(read("package.json")).version).not.toBe("0.0.1");
});
});
});
+10
View File
@@ -87,6 +87,16 @@ if [ -d "$RES_SRC" ]; then
cp "$RES_SRC"/values/*.xml "$RES_DST/values/"
echo " Copied res: values"
fi
# xml/ (network_security_config.xml): referenced from the manifest, so a
# missing copy fails the resource link rather than degrading quietly.
# Merged into Tauri's generated xml/ (which holds file_paths.xml) rather
# than replacing it.
if [ -d "$RES_SRC/xml" ]; then
mkdir -p "$RES_DST/xml"
cp "$RES_SRC"/xml/*.xml "$RES_DST/xml/"
echo " Copied res: xml"
fi
# We ship only the color adaptive icon (background + foreground). Drop any
# monochrome layer Tauri may generate: the themed-icon monochrome doesn't
# render well, and our adaptive-icon xml no longer references it, so a stray
+39 -1
View File
@@ -150,6 +150,12 @@ version = "1.0.100"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
[[package]]
name = "ascii"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16"
[[package]]
name = "async-broadcast"
version = "0.7.2"
@@ -552,6 +558,12 @@ dependencies = [
"windows-link 0.2.1",
]
[[package]]
name = "chunked_transfer"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901"
[[package]]
name = "cipher"
version = "0.4.4"
@@ -1671,12 +1683,24 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "http-range"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "21dec9db110f5f872ed9699c3ecf50cf16f423502706ba5c72462e28d3157573"
[[package]]
name = "httparse"
version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
[[package]]
name = "httpdate"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]]
name = "hyper"
version = "1.8.1"
@@ -1994,7 +2018,7 @@ dependencies = [
[[package]]
name = "jellytau"
version = "0.4.1"
version = "0.4.8"
dependencies = [
"aes-gcm",
"async-trait",
@@ -2025,6 +2049,7 @@ dependencies = [
"tauri-plugin-os",
"tauri-specta",
"tempfile",
"tiny_http",
"tokio",
"tokio-rusqlite",
"tokio-util",
@@ -4192,6 +4217,7 @@ dependencies = [
"gtk",
"heck 0.5.0",
"http",
"http-range",
"jni",
"libc",
"log",
@@ -4571,6 +4597,18 @@ dependencies = [
"time-core",
]
[[package]]
name = "tiny_http"
version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "389915df6413a2e74fb181895f933386023c71110878cd0825588928e64cdc82"
dependencies = [
"ascii",
"chunked_transfer",
"httpdate",
"log",
]
[[package]]
name = "tinystr"
version = "0.8.2"
+8 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "jellytau"
version = "0.4.1"
version = "0.4.8"
description = "A Tauri App"
authors = ["you"]
edition = "2021"
@@ -23,7 +23,12 @@ debug = "line-tables-only"
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
# protocol-asset serves downloaded media and cached thumbnails to the webview
# over http://asset.localhost; without it convertFileSrc yields a URL nothing
# answers. Paired with app.security.assetProtocol in tauri.conf.json, which
# scopes it to $APPDATA/**.
# TRACES: UR-071 | DR-134
tauri = { version = "2", features = ["protocol-asset"] }
tauri-plugin-opener = "2"
tauri-plugin-os = "2"
serde = { version = "1", features = ["derive"] }
@@ -54,6 +59,7 @@ env_logger = "0.11"
tauri-specta = { version = "=2.0.0-rc.21", features = ["derive", "typescript"] }
specta-typescript = "=0.0.9"
specta = { version = "=2.0.0-rc.22", features = ["chrono", "derive"] }
tiny_http = { version = "0.12.0", default-features = false }
# Linux-specific dependencies
[target.'cfg(target_os = "linux")'.dependencies]
@@ -29,6 +29,7 @@
android:label="@string/app_name"
android:theme="@style/Theme.jellytau"
android:hardwareAccelerated="true"
android:networkSecurityConfig="@xml/network_security_config"
android:usesCleartextTraffic="${usesCleartextTraffic}">
<activity
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|density"
@@ -64,6 +64,20 @@ class MainActivity : TauriActivity() {
// so on devices with a tall opaque 3-button bar. (UR-066)
WindowInsetsBridge.install(this)
// Hand the player an Activity reference so it can attach its video
// SurfaceView to the content view behind the WebView.
//
// Without this, JellyTauPlayer.currentActivity stays null forever and
// autoAttachSurface() logs "Cannot attach surface - no Activity reference"
// and returns — so the SurfaceView is created, wired to ExoPlayer, and then
// never added to the view hierarchy. Native video decoded to a surface that
// was never on screen. setActivity() stores into a companion-object
// WeakReference, so calling it here (before Rust initializes the player over
// JNI) is safe and is the case it was written for.
//
// TRACES: UR-003, UR-041 | DR-151
com.dtourolle.jellytau.player.JellyTauPlayer.setActivity(this)
// Configure WebView for media playback after Tauri initialization
handler.postDelayed({
configureWebViewForMedia()
@@ -271,6 +285,46 @@ class MainActivity : TauriActivity() {
}, "AndroidNetworkType")
android.util.Log.d("MainActivity", "JavaScript interface 'AndroidNetworkType' added")
// Native video compositing: let the frontend make the WebView transparent
// so the ExoPlayer SurfaceView behind it is visible (UR-003, UR-004).
//
// Toggled rather than set once because a transparent WebView is only
// correct while a native video is on screen — every other screen needs its
// opaque background, and leaving the window transparent shows the
// launcher/wallpaper through the app.
//
// The CSS in app.css clears the *web* layer's backgrounds; this clears the
// WebView widget's own background, which CSS cannot reach. Both are
// required — an opaque WebView hides the surface no matter what the page
// paints.
//
// TRACES: UR-003, UR-004 | DR-150
webView.addJavascriptInterface(object : Any() {
/** Make the WebView background transparent (true) or opaque (false). */
@JavascriptInterface
fun setTransparent(transparent: Boolean) {
handler.post {
val color = if (transparent) {
android.graphics.Color.TRANSPARENT
} else {
android.graphics.Color.BLACK
}
mediaWebView?.setBackgroundColor(color)
// The WebView's window/surface must also stop painting opaque, or a
// hardware-accelerated WebView still composites its own background.
window.setBackgroundDrawable(
android.graphics.drawable.ColorDrawable(color)
)
android.util.Log.d("MainActivity", "WebView transparent = $transparent")
}
}
/** Whether native-video compositing is available on this platform. */
@JavascriptInterface
fun isSupported(): Boolean = true
}, "AndroidVideoSurface")
android.util.Log.d("MainActivity", "JavaScript interface 'AndroidVideoSurface' added")
// Window insets (safe areas). The push path above races the page load, so
// the frontend pulls the current values on mount through this bridge.
webView.addJavascriptInterface(WindowInsetsBridge.jsInterface(), "AndroidInsets")
@@ -1,7 +1,11 @@
package com.dtourolle.jellytau.player
import android.content.Context
import android.media.MediaCodecList
import android.util.Log
import androidx.media3.common.AudioAttributes
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.audio.AudioCapabilities
/**
* Detects hardware codec capabilities using MediaCodecList.
@@ -75,6 +79,36 @@ object CodecDetector {
)
}
/**
* Report how many channels the *current audio output route* can voice.
*
* This is a different question from "can this device decode 5.1", which the
* codec list already answers: a phone decodes a 5.1 AC-3 track happily and
* still has only two channels to play it out of. Left unreported, Jellyfin
* is free to direct-play the multichannel track, and what the user hears is
* device dependent a failed AudioSink configuration (silence) or dialogue
* folded into the surround channels and lost.
*
* Returns 0 when there is no answer; Rust reads that as "unknown" and falls
* back to stereo rather than claiming a capability we have not observed.
*/
@UnstableApi
fun detectMaxAudioChannels(context: Context): Int {
return try {
val capabilities = AudioCapabilities.getCapabilities(
context,
AudioAttributes.DEFAULT,
/* routedDevice= */ null
)
val channels = capabilities.maxChannelCount
Log.i(TAG, "Audio route max channel count: $channels")
channels.coerceAtLeast(0)
} catch (e: Exception) {
Log.e(TAG, "Error querying audio capabilities", e)
0
}
}
/**
* Map Android MIME types to Jellyfin codec names.
*
@@ -130,7 +130,7 @@ class JellyTauPlayer(private val appContext: Context) {
}
// Detect and report hardware codec capabilities to Rust
detectAndReportCodecs()
detectAndReportCodecs(context.applicationContext)
}
}
}
@@ -141,23 +141,31 @@ class JellyTauPlayer(private val appContext: Context) {
* Called during player initialization.
*/
@JvmStatic
fun detectAndReportCodecs() {
fun detectAndReportCodecs(context: Context) {
val capabilities = CodecDetector.detectHardwareCodecs()
// Convert lists to comma-separated strings for JNI transfer
val videoCodecsStr = capabilities.videoCodecs.joinToString(",")
val audioCodecsStr = capabilities.audioCodecs.joinToString(",")
android.util.Log.i("JellyTauPlayer", "Reporting codecs to Rust: video=$videoCodecsStr, audio=$audioCodecsStr")
// What the route can *voice*, which the codec list does not answer.
// 0 means "no answer"; Rust falls back to stereo.
val maxAudioChannels = CodecDetector.detectMaxAudioChannels(context)
android.util.Log.i("JellyTauPlayer", "Reporting codecs to Rust: video=$videoCodecsStr, audio=$audioCodecsStr, maxAudioChannels=$maxAudioChannels")
// Call native method to store in Rust
nativeOnCodecsDetected(videoCodecsStr, audioCodecsStr)
nativeOnCodecsDetected(videoCodecsStr, audioCodecsStr, maxAudioChannels)
}
/**
* Native method to report detected codecs to Rust.
*/
private external fun nativeOnCodecsDetected(videoCodecs: String, audioCodecs: String)
private external fun nativeOnCodecsDetected(
videoCodecs: String,
audioCodecs: String,
maxAudioChannels: Int
)
/**
* Check if the player is initialized.
@@ -232,6 +240,20 @@ class JellyTauPlayer(private val appContext: Context) {
}
private var audioFocusRequest: AudioFocusRequest? = null
/**
* Set when a video was loaded but audio focus was not granted outright.
*
* `setAcceptsDelayedFocusGain(true)` means the system may answer DELAYED and
* hand us focus later; until then it withholds our audio. Starting playback
* anyway plays the video silently, which is exactly the "video has no sound"
* symptom. We hold playback and start it from the AUDIOFOCUS_GAIN callback.
*/
private var pendingPlayOnFocusGain = false
/** Whether we currently hold audio focus, so `play()` does not re-request
* (and leak) a focus request we already own. */
private var hasAudioFocus = false
init {
// Configure audio attributes for music playback with audio focus handling
val audioAttributes = AudioAttributes.Builder()
@@ -361,29 +383,61 @@ class JellyTauPlayer(private val appContext: Context) {
android.util.Log.d("JellyTauPlayer", " Video group: ${group.length} tracks, selected=${group.isSelected}")
}
// CRITICAL FIX: Auto-select first audio track if none is selected
// This fixes the issue where some videos play without audio
// TRACES: UR-004 | DR-146
// Auto-select an audio track if none is selected — some videos
// otherwise play with no sound. Pick a track the renderer says it
// *supports*: when nothing was selected because track 0 failed to
// initialize, forcing track 0 again just reinstates the silence.
if (!hasSelectedAudio && audioTracks.isNotEmpty() && currentMediaType == MediaType.VIDEO) {
android.util.Log.w("JellyTauPlayer", "⚠️ NO AUDIO TRACK SELECTED! Auto-selecting first audio track...")
android.util.Log.w("JellyTauPlayer", "⚠️ NO AUDIO TRACK SELECTED! Looking for a supported audio track...")
val trackSelector = exoPlayer.trackSelector
if (trackSelector != null) {
try {
// Select the first audio track group
val firstAudioGroup = audioTracks[0]
val override = androidx.media3.common.TrackSelectionOverride(
firstAudioGroup.mediaTrackGroup,
0 // Select the first track in this group
)
var chosenGroup: androidx.media3.common.Tracks.Group? = null
var chosenIndex = -1
outer@ for (group in audioTracks) {
for (i in 0 until group.length) {
if (group.isTrackSupported(i)) {
chosenGroup = group
chosenIndex = i
break@outer
}
}
}
val parameters = trackSelector.parameters
.buildUpon()
.clearOverridesOfType(C.TRACK_TYPE_AUDIO)
.addOverride(override)
.build()
if (chosenGroup == null) {
// Every track is undecodable on this device. The
// server should have transcoded; say so loudly
// rather than leaving a silent video unexplained.
android.util.Log.e(
"JellyTauPlayer",
"✗ No supported audio track in ${audioTracks.size} group(s) - " +
"device cannot decode any of them, expected a transcode"
)
} else {
val format = chosenGroup.getTrackFormat(chosenIndex)
val override = androidx.media3.common.TrackSelectionOverride(
chosenGroup.mediaTrackGroup,
chosenIndex
)
trackSelector.setParameters(parameters)
android.util.Log.d("JellyTauPlayer", "✓ Auto-selected first audio track")
val parameters = trackSelector.parameters
.buildUpon()
// Audio may also be off because the track type
// was disabled; an override alone would not
// bring it back.
.setTrackTypeDisabled(C.TRACK_TYPE_AUDIO, false)
.clearOverridesOfType(C.TRACK_TYPE_AUDIO)
.addOverride(override)
.build()
trackSelector.setParameters(parameters)
android.util.Log.d(
"JellyTauPlayer",
"✓ Auto-selected supported audio track $chosenIndex (${format.sampleMimeType}, ${format.channelCount}ch)"
)
}
} catch (e: Exception) {
android.util.Log.e("JellyTauPlayer", "Failed to auto-select audio track", e)
}
@@ -423,6 +477,15 @@ class JellyTauPlayer(private val appContext: Context) {
*/
fun play() {
mainHandler.post {
// Video manages focus by hand, so an explicit play after a refusal (or
// after a LOSS paused us) has to ask again — otherwise it resumes into
// a stream the system is still muting.
if (currentMediaType == MediaType.VIDEO && !hasAudioFocus && !requestAudioFocus()) {
pendingPlayOnFocusGain = true
android.util.Log.d("JellyTauPlayer", "play() without audio focus - holding until GAIN")
return@post
}
pendingPlayOnFocusGain = false
exoPlayer.play()
}
}
@@ -811,14 +874,17 @@ class JellyTauPlayer(private val appContext: Context) {
android.util.Log.d("JellyTauPlayer", "ExoPlayer audio session ID: ${exoPlayer.audioSessionId}")
// Setup video surface if needed
var focusGranted = true
if (currentMediaType == MediaType.VIDEO) {
getOrCreateSurfaceView()
android.util.Log.d("JellyTauPlayer", "Video surface created for playback")
// Automatically attach the surface to the Activity
autoAttachSurface()
// CRITICAL: Request audio focus for video playback
requestAudioFocus()
// CRITICAL: Request audio focus for video playback. Video manages
// focus by hand (handleAudioFocus=false above), so nothing else
// will hold playback back if the request is delayed or refused.
focusGranted = requestAudioFocus()
} else {
clearVideoSurface()
// Abandon audio focus when switching to audio (audio uses ExoPlayer's built-in handling)
@@ -888,8 +954,13 @@ class JellyTauPlayer(private val appContext: Context) {
}
android.util.Log.d("JellyTauPlayer", "✓ Current volume: ${exoPlayer.volume}, deviceVolume: ${audioManager.getStreamVolume(AudioManager.STREAM_MUSIC)}/${audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC)}")
exoPlayer.playWhenReady = true
android.util.Log.d("JellyTauPlayer", "playWhenReady set to TRUE. Current state: ${exoPlayer.playbackState}")
// Only roll if we hold audio focus. A DELAYED grant means the system
// is withholding our audio until it calls back with AUDIOFOCUS_GAIN;
// playing through it produces picture with no sound. Playback resumes
// from the focus listener instead.
pendingPlayOnFocusGain = !focusGranted
exoPlayer.playWhenReady = focusGranted
android.util.Log.d("JellyTauPlayer", "playWhenReady set to $focusGranted (pendingPlayOnFocusGain=$pendingPlayOnFocusGain). Current state: ${exoPlayer.playbackState}")
// Start the foreground service for lockscreen controls
startPlaybackService()
@@ -1146,8 +1217,14 @@ class JellyTauPlayer(private val appContext: Context) {
/**
* Request audio focus for video playback.
* This is critical for video to have audio on Android.
*
* TRACES: UR-004 | DR-145
*
* @return true if focus was granted outright and playback may start now.
* false for a DELAYED or refused request the caller must hold playback
* and let the AUDIOFOCUS_GAIN callback start it, or the video plays mute.
*/
private fun requestAudioFocus() {
private fun requestAudioFocus(): Boolean {
android.util.Log.d("JellyTauPlayer", "Requesting audio focus for video playback")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
@@ -1164,17 +1241,29 @@ class JellyTauPlayer(private val appContext: Context) {
when (focusChange) {
AudioManager.AUDIOFOCUS_GAIN -> {
android.util.Log.d("JellyTauPlayer", "✓ Audio focus GAINED - ensuring full volume")
hasAudioFocus = true
if (exoPlayer.volume < 1.0f) {
exoPlayer.volume = 1.0f
android.util.Log.d("JellyTauPlayer", " Volume restored to 1.0 from ${exoPlayer.volume}")
}
// A delayed grant arriving: this is the point at which
// the video may actually be heard, so start it now.
if (pendingPlayOnFocusGain) {
pendingPlayOnFocusGain = false
android.util.Log.d("JellyTauPlayer", " Delayed focus granted - starting held playback")
exoPlayer.playWhenReady = true
}
}
AudioManager.AUDIOFOCUS_LOSS -> {
android.util.Log.d("JellyTauPlayer", "Audio focus LOST - pausing")
hasAudioFocus = false
pendingPlayOnFocusGain = false
pause()
}
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> {
android.util.Log.d("JellyTauPlayer", "Audio focus LOST TRANSIENT - pausing")
hasAudioFocus = false
pendingPlayOnFocusGain = false
pause()
}
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> {
@@ -1185,16 +1274,25 @@ class JellyTauPlayer(private val appContext: Context) {
}
.build()
val result = audioManager.requestAudioFocus(audioFocusRequest!!)
when (result) {
return when (val result = audioManager.requestAudioFocus(audioFocusRequest!!)) {
AudioManager.AUDIOFOCUS_REQUEST_GRANTED -> {
android.util.Log.d("JellyTauPlayer", "✓ Audio focus GRANTED")
hasAudioFocus = true
true
}
AudioManager.AUDIOFOCUS_REQUEST_FAILED -> {
android.util.Log.e("JellyTauPlayer", "✗ Audio focus REQUEST FAILED!")
// Something holds exclusive focus (a call, say). Playing now
// would be a silent video, so hold and wait for the grant.
android.util.Log.e("JellyTauPlayer", "✗ Audio focus REQUEST FAILED - holding playback")
false
}
AudioManager.AUDIOFOCUS_REQUEST_DELAYED -> {
android.util.Log.d("JellyTauPlayer", "⏳ Audio focus DELAYED")
android.util.Log.d("JellyTauPlayer", "⏳ Audio focus DELAYED - holding playback until GAIN")
false
}
else -> {
android.util.Log.w("JellyTauPlayer", "Unknown audio focus result: $result - holding playback")
false
}
}
} else {
@@ -1206,10 +1304,15 @@ class JellyTauPlayer(private val appContext: Context) {
AudioManager.STREAM_MUSIC,
AudioManager.AUDIOFOCUS_GAIN
)
if (result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED) {
return if (result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED) {
android.util.Log.d("JellyTauPlayer", "✓ Audio focus GRANTED (legacy)")
hasAudioFocus = true
true
} else {
// Pre-O has no delayed grant and no listener to resume from, so a
// refusal is terminal for this attempt; the user can hit play again.
android.util.Log.e("JellyTauPlayer", "✗ Audio focus REQUEST FAILED (legacy)!")
false
}
}
}
@@ -1220,6 +1323,11 @@ class JellyTauPlayer(private val appContext: Context) {
private fun abandonAudioFocus() {
android.util.Log.d("JellyTauPlayer", "Abandoning audio focus")
// No focus, nothing to resume: a stale flag would start playback the next
// time some unrelated GAIN arrives.
pendingPlayOnFocusGain = false
hasAudioFocus = false
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
audioFocusRequest?.let {
val result = audioManager.abandonAudioFocusRequest(it)
@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Cleartext HTTP stays blocked everywhere except loopback.
Downloaded media is served to the webview by a local HTTP server on
127.0.0.1 (see media_server.rs / DR-137). Release builds set
usesCleartextTraffic="false", so Android's network security policy rejected
those requests before any I/O happened and offline video failed instantly with
NETWORK_NO_SOURCE.
Only 127.0.0.1 is exempted. The base config keeps the release default, so a
remote server still has to be HTTPS — this must not become a blanket
cleartext opt-in.
TRACES: UR-071 | DR-138
-->
<network-security-config>
<base-config cleartextTrafficPermitted="false" />
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="false">127.0.0.1</domain>
</domain-config>
</network-security-config>
+240 -6
View File
@@ -106,6 +106,14 @@ const CATALOG_ITEM_TYPES: &[&str] = &[
"Playlist",
];
/// Jellyfin item types whose download is a *video* stream rather than an audio
/// one. The download queue stores an opaque `media_type` ('audio'/'video'); this
/// is where the taxonomy that produces it lives, so the frontend never has to
/// know which item types are video.
///
/// TRACES: UR-071 | DR-135
const VIDEO_ITEM_TYPES: &[&str] = &["Movie", "Episode", "Video", "MusicVideo"];
#[derive(specta::Type, Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CatalogSyncResult {
@@ -463,6 +471,51 @@ pub struct ResumeQueuedResult {
pub failed: usize,
}
/// Requeue video downloads that were fetched as audio.
///
/// Before [`resolve_pending_download_urls`] consulted the item's type, a row
/// with no `media_type` — which is every row queued from a media card, since
/// `download_item` does not record one — resolved against
/// `get_audio_stream_url`. A movie queued that way completed with an audio-only
/// transcode on disk, so playing it offline could only ever fail. Those rows are
/// identifiable after the fact (no `media_type`, but a video item), so reset them
/// to pending with no URL and let the resolver fetch the real video.
///
/// Rows carrying an explicit `media_type` were resolved correctly and are left
/// alone, as are genuine audio downloads.
///
/// Returns the number of rows requeued.
///
/// TRACES: UR-071 | DR-136 | UT-126
pub(crate) async fn requeue_mistyped_video_downloads(
db_service: &Arc<crate::storage::db_service::RusqliteService>,
) -> Result<usize, String> {
let video_types = VIDEO_ITEM_TYPES
.iter()
.map(|t| format!("'{t}'"))
.collect::<Vec<_>>()
.join(", ");
let query = Query::new(&format!(
"UPDATE downloads
SET status = 'pending', stream_url = NULL, progress = 0,
bytes_downloaded = 0, started_at = NULL, completed_at = NULL
WHERE media_type IS NULL
AND status = 'completed'
AND item_id IN (SELECT id FROM items WHERE item_type IN ({video_types}))"
));
let n = db_service.execute(query).await.map_err(|e| e.to_string())? as usize;
if n > 0 {
info!(
"[Catalog] Requeued {} video download(s) that were fetched as audio",
n
);
}
Ok(n)
}
/// Core of [`resume_queued_downloads`], factored out for testing: select every
/// `pending`/`stream_url IS NULL` row, resolve each via `resolve` (returning
/// `None` leaves the row pending), and heal the row so the pump can start it.
@@ -476,11 +529,31 @@ where
F: Fn(String, String, String) -> Fut,
Fut: std::future::Future<Output = Option<String>>,
{
let rows_query = Query::new(
"SELECT id, item_id, COALESCE(media_type, 'audio'), COALESCE(quality_preset, 'original')
FROM downloads
WHERE status = 'pending' AND stream_url IS NULL",
);
// A row's own media_type wins; otherwise the *item's* type decides. Rows
// queued from a media card never carry one (`download_item` does not record
// it), and defaulting that NULL to 'audio' resolved movies against
// `get_audio_stream_url` — the file on disk was an audio-only transcode, so
// offline video could never play. Falling back to 'audio' only when the item
// is unknown keeps the historical behaviour for uncached items.
// TRACES: UR-071, UR-052 | DR-135
let video_types = VIDEO_ITEM_TYPES
.iter()
.map(|t| format!("'{t}'"))
.collect::<Vec<_>>()
.join(", ");
let rows_query = Query::new(&format!(
"SELECT d.id, d.item_id,
COALESCE(
d.media_type,
CASE WHEN i.item_type IN ({video_types}) THEN 'video'
WHEN i.item_type IS NOT NULL THEN 'audio'
END,
'audio'),
COALESCE(d.quality_preset, 'original')
FROM downloads d
LEFT JOIN items i ON i.id = d.item_id
WHERE d.status = 'pending' AND d.stream_url IS NULL"
));
let rows: Vec<(i64, String, String, String)> = db_service
.query_many(rows_query, |row| {
Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?))
@@ -590,6 +663,16 @@ pub async fn resume_queued_downloads(
Err(e) => warn!("[Catalog] Failed to reset stale downloads: {}", e),
}
// Repair rows that completed as audio because their media_type was missing;
// they hold an audio-only transcode where a video should be, so requeue them
// for the resolver below. TRACES: UR-071 | DR-136
if let Err(e) = requeue_mistyped_video_downloads(&db_service).await {
warn!(
"[Catalog] Failed to requeue mis-typed video downloads: {}",
e
);
}
// Resolve each row's URL against the (now reachable) repository.
let repo_for_resolve = Arc::clone(&repo);
let outcome = resolve_pending_download_urls(
@@ -699,7 +782,15 @@ mod tests {
stream_url TEXT,
target_dir TEXT,
media_type TEXT,
quality_preset TEXT
quality_preset TEXT,
progress REAL DEFAULT 0,
bytes_downloaded INTEGER DEFAULT 0,
started_at TEXT,
completed_at TEXT
);
CREATE TABLE items (
id TEXT PRIMARY KEY,
item_type TEXT
);
"#,
)
@@ -707,6 +798,18 @@ mod tests {
Arc::new(RusqliteService::new(Arc::new(Mutex::new(conn))))
}
async fn insert_item(db: &Arc<RusqliteService>, item_id: &str, item_type: &str) {
db.execute(Query::with_params(
"INSERT INTO items (id, item_type) VALUES (?, ?)",
vec![
QueryParam::String(item_id.to_string()),
QueryParam::String(item_type.to_string()),
],
))
.await
.unwrap();
}
async fn insert_download(
db: &Arc<RusqliteService>,
item_id: &str,
@@ -799,6 +902,137 @@ mod tests {
assert_eq!(url, None);
}
/// A movie queued from a media card has no `media_type` — `download_item`
/// never records one. Defaulting that NULL to 'audio' resolved the row
/// against `get_audio_stream_url`, so the "downloaded movie" on disk was an
/// audio-only transcode and offline video playback could never work. The
/// item's own type is the authority.
///
/// TRACES: UR-071, UR-052 | DR-135 | UT-125
#[tokio::test]
async fn null_media_type_resolves_from_the_item_type_not_audio() {
let db = test_db();
insert_item(&db, "movie-1", "Movie").await;
insert_item(&db, "ep-1", "Episode").await;
insert_item(&db, "track-1", "Audio").await;
for id in ["movie-1", "ep-1", "track-1"] {
insert_download(&db, id, "pending", None, None).await;
}
let seen = Arc::new(Mutex::new(Vec::new()));
let seen_c = Arc::clone(&seen);
resolve_pending_download_urls(&db, "/data", move |item_id, media_type, _q| {
let seen = Arc::clone(&seen_c);
async move {
seen.lock().unwrap().push((item_id.clone(), media_type));
Some(format!("http://resolved/{item_id}"))
}
})
.await
.unwrap();
let seen = seen.lock().unwrap().clone();
let of = |id: &str| {
seen.iter()
.find(|(i, _)| i == id)
.map(|(_, m)| m.clone())
.unwrap()
};
assert_eq!(of("movie-1"), "video", "a Movie must download as video");
assert_eq!(of("ep-1"), "video", "an Episode must download as video");
assert_eq!(of("track-1"), "audio", "a track is still audio");
}
/// An unknown item (never cached locally) has no type to derive from, so it
/// keeps the historical audio default rather than failing the row.
///
/// TRACES: UR-071 | DR-135 | UT-125
#[tokio::test]
async fn unknown_item_falls_back_to_audio() {
let db = test_db();
insert_download(&db, "ghost", "pending", None, None).await;
let seen = Arc::new(Mutex::new(String::new()));
let seen_c = Arc::clone(&seen);
resolve_pending_download_urls(&db, "/data", move |_id, media_type, _q| {
let seen = Arc::clone(&seen_c);
async move {
*seen.lock().unwrap() = media_type;
Some("http://x".to_string())
}
})
.await
.unwrap();
assert_eq!(*seen.lock().unwrap(), "audio");
}
/// An explicit `media_type` on the row always wins over the item's type.
///
/// TRACES: UR-071 | DR-135 | UT-125
#[tokio::test]
async fn explicit_media_type_beats_the_item_type() {
let db = test_db();
insert_item(&db, "odd", "Audio").await;
insert_download(&db, "odd", "pending", None, Some("video")).await;
let seen = Arc::new(Mutex::new(String::new()));
let seen_c = Arc::clone(&seen);
resolve_pending_download_urls(&db, "/data", move |_id, media_type, _q| {
let seen = Arc::clone(&seen_c);
async move {
*seen.lock().unwrap() = media_type;
Some("http://x".to_string())
}
})
.await
.unwrap();
assert_eq!(*seen.lock().unwrap(), "video");
}
/// Rows already downloaded under the audio default hold an audio-only
/// transcode on disk, so they play as a broken video forever. They are
/// identifiable — no `media_type` but a video item — and are requeued so the
/// resolver fetches the real video. Correctly-typed rows and genuine audio
/// downloads must be left alone.
///
/// TRACES: UR-071 | DR-136 | UT-126
#[tokio::test]
async fn requeues_video_downloaded_under_the_audio_default() {
let db = test_db();
insert_item(&db, "movie-1", "Movie").await;
insert_item(&db, "track-1", "Audio").await;
insert_item(&db, "movie-ok", "Movie").await;
// Mis-downloaded: completed, no media_type, video item.
insert_download(&db, "movie-1", "completed", Some("http://audio/url"), None).await;
// A real audio download: untouched.
insert_download(&db, "track-1", "completed", Some("http://audio/ok"), None).await;
// A correctly-typed video download: untouched.
insert_download(
&db,
"movie-ok",
"completed",
Some("http://video/ok"),
Some("video"),
)
.await;
let requeued = requeue_mistyped_video_downloads(&db).await.unwrap();
assert_eq!(requeued, 1);
let (status, url, _t) = get_row(&db, "movie-1").await;
assert_eq!(status, "pending", "the mis-typed row must download again");
assert_eq!(url, None, "its audio URL must be cleared so it re-resolves");
let (status, url, _t) = get_row(&db, "track-1").await;
assert_eq!(status, "completed", "a real audio download is untouched");
assert_eq!(url.as_deref(), Some("http://audio/ok"));
let (status, _u, _t) = get_row(&db, "movie-ok").await;
assert_eq!(status, "completed", "a correct video download is untouched");
}
#[tokio::test]
async fn video_rows_use_media_type_in_resolver() {
let db = test_db();
+2
View File
@@ -17,6 +17,7 @@ pub mod repository;
pub mod sessions;
pub mod storage;
pub mod sync;
pub mod sync_drain;
pub use auth::*;
pub use catalog::*;
@@ -34,3 +35,4 @@ pub use repository::{RepositoryManager, RepositoryManagerWrapper, *};
pub use sessions::*;
pub use storage::*;
pub use sync::*;
pub use sync_drain::*;
+197 -1
View File
@@ -202,6 +202,27 @@ pub struct PlayItemRequest {
/// look up the next episode when a background-audio track ends.
#[serde(default)]
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?
@@ -373,7 +394,10 @@ pub(super) async fn create_media_item(
needs_transcoding: req.needs_transcoding,
video_width: 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
server_id: None, // Not available from video-only request
})
@@ -1630,6 +1654,43 @@ pub async fn player_get_queue(
Ok(get_queue_status(&controller))
}
/// What playback facilities this platform's backend actually provides.
///
/// The frontend is presentation-only and must not re-derive backend facts from
/// `navigator.userAgent` — that sniffing was a second copy of the same platform
/// decision Rust already makes with `cfg!`, and it drifted. These flags are the
/// single source of truth; the frontend consumes them.
///
/// TRACES: UR-003, UR-005 | DR-004, DR-023, DR-024
#[derive(specta::Type, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PlaybackCapabilities {
/// True when audio is rendered by a webview `<audio>` element rather than a
/// native backend. Native audio exists on Linux (mpv) and Android
/// (ExoPlayer); everything else (Windows, future desktops) uses the webview.
pub uses_webview_audio: bool,
/// True when video can be rendered by a native surface composited *behind*
/// a transparent webview. Android only: ExoPlayer draws into a SurfaceView
/// beneath the WebView. Linux cannot do this (WebKitGTK/Wayland
/// compositing), so it stays on the HTML5 element.
pub supports_native_video: bool,
}
/// Report this platform's playback capabilities to the frontend.
///
/// TRACES: UR-003, UR-005 | DR-004, DR-023, DR-024
#[tauri::command]
#[specta::specta]
pub async fn player_get_capabilities() -> Result<PlaybackCapabilities, String> {
// Mirrors the cfg gates the backends themselves are built under.
let native_audio = cfg!(any(target_os = "android", target_os = "linux"));
Ok(PlaybackCapabilities {
uses_webview_audio: !native_audio,
supports_native_video: cfg!(target_os = "android"),
})
}
pub(super) fn get_player_status(controller: &PlayerController) -> PlayerStatus {
// Determine backend at compile time based on platform
let (backend, use_html5_element) = if cfg!(target_os = "android") {
@@ -2459,6 +2520,141 @@ pub async fn player_disable_jellyfin(player: State<'_, PlayerStateWrapper>) -> R
#[cfg(test)]
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,
/// rather than fetching an audio-only stream for media already on disk.
///
+24
View File
@@ -80,6 +80,30 @@ pub fn storage_init(db: State<DatabaseWrapper>) -> Result<String, String> {
Ok(database.path().to_string_lossy().to_string())
}
/// A playable URL for a downloaded file on disk.
///
/// Local media is served over a loopback HTTP server rather than handed to the
/// webview as a `file://`/asset URL, because the asset protocol cannot stream a
/// large file — it answers a range-less request with the whole thing, which
/// Chromium abandons. See `media_server` for why real HTTP is used.
///
/// The returned URL carries the server's per-session token, so it is only valid
/// for this run of the app and must not be persisted.
///
/// TRACES: UR-071 | DR-137
#[tauri::command]
#[specta::specta]
pub fn media_local_url(
server: State<crate::media_server::MediaServerWrapper>,
path: String,
) -> Result<String, String> {
server
.0
.as_ref()
.map(|s| s.url_for(&path))
.ok_or_else(|| "Local media server is not running".to_string())
}
/// Get storage directory path (parent directory of the database file)
#[tauri::command]
#[specta::specta]
+29 -15
View File
@@ -2,7 +2,9 @@
//!
//! The sync queue stores mutations (favorites, playback progress, etc.)
//! that need to be synced to the Jellyfin server when connectivity is restored.
//! TRACES: UR-002, UR-017, UR-025 | DR-014
//! Draining it lives in `sync_drain` (DR-131); this module is the storage and
//! read side the UI lists from (DR-132).
//! TRACES: UR-002, UR-017, UR-025 | DR-014, DR-131, DR-132
use serde::{Deserialize, Serialize};
use std::sync::Arc;
@@ -24,6 +26,12 @@ pub struct SyncQueueItem {
pub retry_count: i32,
pub created_at: Option<String>,
pub error_message: Option<String>,
/// Cached title of the item the operation is about, when the catalog knows
/// it. Resolved here rather than by a per-row frontend fetch — the queue
/// list is otherwise a wall of opaque ids.
///
/// TRACES: UR-025 | DR-132
pub item_name: Option<String>,
}
/// Queue a mutation for sync to server
@@ -74,20 +82,20 @@ pub async fn sync_get_pending(
Arc::new(database.service())
};
let sql = if let Some(l) = limit {
format!(
"SELECT id, user_id, operation, item_id, payload, status, retry_count, created_at, error_message
FROM sync_queue
WHERE user_id = ? AND status IN ('pending', 'failed')
ORDER BY created_at ASC
LIMIT {}",
l
)
} else {
"SELECT id, user_id, operation, item_id, payload, status, retry_count, created_at, error_message
FROM sync_queue
WHERE user_id = ? AND status IN ('pending', 'failed')
ORDER BY created_at ASC".to_string()
// The `items` join names the queued item where the catalog has it; a row for
// an item that was never cached still lists, with a null name.
// `abandoned` rows (DR-131 gave up on them) are excluded here for the same
// reason they are excluded from the count — they are no longer waiting.
const SELECT: &str = "SELECT q.id, q.user_id, q.operation, q.item_id, q.payload, q.status,
COALESCE(q.retry_count, 0), q.created_at, q.error_message, i.name
FROM sync_queue q
LEFT JOIN items i ON i.id = q.item_id
WHERE q.user_id = ? AND q.status IN ('pending', 'failed')
ORDER BY q.created_at ASC, q.id ASC";
let sql = match limit {
Some(l) => format!("{} LIMIT {}", SELECT, l),
None => SELECT.to_string(),
};
let query = Query::with_params(sql, vec![QueryParam::String(user_id)]);
@@ -104,6 +112,7 @@ pub async fn sync_get_pending(
retry_count: row.get(6)?,
created_at: row.get(7)?,
error_message: row.get(8)?,
item_name: row.get(9)?,
})
})
.await
@@ -256,6 +265,7 @@ mod tests {
retry_count: 0,
created_at: Some("2024-02-14T08:00:00Z".to_string()),
error_message: None,
item_name: None,
};
// Should serialize successfully
@@ -280,6 +290,7 @@ mod tests {
retry_count: 3,
created_at: Some("2024-02-14T07:00:00Z".to_string()),
error_message: Some("Connection timeout".to_string()),
item_name: None,
};
let json = serde_json::to_string(&item).unwrap();
@@ -300,6 +311,7 @@ mod tests {
retry_count: 0,
created_at: None,
error_message: None,
item_name: None,
};
let json = serde_json::to_string(&item).unwrap();
@@ -323,6 +335,7 @@ mod tests {
retry_count: 0,
created_at: None,
error_message: None,
item_name: None,
};
let json = serde_json::to_string(&item).unwrap();
@@ -363,6 +376,7 @@ mod tests {
retry_count: 0,
created_at: None,
error_message: None,
item_name: None,
};
// Simulate retries
+838
View File
@@ -0,0 +1,838 @@
//! Draining the offline mutation queue (`sync_queue`) to the server.
//!
//! `sync_queue` had producers but no consumer: `PlaybackReporter::queue_for_sync`
//! inserts a row whenever a start/stop/mark-played cannot reach the server, and
//! nothing ever pushed one. `sync_mark_processing`/`_completed`/`_failed` were
//! registered commands with no callers, so the queue only grew — the offline
//! banner's "N pending" climbed forever and the watch positions those rows stood
//! for never reached Jellyfin.
//!
//! Same shape as the favourites drain (DR-120), and for the same reason: a drain
//! started by a component dies with it, so it lives in Rust and hangs off the
//! `connectivity:reconnected` transition the `ConnectivityMonitor` already emits.
//!
//! TRACES: UR-025, UR-002 | DR-131 | UT-122
use std::sync::Arc;
use async_trait::async_trait;
use log::{debug, info, warn};
use tauri::{Emitter, Listener, Manager};
use crate::repository::types::RepoError;
use crate::repository::MediaRepository;
use crate::storage::db_service::{DatabaseService, Query, QueryParam, RusqliteService};
/// How many times a row may fail before it stops being retried.
///
/// A row that can never succeed (a deleted item, an operation this build does
/// not know how to push) must eventually leave the queue, or it re-creates the
/// bug this module fixes: a count that only ever goes up.
pub const MAX_SYNC_ATTEMPTS: i32 = 5;
/// Emitted after a drain so open views can re-read the queue instead of waiting
/// for the frontend's 10s poll.
pub const SYNC_QUEUE_CHANGED_EVENT: &str = "sync-queue-changed";
/// A queued mutation, resolved from its stored `operation` + JSON `payload`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum QueuedOp {
PlaybackStart {
item_id: String,
position_ticks: i64,
},
/// Also where `update_progress` lands: replaying a mid-playback progress
/// report long after the fact would tell the server we are still playing.
/// What the row actually carries is a resume position, and "stopped at N"
/// is how that reaches Jellyfin's `UserData`.
PlaybackStopped {
item_id: String,
position_ticks: i64,
},
MarkPlayed {
item_id: String,
},
/// Legacy rows only — live favourite toggles drain via `user_data.pending_sync`
/// (DR-120). Supported so a row written by an older build still lands.
Favorite {
item_id: String,
is_favorite: bool,
},
}
/// Turn a stored row into something pushable.
///
/// Payload keys differ by producer: the Rust reporter writes `position_ticks`,
/// while `syncService.queuePlaybackProgress` writes camelCase `positionMs`.
/// Both are accepted rather than normalised at the producer, because rows
/// already in users' databases were written by both.
///
/// TRACES: UR-025 | DR-131 | UT-122
pub fn parse_queued_op(
operation: &str,
item_id: Option<&str>,
payload: Option<&str>,
) -> Result<QueuedOp, String> {
let json: serde_json::Value = match payload {
Some(raw) if !raw.trim().is_empty() => {
serde_json::from_str(raw).map_err(|e| format!("Unreadable payload: {}", e))?
}
_ => serde_json::Value::Null,
};
let item_id = item_id
.filter(|id| !id.is_empty())
.ok_or_else(|| format!("Operation '{}' has no item id", operation))?
.to_string();
let ticks = || -> i64 {
if let Some(t) = json.get("position_ticks").and_then(|v| v.as_i64()) {
return t;
}
if let Some(ms) = json.get("positionMs").and_then(|v| v.as_i64()) {
return ms * 10_000; // ms → Jellyfin ticks (100ns)
}
0
};
match operation {
"report_playback_start" => Ok(QueuedOp::PlaybackStart {
item_id,
position_ticks: ticks(),
}),
"report_playback_stopped" | "update_progress" => Ok(QueuedOp::PlaybackStopped {
item_id,
position_ticks: ticks(),
}),
"mark_played" => Ok(QueuedOp::MarkPlayed { item_id }),
"mark_favorite" => Ok(QueuedOp::Favorite {
item_id,
is_favorite: true,
}),
"unmark_favorite" => Ok(QueuedOp::Favorite {
item_id,
is_favorite: false,
}),
other => Err(format!("Unsupported operation '{}'", other)),
}
}
/// The slice of the repository the drain needs — narrow so it can be doubled in
/// a test without forty `unimplemented!()` methods.
#[async_trait]
pub trait SyncSink: Send + Sync {
async fn push(&self, op: &QueuedOp) -> Result<(), RepoError>;
}
#[async_trait]
impl<T: MediaRepository + ?Sized> SyncSink for T {
async fn push(&self, op: &QueuedOp) -> Result<(), RepoError> {
match op {
QueuedOp::PlaybackStart {
item_id,
position_ticks,
} => self.report_playback_start(item_id, *position_ticks).await,
QueuedOp::PlaybackStopped {
item_id,
position_ticks,
} => self.report_playback_stopped(item_id, *position_ticks).await,
QueuedOp::MarkPlayed { item_id } => self.mark_played(item_id).await,
QueuedOp::Favorite {
item_id,
is_favorite,
} => {
if *is_favorite {
self.mark_favorite(item_id).await
} else {
self.unmark_favorite(item_id).await
}
}
}
}
}
/// What a drain did, for logging and for the frontend's "Sync now" button.
#[derive(
Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize, specta::Type,
)]
#[serde(rename_all = "camelCase")]
pub struct DrainReport {
/// Rows that reached the server and are now `completed`.
pub pushed: i32,
/// Rows that failed and will be retried on the next reconnect.
pub deferred: i32,
/// Rows that exhausted `MAX_SYNC_ATTEMPTS` and were given up on.
pub abandoned: i32,
/// Rows still waiting afterwards (what the badge counts).
pub remaining: i32,
}
/// Why a push failed, and whether the row should be charged an attempt for it.
struct PushFailure {
reason: String,
/// The server could not be reached at all — retry later, free of charge.
transient: bool,
}
#[derive(Debug, Clone)]
struct QueuedRow {
id: i64,
operation: String,
item_id: Option<String>,
payload: Option<String>,
retry_count: i32,
}
async fn read_queue(db: &Arc<RusqliteService>, user_id: &str) -> Result<Vec<QueuedRow>, String> {
db.query_many(
Query::with_params(
"SELECT id, operation, item_id, payload, COALESCE(retry_count, 0) \
FROM sync_queue \
WHERE user_id = ? AND status IN ('pending', 'failed') \
ORDER BY created_at ASC, id ASC",
vec![QueryParam::String(user_id.to_string())],
),
|row| {
Ok(QueuedRow {
id: row.get(0)?,
operation: row.get(1)?,
item_id: row.get(2)?,
payload: row.get(3)?,
retry_count: row.get(4)?,
})
},
)
.await
}
async fn remaining_count(db: &Arc<RusqliteService>, user_id: &str) -> Result<i32, String> {
db.query_one(
Query::with_params(
"SELECT COUNT(*) FROM sync_queue WHERE user_id = ? AND status IN ('pending', 'failed')",
vec![QueryParam::String(user_id.to_string())],
),
|row| row.get(0),
)
.await
}
/// Push every queued mutation for this user, oldest first.
///
/// Chronological order matters: a stale start replayed after a later stop would
/// otherwise move the server's resume position backwards.
///
/// A row that fails keeps its place and is retried on the next reconnect, until
/// `MAX_SYNC_ATTEMPTS` — after which it is abandoned, because a row nothing can
/// ever push is exactly what turned this queue into a counter that only grew.
///
/// TRACES: UR-025, UR-002 | DR-131 | UT-122
pub async fn drain_sync_queue(
db: &Arc<RusqliteService>,
sink: &dyn SyncSink,
user_id: &str,
) -> Result<DrainReport, String> {
let queued = read_queue(db, user_id).await?;
if queued.is_empty() {
return Ok(DrainReport::default());
}
info!(
"[SyncQueue] Pushing {} operation(s) queued while offline",
queued.len()
);
let mut report = DrainReport::default();
for row in queued {
let outcome = match parse_queued_op(
&row.operation,
row.item_id.as_deref(),
row.payload.as_deref(),
) {
Ok(op) => sink.push(&op).await.map_err(|e| PushFailure {
// An unreachable server is not the row's fault: burning its
// budget would abandon perfectly good rows just because the app
// was opened offline a few times.
transient: matches!(e, RepoError::Offline | RepoError::Network { .. }),
reason: e.to_string(),
}),
// An unreadable or unsupported row can never succeed, so it does
// burn attempts rather than being deleted outright — the panel shows
// the reason until it is abandoned.
Err(reason) => Err(PushFailure {
reason,
transient: false,
}),
};
match outcome {
Ok(()) => {
mark_completed(db, row.id).await?;
report.pushed += 1;
}
Err(failure) if failure.transient => {
mark_deferred(db, row.id, &failure.reason).await?;
debug!(
"[SyncQueue] Server unreachable, {} stays queued: {}",
row.operation, failure.reason
);
report.deferred += 1;
}
Err(failure) => {
let attempts = row.retry_count + 1;
let give_up = attempts >= MAX_SYNC_ATTEMPTS;
mark_failed(db, row.id, attempts, give_up, &failure.reason).await?;
if give_up {
warn!(
"[SyncQueue] Giving up on {} after {} attempts: {}",
row.operation, attempts, failure.reason
);
report.abandoned += 1;
} else {
debug!(
"[SyncQueue] Deferring {} (attempt {}): {}",
row.operation, attempts, failure.reason
);
report.deferred += 1;
}
}
}
}
report.remaining = remaining_count(db, user_id).await?;
info!(
"[SyncQueue] Drain finished: {} pushed, {} deferred, {} abandoned, {} remaining",
report.pushed, report.deferred, report.abandoned, report.remaining
);
Ok(report)
}
async fn mark_completed(db: &Arc<RusqliteService>, id: i64) -> Result<(), String> {
db.execute(Query::with_params(
"UPDATE sync_queue \
SET status = 'completed', processed_at = CURRENT_TIMESTAMP, error_message = NULL \
WHERE id = ?",
vec![QueryParam::Int64(id)],
))
.await?;
Ok(())
}
/// Put a row back in the queue untouched apart from its error note — used when
/// the server was simply unreachable.
async fn mark_deferred(db: &Arc<RusqliteService>, id: i64, reason: &str) -> Result<(), String> {
db.execute(Query::with_params(
"UPDATE sync_queue SET status = 'pending', error_message = ? WHERE id = ?",
vec![
QueryParam::String(reason.to_string()),
QueryParam::Int64(id),
],
))
.await?;
Ok(())
}
async fn mark_failed(
db: &Arc<RusqliteService>,
id: i64,
attempts: i32,
give_up: bool,
reason: &str,
) -> Result<(), String> {
db.execute(Query::with_params(
"UPDATE sync_queue \
SET status = ?, retry_count = ?, error_message = ?, processed_at = CURRENT_TIMESTAMP \
WHERE id = ?",
vec![
QueryParam::String(if give_up { "abandoned" } else { "failed" }.to_string()),
QueryParam::Int(attempts),
QueryParam::String(reason.to_string()),
QueryParam::Int64(id),
],
))
.await?;
Ok(())
}
/// Drain on every offline→online transition.
///
/// TRACES: UR-025 | DR-131
pub fn spawn_sync_queue_drain(app: tauri::AppHandle) {
let handle = app.clone();
app.listen("connectivity:reconnected", move |_event| {
let app = handle.clone();
tauri::async_runtime::spawn(async move {
if let Err(e) = run_drain(&app).await {
warn!("[SyncQueue] Drain skipped: {}", e);
}
});
});
}
/// Resolve app state and drain. Shared by the reconnect hook and the manual
/// "Sync now" command.
pub async fn run_drain(app: &tauri::AppHandle) -> Result<DrainReport, String> {
let db_service: Arc<RusqliteService> = {
let db = app.state::<crate::commands::storage::DatabaseWrapper>();
let database = db.0.lock().map_err(|e| e.to_string())?;
Arc::new(database.service())
};
let (repo, user_id) = {
let manager = app.state::<crate::commands::repository::RepositoryManagerWrapper>();
let handles = manager.0.handles();
let Some(handle) = handles.first() else {
// Not signed in — nothing to push on behalf of.
return Ok(DrainReport::default());
};
let repo = manager.0.get(handle).ok_or("Repository not found")?;
let user_id = repo.user_id().to_string();
(repo, user_id)
};
let report = drain_sync_queue(&db_service, repo.as_ref(), &user_id).await?;
if report.pushed > 0 || report.abandoned > 0 {
if let Err(e) = app.emit(SYNC_QUEUE_CHANGED_EVENT, &report) {
warn!("[SyncQueue] Failed to emit change event: {}", e);
}
}
Ok(report)
}
/// Push the queue now, on the user's say-so, instead of waiting for a reconnect.
///
/// TRACES: UR-025 | DR-132
#[tauri::command]
#[specta::specta]
pub async fn sync_process_pending(app: tauri::AppHandle) -> Result<DrainReport, String> {
run_drain(&app).await
}
#[cfg(test)]
mod tests {
use super::*;
use rusqlite::Connection;
use std::sync::Mutex;
/// Records what the server was asked to do, and can be told to fail.
struct RecordingSink {
calls: Mutex<Vec<QueuedOp>>,
fail_with: Option<RepoError>,
}
impl RecordingSink {
fn new() -> Self {
Self {
calls: Mutex::new(Vec::new()),
fail_with: None,
}
}
/// The server is there and refuses the operation — the row's own fault.
fn always_rejecting() -> Self {
Self {
calls: Mutex::new(Vec::new()),
fail_with: Some(RepoError::Server {
message: "HTTP 400".to_string(),
}),
}
}
/// The server cannot be reached at all — nothing to do with the row.
fn unreachable() -> Self {
Self {
calls: Mutex::new(Vec::new()),
fail_with: Some(RepoError::Offline),
}
}
fn calls(&self) -> Vec<QueuedOp> {
self.calls.lock().unwrap().clone()
}
}
#[async_trait]
impl SyncSink for RecordingSink {
async fn push(&self, op: &QueuedOp) -> Result<(), RepoError> {
if let Some(err) = &self.fail_with {
return Err(err.clone());
}
self.calls.lock().unwrap().push(op.clone());
Ok(())
}
}
fn test_db() -> Arc<RusqliteService> {
let conn = Connection::open_in_memory().unwrap();
conn.execute_batch(
r#"
CREATE TABLE sync_queue (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
operation TEXT NOT NULL,
item_id TEXT,
payload TEXT,
status TEXT DEFAULT 'pending',
retry_count INTEGER DEFAULT 0,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
processed_at TEXT,
error_message TEXT
);
"#,
)
.unwrap();
Arc::new(RusqliteService::new(Arc::new(Mutex::new(conn))))
}
/// (user, operation, item_id, payload, status, retry_count, created_at)
type Seed<'a> = (
&'a str,
&'a str,
&'a str,
Option<&'a str>,
&'a str,
i32,
&'a str,
);
async fn seed(db: &Arc<RusqliteService>, rows: &[Seed<'_>]) {
for (user, op, item, payload, status, retries, created) in rows {
db.execute(Query::with_params(
"INSERT INTO sync_queue (user_id, operation, item_id, payload, status, retry_count, created_at) \
VALUES (?, ?, ?, ?, ?, ?, ?)",
vec![
QueryParam::String(user.to_string()),
QueryParam::String(op.to_string()),
QueryParam::String(item.to_string()),
payload
.map(|p| QueryParam::String(p.to_string()))
.unwrap_or(QueryParam::Null),
QueryParam::String(status.to_string()),
QueryParam::Int(*retries),
QueryParam::String(created.to_string()),
],
))
.await
.unwrap();
}
}
async fn row_state(db: &Arc<RusqliteService>, item_id: &str) -> (String, i32) {
db.query_one(
Query::with_params(
"SELECT status, COALESCE(retry_count, 0) FROM sync_queue WHERE item_id = ?",
vec![QueryParam::String(item_id.to_string())],
),
|row| Ok((row.get::<_, String>(0)?, row.get::<_, i32>(1)?)),
)
.await
.unwrap()
}
/// UT-122 — the bug itself: rows queued while offline reach the server on
/// reconnect and stop counting towards the offline banner's badge.
///
/// TRACES: UR-025 | DR-131 | UT-122
#[tokio::test]
async fn test_drain_pushes_queued_operations_and_clears_the_queue() {
let db = test_db();
seed(
&db,
&[
(
"u1",
"report_playback_start",
"ep1",
Some(r#"{"position_ticks": 100}"#),
"pending",
0,
"2026-08-01T10:00:00Z",
),
(
"u1",
"report_playback_stopped",
"ep2",
Some(r#"{"position_ticks": 5000}"#),
"pending",
0,
"2026-08-01T10:01:00Z",
),
(
"u1",
"mark_played",
"ep3",
None,
"pending",
0,
"2026-08-01T10:02:00Z",
),
],
)
.await;
let sink = RecordingSink::new();
let report = drain_sync_queue(&db, &sink, "u1").await.unwrap();
assert_eq!(
sink.calls(),
vec![
QueuedOp::PlaybackStart {
item_id: "ep1".to_string(),
position_ticks: 100
},
QueuedOp::PlaybackStopped {
item_id: "ep2".to_string(),
position_ticks: 5000
},
QueuedOp::MarkPlayed {
item_id: "ep3".to_string()
},
],
"every queued operation pushes, oldest first"
);
assert_eq!(report.pushed, 3);
assert_eq!(report.remaining, 0, "the badge must reach zero");
assert_eq!(row_state(&db, "ep1").await.0, "completed");
}
/// A push that fails stays queued for the next reconnect rather than being
/// dropped.
///
/// TRACES: UR-025 | DR-131 | UT-122
#[tokio::test]
async fn test_drain_defers_failed_pushes() {
let db = test_db();
seed(
&db,
&[(
"u1",
"report_playback_stopped",
"ep1",
Some(r#"{"position_ticks": 42}"#),
"pending",
0,
"2026-08-01T10:00:00Z",
)],
)
.await;
let report = drain_sync_queue(&db, &RecordingSink::always_rejecting(), "u1")
.await
.unwrap();
assert_eq!(report.deferred, 1);
assert_eq!(report.remaining, 1);
assert_eq!(row_state(&db, "ep1").await, ("failed".to_string(), 1));
}
/// An unreachable server does not charge the row an attempt — otherwise
/// opening the app offline a few times abandons perfectly good rows.
///
/// TRACES: UR-025 | DR-131 | UT-122
#[tokio::test]
async fn test_unreachable_server_does_not_burn_the_retry_budget() {
let db = test_db();
seed(
&db,
&[(
"u1",
"mark_played",
"ep1",
None,
"pending",
MAX_SYNC_ATTEMPTS - 1,
"2026-08-01T10:00:00Z",
)],
)
.await;
let report = drain_sync_queue(&db, &RecordingSink::unreachable(), "u1")
.await
.unwrap();
assert_eq!(report.deferred, 1);
assert_eq!(report.abandoned, 0);
assert_eq!(
row_state(&db, "ep1").await,
("pending".to_string(), MAX_SYNC_ATTEMPTS - 1),
"still queued, with its budget intact"
);
}
/// A row that can never succeed must eventually leave the queue, or the
/// count climbs forever — which is the bug this module exists to fix.
///
/// TRACES: UR-025 | DR-131 | UT-122
#[tokio::test]
async fn test_drain_abandons_a_row_after_max_attempts() {
let db = test_db();
seed(
&db,
&[(
"u1",
"report_playback_stopped",
"doomed",
Some(r#"{"position_ticks": 1}"#),
"failed",
MAX_SYNC_ATTEMPTS - 1,
"2026-08-01T10:00:00Z",
)],
)
.await;
let report = drain_sync_queue(&db, &RecordingSink::always_rejecting(), "u1")
.await
.unwrap();
assert_eq!(report.abandoned, 1);
assert_eq!(report.remaining, 0, "an abandoned row stops being counted");
assert_eq!(row_state(&db, "doomed").await.0, "abandoned");
}
/// An operation this build cannot push does not wedge the queue behind it.
///
/// TRACES: UR-025 | DR-131 | UT-122
#[tokio::test]
async fn test_unsupported_operation_records_a_reason_and_lets_others_through() {
let db = test_db();
seed(
&db,
&[
(
"u1",
"playlist_reorder_item",
"pl1",
None,
"pending",
0,
"2026-08-01T10:00:00Z",
),
(
"u1",
"mark_played",
"ep1",
None,
"pending",
0,
"2026-08-01T10:01:00Z",
),
],
)
.await;
let sink = RecordingSink::new();
let report = drain_sync_queue(&db, &sink, "u1").await.unwrap();
assert_eq!(
sink.calls(),
vec![QueuedOp::MarkPlayed {
item_id: "ep1".to_string()
}],
"the unsupported row must not block the ones behind it"
);
assert_eq!(report.pushed, 1);
assert_eq!(report.deferred, 1);
assert_eq!(row_state(&db, "pl1").await, ("failed".to_string(), 1));
}
/// Another user's queued changes are not pushed with this user's token.
///
/// TRACES: UR-025 | DR-131 | UT-122
#[tokio::test]
async fn test_drain_only_touches_the_given_user() {
let db = test_db();
seed(
&db,
&[
(
"u1",
"mark_played",
"mine",
None,
"pending",
0,
"2026-08-01T10:00:00Z",
),
(
"u2",
"mark_played",
"theirs",
None,
"pending",
0,
"2026-08-01T10:00:00Z",
),
],
)
.await;
let sink = RecordingSink::new();
drain_sync_queue(&db, &sink, "u1").await.unwrap();
assert_eq!(
sink.calls(),
vec![QueuedOp::MarkPlayed {
item_id: "mine".to_string()
}]
);
assert_eq!(row_state(&db, "theirs").await.0, "pending");
}
/// Nothing queued means no server calls at all — a reconnect must not
/// generate traffic just because it happened.
///
/// TRACES: UR-025 | DR-131 | UT-122
#[tokio::test]
async fn test_drain_is_a_noop_when_the_queue_is_empty() {
let db = test_db();
let sink = RecordingSink::new();
let report = drain_sync_queue(&db, &sink, "u1").await.unwrap();
assert_eq!(report, DrainReport::default());
assert!(sink.calls().is_empty());
}
/// Both payload dialects parse: `position_ticks` from the Rust reporter and
/// camelCase `positionMs` from the frontend's queue helper.
///
/// TRACES: UR-025 | DR-131 | UT-122
#[test]
fn test_parse_accepts_both_payload_dialects() {
assert_eq!(
parse_queued_op(
"report_playback_stopped",
Some("ep1"),
Some(r#"{"position_ticks": 1234}"#)
)
.unwrap(),
QueuedOp::PlaybackStopped {
item_id: "ep1".to_string(),
position_ticks: 1234
}
);
assert_eq!(
parse_queued_op("update_progress", Some("ep1"), Some(r#"{"positionMs": 5}"#)).unwrap(),
QueuedOp::PlaybackStopped {
item_id: "ep1".to_string(),
position_ticks: 50_000
},
"milliseconds convert to ticks"
);
assert_eq!(
parse_queued_op("mark_played", Some("ep1"), None).unwrap(),
QueuedOp::MarkPlayed {
item_id: "ep1".to_string()
},
"a payload-less operation is not an error"
);
assert!(parse_queued_op("mark_played", None, None).is_err());
assert!(parse_queued_op("teleport", Some("ep1"), None).is_err());
}
}
+40
View File
@@ -5,6 +5,7 @@ mod credentials;
mod domain;
mod download;
mod jellyfin;
mod media_server;
mod playback_mode;
mod playback_reporting;
mod player;
@@ -85,6 +86,7 @@ use commands::{
lms_unsync_player,
mark_download_completed,
mark_download_failed,
media_local_url,
offline_get_items,
offline_is_available,
offline_search,
@@ -121,6 +123,7 @@ use commands::{
player_get_audio_settings,
player_get_autoplay_settings,
player_get_cache_config,
player_get_capabilities,
player_get_eq_presets,
player_get_queue,
// Session management commands
@@ -273,6 +276,7 @@ use commands::{
sync_mark_completed,
sync_mark_failed,
sync_mark_processing,
sync_process_pending,
// Sync queue commands
sync_queue_mutation,
thumbnail_clear_cache,
@@ -679,6 +683,7 @@ fn specta_builder() -> Builder<tauri::Wry> {
player_cycle_repeat,
player_get_status,
player_get_queue,
player_get_capabilities,
player_add_to_queue,
player_add_track_by_id,
player_add_tracks_by_ids,
@@ -806,6 +811,7 @@ fn specta_builder() -> Builder<tauri::Wry> {
get_download_storage_stats,
mark_download_completed,
mark_download_failed,
media_local_url,
start_download,
enqueue_download,
enqueue_video_downloads,
@@ -846,6 +852,7 @@ fn specta_builder() -> Builder<tauri::Wry> {
sync_mark_completed,
sync_mark_failed,
sync_get_pending_count,
sync_process_pending,
sync_cleanup_completed,
sync_clear_user,
// Thumbnail cache and image commands
@@ -1001,6 +1008,16 @@ fn set_env_if_unset(key: &str, value: &str) {
}
}
/// Downloaded media and cached thumbnails are handed to the webview as
/// `http://asset.localhost/…` URLs by `convertFileSrc`. Tauri only answers that
/// origin when the `protocol-asset` cargo feature is compiled in *and*
/// `app.security.assetProtocol.enable` is set in `tauri.conf.json`, which also
/// scopes it to `$APPDATA/**` — the storage root holding the database,
/// `downloads/` and the thumbnail cache. Both are required together: with either
/// missing the URL resolves to nothing and the webview reports
/// `NETWORK_NO_SOURCE`, which is how offline video came to fail silently.
///
/// TRACES: UR-071 | DR-134
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
// Initialize logger
@@ -1228,6 +1245,22 @@ pub fn run() {
let smart_cache_wrapper = SmartCacheWrapper(Mutex::new(smart_cache));
app.manage(smart_cache_wrapper);
// Serve downloaded media over loopback HTTP. The webview cannot
// stream a large file through the asset protocol (see media_server),
// so local playback resolves its URL from here instead.
// TRACES: UR-071 | DR-137
info!("[INIT] Starting local media server...");
let media_server = match media_server::start(app_data_dir.clone()) {
Ok(s) => Some(s),
Err(e) => {
// Not fatal: streaming still works, and the command reports
// a clear error if local playback is attempted.
error!("[INIT ERROR] Local media server failed to start: {}", e);
None
}
};
app.manage(media_server::MediaServerWrapper(media_server));
// Initialize download manager
info!("[INIT] Initializing download manager...");
let download_dir = app_data_dir.join("downloads");
@@ -1309,6 +1342,13 @@ pub fn run() {
info!("[INIT] Starting favourites drain...");
commands::favorites::spawn_favorites_drain(app.handle().clone());
// Push playback reports queued while the server was unreachable.
// Without this the `sync_queue` rows the reporter writes offline
// are never sent and the offline banner's count only grows.
// TRACES: UR-025, UR-002 | DR-131
info!("[INIT] Starting sync-queue drain...");
commands::sync_drain::spawn_sync_queue_drain(app.handle().clone());
info!("[INIT] Application setup completed successfully");
Ok(())
})
+588
View File
@@ -0,0 +1,588 @@
//! A loopback HTTP server for locally downloaded media.
//!
//! Tauri's built-in `asset` protocol cannot serve a downloaded film to the
//! webview. Its response to a request *without* a `Range` header reads the whole
//! file into a `Vec<u8>`, and it only advertises `Accept-Ranges: bytes` from
//! inside the range branch — so the first request never learns ranges are
//! available and a multi-gigabyte body is attempted instead. Chromium abandoned
//! it with `PIPELINE_ERROR_READ` after ~31s, which reached the user as
//! "downloaded video does not play offline".
//!
//! Serving over real HTTP on 127.0.0.1 rather than a custom URI scheme is
//! deliberate: it makes range support a property of the transport instead of
//! depending on whether a platform's webview forwards `Range` to a custom
//! scheme, which differs between Android and the desktop webviews.
//!
//! Two things confine it, because **loopback is shared between apps on
//! Android** — any other installed app can connect to this port:
//!
//! - it binds `127.0.0.1` only, so nothing off-device can reach it; and
//! - every URL carries a random per-session token, so another app cannot guess a
//! working URL, and paths are confined to the app data directory even if one
//! did.
//!
//! Phase 1 serves local files only. The same origin is the intended home for
//! remote passthrough (and download-while-watching) later; see the stage-2 spec.
//!
//! TRACES: UR-071 | DR-137 | UT-127
use std::fs::File;
use std::io::{Read, Seek, SeekFrom};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use log::{debug, error, info, warn};
use rand::Rng;
use tiny_http::{Header, Response, Server, StatusCode};
/// Bytes per response. Large enough that a film needs relatively few round
/// trips, small enough that one response is never a memory problem on a phone.
/// Tauri's asset protocol uses 1 MiB; 4 MiB quarters the request count for the
/// multi-gigabyte files this exists to serve.
const CHUNK_LEN: u64 = 4 * 1024 * 1024;
/// Managed state. `None` when the server could not bind — local playback then
/// fails with a clear error instead of the app refusing to start.
pub struct MediaServerWrapper(pub Option<MediaServer>);
/// A running server. Dropping this does not stop the thread; the server lives
/// for the life of the process by design, since playback can start at any time.
pub struct MediaServer {
port: u16,
token: String,
}
impl MediaServer {
/// The base a media URL is built on, e.g. `http://127.0.0.1:53412/<token>`.
pub fn base_url(&self) -> String {
format!("http://127.0.0.1:{}/{}", self.port, self.token)
}
/// A playable URL for an absolute on-disk path.
pub fn url_for(&self, path: &str) -> String {
format!("{}/{}", self.base_url(), urlencoding::encode(path))
}
}
/// Bind to an ephemeral loopback port and start serving `root` in a background
/// thread.
///
/// TRACES: UR-071 | DR-137
pub fn start(root: PathBuf) -> Result<MediaServer, String> {
// Port 0 → the OS picks a free one. Binding 127.0.0.1 (not 0.0.0.0) keeps
// this off the network.
let server =
Server::http("127.0.0.1:0").map_err(|e| format!("Failed to bind media server: {e}"))?;
let port = server
.server_addr()
.to_ip()
.ok_or_else(|| "Media server bound to a non-IP address".to_string())?
.port();
let token: String = {
let mut rng = rand::thread_rng();
(0..32)
.map(|_| char::from_digit(rng.gen_range(0..16), 16).unwrap())
.collect()
};
info!(
"[MediaServer] Serving {} on 127.0.0.1:{}",
root.display(),
port
);
let server = Arc::new(server);
let shared = Arc::new((root, token.clone()));
std::thread::Builder::new()
.name("media-server".into())
.spawn(move || loop {
let request = match server.recv() {
Ok(r) => r,
Err(e) => {
error!("[MediaServer] accept failed: {e}");
continue;
}
};
let shared = Arc::clone(&shared);
// A thread per request: media clients open several connections at
// once, and a blocking read of one must not stall the others.
if let Err(e) = std::thread::Builder::new()
.name("media-server-req".into())
.spawn(move || {
let (root, token) = &*shared;
handle(request, root, token);
})
{
error!("[MediaServer] could not spawn handler: {e}");
}
})
.map_err(|e| format!("Failed to start media server thread: {e}"))?;
Ok(MediaServer { port, token })
}
fn header(name: &str, value: &str) -> Header {
Header::from_bytes(name.as_bytes(), value.as_bytes())
.expect("static header name/value are valid")
}
fn empty(status: u16) -> Response<std::io::Empty> {
Response::empty(StatusCode(status)).with_header(header("Accept-Ranges", "bytes"))
}
fn handle(request: tiny_http::Request, root: &Path, token: &str) {
let url = request.url().to_string();
let method = request.method().as_str().to_string();
let outcome = match route(&url, root, token) {
Ok(path) => path,
Err(status) => {
let _ = request.respond(empty(status));
return;
}
};
if method != "GET" && method != "HEAD" {
let _ = request.respond(empty(405));
return;
}
let mut file = match File::open(&outcome) {
Ok(f) => f,
Err(e) => {
warn!("[MediaServer] {}: {}", outcome.display(), e);
let _ = request.respond(empty(404));
return;
}
};
let len = match file.metadata() {
Ok(m) => m.len(),
Err(e) => {
warn!(
"[MediaServer] metadata failed for {}: {}",
outcome.display(),
e
);
let _ = request.respond(empty(404));
return;
}
};
let range = request
.headers()
.iter()
.find(|h| h.field.equiv("Range"))
.map(|h| h.value.as_str().to_string());
debug!(
"[MediaServer] {} {} ({} bytes) range={:?}",
method,
outcome.display(),
len,
range
);
let Some(span) = span_for(range.as_deref(), len) else {
let _ = request
.respond(empty(416).with_header(header("Content-Range", &format!("bytes */{len}"))));
return;
};
// Sniff before seeking to the span, for extension-less files.
let mut head = [0u8; 16];
let head_len = file.read(&mut head).unwrap_or(0);
let mime = content_type(&outcome, &head[..head_len]);
if method == "HEAD" {
let _ = request.respond(
empty(200)
.with_header(header("Content-Type", mime))
.with_header(header("Content-Length", &len.to_string())),
);
return;
}
if let Err(e) = file.seek(SeekFrom::Start(span.start)) {
warn!("[MediaServer] seek failed for {}: {}", outcome.display(), e);
let _ = request.respond(empty(500));
return;
}
// Streamed straight from the file handle: at no point is more than the
// span in memory, and the span is capped at CHUNK_LEN.
let nbytes = span.len();
let body = file.take(nbytes);
// tiny_http switches to chunked transfer above a 32 KiB default, which drops
// Content-Length — and a 206 without one is unusable to Chromium's media
// loader, which needs the range's size. Raising the threshold past our own
// cap keeps every response length-delimited.
let response = Response::new(
StatusCode(206),
vec![
header("Accept-Ranges", "bytes"),
header("Content-Type", mime),
header(
"Content-Range",
&format!("bytes {}-{}/{}", span.start, span.end, len),
),
],
body,
Some(nbytes as usize),
None,
)
.with_chunked_threshold(usize::MAX);
if let Err(e) = request.respond(response) {
// A client that seeks away closes the connection mid-body; that is
// normal and must not be logged as a failure.
debug!("[MediaServer] response ended early: {e}");
}
}
/// Check the token and resolve the path, or return the status to answer with.
fn route(url: &str, root: &Path, token: &str) -> Result<PathBuf, u16> {
let trimmed = url.trim_start_matches('/');
let (got_token, rest) = trimmed.split_once('/').ok_or(404u16)?;
// Constant-time-ish: length check first, then a byte compare. The token is
// the only thing standing between another app on the device and this server.
if got_token.len() != token.len() || got_token != token {
warn!("[MediaServer] Rejected a request with a bad token");
return Err(403);
}
// Strip any query string before decoding.
let raw = rest.split('?').next().unwrap_or("");
match resolve_path(raw, root) {
Resolved::Allow(p) => Ok(p),
Resolved::Forbidden => {
warn!("[MediaServer] Refused a path outside the app data directory");
Err(403)
}
}
}
/// What a request path resolved to, before any file is touched.
#[derive(Debug, PartialEq, Eq)]
pub enum Resolved {
Allow(PathBuf),
/// Escaped the allowed root.
Forbidden,
}
/// Resolve a percent-encoded request path to a file inside `root`.
///
/// `..` segments are folded away lexically rather than through `canonicalize`,
/// so a missing file still resolves (and then 404s) instead of being reported as
/// a scope violation.
///
/// TRACES: UR-071 | DR-137 | UT-127
pub fn resolve_path(raw: &str, root: &Path) -> Resolved {
let decoded = match urlencoding::decode(raw) {
Ok(d) => d.into_owned(),
Err(_) => raw.to_string(),
};
let mut normalised = PathBuf::new();
for part in Path::new(&decoded).components() {
match part {
std::path::Component::ParentDir => {
normalised.pop();
}
std::path::Component::CurDir => {}
other => normalised.push(other),
}
}
if normalised.starts_with(root) {
Resolved::Allow(normalised)
} else {
Resolved::Forbidden
}
}
/// The byte range a response should carry. `end` is inclusive.
#[derive(Debug, PartialEq, Eq)]
pub struct Span {
pub start: u64,
pub end: u64,
}
impl Span {
pub fn len(&self) -> u64 {
self.end + 1 - self.start
}
}
/// Decide which span to send for a `Range` header (or its absence).
///
/// `None` means unsatisfiable — answer 416. A missing or unparseable header
/// yields the first chunk, so a client that did not ask for a range still gets a
/// bounded response it can continue from, which is exactly the case the asset
/// protocol answered with the whole file.
///
/// TRACES: UR-071 | DR-137 | UT-127
pub fn span_for(range: Option<&str>, len: u64) -> Option<Span> {
if len == 0 {
return Some(Span { start: 0, end: 0 });
}
let last = len - 1;
let first_chunk = Span {
start: 0,
end: (CHUNK_LEN - 1).min(last),
};
let Some(raw) = range else {
return Some(first_chunk);
};
let Some(spec) = raw.trim().strip_prefix("bytes=") else {
return Some(first_chunk);
};
// Only the first range of a multi-range request is honoured; media clients
// ask for one, and a single 206 is a valid answer either way.
let spec = spec.split(',').next().unwrap_or("").trim();
let Some((from, to)) = spec.split_once('-') else {
return Some(first_chunk);
};
let (start, end) = if from.is_empty() {
// Suffix form: `-500` is the final 500 bytes.
let suffix: u64 = match to.parse() {
Ok(n) => n,
Err(_) => return Some(first_chunk),
};
if suffix == 0 {
return None;
}
(len.saturating_sub(suffix), last)
} else {
let start: u64 = match from.parse() {
Ok(n) => n,
Err(_) => return Some(first_chunk),
};
let end = if to.is_empty() {
last
} else {
match to.parse::<u64>() {
Ok(n) => n.min(last),
Err(_) => return Some(first_chunk),
}
};
(start, end)
};
if start > last || end < start {
return None;
}
Some(Span {
start,
end: end.min(start + CHUNK_LEN - 1),
})
}
/// Guess a content type.
///
/// **Magic bytes win over the extension.** Downloading at `original` quality
/// asks Jellyfin for a direct static copy, which returns the *source file's*
/// bytes under a `.mp4` name whatever the real container is — a downloaded film
/// named `.mp4` turned out to be an AVI holding XVID. Trusting the extension
/// there labels it `video/mp4` and the player is handed a container that is not
/// what the header claims. The extension is only a fallback for a file whose
/// bytes are unrecognised, and for the extension-less files the offline queue
/// writes under an item id.
///
/// TRACES: UR-071 | DR-137 | UT-127
fn content_type(path: &Path, head: &[u8]) -> &'static str {
// `ftyp` at offset 4 marks an ISO base media file (mp4 and friends).
if head.len() > 11 && &head[4..8] == b"ftyp" {
return "video/mp4";
}
if head.len() > 11 && head.starts_with(b"RIFF") && &head[8..11] == b"AVI" {
return "video/x-msvideo";
}
if head.starts_with(b"\x1aE\xdf\xa3") {
return "video/x-matroska";
}
if head.starts_with(b"ID3") || head.starts_with(b"\xff\xfb") {
return "audio/mpeg";
}
if head.starts_with(b"OggS") {
return "audio/ogg";
}
if head.starts_with(b"fLaC") {
return "audio/flac";
}
match path
.extension()
.and_then(|e| e.to_str())
.map(|e| e.to_ascii_lowercase())
.as_deref()
{
Some("mp4" | "m4v" | "mov") => return "video/mp4",
Some("mkv") => return "video/x-matroska",
Some("webm") => return "video/webm",
Some("mp3") => return "audio/mpeg",
Some("m4a" | "aac") => return "audio/mp4",
Some("flac") => return "audio/flac",
Some("ogg" | "opus") => return "audio/ogg",
Some("wav") => return "audio/wav",
Some("avi") => return "video/x-msvideo",
_ => {}
}
"application/octet-stream"
}
#[cfg(test)]
mod tests {
use super::*;
/// The whole point: a request with no `Range` must still come back bounded.
/// That is the case Tauri's asset protocol answers with the entire file —
/// the read Chromium abandoned after 31s.
///
/// TRACES: UR-071 | DR-137 | UT-127
#[test]
fn a_rangeless_request_is_answered_with_one_chunk_not_the_file() {
let huge = 8 * 1024 * 1024 * 1024; // 8 GiB
let span = span_for(None, huge).unwrap();
assert_eq!(span.start, 0);
assert_eq!(span.len(), CHUNK_LEN);
}
/// TRACES: UR-071 | DR-137 | UT-127
#[test]
fn no_response_ever_exceeds_one_chunk() {
let len = 8 * 1024 * 1024 * 1024;
for h in [
"bytes=0-",
"bytes=0-99999999999",
"bytes=1024-",
"bytes=-99999999",
] {
let span = span_for(Some(h), len).unwrap();
assert!(span.len() <= CHUNK_LEN, "{h} produced {} bytes", span.len());
}
}
/// TRACES: UR-071 | DR-137 | UT-127
#[test]
fn ranges_are_honoured() {
let len = 1000u64;
assert_eq!(
span_for(Some("bytes=100-199"), len).unwrap(),
Span {
start: 100,
end: 199
}
);
// Open-ended runs to the end of a small file.
assert_eq!(
span_for(Some("bytes=900-"), len).unwrap(),
Span {
start: 900,
end: 999
}
);
// Suffix form.
assert_eq!(
span_for(Some("bytes=-100"), len).unwrap(),
Span {
start: 900,
end: 999
}
);
// Past the end is unsatisfiable, not a clamp — a clamp would make a
// seek past the end silently replay earlier bytes.
assert!(span_for(Some("bytes=1000-"), len).is_none());
}
/// A malformed header must not fail the request: playing from the start is
/// strictly better than refusing to open the file.
///
/// TRACES: UR-071 | DR-137 | UT-127
#[test]
fn a_malformed_range_falls_back_to_the_first_chunk() {
assert_eq!(span_for(Some("pages=1-2"), 5000).unwrap().start, 0);
assert_eq!(span_for(Some("bytes=abc-def"), 5000).unwrap().start, 0);
}
/// TRACES: UR-071 | DR-137 | UT-127
#[test]
fn reads_are_confined_to_the_app_data_directory() {
let root = Path::new("/data/user/0/app");
assert_eq!(
resolve_path("/data/user/0/app/videos/f.mp4", root),
Resolved::Allow(PathBuf::from("/data/user/0/app/videos/f.mp4"))
);
// Percent-encoded, as the URL builder produces.
assert_eq!(
resolve_path("%2Fdata%2Fuser%2F0%2Fapp%2Fa%20b.mp4", root),
Resolved::Allow(PathBuf::from("/data/user/0/app/a b.mp4"))
);
// Traversal out of the root, and an unrelated absolute path, are refused.
assert_eq!(
resolve_path("/data/user/0/app/../../../etc/passwd", root),
Resolved::Forbidden
);
assert_eq!(resolve_path("/etc/passwd", root), Resolved::Forbidden);
}
/// Loopback is shared between apps on Android, so the token is the only
/// thing stopping another installed app from reading downloaded media.
///
/// TRACES: UR-071 | DR-137 | UT-127
#[test]
fn a_request_without_the_right_token_is_refused() {
let root = Path::new("/data/user/0/app");
let good = "0123456789abcdef0123456789abcdef";
assert_eq!(
route(
&format!("/{good}/%2Fdata%2Fuser%2F0%2Fapp%2Ff.mp4"),
root,
good
),
Ok(PathBuf::from("/data/user/0/app/f.mp4"))
);
assert_eq!(
route("/wrong-token/%2Fdata%2Fuser%2F0%2Fapp%2Ff.mp4", root, good),
Err(403)
);
// No token segment at all.
assert_eq!(route("/f.mp4", root, good), Err(404));
// Right token, but a path outside the root is still refused.
assert_eq!(
route(&format!("/{good}/%2Fetc%2Fpasswd"), root, good),
Err(403)
);
}
/// TRACES: UR-071 | DR-137 | UT-127
#[test]
fn content_type_uses_the_extension_then_the_magic_bytes() {
assert_eq!(content_type(Path::new("/a/f.mp4"), &[]), "video/mp4");
assert_eq!(content_type(Path::new("/a/f.mp3"), &[]), "audio/mpeg");
// A `.mp4` that is really an AVI: downloading at `original` quality
// copies the source bytes under an mp4 name, so the extension lies and
// the magic bytes must win.
let avi_head = b"RIFF\xcc\xf3\xbc\x2bAVI LIST";
assert_eq!(
content_type(Path::new("/a/film.mp4"), avi_head),
"video/x-msvideo"
);
// Extension-less, as the offline queue writes them: sniff instead.
let mp4_head = b"\x00\x00\x00\x20ftypisom\x00\x00\x02\x00";
assert_eq!(content_type(Path::new("/a/abc123"), mp4_head), "video/mp4");
assert_eq!(
content_type(Path::new("/a/abc123"), b"ID3\x03junk"),
"audio/mpeg"
);
}
}
+33 -7
View File
@@ -58,11 +58,20 @@ static POSITION_THROTTLER: OnceLock<Arc<EventThrottler>> = OnceLock::new();
struct DetectedCodecs {
video_codecs: Vec<String>,
audio_codecs: Vec<String>,
/// Channels the *current audio output route* accepts, as reported by
/// media3's `AudioCapabilities`. Distinct from the codec lists: a device
/// decodes 5.1 happily and still has only two channels to play it out of.
/// `None` when the platform had no answer.
max_audio_channels: Option<u32>,
}
impl DetectedCodecs {
/// Create from comma-separated codec strings (from JNI)
fn from_jni_strings(video_codecs: &str, audio_codecs: &str) -> Self {
fn from_jni_strings(
video_codecs: &str,
audio_codecs: &str,
max_audio_channels: Option<u32>,
) -> Self {
Self {
video_codecs: video_codecs
.split(',')
@@ -74,6 +83,7 @@ impl DetectedCodecs {
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
.collect(),
max_audio_channels,
}
}
@@ -88,11 +98,19 @@ impl DetectedCodecs {
}
}
/// Public function to get detected codecs (for use in repository layer)
pub fn get_detected_codecs() -> Option<(String, String)> {
DETECTED_CODECS
.get()
.map(|codecs| (codecs.video_codecs_string(), codecs.audio_codecs_string()))
/// Public function to get detected codecs (for use in repository layer).
///
/// Returns `(video, audio, max_audio_channels)` — the third element is how many
/// channels the current audio output can actually voice, which bounds what the
/// server may direct-play.
pub fn get_detected_codecs() -> Option<(String, String, Option<u32>)> {
DETECTED_CODECS.get().map(|codecs| {
(
codecs.video_codecs_string(),
codecs.audio_codecs_string(),
codecs.max_audio_channels,
)
})
}
/// Trait for handling media commands from Android MediaSession.
@@ -1125,6 +1143,7 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_00024Co
_class: JClass,
video_codecs: JString,
audio_codecs: JString,
max_audio_channels: jint,
) {
let video_str: String = env
.get_string(&video_codecs)
@@ -1136,7 +1155,10 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_00024Co
.map(|s| s.into())
.unwrap_or_default();
let codecs = DetectedCodecs::from_jni_strings(&video_str, &audio_str);
// Kotlin sends 0 when AudioCapabilities had no answer for the current route.
let channels = u32::try_from(max_audio_channels).ok().filter(|c| *c > 0);
let codecs = DetectedCodecs::from_jni_strings(&video_str, &audio_str, channels);
log::info!(
"[CodecDetection] Detected {} video codecs: {}",
@@ -1148,6 +1170,10 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_00024Co
codecs.audio_codecs.len(),
codecs.audio_codecs_string()
);
log::info!(
"[CodecDetection] Audio route max channels: {:?}",
codecs.max_audio_channels
);
// Store in global state
if DETECTED_CODECS.set(codecs).is_err() {
+19 -1
View File
@@ -23,6 +23,23 @@ pub enum QueueContext {
}
/// 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)]
pub struct SubtitleTrack {
/// Stream index in the media source
@@ -33,7 +50,8 @@ pub struct SubtitleTrack {
pub language: Option<String>,
/// Display title
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,
}
+4 -1
View File
@@ -32,7 +32,7 @@ pub mod webview_audio_backend;
pub use autoplay::{AutoplayDecision, AutoplaySettings};
pub use backend::{NullBackend, PlayerBackend, PlayerError};
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 seek::{determine_video_seek_strategy, VideoSeekStrategy};
pub use session::{MediaSessionManager, MediaSessionType};
@@ -3248,6 +3248,9 @@ mod tests {
async fn clear_watch_history(&self, _: &str) -> Result<(), repo_types::RepoError> {
unimplemented!()
}
async fn mark_played(&self, _: &str) -> Result<(), repo_types::RepoError> {
unimplemented!()
}
async fn get_person(
&self,
_: &str,
+274
View File
@@ -0,0 +1,274 @@
//! Device-profile policy: turning what a device *reports* about its audio
//! output into the constraints we send Jellyfin.
//!
//! The platform layer reports raw facts (what `MediaCodecList` enumerates, how
//! many channels the current audio route accepts); deciding what those facts
//! mean for a `DeviceProfile` is domain logic and lives here, on the Rust side
//! of the boundary, where it is testable without a device.
/// Channel count assumed when the platform cannot tell us — every audio route
/// can voice stereo, so it is the only safe floor.
const FALLBACK_AUDIO_CHANNELS: u32 = 2;
/// Upper bound we are willing to claim. Jellyfin profiles top out at 7.1, and a
/// nonsense reading from a driver should not become a nonsense profile.
const MAX_SUPPORTED_AUDIO_CHANNELS: u32 = 8;
/// Decide the `MaxAudioChannels` to advertise, given what the current audio
/// route reported.
///
/// Without this constraint Jellyfin is free to direct-play a 5.1 or 7.1 track to
/// a sink that only has two channels. What the user hears then is device
/// dependent and rarely correct — a failed `AudioSink` configuration (silence),
/// or centre-channel dialogue folded away to near-inaudibility. Naming the real
/// channel count makes the server downmix instead, which is always audible.
///
/// A missing or zero reading means "route not established yet", not "no audio":
/// fall back to stereo rather than claiming a capability we have not seen.
///
/// TRACES: UR-004 | DR-141 | UT-141
pub fn clamp_max_audio_channels(reported: Option<u32>) -> u32 {
match reported {
Some(channels) if channels >= 1 => channels.min(MAX_SUPPORTED_AUDIO_CHANNELS),
_ => FALLBACK_AUDIO_CHANNELS,
}
}
/// The channel cap for this device, reading the platform's report where one
/// exists.
///
/// TRACES: UR-004 | DR-141 | UT-141
pub fn max_audio_channels() -> u32 {
#[cfg(target_os = "android")]
let reported = crate::player::get_detected_codecs().and_then(|(_, _, channels)| channels);
// Desktop plays video through the WebKitGTK HTML5 <video> element, which we
// do not interrogate for a channel count; stereo is the safe assumption.
#[cfg(not(target_os = "android"))]
let reported: Option<u32> = None;
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)]
mod tests {
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]
fn an_unknown_route_falls_back_to_stereo() {
// Codec detection has not run yet, or the platform has no answer. Never
// claim surround we have not seen — every sink can do stereo.
assert_eq!(clamp_max_audio_channels(None), 2);
}
#[test]
fn a_zero_reading_is_not_a_capability() {
// A route that has not been established reports 0; taking that literally
// would advertise a device with no audio at all.
assert_eq!(clamp_max_audio_channels(Some(0)), 2);
}
#[test]
fn a_stereo_sink_is_reported_as_stereo() {
// The phone speaker / Bluetooth headset case: the server must downmix
// 5.1 rather than direct-play it.
assert_eq!(clamp_max_audio_channels(Some(2)), 2);
}
#[test]
fn a_surround_route_keeps_its_channels() {
// HDMI to an AVR: 5.1 and 7.1 direct play stay available.
assert_eq!(clamp_max_audio_channels(Some(6)), 6);
assert_eq!(clamp_max_audio_channels(Some(8)), 8);
}
#[test]
fn an_absurd_reading_is_capped_rather_than_forwarded() {
// Some drivers report the AudioTrack maximum rather than the route's.
assert_eq!(clamp_max_audio_channels(Some(32)), 8);
}
#[test]
fn a_mono_route_is_taken_at_its_word() {
assert_eq!(clamp_max_audio_channels(Some(1)), 1);
}
}
+13
View File
@@ -812,6 +812,11 @@ impl MediaRepository for HybridRepository {
self.online.clear_watch_history(item_id).await
}
async fn mark_played(&self, item_id: &str) -> Result<(), RepoError> {
// Write operations go directly to server
self.online.mark_played(item_id).await
}
async fn get_person(&self, person_id: &str) -> Result<MediaItem, RepoError> {
let offline = Arc::clone(&self.offline);
let online = Arc::clone(&self.online);
@@ -1237,6 +1242,10 @@ mod tests {
unimplemented!()
}
async fn mark_played(&self, _item_id: &str) -> Result<(), RepoError> {
unimplemented!()
}
async fn get_person(&self, _person_id: &str) -> Result<MediaItem, RepoError> {
unimplemented!()
}
@@ -1507,6 +1516,10 @@ mod tests {
unimplemented!()
}
async fn mark_played(&self, _item_id: &str) -> Result<(), RepoError> {
unimplemented!()
}
async fn get_person(&self, _person_id: &str) -> Result<MediaItem, RepoError> {
unimplemented!()
}
+9
View File
@@ -1,3 +1,4 @@
pub mod device_profile;
pub mod hybrid;
pub mod offline;
pub mod online;
@@ -234,6 +235,14 @@ pub trait MediaRepository: Send + Sync {
/// TRACES: UR-064 | DR-106
async fn clear_watch_history(&self, item_id: &str) -> Result<(), RepoError>;
/// Mark an item played — the inverse of `clear_watch_history`. Needed by the
/// sync-queue drain, which replays `mark_played` rows queued while the
/// server was unreachable; reporting a stop at a made-up position was the
/// previous stand-in and does not set the played flag reliably.
///
/// TRACES: UR-025 | DR-131 | JA-035
async fn mark_played(&self, item_id: &str) -> Result<(), RepoError>;
/// Get person details
async fn get_person(&self, person_id: &str) -> Result<MediaItem, RepoError>;
+6
View File
@@ -2050,6 +2050,12 @@ impl MediaRepository for OfflineRepository {
Err(RepoError::Offline)
}
async fn mark_played(&self, _item_id: &str) -> Result<(), RepoError> {
// Offline the local flag is written by `storage_mark_played` and the
// server half is queued in `sync_queue`; this path has no server.
Err(RepoError::Offline)
}
async fn get_person(&self, person_id: &str) -> Result<MediaItem, RepoError> {
let query = Query::with_params(
"SELECT id, name, overview, primary_image_tag
+152 -27
View File
@@ -1,9 +1,7 @@
//! TRACES: UR-002, UR-007 | DR-013 | IR-010
use async_trait::async_trait;
#[cfg(target_os = "android")]
use log::warn;
use log::{debug, error, info};
use log::{debug, error, info, warn};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
@@ -382,6 +380,8 @@ impl OnlineRepository {
/// forces the server to transcode the whole file before playback can begin —
/// which manifests as playback never starting. `StartTimeTicks` makes the
/// server begin the transcode at the requested position.
///
/// TRACES: UR-004 | DR-140 | UT-130
pub async fn get_video_stream_url(
&self,
item_id: &str,
@@ -392,9 +392,6 @@ impl OnlineRepository {
// Convert seconds to ticks (10,000,000 ticks per second)
let start_time_ticks = start_time_seconds.map(|seconds| (seconds * 10_000_000.0) as i64);
// Use provided audio stream index, or default to 0
let audio_index = audio_stream_index.unwrap_or(0).to_string();
// Build an HLS transcode URL. VideoCodec lists h264 first so the server
// transcodes HEVC/10-bit/unsupported sources to h264 the WebView can decode.
let mut params = vec![
@@ -402,16 +399,28 @@ impl OnlineRepository {
("DeviceId", "jellytau-tauri".to_string()),
("VideoCodec", "h264".to_string()),
("AudioCodec", "aac".to_string()),
("AudioStreamIndex", audio_index),
("MaxStreamingBitrate", "20000000".to_string()),
("VideoBitrate", "18000000".to_string()),
("AudioBitrate", "384000".to_string()),
("TranscodingMaxAudioChannels", "2".to_string()),
(
"TranscodingMaxAudioChannels",
super::device_profile::max_audio_channels().to_string(),
),
("SegmentContainer", "ts".to_string()),
("TranscodingContainer", "ts".to_string()),
("TranscodingProtocol", "hls".to_string()),
];
// Only pin an audio track when the user actually picked one. Jellyfin's
// `MediaStream.Index` is global across *all* streams in a media source, so
// index 0 is the video stream on virtually every file — defaulting to 0
// asks the server to transcode the video stream as the audio track, which
// yields a picture with no sound. Omitting the param lets the server use
// the source's `DefaultAudioStreamIndex`.
if let Some(index) = audio_stream_index {
params.push(("AudioStreamIndex", index.to_string()));
}
if let Some(source_id) = media_source_id {
params.push(("MediaSourceId", source_id.to_string()));
}
@@ -438,7 +447,7 @@ impl OnlineRepository {
/// Get an **audio-only** stream URL for a *video* item, for the
/// background-audio handoff (UR-040).
///
/// TRACES: UR-040 | JA-032 | UT-059
/// TRACES: UR-040 | JA-032, DR-140 | UT-059, UT-130
///
/// This deliberately targets `/Audio/{id}/universal`, NOT the video stream:
/// the server extracts/transcodes only the item's audio track and streams
@@ -463,13 +472,10 @@ impl OnlineRepository {
start_time_seconds: Option<f64>,
audio_stream_index: Option<i32>,
) -> Result<String, RepoError> {
let audio_index = audio_stream_index.unwrap_or(0).to_string();
let mut params = vec![
("UserId", self.user_id.clone()),
("api_key", self.access_token.clone()),
("DeviceId", "jellytau-tauri".to_string()),
("AudioStreamIndex", audio_index),
// Progressive mp3 over HTTP — ExoPlayer-friendly; no HLS/ts.
("Container", "mp3".to_string()),
("AudioCodec", "mp3".to_string()),
@@ -478,6 +484,12 @@ impl OnlineRepository {
("MaxStreamingBitrate", "384000".to_string()),
];
// Carry the track over only if one was actually selected — index 0 is the
// video stream, not "the first audio track" (see `get_video_stream_url`).
if let Some(index) = audio_stream_index {
params.push(("AudioStreamIndex", index.to_string()));
}
if let Some(source_id) = media_source_id {
params.push(("MediaSourceId", source_id.to_string()));
}
@@ -1239,7 +1251,11 @@ impl MediaRepository for OnlineRepository {
#[serde(rename_all = "PascalCase")]
struct PlaybackInfoRequest {
user_id: String,
audio_stream_index: i32,
/// Omitted so the server resolves the source's default audio stream.
/// Never send 0 here: the index is global across all streams, so 0 is
/// the video stream and the negotiated source comes back soundless.
#[serde(skip_serializing_if = "Option::is_none")]
audio_stream_index: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
subtitle_stream_index: Option<i32>,
start_time_ticks: i64,
@@ -1256,6 +1272,10 @@ impl MediaRepository for OnlineRepository {
name: String,
max_streaming_bitrate: i64,
max_static_bitrate: i64,
/// Channels the device's audio route can actually voice. Without it
/// the server may direct-play a 5.1 track to a two-channel sink,
/// which is silence or inaudible dialogue depending on the device.
max_audio_channels: String,
direct_play_profiles: Vec<DirectPlayProfile>,
transcoding_profiles: Vec<TranscodingProfile>,
subtitle_profiles: Vec<SubtitleProfile>,
@@ -1283,6 +1303,7 @@ impl MediaRepository for OnlineRepository {
#[serde(skip_serializing_if = "Option::is_none")]
video_codec: Option<String>,
audio_codec: String,
max_audio_channels: String,
}
#[derive(Debug, Serialize)]
@@ -1319,12 +1340,16 @@ impl MediaRepository for OnlineRepository {
index: i32,
#[serde(default)]
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
#[cfg(target_os = "android")]
let (video_codecs, audio_codecs) =
crate::player::get_detected_codecs().unwrap_or_else(|| {
let (video_codecs, audio_codecs) = crate::player::get_detected_codecs()
.map(|(video, audio, _channels)| (video, audio))
.unwrap_or_else(|| {
warn!("[DeviceProfile] Codec detection not complete, using conservative defaults");
("h264,hevc".to_string(), "aac,mp3".to_string())
});
@@ -1332,8 +1357,9 @@ impl MediaRepository for OnlineRepository {
// Linux desktop plays video through the WebKitGTK HTML5 <video> element,
// which cannot reliably decode HEVC/AV1/VP9. Advertise only codecs the
// WebView can decode so Jellyfin transcodes anything else to h264 HLS.
// (Audio-only files still direct-play via MPV, but the PlaybackInfo
// profile is shared, so we keep the broadly-supported audio codecs.)
// (Audio-only files still direct-play via MPV; these codecs are what
// 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"))]
let (video_codecs, audio_codecs) =
("h264".to_string(), "aac,mp3,opus,vorbis,flac".to_string());
@@ -1344,25 +1370,47 @@ impl MediaRepository for OnlineRepository {
"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 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
// multichannel track is downmixed by the server rather than direct-played
// into a sink that has nowhere to put the extra channels.
let max_audio_channels = super::device_profile::max_audio_channels().to_string();
info!("[DeviceProfile] Max audio channels: {}", max_audio_channels);
// Create device profile with detected hardware capabilities
let device_profile = DeviceProfile {
name: "JellyTau Native Player".to_string(),
max_streaming_bitrate: 999_999_999,
max_static_bitrate: 999_999_999,
max_audio_channels: max_audio_channels.clone(),
direct_play_profiles: vec![
DirectPlayProfile {
profile_type: "Video".to_string(),
container: "mp4,mkv,avi,mov,flv,ts,m2ts,webm,ogv,3gp".to_string(),
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 {
profile_type: "Audio".to_string(),
container: "mp3,aac,flac,alac,wav,ogg,wma,opus".to_string(),
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(),
},
],
@@ -1374,6 +1422,7 @@ impl MediaRepository for OnlineRepository {
container: "ts".to_string(),
video_codec: Some("h264,hevc".to_string()),
audio_codec: "aac,mp3".to_string(),
max_audio_channels: max_audio_channels.clone(),
},
TranscodingProfile {
profile_type: "Audio".to_string(),
@@ -1382,6 +1431,7 @@ impl MediaRepository for OnlineRepository {
container: "mp3".to_string(),
video_codec: None,
audio_codec: "mp3".to_string(),
max_audio_channels: max_audio_channels.clone(),
},
],
subtitle_profiles: vec![
@@ -1399,7 +1449,7 @@ impl MediaRepository for OnlineRepository {
// POST to PlaybackInfo with device profile containing detected codecs
let request_body = PlaybackInfoRequest {
user_id: self.user_id.clone(),
audio_stream_index: 0, // Request first audio stream
audio_stream_index: None, // Let the server pick the source default
subtitle_stream_index: None,
start_time_ticks: 0,
is_playback: true,
@@ -1426,13 +1476,35 @@ 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)
let stream_url = if let Some(transcoding_url) = &source.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 {
// Fall back to direct stream URL
// Fall back to direct stream URL. No audioStreamIndex: static=true
// serves the original file untouched, and pinning index 0 (the video
// stream) only misleads servers that do honour it.
format!(
"{}/Videos/{}/stream?static=true&container=mp4&mediaSourceId={}&deviceId=jellytau&api_key={}&audioStreamIndex=0&userId={}",
"{}/Videos/{}/stream?static=true&container=mp4&mediaSourceId={}&deviceId=jellytau&api_key={}&userId={}",
self.server_url,
item_id,
source.id,
@@ -1447,8 +1519,9 @@ impl MediaRepository for OnlineRepository {
media_source_id: source.id.clone(),
play_session_id: response.play_session_id,
stream_url,
direct_play: source.supports_direct_play,
needs_transcoding: !source.supports_direct_play && source.supports_transcoding,
direct_play: source.supports_direct_play && !audio_forces_transcode,
needs_transcoding: audio_forces_transcode
|| (!source.supports_direct_play && source.supports_transcoding),
})
}
@@ -1858,6 +1931,48 @@ impl MediaRepository for OnlineRepository {
result
}
/// `POST /Users/{userId}/PlayedItems/{itemId}` — the mirror image of
/// `clear_watch_history`.
///
/// TRACES: UR-025 | DR-131 | JA-035
async fn mark_played(&self, item_id: &str) -> Result<(), RepoError> {
let endpoint = format!("/Users/{}/PlayedItems/{}", self.user_id, item_id);
let url = format!("{}{}", self.server_url, endpoint);
let result = async {
let request = self
.http_client
.client
.post(&url)
.header("X-Emby-Authorization", self.auth_header())
.header("Content-Length", "0")
.build()
.map_err(|e| RepoError::Network {
message: format!("Failed to build request: {}", e),
})?;
let response = self
.http_client
.request_with_retry(request)
.await
.map_err(|e| RepoError::Network {
message: e.to_string(),
})?;
if !response.status().is_success() {
return Err(RepoError::Server {
message: format!("HTTP {}", response.status()),
});
}
Ok(())
}
.await;
self.report_outcome(&result).await;
result
}
async fn get_person(&self, person_id: &str) -> Result<MediaItem, RepoError> {
let endpoint = format!("/Users/{}/Items/{}", self.user_id, person_id);
let item: JellyfinItem = self.get_json(&endpoint).await?;
@@ -2267,8 +2382,14 @@ mod tests {
assert!(url.starts_with("https://test.server.com/Videos/vid-1/master.m3u8?"));
assert!(!url.contains("StartTimeTicks"));
assert!(!url.contains("MediaSourceId"));
// Defaults to first audio stream
assert!(url.contains("AudioStreamIndex=0"));
// With no track chosen, the param must be OMITTED so the server picks the
// source's DefaultAudioStreamIndex. `MediaStream.Index` is global across
// all streams of a source, so index 0 is the *video* stream on virtually
// every file — sending it asks for a "audio track" that has no audio.
assert!(
!url.contains("AudioStreamIndex"),
"must not pin an audio index when none was chosen: {url}"
);
}
#[tokio::test]
@@ -2325,8 +2446,12 @@ mod tests {
assert!(url.starts_with("https://test.server.com/Audio/vid-1/universal?"));
assert!(!url.contains("StartTimeTicks"));
assert!(!url.contains("MediaSourceId"));
// Defaults to first audio stream.
assert!(url.contains("AudioStreamIndex=0"));
// Same as the video path: omit rather than pin index 0 (the video stream),
// and let the server fall back to the source's default audio stream.
assert!(
!url.contains("AudioStreamIndex"),
"must not pin an audio index when none was chosen: {url}"
);
}
#[tokio::test]
+10
View File
@@ -0,0 +1,10 @@
{
"$schema": "https://schema.tauri.app/config/2",
"app": {
"windows": [
{
"transparent": true
}
]
}
}
+6 -2
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "jellytau",
"version": "0.4.1",
"version": "0.4.8",
"identifier": "com.dtourolle.jellytau",
"build": {
"beforeDevCommand": "bun run dev",
@@ -18,7 +18,11 @@
}
],
"security": {
"csp": null
"csp": null,
"assetProtocol": {
"enable": true,
"scope": ["$APPDATA/**"]
}
}
},
"bundle": {
+24
View File
@@ -51,6 +51,30 @@ html, body {
background-color: var(--color-background);
}
/* Native-video compositing (Android).
*
* TRACES: UR-003, UR-004 | DR-150
*
* When ExoPlayer renders into a SurfaceView *behind* the WebView, every opaque
* layer between the viewport and that surface hides the video. The WebView
* itself is made transparent by `"transparent": true` in
* tauri.android.conf.json; these rules clear the app's own painted backgrounds.
*
* Scoped to `[data-native-video="active"]` set on <html> by
* $lib/stores/nativeVideo.ts only while a native video session is on screen
* because every other screen genuinely needs its opaque background. The app
* shell (+layout.svelte) also paints --color-background across the viewport, so
* it is cleared here too; the shell is the layer directly over the surface.
*
* `background: transparent` (not a colour) is required: an alpha-0 colour still
* composites in some WebView versions.
*/
html[data-native-video="active"],
html[data-native-video="active"] body,
html[data-native-video="active"] [data-app-shell] {
background: transparent !important;
}
body {
@apply text-white antialiased;
font-family: system-ui, -apple-system, sans-serif;
+127 -3
View File
@@ -143,6 +143,14 @@ async playerGetStatus() : Promise<PlayerStatus> {
async playerGetQueue() : Promise<QueueStatus> {
return await TAURI_INVOKE("player_get_queue");
},
/**
* Report this platform's playback capabilities to the frontend.
*
* TRACES: UR-003, UR-005 | DR-004, DR-023, DR-024
*/
async playerGetCapabilities() : Promise<PlaybackCapabilities> {
return await TAURI_INVOKE("player_get_capabilities");
},
async playerAddToQueue(request: AddToQueueRequest) : Promise<QueueStatus> {
return await TAURI_INVOKE("player_add_to_queue", { request });
},
@@ -873,6 +881,22 @@ async markDownloadCompleted(downloadId: number, bytesDownloaded: number, filePat
async markDownloadFailed(downloadId: number, errorMessage: string) : Promise<null> {
return await TAURI_INVOKE("mark_download_failed", { downloadId, errorMessage });
},
/**
* A playable URL for a downloaded file on disk.
*
* Local media is served over a loopback HTTP server rather than handed to the
* webview as a `file://`/asset URL, because the asset protocol cannot stream a
* large file it answers a range-less request with the whole thing, which
* Chromium abandons. See `media_server` for why real HTTP is used.
*
* The returned URL carries the server's per-session token, so it is only valid
* for this run of the app and must not be persisted.
*
* TRACES: UR-071 | DR-137
*/
async mediaLocalUrl(path: string) : Promise<string> {
return await TAURI_INVOKE("media_local_url", { path });
},
/**
* Start downloading a file immediately
* This command actually downloads the file using the worker
@@ -1130,6 +1154,14 @@ async syncMarkFailed(id: number, error: string) : Promise<null> {
async syncGetPendingCount(userId: string) : Promise<number> {
return await TAURI_INVOKE("sync_get_pending_count", { userId });
},
/**
* Push the queue now, on the user's say-so, instead of waiting for a reconnect.
*
* TRACES: UR-025 | DR-132
*/
async syncProcessPending() : Promise<DrainReport> {
return await TAURI_INVOKE("sync_process_pending");
},
/**
* Delete completed sync operations older than specified days
*/
@@ -1879,6 +1911,26 @@ export type DownloadVideoRequest = { itemId: string; userId: string; filePath: s
* Enhanced response with pre-computed stats
*/
export type DownloadsResponse = { downloads: DownloadInfo[]; stats: DownloadStats }
/**
* What a drain did, for logging and for the frontend's "Sync now" button.
*/
export type DrainReport = {
/**
* Rows that reached the server and are now `completed`.
*/
pushed: number;
/**
* Rows that failed and will be retried on the next reconnect.
*/
deferred: number;
/**
* Rows that exhausted `MAX_SYNC_ATTEMPTS` and were given up on.
*/
abandoned: number;
/**
* Rows still waiting afterwards (what the badge counts).
*/
remaining: number }
/**
* Built-in equalizer presets. A preset *is* a gain curve defined by the band
* layout above (a domain concept), not a mere label the curve numbers live
@@ -2187,7 +2239,29 @@ itemType?: string | null;
* Series ID for TV episodes. Needed alongside `item_type` so the backend can
* 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?
*/
@@ -2227,6 +2301,30 @@ export type PlayTracksRequest = { trackIds: string[]; startIndex: number; shuffl
* over playback from a remote session so we don't restart from 0.
*/
startPosition?: number | null }
/**
* What playback facilities this platform's backend actually provides.
*
* The frontend is presentation-only and must not re-derive backend facts from
* `navigator.userAgent` that sniffing was a second copy of the same platform
* decision Rust already makes with `cfg!`, and it drifted. These flags are the
* single source of truth; the frontend consumes them.
*
* TRACES: UR-003, UR-005 | DR-004, DR-023, DR-024
*/
export type PlaybackCapabilities = {
/**
* True when audio is rendered by a webview `<audio>` element rather than a
* native backend. Native audio exists on Linux (mpv) and Android
* (ExoPlayer); everything else (Windows, future desktops) uses the webview.
*/
usesWebviewAudio: boolean;
/**
* True when video can be rendered by a native surface composited *behind*
* a transparent webview. Android only: ExoPlayer draws into a SurfaceView
* beneath the WebView. Linux cannot do this (WebKitGTK/Wayland
* compositing), so it stays on the HTML5 element.
*/
supportsNativeVideo: boolean }
/**
* Playback information
*/
@@ -2725,6 +2823,23 @@ export type StreamKind = "audio" | "video" | "subtitle" |
"other"
/**
* 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 = {
/**
@@ -2744,13 +2859,22 @@ language: 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 }
/**
* Sync queue item returned to frontend
*/
export type SyncQueueItem = { id: number; userId: string; operation: string; itemId: string | null; payload: string | null; status: string; retryCount: number; createdAt: string | null; errorMessage: string | null }
export type SyncQueueItem = { id: number; userId: string; operation: string; itemId: string | null; payload: string | null; status: string; retryCount: number; createdAt: string | null; errorMessage: string | null;
/**
* Cached title of the item the operation is about, when the catalog knows
* it. Resolved here rather than by a per-row frontend fetch the queue
* list is otherwise a wall of opaque ids.
*
* TRACES: UR-025 | DR-132
*/
itemName: string | null }
/**
* Statistics about the thumbnail cache
*/
+12 -9
View File
@@ -1,19 +1,22 @@
<!--
Shared application header. Lifted out of the library layout so the account
menu (and desktop nav) are available on every authenticated, non-immersive
screen, not only under /library. Routes that need in-header search (the
library layout) pass it in via the `search` snippet; other routes omit it.
screen, not only under /library.
The search box is owned here rather than passed in by a layout, so the same
bar renders on the library routes and on /search — searching from the header
no longer hands you to a screen with a different input.
TRACES: UR-054 | DR-076
-->
<script lang="ts">
import type { Snippet } from "svelte";
import { page } from "$app/stores";
import AccountMenu from "$lib/components/account/AccountMenu.svelte";
let { search }: { search?: Snippet } = $props();
import HeaderSearch from "$lib/components/search/HeaderSearch.svelte";
import { showHeaderSearch } from "$lib/utils/layoutShell";
const pathname = $derived($page.url.pathname);
const withSearch = $derived(showHeaderSearch({ pathname }));
</script>
<header class="sticky top-0 z-50 bg-[var(--color-background)]/95 backdrop-blur border-b border-gray-800 flex-shrink-0">
@@ -51,10 +54,10 @@
</a>
</nav>
<!-- Optional in-header search (library layout supplies it). -->
{#if search}
<div class="flex-1 max-w-md hidden md:block space-y-2">
{@render search()}
<!-- In-header search: the single md+ search input (library + /search). -->
{#if withSearch}
<div class="flex-1 max-w-md hidden md:block">
<HeaderSearch />
</div>
{/if}
+9 -1
View File
@@ -3,9 +3,16 @@
value?: string;
placeholder?: string;
onSearch?: (query: string) => void;
/** Exposed so a parent can focus/position the caret (see HeaderSearch). */
inputEl?: HTMLInputElement | null;
}
let { value = $bindable(""), placeholder = "Search...", onSearch }: Props = $props();
let {
value = $bindable(""),
placeholder = "Search...",
onSearch,
inputEl = $bindable(null),
}: Props = $props();
let debounceTimer: ReturnType<typeof setTimeout>;
@@ -39,6 +46,7 @@
</div>
<input
bind:this={inputEl}
type="text"
{value}
{placeholder}
@@ -1,11 +1,23 @@
<!-- TRACES: UR-048 | DR-061, DR-062 -->
<!--
The one and only episode surface (ux-flows §5B.1) — so it carries everything
an episode can do, not just Play. A bare Episode page used to exist alongside
it with a *different* set of affordances (download, breadcrumbs, cast), which
meant opening an episode from Continue Watching silently lost them.
TRACES: UR-048, UR-058 | DR-061, DR-062, DR-142
-->
<script lang="ts">
import { goto } from "$app/navigation";
import { truncateMiddle } from "$lib/utils/truncateMiddle";
import type { MediaItem } from "$lib/api/types";
import CachedImage from "$lib/components/common/CachedImage.svelte";
import FavoriteButton from "$lib/components/FavoriteButton.svelte";
import VideoDownloadButton from "./VideoDownloadButton.svelte";
import CastSection from "./CastSection.svelte";
import GenreTags from "./GenreTags.svelte";
import RelatedItemsSection from "./RelatedItemsSection.svelte";
import { favoriteOverrides, resolveIsFavorite } from "$lib/stores/favorites";
import { seasonAnchorId } from "./seriesNavigation";
import {
isCurrentEpisode as isSameEpisode,
adjacentEpisodes as computeAdjacent,
@@ -14,12 +26,17 @@
interface Props {
episode: MediaItem;
series: MediaItem;
allEpisodes: MediaItem[];
/**
* The parent series. `null` only for an episode that carries no `seriesId`
* (a deep link into a stale cache) — the view still renders, minus the
* affordances that need series context.
*/
series?: MediaItem | null;
allEpisodes?: MediaItem[];
onBack?: () => void;
}
let { episode, series, allEpisodes, onBack }: Props = $props();
let { episode, series = null, allEpisodes = [], onBack }: Props = $props();
// Pure logic lives in ./episodeStrip.ts (unit-tested). Wrap for local use.
function isCurrentEpisode(ep: MediaItem): boolean {
@@ -28,6 +45,10 @@
const adjacentEpisodes = $derived(() => computeAdjacent(episode, allEpisodes));
// A strip of exactly one card is the current episode talking to itself — the
// spec wants the *next* episodes, so with no siblings there is nothing to show.
const hasEpisodeStrip = $derived(adjacentEpisodes().length > 1);
// Compute best backdrop source (no fetch, pure derivation)
const backdropSource = $derived.by(() => {
if (episode.backdropImageTags?.[0]) {
@@ -36,12 +57,28 @@
if (episode.imageId) {
return { itemId: episode.id, imageType: "Primary" as const, tag: episode.imageId };
}
if (series.backdropImageTags?.[0]) {
if (series?.backdropImageTags?.[0]) {
return { itemId: series.id, imageType: "Backdrop" as const, tag: series.backdropImageTags[0] };
}
return null;
});
// Cast and genres are the episode's own when the server sent them, else the
// series' — a list-level episode fetch often carries neither, and an empty
// Cast row on an episode of a show with a known cast reads as broken.
const people = $derived(episode.people?.length ? episode.people : series?.people ?? []);
const genres = $derived(episode.genres?.length ? episode.genres : series?.genres ?? []);
// "More Like This" on an episode means similar *shows* (UR-048), so it keys
// off the series rather than the episode.
const seriesName = $derived(series?.name ?? episode.seriesName ?? null);
const seriesHref = $derived(series ? `/library/${series.id}` : null);
const seasonHref = $derived(
series && episode.parentIndexNumber != null
? `/library/${series.id}#${seasonAnchorId(episode.parentIndexNumber)}`
: null
);
function formatDuration(ms?: number | null): string {
if (!ms) return "";
const seconds = Math.floor(ms / 1000);
@@ -66,6 +103,7 @@
}
function handleEpisodeClick(ep: MediaItem) {
if (!series) return;
goto(`/library/${series.id}?episode=${ep.id}`);
}
@@ -112,8 +150,19 @@
<!-- Content -->
<div class="relative h-full flex flex-col justify-end p-8 max-w-3xl">
<div class="space-y-4">
<!-- Series name -->
<p class="text-gray-300 text-lg">{series.name}</p>
<!-- Series name — a link, so the episode page is a navigable hub
rather than a dead end (UR-058). -->
{#if seriesName}
<p class="text-lg">
{#if seriesHref}
<a href={seriesHref} class="text-gray-300 hover:text-white hover:underline transition-colors">
{seriesName}
</a>
{:else}
<span class="text-gray-300">{seriesName}</span>
{/if}
</p>
{/if}
<!-- Episode title -->
<h1 class="text-4xl font-bold text-white drop-shadow-lg">
@@ -122,9 +171,20 @@
<!-- Metadata -->
<div class="flex items-center gap-4 text-sm text-gray-200">
<span class="px-2 py-1 bg-[var(--color-jellyfin)] rounded font-semibold">
{episodeLabel}
</span>
<!-- The badge links to the season's place in the series list —
seasons have no page of their own (DR-103). -->
{#if seasonHref}
<a
href={seasonHref}
class="px-2 py-1 bg-[var(--color-jellyfin)] rounded font-semibold hover:brightness-110 transition-all"
>
{episodeLabel}
</a>
{:else}
<span class="px-2 py-1 bg-[var(--color-jellyfin)] rounded font-semibold">
{episodeLabel}
</span>
{/if}
{#if duration}
<span>{duration}</span>
{/if}
@@ -168,7 +228,8 @@
</div>
{/if}
<!-- Play button + favourite. TRACES: UR-068 | DR-119 -->
<!-- Play / Download / Favourite — the full hero action row of
ux-flows §5B.2. TRACES: UR-058, UR-068 | DR-119, DR-142 -->
<div class="pt-2 flex items-center gap-3">
<button
onclick={handlePlay}
@@ -179,6 +240,16 @@
</svg>
{progress > 0 && progress < 95 ? "Resume" : "Play"}
</button>
<VideoDownloadButton
itemId={episode.id}
itemName={episode.name}
isMovie={false}
seriesName={seriesName ?? undefined}
seasonName={episode.seasonName ?? undefined}
seasonNumber={episode.parentIndexNumber ?? undefined}
episodeNumber={episode.indexNumber ?? undefined}
size="lg"
/>
<FavoriteButton
itemId={episode.id}
isFavorite={resolveIsFavorite(episode, $favoriteOverrides)}
@@ -189,7 +260,10 @@
</div>
</div>
<!-- Adjacent episodes -->
<!-- Adjacent episodes. Nothing may be inserted between the hero and this
strip — continuation content comes before discovery content
(ux-flows §5B.2). TRACES: UR-048 | DR-061, DR-062 -->
{#if hasEpisodeStrip}
<div class="space-y-4">
<h2 class="text-xl font-semibold text-white">More Episodes</h2>
@@ -273,4 +347,27 @@
{/each}
</div>
</div>
{/if}
<!-- Discovery content, strictly below the episode strip (ux-flows §5B.2:
hero → strip → cast → similar). TRACES: UR-048 | DR-062, DR-142 -->
{#if genres.length}
<GenreTags {genres} maxShow={6} itemKind="episode" />
{/if}
{#if people.length}
<CastSection {people} />
{/if}
<!-- "More Like This" on an episode means similar shows, so it keys off the
series. Skipped for a series-less episode, which has nothing to match on. -->
{#if series && (series.genres?.length || series.people?.length)}
<RelatedItemsSection
currentItemId={series.id}
itemKind="series"
genres={series.genres ?? undefined}
people={series.people ?? undefined}
limit={12}
/>
{/if}
</div>
@@ -0,0 +1,175 @@
// The Episode Focus View is the *only* episode surface (ux-flows §5B.1), so it
// has to carry everything the bare Episode page used to: download, breadcrumbs
// back to the series/season, cast and similar shows. It shipped with only Play
// and Favourite, which is why "open an episode from Continue Watching" lost the
// download affordance.
//
// TRACES: UR-048, UR-058 | DR-062, DR-142 | UT-131, UT-132, UT-133, UT-134, UT-135
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/svelte";
import type { MediaItem } from "$lib/api/types";
const h = vi.hoisted(() => {
function shim<T>(initial: T) {
let value = initial;
const subs = new Set<(v: T) => void>();
return {
set(v: T) {
value = v;
subs.forEach((fn) => fn(value));
},
subscribe(fn: (v: T) => void) {
subs.add(fn);
fn(value);
return () => subs.delete(fn);
},
};
}
return {
downloadsStore: shim({ downloads: {} as Record<string, unknown> }),
favoriteOverridesStore: shim(new Map<string, boolean>()),
getSimilarItems: vi.fn(async () => ({ items: [] as MediaItem[] })),
search: vi.fn(async () => ({ items: [] as MediaItem[] })),
};
});
vi.mock("$lib/stores/downloads", () => ({
downloads: {
subscribe: h.downloadsStore.subscribe,
downloadVideo: vi.fn(),
pinItem: vi.fn(),
unpinItem: vi.fn(),
delete: vi.fn(),
cancel: vi.fn(),
},
}));
vi.mock("$lib/stores/favorites", async () => {
const actual = await vi.importActual<typeof import("$lib/stores/favorites")>(
"$lib/stores/favorites"
);
return { ...actual, favoriteOverrides: { subscribe: h.favoriteOverridesStore.subscribe } };
});
vi.mock("$lib/stores/auth", () => ({
auth: {
getRepository: () => ({ getSimilarItems: h.getSimilarItems, search: h.search }),
getUserId: () => "user-1",
},
user: { subscribe: (fn: (v: unknown) => void) => (fn({ id: "user-1" }), () => {}) },
}));
// CachedImage does async repo/image work irrelevant to these tests.
vi.mock("$lib/components/common/CachedImage.svelte", async () => ({
default: (await import("./__mocks__/StubImage.svelte")).default,
}));
import EpisodeFocusView from "./EpisodeFocusView.svelte";
const SERIES: MediaItem = {
id: "series-1",
name: "The Show",
kind: "series",
genres: ["Drama"],
people: [{ id: "p-1", name: "Lead Actor", type: "Actor" }],
} as unknown as MediaItem;
function episode(overrides: Partial<MediaItem> = {}): MediaItem {
return {
id: "ep-4",
name: "The Fourth One",
kind: "episode",
seriesId: "series-1",
seriesName: "The Show",
parentIndexNumber: 2,
indexNumber: 4,
durationMs: 2_880_000,
overview: "Something happens.",
genres: ["Drama"],
people: [{ id: "p-1", name: "Lead Actor", type: "Actor" }],
...overrides,
} as unknown as MediaItem;
}
function sibling(id: string, number: number): MediaItem {
return {
id,
name: `Episode ${number}`,
kind: "episode",
seriesId: "series-1",
parentIndexNumber: 2,
indexNumber: number,
} as unknown as MediaItem;
}
const allEpisodes = [sibling("ep-3", 3), episode(), sibling("ep-5", 5)];
describe("EpisodeFocusView — full episode functionality (DR-142)", () => {
beforeEach(() => {
vi.clearAllMocks();
h.downloadsStore.set({ downloads: {} });
h.favoriteOverridesStore.set(new Map());
});
it("offers a download control in the hero", () => {
render(EpisodeFocusView, {
props: { episode: episode(), series: SERIES, allEpisodes },
});
expect(screen.getByLabelText(/Download for offline playback/i)).toBeTruthy();
});
it("links the series name back to the series page", () => {
render(EpisodeFocusView, {
props: { episode: episode(), series: SERIES, allEpisodes },
});
const link = screen.getByRole("link", { name: "The Show" });
expect(link.getAttribute("href")).toBe("/library/series-1");
});
it("links the season badge to the season's place in the series list", () => {
render(EpisodeFocusView, {
props: { episode: episode(), series: SERIES, allEpisodes },
});
const link = screen.getByRole("link", { name: "S2E4" });
expect(link.getAttribute("href")).toBe("/library/series-1#season-2");
});
it("renders cast below the episode strip, never above it (DR-062)", () => {
const { container } = render(EpisodeFocusView, {
props: { episode: episode(), series: SERIES, allEpisodes },
});
const headings = [...container.querySelectorAll("h2")].map((h2) => h2.textContent?.trim());
const strip = headings.indexOf("More Episodes");
const cast = headings.findIndex((t) => t?.startsWith("Cast"));
expect(strip).toBeGreaterThanOrEqual(0);
expect(cast).toBeGreaterThan(strip);
});
it("hides the episode strip when the episode has no siblings", () => {
render(EpisodeFocusView, {
props: { episode: episode(), series: SERIES, allEpisodes: [] },
});
expect(screen.queryByText("More Episodes")).toBeNull();
});
it("renders without a series for an episode that carries no seriesId", () => {
render(EpisodeFocusView, {
props: {
episode: episode({ seriesId: null, seriesName: null }),
series: null,
allEpisodes: [],
},
});
// Still a complete surface: title, play and download all present.
expect(screen.getByText("The Fourth One")).toBeTruthy();
expect(screen.getByLabelText(/Download for offline playback/i)).toBeTruthy();
expect(screen.queryByRole("link", { name: "The Show" })).toBeNull();
});
});
@@ -11,6 +11,7 @@
import ResultsCounter from "$lib/components/common/ResultsCounter.svelte";
import CachedImage from "$lib/components/common/CachedImage.svelte";
import { useServerReachabilityReload } from "$lib/composables/useServerReachabilityReload";
import { useOfflineFilterReload } from "$lib/composables/useOfflineFilterReload";
import type { Genre, MediaItem, ItemType } from "$lib/api/types";
/**
@@ -52,12 +53,16 @@
let selectedGenre = $state<Genre | null>(null);
let genreItems = $state<MediaItem[]>([]);
let loadingItems = $state(false);
const { markLoaded } = useServerReachabilityReload(async () => {
async function reloadGenreBrowse() {
await loadGenres();
if (selectedGenre) {
await loadGenreItems(selectedGenre);
}
});
}
const { markLoaded } = useServerReachabilityReload(reloadGenreBrowse);
// Re-query when the offline downloaded-only gate changes. TRACES: UR-052 | DR-143
useOfflineFilterReload(reloadGenreBrowse);
onMount(async () => {
await loadGenres();
@@ -14,6 +14,7 @@
import BackButton from "$lib/components/common/BackButton.svelte";
import ResultsCounter from "$lib/components/common/ResultsCounter.svelte";
import { useServerReachabilityReload } from "$lib/composables/useServerReachabilityReload";
import { useOfflineFilterReload } from "$lib/composables/useOfflineFilterReload";
import type { MediaItem, Library, ItemType, SearchResult } from "$lib/api/types";
import LibraryGrid from "./LibraryGrid.svelte";
import TrackList from "./TrackList.svelte";
@@ -95,6 +96,11 @@
await loadItems();
});
// Re-query when the offline downloaded-only gate changes — going offline, or
// toggling "Show all server media". Without this the listing kept whatever it
// was first loaded with and the toggle only greyed cards. TRACES: UR-052 | DR-143
useOfflineFilterReload(() => loadItems());
onMount(async () => {
await loadItems();
markLoaded();
@@ -1,9 +1,11 @@
// TRACES: UR-062 | DR-102, DR-103, DR-142 | UT-136
import { describe, it, expect } from "vitest";
import type { MediaItem } from "$lib/api/types";
import {
seasonAnchorId,
seasonRedirectTarget,
episodeFocusHref,
episodeRedirectTarget,
seriesPlayHref,
seriesPlayLabel,
groupEpisodesBySeason,
@@ -103,6 +105,17 @@ describe("episodeFocusHref", () => {
});
});
describe("episodeRedirectTarget", () => {
it("sends a bare episode page to the episode inside its series", () => {
expect(episodeRedirectTarget(ep("s1e2", 1, 2))).toBe("/library/series-1?episode=s1e2");
});
it("does not redirect an episode that has no series to fall back on", () => {
const orphan = { ...ep("lone", 1, 2), seriesId: undefined } as MediaItem;
expect(episodeRedirectTarget(orphan)).toBeNull();
});
});
describe("groupEpisodesBySeason", () => {
it("groups episodes under their season headers, in season order", () => {
const seasons = [seasonHeader(2), seasonHeader(1)];
+16 -1
View File
@@ -10,7 +10,7 @@
// lives in Rust (`repository_get_series_current_episode`); this module only
// renders and routes around the answer.
//
// TRACES: UR-062 | DR-102, DR-103
// TRACES: UR-062 | DR-102, DR-103, DR-142
import type { MediaItem } from "$lib/api/types";
export interface SeasonData {
@@ -57,6 +57,21 @@ export function episodeFocusHref(episode: MediaItem): string {
return `/library/${episode.seriesId}?episode=${episode.id}`;
}
/**
* Where a bare `/library/<episodeId>` should actually land the same rule
* seasons follow (DR-103). An episode is never a page of its own, so a deep
* link, a stale bookmark, or any caller that missed `episodeFocusHref` is
* redirected into the series' Episode Focus View.
*
* Returns `null` for an episode with no `seriesId` (a deep link into a stale
* cache): there is nothing to redirect *to*, so the caller renders the Focus
* View series-less rather than stranding the user (ux-flows §5B.1).
*/
export function episodeRedirectTarget(episode: MediaItem): string | null {
if (!episode.seriesId) return null;
return episodeFocusHref(episode);
}
/**
* Where the series hero button goes.
*
+201 -76
View File
@@ -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 -->
<script lang="ts">
import { onMount, onDestroy, untrack } from "svelte";
import { onMount, onDestroy, tick, untrack } from "svelte";
import { get } from "svelte/store";
import { goto } from "$app/navigation";
import { commands } from "$lib/api/bindings";
@@ -14,12 +14,30 @@
import SleepTimerIndicator from "./SleepTimerIndicator.svelte";
import CachedImage from "../common/CachedImage.svelte";
import { videoFitClass } from "./videoFit";
import {
resolveSubtitleTracks,
reconcileSelectedSubtitle,
videoCrossOriginMode,
nativeSubtitleTracks,
nativeSubtitleArrayIndex,
type RenderableSubtitleTrack,
} from "./subtitleTracks";
import { sleepTimerActive, sleepTimerExpiredSignal } from "$lib/stores/sleepTimer";
import { playbackPosition, playerState } from "$lib/stores/player";
import * as html5Adapter from "$lib/player/html5Adapter";
import { playerController } from "$lib/player";
import { Html5PlayerAdapter, type Html5ElementBridge } from "$lib/player/adapters";
import {
createAdapter,
Html5PlayerAdapter,
type PlayerAdapter,
type Html5ElementBridge,
} from "$lib/player/adapters";
import { createRustReportHost } from "$lib/player/adapters/rustReportHost";
import { experimentalNativeVideo } from "$lib/stores/nativeVideo";
import {
enableNativeVideoCompositing,
disableNativeVideoCompositing,
} from "$lib/utils/videoSurface";
import { isPipSupported, enterPip, setAutoEnterEnabled } from "$lib/utils/pictureInPicture";
import {
createTapGestureState,
@@ -158,7 +176,10 @@
// VideoPlayer supplies a narrow bridge for the element/HLS-coupled parts and
// registers the adapter with the facade so control intents — from UI OR from a
// backend control event (lockscreen/remote/sleep) — reach this element.
let playerAdapter: Html5PlayerAdapter | null = null;
// Widened from Html5PlayerAdapter: the native path registers a
// NativePlayerAdapter here. Element-coupled work is guarded by
// `useHtml5Element`, not by narrowing this type.
let playerAdapter: PlayerAdapter | null = null;
function tearDownHls() {
if (hls) {
@@ -275,6 +296,61 @@
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)
let lastStreamUrlProp = $state("");
@@ -544,28 +620,23 @@
console.log("[VideoPlayer] Initializing player for:", media.name);
console.log("[VideoPlayer] Stream URL:", currentStreamUrl);
// Build subtitle tracks for native player
const subtitleTracks = [];
if (media.mediaStreams && mediaSourceId) {
const subtitles = media.mediaStreams.filter(s => s.kind === "subtitle");
for (const sub of subtitles) {
try {
const url = await getSubtitleUrl(sub.index);
if (url) {
subtitleTracks.push({
index: sub.index,
url: url,
language: sub.language || null,
label: sub.displayTitle || sub.language || `Track ${sub.index}`,
mime_type: "text/vtt" // Jellyfin converts to WebVTT
});
}
} catch (err) {
console.warn(`[VideoPlayer] Failed to build subtitle URL for track ${sub.index}:`, err);
}
}
console.log(`[VideoPlayer] Built ${subtitleTracks.length} subtitle tracks for native player`);
}
// Resolve subtitle URLs for the native (ExoPlayer) path. These must be
// in hand *before* the play request: ExoPlayer sideloads subtitles as
// MediaItem.SubtitleConfigurations, which have to exist before
// prepare() — there is no way to add one to a loaded item afterwards.
//
// Awaiting here is safe despite the native-mode pitfall: that rule is
// about Svelte *lifecycle* calls (onMount/onDestroy) after an await,
// which throw lifecycle_outside_component and used to be misread as an
// init failure. Nothing is registered here, and the background-audio
// subscriptions above already ran synchronously. resolveSubtitleTracks
// fans the requests out in parallel, so this costs one round trip, not
// one per subtitle stream as the old serial loop did.
// TRACES: UR-020 | IR-016, JA-008 | UT-147
sentSubtitleTracks = mediaSourceId
? await resolveSubtitleTracks(media.mediaStreams, (index) => getSubtitleUrl(index))
: [];
console.log(`[VideoPlayer] Sending ${sentSubtitleTracks.length} subtitle tracks to the backend`);
// Call Rust backend to start playback
// Rust will choose ExoPlayer (Android), libmpv (Linux), or tell us to use HTML5
@@ -576,6 +647,10 @@
id: media.id,
videoCodec: needsTranscoding ? "hevc" : "h264",
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
@@ -583,15 +658,16 @@
backendChosen = true;
console.log(`[VideoPlayer] Backend: ${response.backend}, useHtml5Element: ${useHtml5Element}`);
// INTERIM (until the video-player API refactor lands): always render
// through the webview HTML5 element, including Android. The native
// ExoPlayer SurfaceView sits behind an opaque webview and has never
// actually been visible (an init bug kept the app on the HTML5 path
// since the POC), so true native mode plays audio behind a frozen
// picture. Stop the native backend and let the webview own playback,
// matching Linux behavior and avoiding dual audio.
if (!useHtml5Element) {
console.warn("[VideoPlayer] Native video backend reported - overriding to HTML5 rendering (native surface not visible through webview)");
// Rust reported a native backend (Android/ExoPlayer). Honour it only if
// the user opted into the experimental native path; otherwise fall back
// to the webview element, which is what shipped by default.
//
// The flag is a suppressor, never a promoter — see createAdapter(). When
// it is off we must also stop the native backend that player_play_item
// just started, or ExoPlayer and the <video> element both decode the
// same stream and the audio doubles.
if (!useHtml5Element && !$experimentalNativeVideo) {
console.log("[VideoPlayer] Native backend available but experimentalNativeVideo is off - using HTML5");
useHtml5Element = true;
try {
await commands.playerStop();
@@ -599,6 +675,14 @@
} catch (err) {
console.warn("[VideoPlayer] Failed to stop native backend:", err);
}
} else if (!useHtml5Element) {
// Native path: clear the opaque layers between the viewport and the
// ExoPlayer SurfaceView (webview widget background + page background).
// Paired with disableNativeVideoCompositing() in the teardown path —
// leaving this on renders the rest of the app over a transparent
// window.
console.log("[VideoPlayer] Using native ExoPlayer video surface");
enableNativeVideoCompositing();
}
// If using HTML5 element for non-transcoded content, stop the backend player
@@ -617,14 +701,24 @@
didStartNativePlayback = true; // Track that we need to stop backend on unmount
}
// Register the HTML5 player adapter with the facade so control intents
// (UI or backend lockscreen/remote/sleep events) route to this element.
if (useHtml5Element) {
// Register the adapter with the facade so control intents (UI, or a
// backend lockscreen/remote/sleep event) route to whatever is actually
// rendering. Both paths need one: the native adapter forwards control
// intents to ExoPlayer over IPC.
{
const host = createRustReportHost(media.id, {
onEnded: () => notifyEnded(),
onStreamUrlChanged: (u) => { currentStreamUrl = u; },
});
playerAdapter = new Html5PlayerAdapter(host, adapterBridge);
playerAdapter = createAdapter({
backendKind: useHtml5Element ? "html5" : "native",
host,
bridge: adapterBridge,
// useHtml5Element is already the resolved decision above, so the
// flag has had its say; pass it through for the invariant check.
experimentalNativeVideo: $experimentalNativeVideo,
});
// No-op for the native adapter, which owns no DOM element.
playerAdapter.attach(videoElement);
playerController.setActiveAdapter(playerAdapter);
}
@@ -718,6 +812,14 @@
});
onDestroy(async () => {
// FIRST, and synchronously: restore the opaque webview/page backgrounds.
//
// This callback is async, so anything after an `await` may run a frame or
// more later. Leaving the window transparent for even that long shows the
// launcher/wallpaper through the app as the player unwinds. Unconditional
// and idempotent — a no-op when compositing was never enabled.
disableNativeVideoCompositing();
// Stop RAF loop
stopTimeUpdates();
@@ -1670,41 +1772,59 @@
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;
showSubtitleMenu = false;
// For HTML5 video element, update the text tracks
if (useHtml5Element && videoElement && videoElement.textTracks) {
// Disable all text tracks first
for (let i = 0; i < videoElement.textTracks.length; i++) {
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) {
if (useHtml5Element) {
applySubtitleToElement(streamIndex);
} else {
// For native backend (Android), send command to change subtitle track
try {
// Use array index for ExoPlayer (0-based position in subtitle tracks array)
// If streamIndex is null (disable), pass null; otherwise use arrayIndex
const indexToUse = streamIndex === null ? null : (arrayIndex !== undefined ? arrayIndex : streamIndex);
const indexToUse = nativeSubtitleArrayIndex(sentSubtitleTracks, streamIndex);
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) {
console.error("[VideoPlayer] Failed to set subtitle track:", error);
}
@@ -1743,6 +1863,7 @@
<video
bind:this={videoElement}
src={currentStreamUrl.includes('.m3u8') && Hls.isSupported() ? '' : currentStreamUrl}
crossorigin={videoCrossOrigin}
class={videoFitClass()}
class:invisible={!isMediaReady}
style="filter: brightness({brightness})"
@@ -1761,18 +1882,22 @@
onloadstart={handleLoadStart}
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
kind="subtitles"
src={getSubtitleUrl(track.index)}
srclang={track.language || "unknown"}
label={track.displayTitle || track.language || `Track ${track.index}`}
data-stream-index={track.index}
default={track.isDefault}
src={track.url}
srclang={track.srclang}
label={track.label}
data-stream-index={track.streamIndex}
/>
{/each}
-->
</video>
{:else}
<!-- Android ExoPlayer - video rendered natively in SurfaceView behind WebView -->
@@ -2072,9 +2197,9 @@
{/if}
</button>
<!-- Subtitle tracks -->
{#each subtitleTracks() as track, i}
{#each subtitleTracks() as track}
<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' : ''}"
>
<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*\)/);
});
});
+227
View File
@@ -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,88 @@
<!--
The header search box (md+ only; below md the bottom-nav Search tab and the
/search page's own input serve that role).
One box, one results surface. The bar renders on the library routes *and on
/search itself*, so searching from the header no longer swaps you onto a
screen whose input is somewhere else: the box you typed in stays where it is
and keeps driving the results. Off /search it navigates there (the only
surface that renders results); on /search it republishes the query into the
URL, which the page consumes.
TRACES: UR-049, UR-054 | DR-063, DR-064, DR-147
-->
<script lang="ts">
import { onMount, tick } from "svelte";
import { goto } from "$app/navigation";
import { page } from "$app/stores";
import { library } from "$lib/stores/library";
import Search from "$lib/components/Search.svelte";
import {
isSearchRoute,
parseSearchScope,
resolveSearchScope,
searchRouteUrl,
type SearchScope,
} from "$lib/utils/searchScope";
// Seeded from the URL, then owned by the user. A navigation to /search
// remounts this component (library and root render their own AppHeader), so
// reading `?q=` here is what carries a half-typed query across that hop.
let value = $state($page.url.searchParams.get("q") ?? "");
let inputEl = $state<HTMLInputElement | null>(null);
let scope = $state<SearchScope>(
isSearchRoute($page.url.pathname)
? parseSearchScope($page.url.searchParams.get("scope"))
: resolveSearchScope($page.url.pathname)
);
$effect(() => {
const url = $page.url;
if (isSearchRoute(url.pathname)) {
// On /search the scope chips own the scope and publish it in the URL, so
// the bar follows rather than overriding it on the next keystroke.
scope = parseSearchScope(url.searchParams.get("scope"));
} else if (!value.trim()) {
// Elsewhere the route seeds the scope, but only while no search is
// active: navigating must not snap a widened search back to the section
// the user happens to be in.
scope = resolveSearchScope(url.pathname);
}
});
// Landing on /search with a seeded query means the user was mid-type in the
// previous route's header. That box is gone; put the caret back in this one
// so their next keystroke lands in the search field and not nowhere.
onMount(async () => {
if (!value) return;
await tick();
inputEl?.focus();
inputEl?.setSelectionRange(value.length, value.length);
});
async function handleSearch(query: string) {
if (isSearchRoute($page.url.pathname)) {
// Already on the results surface — republish in place. replaceState keeps
// a whole session of typing to a single history entry.
await goto(searchRouteUrl(query, scope), {
replaceState: true,
keepFocus: true,
noScroll: true,
});
return;
}
if (!query.trim()) {
library.clearSearch();
return;
}
await goto(searchRouteUrl(query, scope));
}
</script>
<Search
bind:value
bind:inputEl
placeholder="Search your library..."
onSearch={handleSearch}
/>
@@ -0,0 +1,110 @@
/**
* The header search bar is the single md+ search input.
*
* Off /search it navigates there (the only surface that renders results); on
* /search it stays put and republishes the query into the URL, so the user goes
* on typing in the same box instead of being handed to a second input owned by
* the page.
*
* TRACES: UR-049, UR-054 | DR-147
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/svelte";
const { pageStore, goto, clearSearch } = vi.hoisted(() => {
const { writable } = require("svelte/store");
return {
pageStore: writable({ url: new URL("http://localhost/library/music") }),
goto: vi.fn(),
clearSearch: vi.fn(),
};
});
vi.mock("$app/stores", () => ({ page: pageStore, navigating: { subscribe: () => () => {} } }));
vi.mock("$app/navigation", () => ({ goto, afterNavigate: vi.fn(), beforeNavigate: vi.fn() }));
vi.mock("$lib/stores/library", () => ({
library: { subscribe: () => () => {}, search: vi.fn(), clearSearch },
}));
import HeaderSearch from "./HeaderSearch.svelte";
const afterDebounce = () => new Promise((resolve) => setTimeout(resolve, 450));
function input(): HTMLInputElement {
return screen.getByPlaceholderText("Search your library...") as HTMLInputElement;
}
beforeEach(() => {
vi.clearAllMocks();
pageStore.set({ url: new URL("http://localhost/library/music") });
});
describe("from a library route", () => {
it("routes to /search with the query and the route's scope", async () => {
render(HeaderSearch);
await fireEvent.input(input(), { target: { value: "jazz" } });
await afterDebounce();
expect(goto).toHaveBeenCalledWith("/search?q=jazz&scope=music");
});
it("clears the results rather than navigating on an empty query", async () => {
render(HeaderSearch);
await fireEvent.input(input(), { target: { value: "jazz" } });
await afterDebounce();
goto.mockClear();
await fireEvent.input(input(), { target: { value: "" } });
await afterDebounce();
expect(goto).not.toHaveBeenCalled();
expect(clearSearch).toHaveBeenCalled();
});
});
describe("on /search", () => {
beforeEach(() => {
pageStore.set({ url: new URL("http://localhost/search?q=jazz&scope=music") });
});
it("carries the query over from the URL and puts the caret back in the box", async () => {
render(HeaderSearch);
await waitFor(() => expect(document.activeElement).toBe(input()));
expect(input().value).toBe("jazz");
expect(input().selectionStart).toBe(4);
});
it("republishes in place instead of pushing a second results screen", async () => {
render(HeaderSearch);
await fireEvent.input(input(), { target: { value: "jazzy" } });
await afterDebounce();
expect(goto).toHaveBeenCalledWith("/search?q=jazzy&scope=music", {
replaceState: true,
keepFocus: true,
noScroll: true,
});
// The box the user is typing in keeps its text — nothing re-seeds it.
expect(input().value).toBe("jazzy");
});
it("searches with the scope the page's chips published, not the route default", async () => {
// A chip pick lands in the URL; the bar must adopt it or the next keystroke
// would silently widen the search back to All.
pageStore.set({ url: new URL("http://localhost/search?q=jazz&scope=tv") });
render(HeaderSearch);
await fireEvent.input(input(), { target: { value: "jazzy" } });
await afterDebounce();
expect(goto).toHaveBeenCalledWith(
"/search?q=jazzy&scope=tv",
expect.objectContaining({ replaceState: true })
);
});
});
@@ -0,0 +1,137 @@
<!--
What the "N waiting to sync" badge actually stands for.
These are outgoing changes — watch positions, watched flags — made while the
server was unreachable and still waiting to reach Jellyfin. They are *not*
downloads, which is where the badge used to send people looking: the Downloads
page lists the `downloads` table and structurally cannot show these.
Rows push themselves on reconnect (DR-131); "Sync now" only asks for that to
happen immediately.
TRACES: UR-025 | DR-132
-->
<script lang="ts">
import { onMount } from "svelte";
import { commands, type SyncQueueItem } from "$lib/api/bindings";
import { syncService } from "$lib/services/syncService";
import { pendingSyncCount } from "$lib/stores/appState";
import { isConnected } from "$lib/stores/connectivity";
import {
describeOperation,
describeSubject,
isStuck,
summarize,
sortForDisplay,
} from "$lib/services/pendingSync.logic";
let items = $state<SyncQueueItem[]>([]);
let loading = $state(true);
let syncing = $state(false);
let error = $state<string | null>(null);
let lastResult = $state<string | null>(null);
const summary = $derived(summarize(items));
export async function refresh() {
try {
loading = true;
error = null;
items = sortForDisplay(await syncService.getPending());
pendingSyncCount.set(items.length);
} catch (err) {
error = err instanceof Error ? err.message : String(err);
} finally {
loading = false;
}
}
onMount(refresh);
async function syncNow() {
try {
syncing = true;
error = null;
const report = await commands.syncProcessPending();
lastResult =
report.pushed > 0
? `Sent ${report.pushed} update${report.pushed === 1 ? "" : "s"}.`
: "Nothing could be sent — the server is still unreachable.";
await refresh();
} catch (err) {
error = err instanceof Error ? err.message : String(err);
} finally {
syncing = false;
}
}
function formatQueuedAt(createdAt: string | null): string {
if (!createdAt) return "";
const parsed = Date.parse(createdAt);
return Number.isNaN(parsed) ? "" : new Date(parsed).toLocaleString();
}
</script>
<div class="space-y-4">
<p class="text-sm text-gray-400">
Changes made while the server was unreachable — watch positions and watched
flags — waiting to reach Jellyfin. They send themselves when the server comes
back. This is not the download queue; downloaded media lives under Downloads.
</p>
{#if loading}
<p class="py-6 text-center text-sm text-gray-400">Loading queued updates…</p>
{:else if items.length === 0}
<p class="py-6 text-center text-sm text-gray-500">Everything is synced.</p>
{:else}
<div class="flex flex-wrap items-center gap-3">
<button
onclick={syncNow}
disabled={syncing || !$isConnected}
class="rounded-lg bg-[var(--color-jellyfin)] px-4 py-2 text-sm font-medium text-white transition hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-40"
title={$isConnected ? "Send these now" : "Needs a reachable server"}
>
{syncing ? "Sending…" : "Sync now"}
</button>
{#if summary.stuck > 0}
<span class="text-xs text-amber-400">
{summary.stuck} failed and will be retried
</span>
{/if}
{#if lastResult}
<span class="text-xs text-gray-400">{lastResult}</span>
{/if}
</div>
<ul class="space-y-2">
{#each items as item (item.id)}
<li
class="rounded-lg border-l-4 bg-[var(--color-surface)] p-3 {isStuck(item)
? 'border-amber-500/60'
: 'border-gray-600/60'}"
>
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<p class="truncate text-sm font-medium text-white">
{describeOperation(item.operation)}
</p>
<p class="truncate text-xs text-gray-400">{describeSubject(item)}</p>
{#if item.errorMessage}
<p class="mt-1 text-xs text-amber-400">
{item.errorMessage}{item.retryCount > 0 ? ` (attempt ${item.retryCount})` : ""}
</p>
{/if}
</div>
<span class="shrink-0 text-[11px] text-gray-500">
{formatQueuedAt(item.createdAt)}
</span>
</div>
</li>
{/each}
</ul>
{/if}
{#if error}
<p class="text-sm text-red-400">{error}</p>
{/if}
</div>
@@ -0,0 +1,57 @@
<!--
Modal wrapper for the pending-sync queue, opened from the offline banner's
badge so the count is answerable where the user reads it.
TRACES: UR-025 | DR-132
-->
<script lang="ts">
import PendingSyncList from "./PendingSyncList.svelte";
interface Props {
isOpen: boolean;
onClose: () => void;
}
let { isOpen, onClose }: Props = $props();
function handleBackdropClick(event: MouseEvent) {
if (event.target === event.currentTarget) onClose();
}
</script>
{#if isOpen}
<div
class="fixed inset-0 z-50 flex items-end justify-center bg-black/60 p-0 sm:items-center sm:p-4"
onclick={handleBackdropClick}
onkeydown={(e) => { if (e.key === "Escape") onClose(); }}
role="dialog"
aria-modal="true"
aria-labelledby="pending-sync-title"
tabindex="-1"
>
<div
class="flex max-h-[80vh] w-full flex-col rounded-t-2xl bg-[var(--color-surface)] shadow-2xl sm:max-h-[70vh] sm:max-w-lg sm:rounded-2xl"
onclick={(e) => e.stopPropagation()}
role="none"
>
<div class="flex items-center justify-between border-b border-gray-800 px-6 py-4">
<h2 id="pending-sync-title" class="text-lg font-semibold text-white">
Waiting to sync
</h2>
<button
onclick={onClose}
class="-m-2 p-2 text-gray-400 transition-colors hover:text-white"
aria-label="Close"
>
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div class="flex-1 overflow-y-auto p-6">
<PendingSyncList />
</div>
</div>
</div>
{/if}
@@ -0,0 +1,55 @@
// TRACES: UR-052 | DR-143 | UT-140
import { describe, it, expect, vi, beforeEach } from "vitest";
import { writable } from "svelte/store";
const h = vi.hoisted(() => ({
version: null as ReturnType<typeof import("svelte/store").writable<number>> | null,
destroyFns: [] as Array<() => void>,
}));
vi.mock("svelte", () => ({
onDestroy: (fn: () => void) => h.destroyFns.push(fn),
}));
vi.mock("$lib/services/offlineCatalog", () => ({
get catalogFilterVersion() {
return h.version;
},
}));
import { useOfflineFilterReload } from "./useOfflineFilterReload";
describe("useOfflineFilterReload (DR-143)", () => {
beforeEach(() => {
h.version = writable(0);
h.destroyFns.length = 0;
});
it("does not reload for the value the page already loaded under", () => {
const reload = vi.fn();
useOfflineFilterReload(reload);
expect(reload).not.toHaveBeenCalled();
});
it("reloads once each time the gate settles into a new state", () => {
const reload = vi.fn();
useOfflineFilterReload(reload);
h.version!.set(1);
expect(reload).toHaveBeenCalledTimes(1);
h.version!.set(2);
expect(reload).toHaveBeenCalledTimes(2);
});
it("stops reloading a page that has been destroyed", () => {
const reload = vi.fn();
useOfflineFilterReload(reload);
h.destroyFns.forEach((fn) => fn());
h.version!.set(1);
expect(reload).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,47 @@
/**
* Re-query a listing when the offline "downloaded only" gate changes.
*
* The gate is a process-wide flag in Rust, consulted only while a query runs,
* so flipping it has no effect on rows already on screen. Library pages loaded
* once on mount and reloaded only on the offline online transition
* (`useServerReachabilityReload`), which left two gaps the user sees as a broken
* filter:
*
* - going *offline* never reloaded, so the full server catalog stayed on
* screen under a now-closed gate;
* - toggling "Show all server media" never reloaded, so it only greyed the
* cards already listed instead of adding or removing any.
*
* `catalogFilterVersion` bumps once the backend has accepted the new gate, so
* the reload this triggers always queries under the intended filter.
*
* Call during component initialisation, like `useServerReachabilityReload`:
*
* ```svelte
* <script>
* useOfflineFilterReload(() => loadItems());
* </script>
* ```
*
* TRACES: UR-052 | DR-143
*/
import { onDestroy } from "svelte";
import { catalogFilterVersion } from "$lib/services/offlineCatalog";
export function useOfflineFilterReload(reloadFn: () => void | Promise<void>): void {
// The value the page is already showing. Seeded from the first subscription
// callback (stores emit synchronously on subscribe) so mounting never
// triggers a redundant second load of what onMount just fetched.
let applied: number | null = null;
const unsubscribe = catalogFilterVersion.subscribe((version) => {
if (applied === null || version === applied) {
applied = version;
return;
}
applied = version;
void reloadFn();
});
onDestroy(unsubscribe);
}
@@ -0,0 +1,95 @@
/**
* Adapter-selection regression guards.
*
* TRACES: UR-003, UR-004 | DR-150 | UT-149
*
* The selection rule has two inputs and one hard safety property:
*
* - Rust says which backend the platform has (`backendKind`).
* - The user opts in with `experimentalNativeVideo`.
* - **The flag off must force HTML5 even when Rust says native.** That is the
* regression guard: a broken spike must not be able to ship as the default.
*
* These are pure functions, so the whole matrix is testable without a device.
*/
import { describe, expect, it } from "vitest";
import { createAdapter } from "./index";
import { Html5PlayerAdapter } from "./html5Adapter";
import { NativePlayerAdapter } from "./nativeAdapter";
import type { AdapterHost } from "./types";
const host: AdapterHost = {
reportState: () => {},
reportPosition: () => {},
reportEnded: () => {},
} as unknown as AdapterHost;
const bridge = {
getElement: () => null,
} as any;
describe("createAdapter", () => {
it("returns the native adapter when Rust says native and the flag is on", () => {
const adapter = createAdapter({
backendKind: "native",
host,
bridge,
experimentalNativeVideo: true,
});
expect(adapter).toBeInstanceOf(NativePlayerAdapter);
expect(adapter.kind).toBe("native");
});
// The regression guard: the flag is a suppressor, so off must beat Rust.
it("forces HTML5 when the flag is off even though Rust says native", () => {
const adapter = createAdapter({
backendKind: "native",
host,
bridge,
experimentalNativeVideo: false,
});
expect(adapter).toBeInstanceOf(Html5PlayerAdapter);
expect(adapter.kind).toBe("html5");
});
it("returns the HTML5 adapter when Rust says html5 and the flag is off", () => {
const adapter = createAdapter({
backendKind: "html5",
host,
bridge,
experimentalNativeVideo: false,
});
expect(adapter).toBeInstanceOf(Html5PlayerAdapter);
});
// The flag must never *promote* a platform Rust said has no native backend
// (e.g. Linux, where WebKitGTK cannot composite a surface behind the webview).
it("stays on HTML5 when Rust says html5 even with the flag on", () => {
const adapter = createAdapter({
backendKind: "html5",
host,
bridge,
experimentalNativeVideo: true,
});
expect(adapter).toBeInstanceOf(Html5PlayerAdapter);
});
it("defaults to HTML5 when the flag is omitted entirely", () => {
const adapter = createAdapter({ backendKind: "native", host, bridge });
expect(adapter).toBeInstanceOf(Html5PlayerAdapter);
});
it("requires a bridge for the HTML5 adapter", () => {
expect(() =>
createAdapter({ backendKind: "html5", host, experimentalNativeVideo: false })
).toThrow(/bridge/i);
});
// The native adapter owns no DOM element, so it must not demand a bridge.
it("does not require a bridge for the native adapter", () => {
expect(() =>
createAdapter({ backendKind: "native", host, experimentalNativeVideo: true })
).not.toThrow();
});
});
+38 -20
View File
@@ -2,13 +2,22 @@
* Player adapter factory + public exports.
*
* `createAdapter` selects the concrete PlayerAdapter for the current platform.
* It is the single place that encodes the INTERIM Android override: the Rust
* backend may report a native ExoPlayer backend, but native Android video
* rendering is blocked upstream (tauri#10152 transparent webview / SurfaceView
* compositing), so we render Android video through the HTML5 adapter for now.
* When that upstream limitation is resolved, flip this to honor `backendKind`.
* Rust decides *which backend this platform has* (`useHtml5Element` from
* `player_play_item`); this factory consumes that decision rather than
* re-deriving it.
*
* TRACES: UR-003 | DR-004
* The `experimentalNativeVideo` flag is a **suppressor, never a promoter**: it
* can force the HTML5 path when Rust says native (so an in-progress spike cannot
* ship as a regression), but it can never select native on a platform whose Rust
* backend reported HTML5 Linux has no way to composite a surface behind a
* WebKitGTK webview, so promoting there would produce a black screen.
*
* The previous unconditional HTML5 override cited tauri#10152 as an upstream
* blocker. That was stale: #10152 is a dormant *feature request*, the capability
* shipped in tauri 27d01834, and the black-screen bug (tauri#8381, #9408) was a
* broken `setBackgroundColor` JNI signature fixed in wry 0.39.4 we ship 0.53.x.
*
* TRACES: UR-003, UR-004 | DR-004, DR-150 | UT-149
*/
import { Html5PlayerAdapter, type Html5ElementBridge } from "./html5Adapter";
@@ -29,27 +38,36 @@ export interface CreateAdapterArgs {
host: AdapterHost;
/** Required for the HTML5 adapter; ignored by the native adapter. */
bridge?: Html5ElementBridge;
/**
* User opt-in for the native video path. Defaults to **off**, so omitting it
* yields today's behaviour (HTML5 everywhere) rather than silently enabling
* the spike.
*/
experimentalNativeVideo?: boolean;
}
/**
* Build the adapter for this platform/stream.
*
* INTERIM: always returns the HTML5 adapter, because the native surface is not
* visible through the webview on current Tauri (see module docs). The bridge is
* therefore required.
* Native is chosen only when Rust reports a native backend AND the user has
* opted in. Every other combination is HTML5.
*/
export function createAdapter({ backendKind, host, bridge }: CreateAdapterArgs): PlayerAdapter {
// INTERIM OVERRIDE: force HTML5 rendering even when the backend reports native.
const effectiveKind: BackendKind = "html5";
export function createAdapter({
backendKind,
host,
bridge,
experimentalNativeVideo = false,
}: CreateAdapterArgs): PlayerAdapter {
const effectiveKind: BackendKind =
backendKind === "native" && experimentalNativeVideo ? "native" : "html5";
if (effectiveKind === "html5") {
if (!bridge) {
throw new Error("createAdapter: Html5ElementBridge is required for the HTML5 adapter");
}
return new Html5PlayerAdapter(host, bridge);
if (effectiveKind === "native") {
// The native surface is owned by the backend — no DOM element, no bridge.
return new NativePlayerAdapter(host);
}
// Reached only once the interim override is lifted (native Android unblocked).
void backendKind;
return new NativePlayerAdapter(host);
if (!bridge) {
throw new Error("createAdapter: Html5ElementBridge is required for the HTML5 adapter");
}
return new Html5PlayerAdapter(host, bridge);
}
+30 -1
View File
@@ -1,5 +1,34 @@
import { describe, it, expect } from "vitest";
import { resolveVideoSource } from "./localSource";
import { downloadedFilePath, resolveVideoSource } from "./localSource";
describe("downloadedFilePath", () => {
// The download worker rewrites `downloads.file_path` to the absolute path it
// actually wrote once the transfer completes, so a completed row is already
// rooted. Joining it onto the storage root again produced
// `/data/user/0/app//data/user/0/app/videos/x.mp4`, which the asset protocol
// cannot open — offline video died with MEDIA_ERR_SRC_NOT_SUPPORTED while
// audio, which resolves the same column through Rust, played fine.
it("leaves a completed download's absolute path alone", () => {
const root = "/data/user/0/com.dtourolle.jellytau";
const stored = `${root}/videos/Taming of the Shrew.mp4`;
expect(downloadedFilePath(root, stored)).toBe(stored);
});
it("roots a path that is still relative to the storage directory", () => {
// Rows only hold a relative path before the worker completes them, but a
// half-migrated database can still carry one.
expect(downloadedFilePath("/var/data/jellytau", "videos/film.mp4")).toBe(
"/var/data/jellytau/videos/film.mp4"
);
});
it("leaves an absolute Windows path alone", () => {
const stored = "C:\\Users\\u\\AppData\\jellytau\\videos\\film.mp4";
expect(downloadedFilePath("C:\\Users\\u\\AppData\\jellytau", stored)).toBe(stored);
});
});
// A stand-in for Tauri's convertFileSrc, so the module stays pure.
const toAssetUrl = (p: string) => `asset://localhost/${encodeURIComponent(p)}`;
+22
View File
@@ -36,6 +36,28 @@ export interface VideoSourceDecision {
isLocal: boolean;
}
/** Absolute on POSIX (`/…`), Windows (`C:\…`, `C:/…`) or a UNC share (`\\…`). */
function isAbsolute(path: string): boolean {
return path.startsWith("/") || path.startsWith("\\") || /^[A-Za-z]:[\\/]/.test(path);
}
/**
* The on-disk path of a row in the `downloads` store.
*
* `downloads.file_path` is stored relative to the storage root while a download
* is queued, but the worker rewrites it to the absolute path it actually wrote
* once the transfer completes so a *completed* row is already rooted. Joining
* it onto the storage root a second time produced
* `/data/user/0/app//data/user/0/app/videos/x.mp4`; the asset protocol could not
* open that, so offline video failed with `MEDIA_ERR_SRC_NOT_SUPPORTED` while
* audio, which resolves the same column through Rust, played fine.
*
* TRACES: UR-071 | DR-133 | UT-124
*/
export function downloadedFilePath(storageRoot: string, filePath: string): string {
return isAbsolute(filePath) ? filePath : `${storageRoot}/${filePath}`;
}
export function resolveVideoSource(inputs: VideoSourceInputs): VideoSourceDecision {
const { localPath, remoteUrl, remoteNeedsTranscoding, toAssetUrl } = inputs;
@@ -0,0 +1,162 @@
// The offline "downloaded only" gate is a *backend* flag, so flipping it only
// changes what the user sees if the listing is re-queried afterwards. Nothing
// re-queried: the toggle greyed cards instantly (a pure frontend derivation in
// MediaCard) while the item list stayed exactly as it was loaded, which is why
// the filter read as "shows everything until I toggle, then greys some of it".
//
// `catalogFilterVersion` is the refetch signal, and it must bump only once the
// backend has actually accepted the new flag — a reload racing the push would
// re-query under the old gate and land back where it started.
//
// TRACES: UR-052 | DR-078, DR-143 | UT-137, UT-138, UT-139
import { describe, it, expect, vi, beforeEach } from "vitest";
import { get } from "svelte/store";
const h = vi.hoisted(() => {
function shim<T>(initial: T) {
let value = initial;
const subs = new Set<(v: T) => void>();
return {
set(v: T) {
value = v;
subs.forEach((fn) => fn(value));
},
subscribe(fn: (v: T) => void) {
subs.add(fn);
fn(value);
return () => subs.delete(fn);
},
/**
* Drop subscribers from previously imported copies of the module.
* `vi.resetModules()` gives each test a fresh module instance, but this
* store outlives them all without this the stale instances keep
* consuming `mockImplementationOnce` and pushing their own state.
*/
reset(v: T) {
subs.clear();
value = v;
},
};
}
return {
isConnectedStore: shim(true),
// Resolution is deferred so a test can observe the window between "command
// issued" and "command accepted".
pending: [] as Array<() => void>,
setShowServerCatalog: vi.fn(
() => new Promise<void>((resolve) => h.pending.push(() => resolve()))
),
};
});
vi.mock("$lib/stores/connectivity", () => ({
isConnected: { subscribe: h.isConnectedStore.subscribe },
}));
vi.mock("$lib/api/bindings", () => ({
commands: {
setShowServerCatalog: h.setShowServerCatalog,
syncFullCatalog: vi.fn(),
resumeQueuedDownloads: vi.fn(),
catalogSyncStatus: vi.fn(),
},
}));
vi.mock("$lib/stores/auth", () => ({
auth: { getRepository: () => ({ getHandle: () => "handle-1" }) },
}));
/** Let every issued setShowServerCatalog settle. */
async function settle() {
h.pending.splice(0).forEach((resolve) => resolve());
await Promise.resolve();
await Promise.resolve();
}
describe("offline filter refetch signal (DR-143)", () => {
beforeEach(() => {
h.setShowServerCatalog.mockReset();
h.setShowServerCatalog.mockImplementation(
() => new Promise<void>((resolve) => h.pending.push(() => resolve()))
);
h.pending.length = 0;
h.isConnectedStore.reset(true);
vi.resetModules();
});
it("bumps the version when going offline closes the gate", async () => {
const mod = await import("./offlineCatalog");
await settle();
const before = get(mod.catalogFilterVersion);
h.isConnectedStore.set(false); // include: true → false
await settle();
expect(h.setShowServerCatalog).toHaveBeenLastCalledWith(false);
expect(get(mod.catalogFilterVersion)).toBeGreaterThan(before);
});
it("bumps only after the backend accepts the flag, never before", async () => {
const mod = await import("./offlineCatalog");
await settle();
const before = get(mod.catalogFilterVersion);
h.isConnectedStore.set(false);
await Promise.resolve(); // command issued, not yet resolved
expect(h.setShowServerCatalog).toHaveBeenLastCalledWith(false);
expect(get(mod.catalogFilterVersion)).toBe(before);
await settle();
expect(get(mod.catalogFilterVersion)).toBeGreaterThan(before);
});
it("bumps both ways as the offline toggle is flipped", async () => {
const mod = await import("./offlineCatalog");
h.isConnectedStore.set(false);
await settle();
const offlineGated = get(mod.catalogFilterVersion);
mod.showServerCatalog.set(true); // reveal the server catalog
await settle();
expect(h.setShowServerCatalog).toHaveBeenLastCalledWith(true);
const revealed = get(mod.catalogFilterVersion);
expect(revealed).toBeGreaterThan(offlineGated);
mod.showServerCatalog.set(false); // back to downloaded-only
await settle();
expect(h.setShowServerCatalog).toHaveBeenLastCalledWith(false);
expect(get(mod.catalogFilterVersion)).toBeGreaterThan(revealed);
});
it("does not bump when the effective gate is unchanged", async () => {
const mod = await import("./offlineCatalog");
await settle();
const before = get(mod.catalogFilterVersion);
// Online, the toggle cannot close the gate — browsing reads the same cache.
mod.showServerCatalog.set(true);
await settle();
expect(get(mod.catalogFilterVersion)).toBe(before);
});
it("retries the push after a failed one rather than latching the old gate", async () => {
const mod = await import("./offlineCatalog");
await settle();
const before = get(mod.catalogFilterVersion);
h.setShowServerCatalog.mockImplementationOnce(() => Promise.reject(new Error("ipc down")));
h.isConnectedStore.set(false);
await settle();
expect(get(mod.catalogFilterVersion)).toBe(before); // nothing to reload for
// The same transition must be attempted again, not skipped as "already sent".
h.isConnectedStore.set(true);
await settle();
h.isConnectedStore.set(false);
await settle();
expect(h.setShowServerCatalog).toHaveBeenLastCalledWith(false);
expect(get(mod.catalogFilterVersion)).toBeGreaterThan(before);
});
});
+37 -5
View File
@@ -25,30 +25,62 @@ import { isConnected } from "$lib/stores/connectivity";
*/
export const showServerCatalog: Writable<boolean> = writable(false);
/**
* Bumped every time the backend's downloads-only gate *settles* into a new
* state. Library pages watch it and re-query.
*
* The gate lives in Rust and is only consulted when a query runs, so flipping
* it changes nothing already on screen. Without this signal the toggle merely
* greyed cards `MediaCard.isServerOnly` is a pure frontend derivation and
* updates instantly while the item list stayed as first loaded. That is the
* "shows everything until I filter" behaviour: the listing had never been
* re-queried under the closed gate. Going offline had the same problem, since
* nothing reloads on the online offline transition either.
*
* It bumps *after* the command resolves, never before: a reload racing the push
* would re-query under the old gate and undo itself.
*
* TRACES: UR-052 | DR-143
*/
export const catalogFilterVersion: Writable<number> = writable(0);
// Keep the backend's offline library queries in sync with the UI toggle. The
// offline cache holds the whole synced catalog, so `get_items` would otherwise
// return every server item even offline with the toggle off. Include the
// non-downloaded catalog only when online (fast browsing reads the same cache)
// or when the "Show all server media" toggle is on.
let lastIncludeCatalog: boolean | null = null;
function pushCatalogVisibility(connected: boolean, showCatalog: boolean): void {
async function pushCatalogVisibility(connected: boolean, showCatalog: boolean): Promise<void> {
const include = connected || showCatalog;
if (include === lastIncludeCatalog) return;
lastIncludeCatalog = include;
commands.setShowServerCatalog(include).catch((err) => {
try {
await commands.setShowServerCatalog(include);
} catch (err) {
console.warn("[OfflineCatalog] Failed to set catalog visibility:", err);
});
// The backend is still on the old gate, so forget that we sent this —
// otherwise the next identical transition is skipped as a no-op and the
// frontend and backend disagree about the filter for the rest of the
// session. No version bump: there is nothing new to re-query under.
lastIncludeCatalog = null;
return;
}
catalogFilterVersion.update((n) => n + 1);
}
let connectedNow = true;
let showCatalogNow = false;
// Fire-and-forget on purpose: the push handles its own failure, and subscribers
// must not block. Consumers wait on `catalogFilterVersion` instead.
isConnected.subscribe((v) => {
connectedNow = v;
pushCatalogVisibility(connectedNow, showCatalogNow);
void pushCatalogVisibility(connectedNow, showCatalogNow);
});
showServerCatalog.subscribe((v) => {
showCatalogNow = v;
pushCatalogVisibility(connectedNow, showCatalogNow);
void pushCatalogVisibility(connectedNow, showCatalogNow);
});
/** Last time a full catalog sync completed, for a UI hint. */
@@ -0,0 +1,85 @@
// TRACES: UR-025 | DR-132 | UT-123
import { describe, it, expect } from "vitest";
import type { SyncQueueItem } from "$lib/api/bindings";
import {
describeOperation,
describeSubject,
isStuck,
summarize,
sortForDisplay,
} from "./pendingSync.logic";
function row(overrides: Partial<SyncQueueItem> = {}): SyncQueueItem {
return {
id: 1,
userId: "u1",
operation: "report_playback_stopped",
itemId: "ep1",
payload: null,
status: "pending",
retryCount: 0,
createdAt: "2026-08-01T10:00:00Z",
errorMessage: null,
itemName: null,
...overrides,
} as SyncQueueItem;
}
describe("pending sync row description", () => {
it("labels the operations the backend can queue", () => {
expect(describeOperation("report_playback_stopped")).toBe("Watch position");
expect(describeOperation("mark_played")).toBe("Marked as watched");
expect(describeOperation("report_playback_start")).toBe("Playback started");
});
it("still renders an operation it has no label for", () => {
// An unlabelled row is the one heading for abandonment — it must not
// render blank, which is the failure this whole surface exists to fix.
expect(describeOperation("teleport_item")).toBe("teleport item");
});
it("names the item when the catalog knows it, and falls back to the id", () => {
expect(describeSubject(row({ itemName: "The Expanse S01E01" }))).toBe(
"The Expanse S01E01",
);
expect(describeSubject(row({ itemName: null, itemId: "abc123" }))).toBe("abc123");
expect(describeSubject(row({ itemName: null, itemId: null }))).toBe("Unknown item");
});
});
describe("stuck rows", () => {
it("treats a failed or retried row as stuck", () => {
expect(isStuck(row({ status: "failed", retryCount: 1 }))).toBe(true);
expect(isStuck(row({ status: "pending", retryCount: 2 }))).toBe(true);
expect(isStuck(row())).toBe(false);
});
it("summarizes a mixed queue", () => {
const summary = summarize([row(), row({ id: 2, status: "failed", retryCount: 1 })]);
expect(summary).toEqual({ total: 2, stuck: 1, allStuck: false });
});
it("reports allStuck only when every row has failed", () => {
expect(summarize([row({ status: "failed", retryCount: 3 })]).allStuck).toBe(true);
expect(summarize([]).allStuck).toBe(false);
});
});
describe("display order", () => {
it("lists oldest first — the order they will be replayed in", () => {
const sorted = sortForDisplay([
row({ id: 3, createdAt: "2026-08-01T12:00:00Z" }),
row({ id: 1, createdAt: "2026-08-01T10:00:00Z" }),
row({ id: 2, createdAt: "2026-08-01T11:00:00Z" }),
]);
expect(sorted.map((r) => r.id)).toEqual([1, 2, 3]);
});
it("keeps timestamp-less rows instead of dropping them", () => {
const sorted = sortForDisplay([
row({ id: 2, createdAt: null }),
row({ id: 1, createdAt: "2026-08-01T10:00:00Z" }),
]);
expect(sorted.map((r) => r.id)).toEqual([1, 2]);
});
});
+79
View File
@@ -0,0 +1,79 @@
// Presentation logic for the pending-sync queue.
//
// The queue's *meaning* lives in Rust (which operations exist, how they push,
// when one is abandoned — DR-131). What lives here is purely how a row reads on
// screen: its label, its subtitle, and whether it is currently erroring. Kept
// out of the component so it can be unit-tested, the same pattern as
// episodeStrip.ts.
//
// TRACES: UR-025 | DR-132 | UT-123
import type { SyncQueueItem } from "$lib/api/bindings";
/** How each queued operation reads in the list. */
const OPERATION_LABELS: Record<string, string> = {
report_playback_start: "Playback started",
report_playback_stopped: "Watch position",
update_progress: "Watch position",
mark_played: "Marked as watched",
mark_favorite: "Added to favourites",
unmark_favorite: "Removed from favourites",
playlist_create: "Playlist created",
playlist_delete: "Playlist deleted",
playlist_rename: "Playlist renamed",
playlist_add_items: "Added to playlist",
playlist_remove_items: "Removed from playlist",
playlist_reorder_item: "Playlist reordered",
};
/**
* A human label for a queued operation. An operation this build has no label
* for still reads as something an unknown row is the case most worth showing,
* since it is the one that will end up abandoned.
*/
export function describeOperation(operation: string): string {
return OPERATION_LABELS[operation] ?? operation.replace(/_/g, " ");
}
/** What the row is about: the item's title if the catalog knows it, else its id. */
export function describeSubject(item: SyncQueueItem): string {
return item.itemName ?? item.itemId ?? "Unknown item";
}
/**
* A row is "stuck" once it has failed at least once that is what justifies
* showing its error, and what a Retry button acts on.
*/
export function isStuck(item: SyncQueueItem): boolean {
return item.status === "failed" || item.retryCount > 0;
}
export interface PendingSyncSummary {
total: number;
stuck: number;
/** True when every queued row has already failed — retrying needs the server. */
allStuck: boolean;
}
export function summarize(items: SyncQueueItem[]): PendingSyncSummary {
const stuck = items.filter(isStuck).length;
return {
total: items.length,
stuck,
allStuck: items.length > 0 && stuck === items.length,
};
}
/**
* Oldest first the order they will be replayed in, so the list reads as the
* queue it is. Rows without a timestamp sort last rather than being dropped.
*/
export function sortForDisplay(items: SyncQueueItem[]): SyncQueueItem[] {
return [...items].sort((a, b) => {
if (!a.createdAt && !b.createdAt) return a.id - b.id;
if (!a.createdAt) return 1;
if (!b.createdAt) return -1;
const diff = Date.parse(a.createdAt) - Date.parse(b.createdAt);
return diff !== 0 ? diff : a.id - b.id;
});
}
+70
View File
@@ -0,0 +1,70 @@
/**
* Platform playback capabilities, read from Rust.
*
* TRACES: UR-003, UR-005 | DR-004, DR-152
*
* "Which backend does this platform have" is a *backend* fact, so Rust owns it
* (`player_get_capabilities`, gated on the same `cfg!` the backends are built
* under). This module is a thin cache over that command.
*
* It exists because the frontend used to re-derive the answer by sniffing
* `navigator.userAgent` for "android"/"linux" a second, silently drifting copy
* of a decision Rust already makes. Consume the value; never re-derive it.
*/
import { commands } from "$lib/api/bindings";
export interface PlaybackCapabilities {
/** Audio renders through a webview `<audio>` element, not a native backend. */
usesWebviewAudio: boolean;
/** Video can render on a native surface behind a transparent webview. */
supportsNativeVideo: boolean;
}
/**
* Conservative defaults for when the backend cannot be reached (very early
* startup, or a command failure). Both false = "assume no special platform
* facilities": no stray `<audio>` element is mounted, and video stays on the
* HTML5 path, which is the safe behaviour everywhere.
*/
const FALLBACK: PlaybackCapabilities = {
usesWebviewAudio: false,
supportsNativeVideo: false,
};
let cached: PlaybackCapabilities | null = null;
let inflight: Promise<PlaybackCapabilities> | null = null;
/**
* Fetch (and memoize) this platform's capabilities. Cached because the answer is
* compile-time constant in Rust it cannot change during a session.
*/
export async function getPlaybackCapabilities(): Promise<PlaybackCapabilities> {
if (cached) return cached;
if (inflight) return inflight;
inflight = (async () => {
try {
const caps = (await commands.playerGetCapabilities()) as PlaybackCapabilities;
cached = {
usesWebviewAudio: !!caps?.usesWebviewAudio,
supportsNativeVideo: !!caps?.supportsNativeVideo,
};
return cached;
} catch (err) {
console.warn("[capabilities] player_get_capabilities failed:", err);
// Do NOT cache the fallback — a later call should get the real answer.
return FALLBACK;
} finally {
inflight = null;
}
})();
return inflight;
}
/** Reset the cache. Test-only. */
export function __resetPlaybackCapabilitiesCache(): void {
cached = null;
inflight = null;
}
+5 -12
View File
@@ -9,18 +9,11 @@
import { commands } from "$lib/api/bindings";
import { auth } from "$lib/stores/auth";
// Types matching Rust structs
export interface SyncQueueItem {
id: number;
userId: string;
operation: string;
itemId: string | null;
payload: string | null;
status: string;
retryCount: number;
createdAt: string | null;
errorMessage: string | null;
}
// The queue row shape comes from the generated bindings rather than a
// hand-written mirror — the mirror had already drifted (it predates `itemName`),
// and a drifted duplicate is how a field silently stops reaching the UI.
import type { SyncQueueItem } from "$lib/api/bindings";
export type { SyncQueueItem };
export type SyncOperation =
| "mark_played"
+8 -14
View File
@@ -23,31 +23,25 @@ import { events } from "$lib/api/bindings";
import { playerController } from "$lib/player";
import { createRustReportHost } from "$lib/player/adapters/rustReportHost";
import { WebviewAudioAdapter } from "$lib/player/adapters/webviewAudioAdapter";
import { getPlaybackCapabilities } from "$lib/services/playbackCapabilities";
let unlisten: UnlistenFn | null = null;
let audioEl: HTMLAudioElement | null = null;
let adapter: WebviewAudioAdapter | null = null;
/** Platforms whose Rust backend renders audio in the webview rather than natively. */
function usesWebviewAudio(): boolean {
// Native audio backends exist only for Linux (mpv) and Android (ExoPlayer).
// Everything else (Windows, and any future desktop) uses the webview element.
// We detect "not linux/android" rather than "is windows" so new desktop
// targets are covered automatically, matching the Rust cfg gate.
if (typeof navigator === "undefined") return false;
const ua = navigator.userAgent.toLowerCase();
const isAndroid = ua.includes("android");
const isLinux = ua.includes("linux") && !isAndroid;
return !isAndroid && !isLinux;
}
/**
* Initialize the webview audio controller. Safe to call unconditionally from the
* root layout; it self-gates on platform and is idempotent.
*/
export async function initWebviewAudio(): Promise<void> {
if (unlisten) return;
if (!usesWebviewAudio()) return;
// Whether this platform needs the webview element is a backend fact, so Rust
// answers it. This used to sniff `navigator.userAgent` for "android"/"linux"
// — a duplicate of the Rust cfg gate that could drift out of step with the
// backends it was trying to describe.
const { usesWebviewAudio } = await getPlaybackCapabilities();
if (!usesWebviewAudio) return;
audioEl = document.createElement("audio");
audioEl.hidden = true;
+91
View File
@@ -0,0 +1,91 @@
// Native-video compositing state.
//
// TRACES: UR-003, UR-004 | DR-150, DR-152
//
// Two separate concerns live here, deliberately:
//
// 1. `experimentalNativeVideo` — the user-facing opt-in flag. Rust already
// decides *which backend this platform has* (`useHtml5Element` from
// `player_play_item`); this flag only *suppresses* that decision so a
// half-working spike cannot ship as a regression. It never turns native on
// where Rust says HTML5.
//
// 2. `nativeVideoActive` — whether a native surface is on screen right now.
// Setting it toggles `data-native-video` on <html>, which is what the CSS in
// app.css keys off to clear the app's opaque backgrounds so the SurfaceView
// behind the WebView is visible. It is deliberately NOT derived from the
// flag: the backgrounds must come back the moment the player unmounts.
//
// Frontend-only preference, stored in localStorage per the `jellytau-view-mode`
// precedent in library.ts — no Rust settings command backs this.
import { writable } from "svelte/store";
const STORAGE_KEY = "jellytau-experimental-native-video";
/** The attribute app.css keys its transparency rules off. */
const NATIVE_VIDEO_ATTR = "data-native-video";
function load(): boolean {
if (typeof localStorage === "undefined") return false;
try {
return localStorage.getItem(STORAGE_KEY) === "true";
} catch {
// Private-mode / disabled storage — default to the safe (HTML5) path.
return false;
}
}
function persist(enabled: boolean) {
if (typeof localStorage === "undefined") return;
try {
localStorage.setItem(STORAGE_KEY, String(enabled));
} catch {
// Quota or private-mode failure — keep the in-memory value.
}
}
function createExperimentalNativeVideoStore() {
const { subscribe, set } = writable<boolean>(load());
return {
subscribe,
set(enabled: boolean) {
persist(enabled);
set(enabled);
},
/** Read the current value without subscribing (init-time decisions). */
current: load,
};
}
/** User opt-in for the native Android video path. Default off. */
export const experimentalNativeVideo = createExperimentalNativeVideoStore();
function createNativeVideoActiveStore() {
const { subscribe, set } = writable<boolean>(false);
return {
subscribe,
/**
* Mark a native video surface as visible (or gone) and sync the <html>
* attribute that app.css uses to clear opaque backgrounds.
*/
set(active: boolean) {
if (typeof document !== "undefined") {
if (active) {
document.documentElement.setAttribute(NATIVE_VIDEO_ATTR, "active");
} else {
document.documentElement.removeAttribute(NATIVE_VIDEO_ATTR);
}
}
set(active);
},
};
}
/**
* Whether a native video surface is currently on screen. Must be cleared on
* player teardown, or the rest of the app renders over a transparent window.
*/
export const nativeVideoActive = createNativeVideoActiveStore();
+23
View File
@@ -19,6 +19,7 @@ import {
showBottomNav,
showGlobalMiniPlayer,
showGlobalHeader,
showHeaderSearch,
routeOwnsLayout,
showBottomUi,
shellReservesBottomInset,
@@ -179,3 +180,25 @@ describe("shellReservesBottomInset", () => {
expect(shellReservesBottomInset({ pathname: "/", isAuthenticated: false })).toBe(false);
});
});
describe("showHeaderSearch", () => {
it("renders the bar on the library routes", () => {
expect(showHeaderSearch({ pathname: "/library" })).toBe(true);
expect(showHeaderSearch({ pathname: "/library/music/albums" })).toBe(true);
expect(showHeaderSearch({ pathname: "/library/abc123" })).toBe(true);
});
it("keeps the bar on /search, so searching does not swap you to another input", () => {
// The regression: the bar existed only under /library, so a header search
// landed the user on /search with the box they were typing in gone and a
// different one (belonging to the page) in its place.
expect(showHeaderSearch({ pathname: "/search" })).toBe(true);
expect(showHeaderSearch({ pathname: "/search/" })).toBe(true);
});
it("stays off routes with nothing to search", () => {
expect(showHeaderSearch({ pathname: "/" })).toBe(false);
expect(showHeaderSearch({ pathname: "/downloads" })).toBe(false);
expect(showHeaderSearch({ pathname: "/settings" })).toBe(false);
});
});
+17
View File
@@ -17,6 +17,8 @@
* TRACES: UR-005 | DR-009
*/
import { isSearchRoute } from "$lib/utils/searchScope";
export interface BottomUiVisibilityInput {
/** Current route pathname, e.g. `$page.url.pathname`. */
pathname: string;
@@ -90,6 +92,21 @@ export function showGlobalHeader({
);
}
/**
* Whether the header renders its search box on this route (md+ only; below md
* the bottom-nav Search tab and /search's own input serve that role).
*
* `/search` is included deliberately: the bar is the single md+ search input,
* so it must survive the hop onto the results page instead of being replaced by
* a second input belonging to that page. The library routes keep it because
* that is where a search is most often started.
*
* TRACES: UR-049, UR-054 | DR-063
*/
export function showHeaderSearch({ pathname }: { pathname: string }): boolean {
return pathname.startsWith("/library") || isSearchRoute(pathname);
}
/**
* Whether any bottom UI is showing for this route (mini player, nav, or both).
* The bottom UI is rendered in flex flow below the scroller (see BottomUi.svelte),
+66
View File
@@ -3,11 +3,14 @@ import {
composeSearchGroups,
DEFAULT_GROUP_ORDER,
groupsForScope,
isSearchRoute,
moveGroup,
normalizeGroupOrder,
parseSearchScope,
reorderGroups,
resolveSearchScope,
searchRouteUrl,
seedFromSearchUrl,
shouldNavigateToSearch,
type SearchGroupId,
} from "./searchScope";
@@ -397,3 +400,66 @@ describe("shouldNavigateToSearch", () => {
expect(shouldNavigateToSearch("/library/music", " ")).toBe(false);
});
});
describe("isSearchRoute", () => {
it("recognises the search surface through query strings and trailing slashes", () => {
expect(isSearchRoute("/search")).toBe(true);
expect(isSearchRoute("/search/")).toBe(true);
expect(isSearchRoute("/search?q=jazz&scope=music")).toBe(true);
expect(isSearchRoute("/library/music")).toBe(false);
expect(isSearchRoute("/")).toBe(false);
});
});
describe("parseSearchScope", () => {
it("accepts the known scopes", () => {
expect(parseSearchScope("music")).toBe("music");
expect(parseSearchScope("tv")).toBe("tv");
});
it("falls back on absent or unrecognised values", () => {
expect(parseSearchScope(null)).toBe("all");
expect(parseSearchScope("")).toBe("all");
expect(parseSearchScope("books")).toBe("all");
expect(parseSearchScope(null, "music")).toBe("music");
});
});
describe("seedFromSearchUrl", () => {
const params = (search: string) => new URL(`http://x/search${search}`).searchParams;
it("seeds a fresh page from the URL", () => {
expect(seedFromSearchUrl(params("?q=jazz&scope=music"), null)).toEqual({
query: "jazz",
scope: "music",
});
});
it("seeds an empty query so an emptied URL clears the page", () => {
expect(seedFromSearchUrl(params(""), null)).toEqual({ query: "", scope: "all" });
expect(seedFromSearchUrl(params(""), { query: "jazz", scope: "all" })).toEqual({
query: "",
scope: "all",
});
});
it("asks for nothing once that same URL has been applied", () => {
// The regression this exists for: the page must consume the URL once, not
// keep re-asserting it. While the seed is unchanged the user's typing and
// chip picks own the input — re-applying snapped it back a keystroke later.
const applied = { query: "jazz", scope: "music" } as const;
expect(seedFromSearchUrl(params("?q=jazz&scope=music"), applied)).toBeNull();
});
it("re-seeds when the query or the scope actually changes", () => {
const applied = { query: "jazz", scope: "music" } as const;
expect(seedFromSearchUrl(params("?q=blues&scope=music"), applied)).toEqual({
query: "blues",
scope: "music",
});
expect(seedFromSearchUrl(params("?q=jazz"), applied)).toEqual({
query: "jazz",
scope: "all",
});
});
});
+52 -2
View File
@@ -71,6 +71,16 @@ export function searchRouteUrl(query: string, scope: SearchScope): string {
return `/search?${params.toString().replace(/\+/g, "%20")}`;
}
/** Normalise a pathname for comparison: drop query, hash and trailing slashes. */
function normalizePath(pathname: string): string {
return pathname.split(/[?#]/)[0].replace(/\/+$/, "") || "/";
}
/** Whether `pathname` is the search surface itself. */
export function isSearchRoute(pathname: string): boolean {
return normalizePath(pathname) === "/search";
}
/**
* Whether a search typed on `pathname` must navigate to `/search` to be seen.
*
@@ -82,8 +92,48 @@ export function searchRouteUrl(query: string, scope: SearchScope): string {
*/
export function shouldNavigateToSearch(pathname: string, query: string): boolean {
if (!query.trim()) return false;
const path = pathname.split(/[?#]/)[0].replace(/\/+$/, "") || "/";
return path !== "/search";
return !isSearchRoute(pathname);
}
/** Read a `?scope=` value, falling back when it is absent or unrecognised. */
export function parseSearchScope(
raw: string | null | undefined,
fallback: SearchScope = "all"
): SearchScope {
return SEARCH_SCOPES.includes(raw as SearchScope) ? (raw as SearchScope) : fallback;
}
/** The query + scope a `/search` URL asks the page to show. */
export interface SearchSeed {
query: string;
scope: SearchScope;
}
/**
* What a `/search` URL should seed the page with, or `null` if it asks for
* nothing new.
*
* The URL is *consumed once per value*, not continuously reconciled against the
* live input. `applied` is the seed the caller last took from the URL: while it
* still matches, the user's own typing and chip picks govern, and only a real
* navigation (the header search sending a new query) re-seeds the page.
*
* Reconciling instead of consuming was the bug this replaced the old effect
* compared the URL against `library.searchQuery`, so every keystroke's search
* re-ran it and snapped the input back to the query the header had sent.
*
* TRACES: UR-049 | DR-063, DR-064
*/
export function seedFromSearchUrl(
params: URLSearchParams,
applied: SearchSeed | null
): SearchSeed | null {
const seed: SearchSeed = {
query: params.get("q") ?? "",
scope: parseSearchScope(params.get("scope")),
};
if (applied && applied.query === seed.query && applied.scope === seed.scope) return null;
return seed;
}
// ---------------------------------------------------------------------------
+86
View File
@@ -0,0 +1,86 @@
/**
* Native video surface compositing, Android only.
*
* TRACES: UR-003, UR-004 | DR-150, DR-151
*
* On Android, ExoPlayer renders video into a SurfaceView that sits *behind* the
* Tauri WebView (`setZOrderMediaOverlay(false)`, added at index 0 of the content
* view by VideoOverlayManager). For that video to be visible, two independent
* opaque layers have to be cleared:
*
* 1. The **WebView widget's own background** reachable only from Kotlin, via
* the `AndroidVideoSurface` @JavascriptInterface installed by MainActivity.
* 2. The **web page's backgrounds** the `html`/`body` colour in app.css and
* the app shell's `bg-[var(--color-background)]`. Handled by the
* `data-native-video` attribute, which $lib/stores/nativeVideo.ts sets and
* app.css keys its transparency rules off.
*
* Clearing only one leaves a black screen with audio, which is exactly the
* failure mode the old INTERIM override in VideoPlayer.svelte was working
* around. Both must be toggled together, so this module owns both halves.
*
* Everything here is a no-op off Android the bridge is simply absent.
*/
import { nativeVideoActive } from "$lib/stores/nativeVideo";
interface AndroidVideoSurfaceBridge {
setTransparent(transparent: boolean): void;
isSupported(): boolean;
}
declare global {
interface Window {
AndroidVideoSurface?: AndroidVideoSurfaceBridge;
}
}
function bridge(): AndroidVideoSurfaceBridge | undefined {
if (typeof window === "undefined") return undefined;
return window.AndroidVideoSurface;
}
/**
* Whether the native-surface bridge exists on this platform. This reports only
* that the *plumbing* is present; whether native video should actually be used
* is Rust's decision (`player_get_capabilities`) gated by the user's
* `experimentalNativeVideo` flag.
*/
export function isNativeSurfaceBridgeAvailable(): boolean {
try {
return bridge()?.isSupported() ?? false;
} catch (err) {
console.warn("[videoSurface] isSupported check failed:", err);
return false;
}
}
/**
* Make the webview transparent so the video surface behind it shows through.
*
* MUST be paired with {@link disableNativeVideoCompositing} on teardown a
* transparent window left behind shows the launcher through the whole app.
*/
export function enableNativeVideoCompositing(): void {
// Page layer first: if the Kotlin call succeeded but this threw, the user
// would see through the app to the home screen.
nativeVideoActive.set(true);
try {
bridge()?.setTransparent(true);
} catch (err) {
console.warn("[videoSurface] setTransparent(true) failed:", err);
nativeVideoActive.set(false);
}
}
/** Restore the opaque webview background. Safe to call unconditionally. */
export function disableNativeVideoCompositing(): void {
try {
bridge()?.setTransparent(false);
} catch (err) {
console.warn("[videoSurface] setTransparent(false) failed:", err);
}
// Always clear the page layer, even if the bridge call failed, so the app is
// never left rendering over a transparent window.
nativeVideoActive.set(false);
}
+50 -5
View File
@@ -5,6 +5,7 @@
import { platform } from "@tauri-apps/plugin-os";
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
import "../app.css";
import { commands } from "$lib/api/bindings";
import { auth, needsReauth, isAuthenticated } from "$lib/stores/auth";
import { connectivity, isConnected } from "$lib/stores/connectivity";
import { initPlayerEvents, cleanupPlayerEvents } from "$lib/services/playerEvents";
@@ -20,6 +21,7 @@
import SleepTimerModal from "$lib/components/player/SleepTimerModal.svelte";
import BottomUi from "$lib/components/BottomUi.svelte";
import AppHeader from "$lib/components/AppHeader.svelte";
import PendingSyncModal from "$lib/components/sync/PendingSyncModal.svelte";
import { isInitialized, pendingSyncCount, isAndroid, showSleepTimerModal } from "$lib/stores/appState";
import {
showBottomNav as computeShowBottomNav,
@@ -34,6 +36,9 @@
let { children } = $props();
/** Offline banner badge → the list of what is actually queued (DR-132). */
let showPendingSync = $state(false);
/** Teardown for the network-transport reporter (WiFi-only gate). */
let stopNetworkReporting: (() => void) | null = null;
let stopFavoritesListener: UnlistenFn | null = null;
@@ -149,6 +154,17 @@
// Start sync service for offline mutation queue
syncService.start();
// Kick the queue once at startup. The Rust drain otherwise only runs on an
// offline→online transition, so a queue built up in a previous session sits
// untouched for a whole run of the app if the server was reachable the
// whole time. Safe when it isn't: an unreachable server leaves rows queued
// without spending their retry budget (DR-131).
if (get(auth).user?.id) {
commands.syncProcessPending().catch((err) =>
console.debug("[Layout] Startup sync drain skipped:", err)
);
}
// Load the last-sync hint for the offline banner. The catalog *index* is no
// longer kicked off from here: the Rust background indexer (DR-109) owns
// when to re-index, so a long session no longer searches a stale catalog and
@@ -200,7 +216,9 @@
}
});
// Update pending sync count periodically
// Update pending sync count periodically, and immediately whenever the Rust
// drain (DR-131) reports it pushed or gave up on rows — otherwise the badge
// lags a reconnect by up to 10s and reads as if nothing happened.
$effect(() => {
if ($isAuthenticated) {
const updateCount = async () => {
@@ -210,7 +228,19 @@
updateCount();
// Update every 10 seconds
const interval = setInterval(updateCount, 10000);
return () => clearInterval(interval);
let unlistenDrain: UnlistenFn | null = null;
listen("sync-queue-changed", () => {
void updateCount();
})
.then((unlisten) => {
unlistenDrain = unlisten;
})
.catch((err) => console.debug("[Layout] sync-queue-changed listen failed:", err));
return () => {
clearInterval(interval);
unlistenDrain?.();
};
}
});
</script>
@@ -236,10 +266,19 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" />
</svg>
<span>You're offline. Some features may be limited.</span>
<!-- The badge is answerable: it opens the queue it counts. Read as
"pending transfers" it used to send people to the Downloads page,
which lists a different table entirely and can never show these.
TRACES: UR-025 | DR-132 -->
{#if $pendingSyncCount > 0}
<span class="bg-white/20 px-2 py-0.5 rounded-full text-xs">
{$pendingSyncCount} pending sync{$pendingSyncCount !== 1 ? 's' : ''}
</span>
<button
type="button"
onclick={() => (showPendingSync = true)}
class="bg-white/20 hover:bg-white/30 px-2 py-0.5 rounded-full text-xs transition-colors"
title="Changes waiting to reach the server"
>
{$pendingSyncCount} waiting to sync
</button>
{/if}
<button
type="button"
@@ -298,6 +337,12 @@
isOpen={$showSleepTimerModal}
onClose={() => showSleepTimerModal.set(false)}
/>
<!-- What the offline banner's badge counts (DR-132) -->
<PendingSyncModal
isOpen={showPendingSync}
onClose={() => (showPendingSync = false)}
/>
{:else}
<div class="flex items-center justify-center h-full">
<div class="w-8 h-8 border-2 border-[var(--color-jellyfin)] border-t-transparent rounded-full animate-spin"></div>
+4 -53
View File
@@ -1,17 +1,8 @@
<script lang="ts">
import { onMount, onDestroy, setContext } from "svelte";
import { goto } from "$app/navigation";
import { page } from "$app/stores";
import { isAuthenticated, isLoading as isAuthLoading } from "$lib/stores/auth";
import { library } from "$lib/stores/library";
import { useScrollGuard } from "$lib/composables/useScrollGuard";
import Search from "$lib/components/Search.svelte";
import {
resolveSearchScope,
searchRouteUrl,
shouldNavigateToSearch,
type SearchScope,
} from "$lib/utils/searchScope";
import AppHeader from "$lib/components/AppHeader.svelte";
import BottomUi from "$lib/components/BottomUi.svelte";
import SleepTimerModal from "$lib/components/player/SleepTimerModal.svelte";
@@ -22,7 +13,6 @@
let { children } = $props();
let searchQuery = $state("");
let showSleepTimerModal = $state(false);
onMount(() => {
@@ -38,38 +28,8 @@
}
});
// The header search outlives navigation, so the route seeds the scope only
// while no search is active. Once the user has typed (or picked a chip),
// their scope governs until they clear the query — navigating must not snap
// a widened search back to the section they happen to be in.
// TRACES: UR-049 | DR-064
let searchScope = $state<SearchScope>(resolveSearchScope($page.url.pathname));
$effect(() => {
const pathname = $page.url.pathname;
if (!searchQuery.trim()) {
searchScope = resolveSearchScope(pathname);
}
});
// The header bar is a *navigator*, not a second results surface: /search is
// the only route that renders searchResults, so searching here routes there
// with the query + route-derived scope in the URL. Previously this ran
// library.search() in place, which was invisible on every /library/** page
// except /library itself.
// TRACES: UR-049 | DR-063
async function handleSearch(query: string) {
if (!query.trim()) {
library.clearSearch();
return;
}
if (shouldNavigateToSearch($page.url.pathname, query)) {
await goto(searchRouteUrl(query, searchScope));
// The query now lives in the URL; clear the header input so returning to
// a library page does not leave a stale term sitting in the box.
searchQuery = "";
}
}
// Search lives in AppHeader (see HeaderSearch.svelte) so the same bar renders
// here and on /search — this layout no longer owns any search state.
</script>
{#if $isAuthLoading}
@@ -78,17 +38,8 @@
</div>
{:else if $isAuthenticated}
<div class="h-full flex flex-col overflow-hidden">
<!-- Header (shared across all authenticated chrome; library supplies search) -->
<AppHeader search={librarySearch} />
{#snippet librarySearch()}
<!-- Scope chips live on /search, which owns the results. -->
<Search
bind:value={searchQuery}
placeholder="Search your library..."
onSearch={handleSearch}
/>
{/snippet}
<!-- Header (shared across all authenticated chrome; it owns the search bar) -->
<AppHeader />
<!-- Main content. The BottomUi below is an in-flow flex sibling, so this
scroller is physically bounded above it and its last row can never
+57 -52
View File
@@ -1,9 +1,10 @@
<!-- TRACES: UR-035, UR-038, UR-048, UR-062 | DR-043, DR-062, DR-102, DR-103 -->
<!-- TRACES: UR-035, UR-038, UR-048, UR-058, UR-062 | DR-043, DR-062, DR-102, DR-103, DR-142 -->
<script lang="ts">
import { onMount, untrack } from "svelte";
import { page } from "$app/stores";
import { goto } from "$app/navigation";
import { navigateBack } from "$lib/utils/navigation";
import { useOfflineFilterReload } from "$lib/composables/useOfflineFilterReload";
import { kindLabel } from "$lib/utils/mediaKind";
import { commands } from "$lib/api/bindings";
import type { MediaItem, Library } from "$lib/api/types";
@@ -33,9 +34,9 @@
import ArtistLinks from "$lib/components/library/ArtistLinks.svelte";
import {
groupEpisodesBySeason,
seasonAnchorId,
seasonRedirectTarget,
episodeFocusHref,
episodeRedirectTarget,
seriesPlayHref,
seriesPlayLabel,
initialExpandedSeasons,
@@ -84,6 +85,13 @@
previousServerReachable = serverReachable;
});
// Re-query when the offline downloaded-only gate changes, so a container's
// contents follow the filter the same way a library listing does.
// TRACES: UR-052 | DR-143
useOfflineFilterReload(() => {
if (itemId) loadItem();
});
async function loadItem() {
if (!itemId) return;
// Only show spinner when navigating to a different item
@@ -113,6 +121,20 @@
// No seriesId (stale cache / deep link) — fall through to the generic
// rendering below rather than stranding the user.
}
// Nor is an episode. A bare `/library/<episodeId>` — a deep link, an old
// bookmark, a caller that missed `episodeFocusHref` — lands in the series'
// Episode Focus View, so there is exactly one episode surface and it never
// has fewer affordances than the other (ux-flows §5B.1).
// TRACES: UR-058 | DR-142
if (item?.kind === "episode") {
const target = episodeRedirectTarget(item);
if (target) {
await goto(target, { replaceState: true });
return;
}
// Series-less episode: rendered by the Focus View below, series and all.
}
console.log(`[LibraryDetail] ✓ Loaded item: ${item?.name} (${item?.kind})`);
console.log(`[LibraryDetail] - Has people? ${item?.people ? `YES (${item.people.length})` : 'NO'}`);
if (item?.people) {
@@ -185,13 +207,18 @@
$page.url.searchParams.get("episode")
);
// If we have a focused episode ID but couldn't find it in the seasons,
// fetch it directly (handles ID mismatch between APIs)
// Always fetch the focused episode in full. The season fan-out is a
// *list* query, so its episodes carry no cast and no genres — the Focus
// View would render a bare hero with the sections missing. This also
// still covers the original case: an episode id the fan-out never
// returned at all (an ID mismatch between APIs).
// TRACES: UR-058 | DR-142
const episodeIdParam = $page.url.searchParams.get("episode");
if (episodeIdParam && !episodes.some((e) => e.id === episodeIdParam)) {
if (episodeIdParam) {
try {
directFetchedEpisode = await repo.getItem(episodeIdParam);
} catch {
// Best-effort: the list entry still renders a usable hero.
console.warn("Could not fetch focused episode directly:", episodeIdParam);
}
}
@@ -271,8 +298,9 @@
}
async function handlePlayAll() {
// For single items (Episode, Movie), play the item directly
if (item?.kind === "episode" || item?.kind === "movie") {
// A movie is a leaf — play it directly. (Episodes never get here; they
// play from the Focus View's own hero button.)
if (item?.kind === "movie") {
goto(`/player/${itemId}`);
} else if (item?.kind === "series" && itemId) {
// Open the episode the viewer is up to, where an explicit Play/Resume
@@ -340,12 +368,16 @@
// An empty series has nowhere for the hero button to lead.
const canPlay = $derived(item?.kind !== "series" || currentEpisode !== null);
// Find the focused episode (check allEpisodes first, then fall back to directly fetched)
const focusedEpisode = $derived(
focusedEpisodeId
? allEpisodes.find((e) => e.id === focusedEpisodeId) ?? directFetchedEpisode
: null
);
// The focused episode, as complete as we can make it: the full item fetched
// above layered over the list entry, so cast/genres are present without losing
// anything the fan-out knew. Either source alone is enough to render.
const focusedEpisode = $derived.by(() => {
if (!focusedEpisodeId) return null;
const listed = allEpisodes.find((e) => e.id === focusedEpisodeId) ?? null;
const fetched = directFetchedEpisode?.id === focusedEpisodeId ? directFetchedEpisode : null;
if (listed && fetched) return { ...listed, ...fetched };
return fetched ?? listed;
});
const isMusicItem = $derived(
item?.kind === "track" || item?.kind === "album" || item?.kind === "artist" || item?.kind === "playlist"
@@ -408,6 +440,10 @@
{allEpisodes}
onBack={handleBackToSeries}
/>
<!-- The same view for a series-less episode, so an episode is never shown
through a second, lesser surface. TRACES: UR-058 | DR-142 -->
{:else if item.kind === "episode"}
<EpisodeFocusView episode={item} series={null} allEpisodes={[]} onBack={goBack} />
{:else}
<div class="space-y-8">
<!-- Back navigation -->
@@ -445,32 +481,7 @@
<div class="flex-1 space-y-4">
<div>
<h1 class="text-3xl font-bold text-white">{item.name}</h1>
{#if item.kind === "episode"}
<!-- Links back to the parent series/season so the episode detail
page is a navigable hub, not a dead end. TRACES: UR-058 | DR-087 -->
{#if item.seriesId && item.seriesName}
<p class="text-lg mt-1">
<a
href={`/library/${item.seriesId}`}
class="text-[var(--color-jellyfin)] hover:underline"
>{item.seriesName}</a>
</p>
{/if}
{#if item.parentIndexNumber || item.indexNumber}
<p class="text-lg text-gray-400 mt-1">
<!-- Links to the season's place in the series list, not to a
season page — seasons have none (DR-103). -->
{#if item.seriesId && item.parentIndexNumber}
<a
href={`/library/${item.seriesId}#${seasonAnchorId(item.parentIndexNumber)}`}
class="hover:underline hover:text-[var(--color-jellyfin)] transition-colors"
>Season {item.parentIndexNumber}</a>
{:else if item.parentIndexNumber}Season {item.parentIndexNumber}{/if}
{#if item.parentIndexNumber && item.indexNumber}, {/if}
{#if item.indexNumber}Episode {item.indexNumber}{/if}
</p>
{/if}
{:else if item.artistItems?.length || item.artists?.length}
{#if item.artistItems?.length || item.artists?.length}
<p class="text-lg text-gray-400 mt-1">
<ArtistLinks
artistItems={item.artistItems}
@@ -515,7 +526,7 @@
{playLabel}
</button>
{/if}
{#if item.kind !== "episode" && item.kind !== "movie"}
{#if item.kind !== "movie"}
<button
onclick={handleShufflePlay}
class="px-6 py-2 bg-[var(--color-surface)] hover:bg-[var(--color-surface-hover)] rounded-lg font-medium flex items-center gap-2 transition-colors"
@@ -551,13 +562,6 @@
isMovie={true}
size="lg"
/>
{:else if item.kind === "episode"}
<VideoDownloadButton
itemId={item.id}
itemName={item.name}
isMovie={false}
size="lg"
/>
{/if}
<!-- Favourite. Sits with Play/Download rather than in the header,
per ux-flows §5B.3/§5B.4. TRACES: UR-068 | DR-119 -->
@@ -615,12 +619,13 @@
</div>
{/if}
<!-- Cast / Related — for Movies and Episodes these sit above the content
block; for Series they render *below* the seasons instead, so
continuation content precedes discovery content (UX §5B.4). -->
<!-- Cast / Related — for Movies these sit above the content block; for
Series they render *below* the seasons instead, so continuation
content precedes discovery content (UX §5B.4). Episodes never reach
here — they render through EpisodeFocusView (§5B.1). -->
{#if item.kind !== "series"}
<!-- Cast Section - for Movies and Episodes -->
{#if (item.kind === "movie" || item.kind === "episode") && item.people?.length}
<!-- Cast Section - for Movies -->
{#if item.kind === "movie" && item.people?.length}
<CastSection people={item.people ?? undefined} />
{/if}
@@ -16,6 +16,7 @@
import { auth } from "$lib/stores/auth";
import { isServerReachable } from "$lib/stores/connectivity";
import { useServerReachabilityReload } from "$lib/composables/useServerReachabilityReload";
import { useOfflineFilterReload } from "$lib/composables/useOfflineFilterReload";
import { navigateBack } from "$lib/utils/navigation";
import { favoriteOverrides, retainFavorites } from "$lib/stores/favorites";
import LibraryGrid from "$lib/components/library/LibraryGrid.svelte";
@@ -81,6 +82,8 @@
});
const { markLoaded } = useServerReachabilityReload(() => load(scope));
// Re-query when the offline downloaded-only gate changes. TRACES: UR-052 | DR-143
useOfflineFilterReload(() => load(scope));
function selectScope(next: (typeof FAVORITE_SCOPES)[number]) {
if (next === scope) return;
+3
View File
@@ -16,6 +16,7 @@
import { movies } from "$lib/stores/movies";
import { isServerReachable } from "$lib/stores/connectivity";
import { useServerReachabilityReload } from "$lib/composables/useServerReachabilityReload";
import { useOfflineFilterReload } from "$lib/composables/useOfflineFilterReload";
import HeroBanner from "$lib/components/home/HeroBanner.svelte";
import Carousel from "$lib/components/home/Carousel.svelte";
import LibraryViewTabs from "$lib/components/library/LibraryViewTabs.svelte";
@@ -69,6 +70,8 @@
}
const { markLoaded, checkServerReachability } = useServerReachabilityReload(load);
// Re-query when the offline downloaded-only gate changes. TRACES: UR-052 | DR-143
useOfflineFilterReload(load);
onMount(async () => {
await load();
+3
View File
@@ -8,6 +8,7 @@
import { music } from "$lib/stores/music";
import { isServerReachable } from "$lib/stores/connectivity";
import { useServerReachabilityReload } from "$lib/composables/useServerReachabilityReload";
import { useOfflineFilterReload } from "$lib/composables/useOfflineFilterReload";
import HeroBanner from "$lib/components/home/HeroBanner.svelte";
import Carousel from "$lib/components/home/Carousel.svelte";
import type { MediaItem } from "$lib/api/types";
@@ -67,6 +68,8 @@
}
const { markLoaded, checkServerReachability } = useServerReachabilityReload(load);
// Re-query when the offline downloaded-only gate changes. TRACES: UR-052 | DR-143
useOfflineFilterReload(load);
onMount(async () => {
await load();
+3
View File
@@ -16,6 +16,7 @@
import { tv } from "$lib/stores/tv";
import { isServerReachable } from "$lib/stores/connectivity";
import { useServerReachabilityReload } from "$lib/composables/useServerReachabilityReload";
import { useOfflineFilterReload } from "$lib/composables/useOfflineFilterReload";
import HeroBanner from "$lib/components/home/HeroBanner.svelte";
import Carousel from "$lib/components/home/Carousel.svelte";
import LibraryViewTabs from "$lib/components/library/LibraryViewTabs.svelte";
@@ -70,6 +71,8 @@
}
const { markLoaded, checkServerReachability } = useServerReachabilityReload(load);
// Re-query when the offline downloaded-only gate changes. TRACES: UR-052 | DR-143
useOfflineFilterReload(load);
onMount(async () => {
await load();
+19 -8
View File
@@ -2,9 +2,8 @@
import { onMount, onDestroy } from "svelte";
import { page } from "$app/stores";
import { goto } from "$app/navigation";
import { convertFileSrc } from "@tauri-apps/api/core";
import { commands } from "$lib/api/bindings";
import { resolveVideoSource } from "$lib/player/localSource";
import { downloadedFilePath, resolveVideoSource } from "$lib/player/localSource";
import type { PlayQueueRequest } from "$lib/api/bindings";
import type { MediaItem, MediaKind } from "$lib/api/types";
import { auth } from "$lib/stores/auth";
@@ -236,14 +235,21 @@
console.log("loadAndPlay: Found local download, using offline playback:", localDownload.filePath);
isOfflinePlayback = true;
// Get the storage path and construct full file path
// Get the storage path and resolve the file's location. A completed
// row already holds an absolute path (the worker rewrites it on
// completion), so it must not be rooted again — see downloadedFilePath.
// TRACES: UR-071 | DR-133
const storagePath = await commands.storageGetPath();
const fullPath = `${storagePath}/${localDownload.filePath}`;
const fullPath = downloadedFilePath(storagePath, localDownload.filePath);
console.log("loadAndPlay: Full local path:", fullPath);
// Convert file path to asset URL that can be played in webview
const localUrl = convertFileSrc(fullPath);
console.log("loadAndPlay: Converted to asset URL:", localUrl);
// Serve the file over the loopback media server rather than the asset
// protocol: the asset protocol answers a range-less request with the
// entire file, so a downloaded film never finished loading. Rust mints
// the URL (it holds the port and the per-session token).
// TRACES: UR-071 | DR-137
const localUrl = await commands.mediaLocalUrl(fullPath);
console.log("loadAndPlay: Local media URL resolved");
if (isVideo) {
// Local video files don't need transcoding and support native seeking
@@ -307,12 +313,17 @@
// at all offline. Rust returns null when nothing is downloaded or the
// file has gone, so this falls back to the server on its own.
// TRACES: UR-071 | DR-123
// A downloaded file is served over the loopback media server, not the
// asset protocol — see DR-137. The URL is minted up front because
// resolveVideoSource stays pure/synchronous.
// TRACES: UR-071 | DR-123, DR-137
const localPath = await commands.playerLocalMediaPath(id);
const localUrl = localPath ? await commands.mediaLocalUrl(localPath) : null;
const source = resolveVideoSource({
localPath,
remoteUrl: playbackInfo.streamUrl,
remoteNeedsTranscoding: playbackInfo.needsTranscoding,
toAssetUrl: convertFileSrc,
toAssetUrl: () => localUrl ?? "",
});
streamUrl = source.url;
+60 -37
View File
@@ -5,40 +5,46 @@
import Search from "$lib/components/Search.svelte";
import SearchResults from "$lib/components/search/SearchResults.svelte";
import SearchScopeChips from "$lib/components/search/SearchScopeChips.svelte";
import { resolveSearchScope, SEARCH_SCOPES, type SearchScope } from "$lib/utils/searchScope";
import {
parseSearchScope,
searchRouteUrl,
seedFromSearchUrl,
type SearchScope,
type SearchSeed,
} from "$lib/utils/searchScope";
import { episodeFocusHref } from "$lib/components/library/seriesNavigation";
import type { MediaItem } from "$lib/api/types";
// `?q=` / `?scope=` seed the page so the desktop header search bar can hand
// a query over by navigating here — /search is the only surface that renders
// results, so every other search affordance routes into it.
// `?q=` / `?scope=` seed the page so the header search bar can hand a query
// over by navigating here — /search is the only surface that renders results,
// so every other search affordance routes into it.
// TRACES: UR-049 | DR-063
const initialQuery = $page.url.searchParams.get("q") ?? "";
const initialScope = $page.url.searchParams.get("scope");
let searchQuery = $state($page.url.searchParams.get("q") ?? "");
let scope = $state<SearchScope>(parseSearchScope($page.url.searchParams.get("scope")));
let searchQuery = $state(initialQuery);
// The URL is *consumed*, not continuously reconciled: `applied` records the
// seed already taken, so the user's typing and chip picks stand until a real
// navigation brings a different one. It is a plain `let` on purpose — as
// `$state` it would be a dependency of the effect that writes it and the
// effect would re-run itself.
//
// Reading `$library.searchQuery` here instead (the previous shape) made every
// search re-run this effect, which then re-asserted the URL's query over
// whatever had been typed since — the input snapped back a keystroke later.
// TRACES: UR-049 | DR-063, DR-064, DR-147
let applied: SearchSeed | null = null;
// Route resolves the *initial* scope only. Deriving it reactively would snap
// a user who widened to All back to the route's scope on any navigation.
// TRACES: UR-049 | DR-064
let scope = $state<SearchScope>(
SEARCH_SCOPES.includes(initialScope as SearchScope)
? (initialScope as SearchScope)
: resolveSearchScope($page.url.pathname)
);
// A query arriving in the URL must actually run — mounting with a seeded
// input alone would render the empty state with a filled box.
$effect(() => {
const q = $page.url.searchParams.get("q") ?? "";
if (!q.trim()) return;
const urlScope = $page.url.searchParams.get("scope");
const nextScope = SEARCH_SCOPES.includes(urlScope as SearchScope)
? (urlScope as SearchScope)
: "all";
if (q === $library.searchQuery && nextScope === scope) return;
searchQuery = q;
scope = nextScope;
library.search(q, nextScope);
const seed = seedFromSearchUrl($page.url.searchParams, applied);
if (!seed) return;
applied = seed;
searchQuery = seed.query;
scope = seed.scope;
if (seed.query.trim()) {
library.search(seed.query, seed.scope);
} else {
library.clearSearch();
}
});
async function handleSearch(query: string) {
@@ -49,9 +55,19 @@
}
}
// Changing the chip re-runs the current query; changing the query keeps scope.
// Chips write the scope to the URL so the header bar (which searches with
// whatever scope the URL carries) and this page cannot disagree about it.
// Applied optimistically as well, so the chip highlights on click rather than
// a navigation later.
// TRACES: UR-049 | DR-064, DR-147
async function handleScopeChange(next: SearchScope) {
scope = next;
applied = { query: searchQuery, scope: next };
await goto(searchRouteUrl(searchQuery, next), {
replaceState: true,
keepFocus: true,
noScroll: true,
});
if (searchQuery.trim()) {
await library.search(searchQuery, next);
}
@@ -75,8 +91,11 @@
goto(`/library/${item.id}`);
break;
case "Episode":
// Episodes play directly
goto(`/player/${item.id}`);
// Tap opens, it does not play (ux-flows §5B.1/§5B.5) — a search hit
// went straight to the player, so it was the one episode entry point
// with no way to reach download, cast or the rest of the episode.
// TRACES: UR-058 | DR-142
goto(episodeFocusHref(item));
break;
default:
goto(`/library/${item.id}`);
@@ -88,13 +107,17 @@
<div class="max-w-6xl mx-auto">
<h1 class="text-2xl font-bold mb-6">Search</h1>
<!-- Search Input -->
<!-- Search input. On md+ the header bar owns the input (it is present on this
route too), so rendering one here as well would put two search boxes on
the same screen; below md the header has none and this is the only one. -->
<div class="mb-6 space-y-3">
<Search
bind:value={searchQuery}
placeholder="Search your library..."
onSearch={handleSearch}
/>
<div class="md:hidden">
<Search
bind:value={searchQuery}
placeholder="Search your library..."
onSearch={handleSearch}
/>
</div>
<SearchScopeChips {scope} onChange={handleScopeChange} />
</div>
+129
View File
@@ -0,0 +1,129 @@
/**
* Regression: `/search` must not fight the user's typing when the page was
* seeded from the URL (`?q=`), i.e. when the desktop header search navigated
* here.
*
* The original seeding effect read `$library.searchQuery`, so every store write
* re-ran it and re-asserted the URL's `q` over whatever had been typed since.
* Typing one more character therefore snapped the input back to the query the
* header had sent, and picking a scope chip snapped back to the URL's scope.
* Arriving from the bottom-nav Search tab (no `?q=`) hit the effect's
* empty-query early return, so the same component behaved correctly the bug
* only showed up on the header's route into the page.
*
* TRACES: UR-049 | DR-147
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/svelte";
const { pageStore, libState, search, clearSearch, goto } = vi.hoisted(() => {
// svelte/store is safe to import inside a hoisted factory (no test-file TDZ).
const { writable } = require("svelte/store");
return {
pageStore: writable({ url: new URL("http://localhost/search") }),
libState: writable({ searchQuery: "", searchResults: [], loadingCount: 0 }),
search: vi.fn(),
clearSearch: vi.fn(),
goto: vi.fn(),
};
});
vi.mock("$app/stores", () => ({ page: pageStore, navigating: { subscribe: () => () => {} } }));
vi.mock("$app/navigation", () => ({ goto, afterNavigate: vi.fn(), beforeNavigate: vi.fn() }));
vi.mock("$lib/stores/library", () => ({
library: { subscribe: libState.subscribe, search, clearSearch },
}));
import Page from "./+page.svelte";
/** The store write `library.search` performs — the trigger for the old loop. */
function primeSearch() {
search.mockImplementation(async (query: string) => {
libState.update((s: Record<string, unknown>) => ({ ...s, searchQuery: query }));
});
clearSearch.mockImplementation(() => {
libState.update((s: Record<string, unknown>) => ({ ...s, searchQuery: "", searchResults: [] }));
});
}
/** Wait past Search.svelte's 300ms input debounce, then let effects settle. */
const afterDebounce = () => new Promise((resolve) => setTimeout(resolve, 450));
function input(): HTMLInputElement {
return screen.getByPlaceholderText("Search your library...") as HTMLInputElement;
}
beforeEach(() => {
vi.clearAllMocks();
libState.set({ searchQuery: "", searchResults: [], loadingCount: 0 });
pageStore.set({ url: new URL("http://localhost/search") });
primeSearch();
// Model SvelteKit: a goto publishes the new URL through the page store, so a
// scope chip's URL round trip really runs through the seeding effect.
goto.mockImplementation(async (url: string) => {
pageStore.set({ url: new URL(url, "http://localhost") });
});
});
describe("/search seeded from the URL", () => {
it("keeps the text the user types on top of the seeded query", async () => {
pageStore.set({ url: new URL("http://localhost/search?q=abc") });
render(Page);
await waitFor(() => expect(search).toHaveBeenCalledWith("abc", "all"));
await fireEvent.input(input(), { target: { value: "abcd" } });
await afterDebounce();
expect(input().value).toBe("abcd");
expect(search).toHaveBeenLastCalledWith("abcd", "all");
});
it("keeps a scope chip the user picks instead of snapping back to the URL", async () => {
pageStore.set({ url: new URL("http://localhost/search?q=abc&scope=music") });
render(Page);
await waitFor(() => expect(search).toHaveBeenCalledWith("abc", "music"));
await fireEvent.click(screen.getByRole("radio", { name: "All" }));
await afterDebounce();
expect(screen.getByRole("radio", { name: "All" }).getAttribute("aria-checked")).toBe("true");
expect(search).toHaveBeenLastCalledWith("abc", "all");
});
it("re-seeds when a fresh query arrives in the URL (header search on this page)", async () => {
pageStore.set({ url: new URL("http://localhost/search?q=abc") });
render(Page);
await waitFor(() => expect(search).toHaveBeenCalledWith("abc", "all"));
pageStore.set({ url: new URL("http://localhost/search?q=zeppelin") });
await waitFor(() => expect(search).toHaveBeenLastCalledWith("zeppelin", "all"));
expect(input().value).toBe("zeppelin");
});
it("clears when the URL query is emptied", async () => {
pageStore.set({ url: new URL("http://localhost/search?q=abc") });
render(Page);
await waitFor(() => expect(search).toHaveBeenCalledWith("abc", "all"));
pageStore.set({ url: new URL("http://localhost/search") });
await waitFor(() => expect(input().value).toBe(""));
expect(screen.getByText("Search your entire library")).toBeTruthy();
});
});
describe("/search entered with no query (bottom-nav Search tab)", () => {
it("searches what the user types and leaves it alone", async () => {
render(Page);
await fireEvent.input(input(), { target: { value: "moon" } });
await afterDebounce();
expect(input().value).toBe("moon");
expect(search).toHaveBeenLastCalledWith("moon", "all");
});
});
+76 -2
View File
@@ -1,6 +1,6 @@
<!-- TRACES: UR-023, UR-027, UR-029, UR-057 | DR-030, DR-048, DR-077, DR-086 -->
<!-- TRACES: UR-023, UR-025, UR-027, UR-029, UR-057 | DR-030, DR-048, DR-077, DR-086, DR-132 -->
<script lang="ts">
import { onMount } from "svelte";
import { onDestroy, onMount } from "svelte";
import { commands } from "$lib/api/bindings";
import type {
AudioSettings,
@@ -20,11 +20,14 @@
} from "$lib/services/imageCache";
import { getCacheConfig, updateCacheConfig } from "$lib/services/preload";
import SearchGroupOrderList from "$lib/components/settings/SearchGroupOrderList.svelte";
import PendingSyncList from "$lib/components/sync/PendingSyncList.svelte";
import { library, viewMode } from "$lib/stores/library";
import {
isNetworkDetectionSupported,
reportNetworkState,
} from "$lib/services/networkType";
import { experimentalNativeVideo } from "$lib/stores/nativeVideo";
import { getPlaybackCapabilities } from "$lib/services/playbackCapabilities";
const episodeLimitOptions = [
{ value: 0, label: "Unlimited" },
@@ -96,8 +99,27 @@
{ label: "Unlimited", bytes: 0 },
];
// Native-video opt-in (Android). `supportsNativeVideo` comes from Rust, which
// owns the "does this platform have a native video surface" decision; the
// toggle is hidden entirely where it cannot apply.
let supportsNativeVideo = $state(false);
let nativeVideoEnabled = $state(false);
const unsubscribeNativeVideo = experimentalNativeVideo.subscribe((v) => {
nativeVideoEnabled = v;
});
function handleNativeVideoToggle() {
experimentalNativeVideo.set(!nativeVideoEnabled);
}
// Not returned from onMount: that callback is async, so its return value is a
// Promise and Svelte would never invoke it as a teardown.
onDestroy(unsubscribeNativeVideo);
onMount(async () => {
await loadSettings();
supportsNativeVideo = (await getPlaybackCapabilities()).supportsNativeVideo;
});
async function loadSettings() {
@@ -658,6 +680,46 @@
</div>
{/if}
</div>
<!-- Native video (experimental). Only rendered where the platform's Rust
backend actually has a native video surface (Android). -->
{#if supportsNativeVideo}
<div class="bg-[var(--color-surface)] rounded-lg p-6 mt-4">
<div class="flex items-center justify-between">
<div class="pr-4">
<h3 class="text-xl font-semibold text-white">
Native Video
<span
class="ml-2 align-middle text-xs font-medium uppercase tracking-wide text-amber-400 border border-amber-400/40 rounded px-1.5 py-0.5"
>
Experimental
</span>
</h3>
<p class="text-sm text-gray-400 mt-1">
Decode video with the device's hardware decoder instead of the
built-in web player. Better performance and battery life, but
less tested — turn this off if video fails to appear.
</p>
</div>
<button
onclick={handleNativeVideoToggle}
class="relative inline-flex h-8 w-14 shrink-0 items-center rounded-full transition-colors {nativeVideoEnabled
? 'bg-[var(--color-jellyfin)]'
: 'bg-gray-600'}"
aria-label="Toggle native video"
>
<span
class="inline-block h-6 w-6 transform rounded-full bg-white transition-transform {nativeVideoEnabled
? 'translate-x-7'
: 'translate-x-1'}"
></span>
</button>
</div>
<p class="text-xs text-gray-500 mt-3">
Takes effect the next time you start a video.
</p>
</div>
{/if}
</div>
<!-- Search Settings -->
@@ -674,6 +736,18 @@
</div>
</div>
<!-- Pending server updates. Lives here as well as behind the offline
banner's badge, because a row that keeps failing is still queued once
the server is reachable again — when no banner is on screen.
TRACES: UR-025 | DR-132 -->
<div class="border-t border-gray-700 pt-6">
<h2 class="text-2xl font-bold text-white mb-4">Waiting to sync</h2>
<div class="bg-[var(--color-surface)] rounded-lg p-6">
<PendingSyncList />
</div>
</div>
<!-- Image Cache Settings -->
<div class="border-t border-gray-700 pt-6">
<h2 class="text-2xl font-bold text-white mb-4">Image Cache</h2>