Compare commits

...
Author SHA1 Message Date
dtourolle 8e98e1c37a test(player): answer the commands the tap-surface tests actually render
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 22m57s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m50s
Traceability Validation / Check Requirement Traces (push) Successful in 24s
Build & Release / Run Tests (push) Successful in 7m21s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 10m3s
Build & Release / Build Linux (push) Successful in 20m32s
Build & Release / Build Windows (push) Successful in 14m29s
Build & Release / Build Android (push) Successful in 31m5s
Build & Release / Create Release (push) Successful in 12s
VideoPlayer.tapSurface.test.ts deliberately does not mock $lib/api/bindings — it
renders the real component against the real bindings, which bottom out in the
globally mocked `invoke`. That mock resolves `undefined` for every command, so
any command whose result is *rendered* blows up: the quality picker assigns the
result straight to state and the template then reads `streamingQualities.length`,
which throws on undefined.

It threw asynchronously, outside any test, so the suite reported 4 unhandled
errors while every test still passed — the state vitest warns "might cause false
positive tests". Answering the two rendered commands removes them.

Authored in the main checkout; brought in here and verified: 83 files, 1009
tests, and the unhandled-error count drops from 4 to 0.
2026-08-16 21:20:55 +02:00
dtourolle 440d7a01a9 chore(release): 0.6.0
🏗️ Build and Test JellyTau / Run Tests (push) Failing after 6m2s
🏗️ Build and Test JellyTau / Android Compile Check (push) Skipped
Publish Documentation / Build & publish docs to gitea-pages (push) Canceled after 32s
Traceability Validation / Check Requirement Traces (push) Successful in 13s
Build & Release / Run Tests (push) Failing after 6m7s
Build & Release / Build Linux (push) Skipped
Build & Release / Build Windows (push) Skipped
Build & Release / Build Android (push) Skipped
Build & Release / Create Release (push) Skipped
Android native video renders a picture, and its transport works.

The path shipped once as audio with no picture and was reverted with the
compositing named as the suspect. It was not the compositing: five independent
defects sat between ExoPlayer and the screen, each able to produce that symptom
on its own — the app shell painting over the surface through a CSS rule aimed at
an attribute nothing set, a poster card with no way to lift on a path that
renders no <video>, JS bridges racing the page load and losing permanently, a
SurfaceView that was never detached, and a frontend that told Rust a webview
element was playing when none existed, so every play/pause intent was aimed at
something that was not there.

Native video stays opt-in. Turning it on surfaced a further unverified path —
the background-audio return is written only for the webview element — and
rotation still needs device confirmation.

Minor rather than patch: the player's touch behaviour changes for everyone (the
control bar now auto-hides on touchscreens, and the system bars go away with the
player), not only for those who opt into native video.
2026-08-16 21:14:08 +02:00
dtourolle dccb5f53dd fix(android): stop the rotation cross-fade replaying the old video frame
Rotating with native video on shows the previous frame flashing in what become
the letterbox bars. It reads as a TextureView artefact — the view retains its
last frame, so between the rotation and fitSurfaceToScreen() landing that frame
sits at the old size — and two fixes were built on that reading:

  1. reveal after two postOnAnimation hops. An animation frame is not a video
     frame; at 24fps the next decoded frame can be several vsyncs away.
  2. reveal on onSurfaceTextureUpdated, i.e. when a real frame lands. This meant
     owning the SurfaceTextureListener and handing ExoPlayer the Surface directly
     instead of via setVideoTextureView, which installs its own and leaves us
     blind to frame arrival.

Neither stopped the flash. The mechanism is the WINDOW's rotation animation:
Android cross-fades a screenshot of the old orientation, that screenshot holds
the old video frame at the old size, and nothing at the TextureView level can
reach it. The app cannot pre-empt the screenshot either — onConfigurationChanged
fires after it is taken.

So the animation itself has to go: ROTATION_ANIMATION_JUMPCUT. That was accepted
and silently ignored, and the platform said why out loud —
"VRI[MainActivity]: setLayoutParams: not fullscreen" — because the attribute is
honoured only for a fullscreen window. FLAG_FULLSCREEN is therefore set with it,
scoped to while native compositing is active so the rest of the app keeps its
normal animation. After the change that complaint is gone from logcat.

The frame-arrival reveal is kept: it replaces a fixed-timeout guess with a real
signal, and its timeout is required rather than defensive — a resize while paused
means no new frame is ever coming, and revealing a stale frame beats a
permanently black player.

NOT CONFIRMED FIXED on device. The forced-rotation harness
(settings put system user_rotation) proved unreliable here, and screenrecord
fixes its canvas at start, so a rotation inside a recording never changes frame
dimensions — which defeated two separate attempts to measure this. DR-194 is
recorded as "Needs device verification" rather than Done.
2026-08-16 18:46:15 +02:00
dtourolle c142568230 fix(player): make transport reach the player that is actually rendering
Play/pause did nothing on the Android native video path — from the on-screen
tap, from the control bar, and from a direct player_toggle invocation — while
seek and skip kept working. That asymmetry was the whole clue: seek decides in
player_seek_video, transport decides in toggle_playback.

DR-195 is the cause. `html5_playing` is Rust's record of "a webview <video> is
active and in this state", and toggle_playback/play/pause all route transport to
that element whenever it is set. The player route mirrored element state into it
UNCONDITIONALLY — from handleReportStart and, fatally, from handleReportProgress,
which VideoPlayer calls on a 10-second interval. So on the native path the
frontend re-declared every ten seconds that an element was playing when none
existed, and every transport intent was emitted into the void. It also explains
the flashing: the control bar and the JRay overlay both key off isPlaying, which
was being contradicted on every tick. The mirror now lives in
mirrorElementStateToRust() in VideoPlayer, gated on useHtml5Element — the only
place that knows whether an element renders at all. The route cannot tell the
paths apart, which is exactly how it came to lie.

DR-193 hands transport authority back to the native backend when an item loads
into it. Necessary but insufficient alone: the progress interval put the flag
straight back, which is why the first device test after it still failed.

DR-192 presents native video through a TextureView instead of a SurfaceView. A
SurfaceView renders on its own layer outside the app window and punches a
transparent region through it, and everything drawn above that hole — here, the
entire Svelte UI — depends on that composition path. The overlay dropped its
incremental damage: the DOM advanced (slider 476 -> 479 across three seconds)
behind a screen showing neither, so the progress bar froze, controls would not
fade and rotation lost the transport UI, while structural DOM changes got
through, which is why the play overlay always appeared to work. It supersedes
DR-191, which forced redraws in a loop and treated the symptom.

DR-194 hides the video view across a resize and reveals it two frames later. A
TextureView retains its last frame, so between a rotation and the re-fit landing
that frame is stretched across the old rect and the previous frame flashes in
what should be the letterbox bars.

Verified on device (Honor ROD2-W09, Android 16) by driving ADB and reading the
live DOM over the devtools socket: surface tap pauses (position frozen across 12
seconds, overlay raised, transport flipped) and resumes; the control bar does
both. UT-189 drives the real 10-second interval under fake timers — an earlier
version asserted on a freshly mounted player, passed with the guard deleted, and
guarded nothing.

Still open, and deliberately not claimed: DR-192's effect on the overlay repaint
is unverified on device, DR-194's letterbox reset is untested, and the native
default (DR-188) stays off pending DR-190, the background-audio return.
2026-08-16 18:03:22 +02:00
dtourolle 95129d04a3 fix(player): make Android native video actually visible, and usable
DR-172 reverted native video to opt-in after it shipped as audio with no
picture, naming the compositing as the suspect. The compositing was fine. Five
separate defects sat between ExoPlayer and the screen, each able to produce that
exact symptom on its own, and each invisible to the others.

DR-185 — the app shell painted over the surface. app.css clears the page's
opaque layers through three selectors, one of which targets `[data-app-shell]`,
an attribute NO component has ever set, in any commit. The shell paints
--color-background across the whole viewport and VideoPlayer stacks above it, so
the WebView composited opaque no matter what else was cleared. Invisible three
ways over: the CSS is valid, the selector is plausible, and a rule matching
nothing looks exactly like a rule matching something already transparent.

DR-182 — nothing could lift the poster card. Every markMediaReady() call site is
an HTML5 <video> event, and the native branch renders no element, so the black
title card covered the surface for the entire session. The first fix hooked
`player://position-update` / `player://state-changed`; those channels are never
emitted by the backend, so it passed a test that fired them by hand and did
nothing on a device. Driven from the player store now, as the seek bar already
was.

DR-183 — the JS bridges raced the page load. Installed 500ms after onCreate by
walking the view tree, while WebView binds injected objects at page-load time,
and the identity guard then declined to re-inject forever. setTransparent(true)
could never arrive. Installed from WryActivity.onWebViewCreate instead, which
wry calls immediately before the first loadUrl.

DR-184 — the SurfaceView was never detached. detachVideoSurface had no callers
anywhere, mirroring the DR-151 defect: every native video left its surface
parented to the content view and the next one stacked another beneath it.

DR-191 — the overlay stopped repainting. Incremental damage (the clock's text,
the control bar's opacity) never reached the screen while structural changes did,
so the progress bar froze, the controls would not fade, and the play overlay
appeared to work because it is added and removed from the DOM. Driven from the
Activity via postInvalidateOnAnimation while compositing is on.

Two UI defects only this path could reveal came with them: isPlaying froze at
its initial value, leaving the play overlay dimming and covering the video
(DR-186), and the control bar's auto-hide was armed solely by mousemove, which a
touchscreen never fires (DR-189). Immersive mode now applies on entering the
player rather than only via the fullscreen button (DR-187).

Verified on a device (Honor ROD2-W09, Android 16): logcat carries
`WebView transparent = true` and `Marking media ready` with video on screen —
the pair DR-172 went looking for and could not find — and skip, seek, rotation
and subtitle rendering were exercised by hand.

The default stays OFF (DR-188). Turning it on surfaced a further unverified
sub-path: returning from background audio is HTML5-only, so playback stays dead
(DR-190, proposed). Shipping it would have repeated DR-161 exactly — a verified
sub-path made default over an unverified one.
2026-08-16 15:28:10 +02:00
dtourolle f0f98feae8 fix(player): strip the burn-in the server puts back into its own transcode URL
The negotiation asks for no subtitle stream (DR-176), but when PlaybackInfo
answers with a TranscodingUrl we played that URL verbatim — and the server
built it from its own subtitle verdict. Jellyfin's StreamInfo.ToUrl appends
SubtitleStreamIndex and SubtitleMethod whenever it picked a track, so the
burn-in we had just declined came straight back through the URL, turning a
remux into a full frame-by-frame re-encode.

Live TV never declined it at all: open_live_stream sent no index, so the
server applied the channel's default track, and broadcast subtitles are DVB
bitmaps that NormalizeSubtitleEmbed converts to burn-in on sight.

without_server_chosen_subtitle() drops SubtitleStreamIndex, SubtitleMethod,
SubtitleCodec and alwaysBurnInSubtitleWhenTranscoding from any URL the server
built — matched case-insensitively, as Jellyfin binds query keys — and
re-appends the -1 sentinel, because an absent index is not "none", it is
"you choose". Applied at both adoption points, plus the sentinel in the
live-stream negotiation body and its fallback URL.
2026-08-16 14:56:40 +02:00
dtourolle d9e1e256e9 fix(auth): trim the username before authenticating
The login form guarded on `username.trim()` but sent the raw value, so a
trailing space from a soft keyboard reached the server verbatim. Jellyfin
reports that as an unknown user, which surfaces as a 401 indistinguishable
from a wrong password — the user is certain of their credentials and the app
insists otherwise.

Normalising in AuthManager rather than the form keeps it on the path every
caller uses, alongside normalize_url. Only surrounding whitespace is
stripped; interior spaces are legal in Jellyfin usernames.
2026-08-16 11:31:34 +02:00
dtourolle 42868fc2e6 feat(login): reveal-password toggle, and stop the keyboard editing credentials
Add an eye/eye-off button inside the password field so a typed password can
be checked against what was intended — the difference between "wrong
password" and "wrong keyboard" was previously invisible.

`bind:value` is not allowed alongside a dynamic `type`, so the field is wired
manually via value/oninput; unlike branching on two separate inputs, this
keeps focus and caret position when the toggle is pressed.

Both fields also get autocapitalize/autocorrect/spellcheck off and proper
autocomplete hints. The Android soft keyboard was free to capitalise or
autocorrect the username, which silently changes a credential the user
believes they typed correctly.
2026-08-16 11:31:27 +02:00
dtourolle c0c6c5023e fix(player): resume a transcoded video by seeking, not by asking for a stream that starts mid-item
🏗️ Build and Test JellyTau / Run Tests (push) Failing after 5m10s
🏗️ Build and Test JellyTau / Android Compile Check (push) Skipped
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m30s
Traceability Validation / Check Requirement Traces (push) Successful in 19s
A resumed transcode played nothing at all: every segment came back 400, hls.js
exhausted its retries and gave up, while the same episode from the beginning was
fine.

Jellyfin builds each segment URI by echoing the master playlist's query string
into it, and its segment handler opens by rejecting any request carrying
StartTimeTicks > 0 (ArgumentException → 400). So one resume position on the
playlist is copied onto every hls1/main/N.ts and 400s all of them — the `> 0`
being exactly why starting from the beginning survived.

HLS does not need the parameter: a playlist spans the whole item and asking for
segment N *is* the seek. It is removed from the URL builder entirely rather than
conditionalised — the builder cannot know whether its response will be
segmented — and the position becomes a seek issued once the player has loaded.
The progressive /Audio/universal builder behind the background-audio handoff has
no segments and keeps its StartTimeTicks, which is why audio-only handoffs
resumed correctly and video ones did not.

Completing that across the boundary, since the URL no longer starts where the
caller asked:

- reloadSource(url, position) now means "reload and resume AT this absolute
  position": it seeks the element once the source is playable and clears the
  transcode offset to zero. It previously set the offset to the position and
  seeked nothing, which was correct only while the URL itself began there —
  left in place it would have shown 20:00 on the scrubber while the opening
  titles played, with no seek ever happening.
- The transcoded resume path in the player page collapses into the same
  "seek after load" branch direct streams already used.
- VideoPlayer's background-audio return does the same: no base, seek to the
  absolute position.
- The stale test asserting StartTimeTicks is present is rewritten to keep its
  other half (an HLS master playlist, never a progressive stream.mp4, carrying
  the chosen source and audio track).

TRACES: UR-004, UR-005, UR-019, UR-021, UR-074 | DR-181 | UT-182, UT-183
2026-08-16 11:08:42 +02:00
dtourolle 521acc75fd build(android): add a side-by-side release build for validating R8
R8 has broken release APKs here before by stripping the JNI-loaded player
and security classes, and the only way to reproduce that was to build with
the real signing key and clobber the install you actually use.

`./scripts/build-and-deploy.sh release --device --debug` now builds a
fully minified release APK — exactly what ships — into the .debug
applicationId slot, signed with the local debug keystore:

  release            com.dtourolle.jellytau        0.5.5
  release --debug    com.dtourolle.jellytau.debug  0.5.5-debug-release
  debug              com.dtourolle.jellytau.debug  0.5.5-debug

It shares the applicationId *and* the signature with the plain debug
build, so the two replace each other cleanly rather than colliding, and
the versionName suffix says which is currently installed. No real key is
needed, so the side-by-side path deliberately skips
write-keystore-properties.sh.

The flag reaches Gradle as JT_SIDE_BY_SIDE=1. CI never sets it, and the
release manifest merges byte-identical without it — verified both ways
through processUniversalReleaseMainManifest.

deploy-android.sh and build-and-deploy.sh learned the flag too, since the
APK path is unchanged but the package to launch is not.
2026-08-16 10:42:59 +02:00
dtourolle 886cbcb29a docs(changelog): backfill every release, and date each fixed defect
CHANGELOG.md stopped at v0.5.0 and had gaps below it. Every tag from
v0.0.1 to v0.5.5 now has an entry, written from the commit bodies rather
than the subjects. Entries before v0.1.2 are shorter and marked as
reconstructed after the fact -- the commit messages of that era ("many
changes", "Playback fix") do not record causes.

docs/defect-windows.md is new: for each fixed defect, the releases it was
actually present in, with the evidence for the dating recorded per row so
a row can be disputed. Dated with `git log -S` on the defective token, not
by blaming the lines a fix removed -- that reliably lands on whatever last
touched the adjacent lines rather than on the defect's origin, and was
used only to shortlist.

Twelve defects date to the v0.0.1 proof of concept and shipped for seven
to eight weeks. They are not regressions but original assumptions nothing
exercised, four of them outright latent: the videoBitrate casing was
harmless until a quality picker existed to select against, and the
unconditional Range header was inert until that fix made transcoded
downloads actually transcode -- so DR-170's code dates to v0.0.1 while its
corruption window is the single release v0.5.1.

Three others are plumbing built and never connected: get_next_up_episodes
accepted a series_id with no caller until v0.3.0, the sync queue ran with
neither producer wired, and both watched-state backend halves sat unused.
No automated check sees these; the code is present, tested and reachable
in principle.

Also corrects the v0.5.5 entry. fa7cb6e9 and dcf08f30 are the same diff
off the same parent -- a local commit and its Gitea PR-merge twin -- and a
merge chain pulled the local one into master during v0.5.5. git log
v0.5.4..v0.5.5 therefore lists an autoplay fix that changed no file in the
release; nextEpisodeService.ts is byte-identical across the tag boundary.
That fix shipped in v0.0.2 and has not regressed. It is the one case where
reading the changelog off commit subjects would have produced a false
entry.

scripts/build-android.sh and src-tauri/src/repository/online.rs are also
modified in this tree by a concurrent session and are deliberately left
uncommitted.
2026-08-16 10:40:33 +02:00
dtourolle 2cc39cd7fd build(android): install the debug build alongside release as its own app
Testing a debug build meant uninstalling the real one first: same
applicationId signed with a different key is INSTALL_FAILED_UPDATE_
INCOMPATIBLE, so every experiment cost the app's settings, credentials
and offline cache.

The debug build type now carries applicationIdSuffix ".debug" and
versionNameSuffix "-debug", so it installs as com.dtourolle.jellytau.debug
("JellyTau Debug", 0.5.5-debug) with its own data directory — two
independent apps on one device.

Only the *application* id is suffixed. Kotlin classes stay in the
`namespace` package com.dtourolle.jellytau, so the JNI loadClass lookups
in player/android/mod.rs, the manifest <service> entry and the R8 keep
rules are untouched, and the FileProvider authority was already
${applicationId}-relative. Launcher names come from the appLabel /
activityLabel manifestPlaceholders rather than resValue, which would
collide with Tauri's generated strings.xml; release resolves them back to
@string/app_name and merges byte-identical.

deploy-android.sh reports the target package and explains an
UPDATE_INCOMPATIBLE failure instead of leaving it raw; logcat.sh takes a
debug|release argument (it was filtering on com.jellytau.app, a package
that has never existed) and attaches by pid when the app is running.

Verified: aapt2 badging on the built APK reports
com.dtourolle.jellytau.debug / 0.5.5-debug / "JellyTau Debug", and the
release manifest merge is unchanged.
2026-08-16 10:36:48 +02:00
dtourolle e457a9884c chore(release): 0.5.5 2026-08-16 10:23:38 +02:00
dtourolle de1c13e72f fix(player,reporting): report real positions, and count an audio-only episode as watched
Returning to the foreground before the background-audio stream had started
playing handed the frontend 0.0s, so the video reloaded at StartTimeTicks=0 —
the episode restarted from the beginning — and the stop report that followed
wrote that zero to Jellyfin as the resume point. Caught on device: locked at
18.4s, unlocked 3.5s later with ExoPlayer still IDLE.

The base that turns a handoff's relative timeline into the episode's is applied
once at the native tick boundary (DR-159), so before the first tick nothing has
applied it. The same blind spot covers webview-rendered media, where nothing is
loaded into the native backend at all and its position is a permanent 0 — which
is why 14 of 14 stop reports in a 35-minute trace were zeroes, one landing 40s
after the frontend had correctly reported 15:22 for the same episode.

- absolute_position(): the maximum of the backend's reading, the last position
  webview media reported, and the handoff base. Exact rather than heuristic —
  at most one term is ever meaningful, and the base is a floor the stream
  cannot physically be behind. duration() gains the same fallback.
- Withhold zero-position stop reports. A zero is never information, and
  Jellyfin stores the reported position as the resume point, so sending one
  only ever destroys a real one.
- Report progress from the controller's own position ticks, through the 30s
  throttler it already shared with the native audio path.
  /Sessions/Playing/Progress was previously requested zero times in 35 minutes.
- Report a finished audio-only episode stopped at its runtime before advancing,
  so Jellyfin's 90% rule marks it played. Nothing else can: the webview is
  suspended and its <video> was torn down at the handoff.
- Split the handoff by source — a downloaded file takes no base and a real
  seek, a stream keeps its StartTimeTicks base and no seek — and stop routing a
  downloaded handoff's absolute seek through the stream rebuild, which refuses
  a non-remote source outright.

Reports go through a PlaybackReportSink, which also collapses three copies of
spawn-a-task-and-hope into one and is what let each of these be written as a
failing test first.

TRACES: UR-005, UR-025, UR-040, UR-071 | DR-178, DR-179, DR-180 |
        UT-176, UT-177, UT-178, UT-179, UT-180, UT-181
2026-08-16 10:23:00 +02:00
dtourolle 5096c01960 fix(player): restore the subtitle sidecar work dropped by the previous commit
The previous commit was assembled from a tree read before 13264e22 landed,
so committing it reverted that commit's changes: the image-based subtitle
filtering in device_profile/types, subtitleTracks and its tests, the
regenerated bindings, and the VideoPlayer menu wiring.

Nothing was lost — the working tree held both changes throughout. This
restores those files to the merged state, leaving both the subtitle fix and
the play-session fix in place.

TRACES: UR-020, UR-004 | DR-176 | UT-168
2026-08-16 10:20:22 +02:00
dtourolle 2d67b0e4f5 fix(player): give every transcode its own play session, and stop the one it replaces
Switching bitrate mid-film stalled playback. The server served the new
playlist and then rejected its segments: 400 on hls1/main/0.ts, six times
over 25 seconds, never recovering, while the UI logged "Streaming quality
changed" as if nothing were wrong.

Jellyfin keys a transcode job by device and play session. Every stream URL
this app built carried the same hardcoded DeviceId and no PlaySessionId at
all, so the second stream for an item was indistinguishable from the first
and nothing ever stopped the old ffmpeg. Re-opening a stream is not rare —
a quality switch, a transcoded seek and an audio-track switch all do it.
Replayed against the server, a second stream opened for a live job's item
alternates per attempt between serving bytes and 400ing, which is why it
read as flaky rather than broken.

begin_video_play_session mints a session id per open and reports the one it
supersedes; the URL builder stops that job (DELETE /Videos/ActiveEncodings,
un-retried — a slow stop must not delay playback) before returning. Putting
it in the builder rather than in each caller covers every re-open path by
construction. adopt_video_play_session takes ownership of the job the server
starts itself when PlaybackInfo answers with a TranscodingUrl: without it the
first switch on a stream has nothing to stop and collides with what is
playing.

Two client faults made the same incident worse and go with it:

- The fatal-HLS-error handler added the transcode seek offset to a position
  that already included it. Past roughly the halfway mark of a film the
  doubled value cleared the "near end" threshold, so any transient network
  error was reported as end-of-stream and autoplay skipped to the next item
  — precisely when a quality switch had just made the offset large. The
  decision now lives in hlsRecovery.ts, against the absolute position.
- The HTML5 reload primitive resolved on its own canplay timeout, so a
  reload the server never served reported success. The picker showed a
  quality that was not playing and the caller had nothing to revert.

TRACES: UR-074, UR-004 | DR-177 | UT-173, UT-174, UT-175
2026-08-16 09:47:27 +02:00
dtourolle 13264e225b fix(player): never let the server burn a subtitle in, and never offer one we cannot draw
Reported as "subtitles are shown even when off", and no toggle in the app
cleared them — because they were not the app's subtitles at all. The server was
painting them into the video.

`PlaybackInfo` omitted `SubtitleStreamIndex`, which does not mean "none": the
server then honours the source's own default/forced flag. On the reported
episode that default is a PGS track — a bitmap, which cannot go out as a
sidecar — so the server fell back to `SubtitleMethod=Encode` and composited it
onto every frame. Confirmed against the live server, which answered the same
PlaybackInfo request two ways: with the index omitted it returned
`SubtitleStreamIndex=2` + `SubtitleMethod=Encode` and a
`SubtitleCodecNotSupported` transcode reason, and its ffmpeg command carried
`[0:2]…[sub];[main][sub]overlay_qsv=…`; with `-1` it selected no subtitle stream
at all. The cost landed on the video, not the subtitle: burn-in rules out
remuxing, so a stream that only needed its audio transcoded was re-encoded frame
by frame.

Three parts:

- The negotiation asks for `SubtitleStreamIndex=-1` and advertises every text
  format we can render (srt/subrip/ass/ssa/vtt) as `External`.
- The stream URL says the same thing, because the negotiation is not what opens
  most streams: a quality switch, a transcoded seek and an audio-track switch
  each rebuild the URL on their own, and an omitted index there lets the server
  pick the default track back up out of whatever session state it still holds.
- The picker offers only subtitles the app can actually draw. Each subtitle
  stream now crosses the boundary carrying `supports_external_delivery`, decided
  in Rust where the codec vocabulary belongs, and `None` for anything that is
  not a subtitle so a `false` cannot be misread as a verdict.
  `subtitleStreamsOf()` drops the rejected ones — and since that one function
  feeds the menu, the `<track>` children and the native play request alike, a
  bitmap track disappears from all three without its URL ever being fetched.
  Only an explicit "no" hides a track; a stream carrying no verdict behaves
  exactly as before.

Nothing is lost by refusing burn-in: the app already fetches the text tracks and
draws them itself (UR-020), so the server's composited copy was always
redundant. Image-based tracks are consequently not offered, which is honest
rather than a regression — the renderer cannot composite a bitmap, and the old
behaviour paid for them by making the whole stream unwatchable.

Tests were written first and observed failing: the Rust one would not compile
against a field that did not exist, and the frontend one resolved a URL for the
PGS track it was supposed to drop.

Carries with it the in-flight per-stream `PlaySessionId` work in online.rs,
whose hunks sit inside the same request builder and could not be separated from
these.

TRACES: UR-020, UR-004 | DR-176 | UT-168
2026-08-16 09:47:09 +02:00
dtourolle 041969f446 fix(player): stop the server burning subtitles into the picture
A transcoded episode stalled every few seconds and seeking took five to
nine seconds to produce a frame. Neither was a seek bug: both seeks in the
capture landed correctly. The stream itself could not keep up.

The episode was HEVC video, E-AC-3 audio, and a PGSSUB subtitle track.
Only the audio needed transcoding — the device profile supports HEVC and
the server would have remuxed the video untouched. But the PlaybackInfo
request omitted SubtitleStreamIndex, and omitting it does not mean "no
subtitles": the server then honours the source's default/forced flag and
picks a track itself. It picked the PGS one. PGS is a bitmap, and the
profile advertised only srt/vtt as External, so it could not go out as a
sidecar — leaving SubtitleMethod=Encode, burn-in.

Burn-in is a video cost, not a subtitle cost. Compositing rules out
remuxing, so the whole HEVC stream was re-encoded to h264 frame by frame.
The server could not sustain that in real time: the buffer never grew past
one segment and playback ran waiting -> HLS error -> canplay -> three
seconds of picture, indefinitely, while each seek restarted the encoder
from scratch. TranscodeReasons named it — SubtitleCodecNotSupported — but
nothing in the log connected that to the stall, so the diagnostic now says
which track it is declining and why.

Ask for SubtitleStreamIndex=-1 explicitly, and advertise every text format
we can render (srt/subrip/ass/ssa/vtt) as External so a subtitle can only
ever arrive as a sidecar. Nothing is lost: the app already fetches subtitle
tracks itself and draws them over the video (UR-020), so the server's
composited copy was always redundant. Image-based tracks are consequently
not offered, which is honest rather than a regression — the renderer cannot
composite a bitmap, and the previous behaviour paid for them by making the
stream unwatchable.

The policy lives beside the other device-profile rules in Rust, where it is
testable without a device.

TRACES: UR-020, UR-004 | DR-176 | UT-168
2026-08-16 09:23:39 +02:00
dtourolle 1a9805f0f3 fix(downloads): queue the whole album, and make every queued track findable offline
An album download put a handful of its tracks on the device while the button
reported the album as downloaded. Two independent gaps, one shared cause.

- `download_album` read its track list from `items WHERE album_id = ?` — the
  local catalog cache. Jellyfin does not return `AlbumId` on every listing
  endpoint, so tracks cached from one of those sit in `items` with a NULL
  `album_id` and are invisible to that query. On the reported database three
  whole albums (18, 12 and 9 tracks) had it NULL on every track; a partially
  linked album queued only the linked subset.
- The frontend then resolved one stream URL per track from its own list and
  paired it with the returned row ids by position. The ids came back in the
  backend's `index_number` order over a different set of rows, so a row could
  be handed another track's URL and any track past the end of the shorter list
  was never started. On Android that loop also stopped wherever the webview was
  suspended.
- `album_id` is what `OfflineRepository::get_items` joins a track to its album
  on, so a track that did download stayed invisible under its album offline —
  the same missing link seen from the other side.

The operation now belongs to Rust end to end:

- `HybridRepository::get_album_tracks` asks the server what the album contains.
  Cache-first `get_items` is right for browsing and wrong for deciding what to
  download; it errors offline so the caller falls back to the ungated local
  catalog, keeping the queue-while-offline flow.
- `queue_album_tracks` writes the album link onto every track it queues, and
  creates an `items` row for tracks the cache has never seen.
- Stream URLs resolve here, through the existing reconnect resolver, now scoped
  to the rows just queued so one album cannot start every unrelated pending row.
  Only the album id crosses the IPC boundary.
- `album_file_names` gives each track its own file. A title repeated inside one
  album (deluxe edition, two discs) mapped to one path, so those downloads
  overwrote each other.

Re-tapping download on a broken album heals it: missing tracks are queued and
the tracks already on disk get their link.

`download_series`/`download_season` still derive their episode lists from the
cache the same way and want the same treatment.

DR-173, UT-170..172. Rust 673 tests, frontend 975 tests, svelte-check and
check:boundary clean.

Note: this tree is shared with a concurrent session. Only the files above are
committed; docs/traceability.md is left to be regenerated once that work lands.
2026-08-16 09:20:32 +02:00
dtourolle 82b6982d68 fix(player): use a speedometer icon for the streaming quality selector
The bitrate ceiling button reused a cloud-download glyph, which read as a
download action rather than a bandwidth setting.
2026-08-16 08:25:43 +02:00
dtourolle 3363ff7f08 Merge branch 'master' into worktree-mosaic-library
# Conflicts:
#	scripts/extract-traces.test.ts
2026-08-16 00:51:46 +02:00
dtourolle 9858b7cb92 chore(release): 0.5.4
🏗️ Build and Test JellyTau / Run Tests (push) Failing after 2m9s
🏗️ Build and Test JellyTau / Android Compile Check (push) Skipped
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m37s
Traceability Validation / Check Requirement Traces (push) Successful in 26s
Build & Release / Run Tests (push) Failing after 5m56s
Build & Release / Build Linux (push) Skipped
Build & Release / Build Windows (push) Skipped
Build & Release / Build Android (push) Skipped
Build & Release / Create Release (push) Skipped
2026-08-16 00:46:17 +02:00
dtourolle f46d7bf676 fix(player): make native Android video opt-in again — it shipped as audio with no picture
Publish Documentation / Build & publish docs to gitea-pages (push) Canceled after 0s
🏗️ Build and Test JellyTau / Run Tests (push) Failing after 4m55s
🏗️ Build and Test JellyTau / Android Compile Check (push) Skipped
Traceability Validation / Check Requirement Traces (push) Successful in 20s
DR-161 flipped experimentalNativeVideo on by default so picture-in-picture could
shrink a real video surface. On a device that shipped sound with a blank screen.

The decode path was never at fault. Logcat shows ExoPlayer running and feeding a
live SurfaceView with an active BufferQueue. The compositing was: the SurfaceView
sits behind the WebView, and the step that clears the opaque layers above it
never took effect — `WebView transparent = false` is logged, `= true` never
appears. The video was rendering correctly the whole time, behind an opaque page.

This is precisely the defect the flag existed to contain;
VideoPlayer.scrubRegression.test.ts had already recorded that "the native
SurfaceView has never been visible through the webview". Enabling it by default
shipped a verified decode path on top of an unverified display path.

Reverting costs nothing that matters: PiP does not depend on it — DR-160 drives
PiP from the WebView <video> — and working video outranks PiP showing a native
surface. The flag stays in Settings, now described as incomplete rather than as a
performance win, so anyone helping test it still can.

Fixing the compositing is the prerequisite for trying this default again (DR-172).
2026-08-16 00:42:56 +02:00
dtourolle 74bffea650 Merge branch 'fix/autoplay-issues'
Records ancestry only: all three of its changes are already on master,
content-identical, having been applied by cherry-pick rather than merge —
the reportMediaId snapshot in VideoPlayer, the `?restart=true` hand-off in
nextEpisodeService, and the POSIX-sh rewrite of the traceability CI loop.

The branch is 167 commits behind, so the files it touched conflicted with
their own newer selves; every conflict resolved to master's version. The
resulting tree is byte-identical to the pre-merge tree.
2026-08-16 00:14:25 +02:00
dtourolle 7e1f0e0547 Merge branch 'master' into worktree-mosaic-library
# Conflicts:
#	docs/traceability.md
2026-08-16 00:06:15 +02:00
dtourolle 99ceeadb83 docs: regenerate the traceability matrix
🏗️ Build and Test JellyTau / Run Tests (push) Failing after 5m7s
🏗️ Build and Test JellyTau / Android Compile Check (push) Skipped
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m26s
Traceability Validation / Check Requirement Traces (push) Successful in 18s
Build & Release / Run Tests (push) Failing after 5m5s
Build & Release / Build Linux (push) Skipped
Build & Release / Build Windows (push) Skipped
Build & Release / Build Android (push) Skipped
Build & Release / Create Release (push) Skipped
The generated matrix had drifted well behind the code — this pass picks up
DR-171/UT-166 along with everything else that had accumulated since it was last
run, which is why the diff is large for a mechanical regeneration.

Coverage 87% (265/303), no orphaned IDs, comfortably above the workflow's 50%
floor. No hand edits: `bun run traces:markdown` output as-is.
2026-08-16 00:04:58 +02:00
dtourolle e015c4c9b1 Merge branch 'master' into worktree-mosaic-library
Renumbers the mosaic's requirement IDs out of the way of the download work
that landed on master in parallel: it had already claimed DR-163/DR-164 and
UT-162, so the mosaic layout is now DR-172, the library favourites scope
DR-173, and its composition test UT-167.

Note for the download branch: its UT-162..UT-165 rows trace to DR-163..DR-166,
none of which are defined in requirements.md — that branch defined DR-167..171
instead. Those references are orphaned and want a look; nothing here touches
them.
2026-08-16 00:04:26 +02:00
dtourolle 7387f35c7e docs(player): correct the stale "native video defaults to off" comments
DR-161 made `experimentalNativeVideo` default to on, but three comments still
described the pre-flip world and one of them was load-bearing:

- `nativeVideo.ts` labelled the store "Default off" directly above a `load()`
  that returns true when nothing is stored.
- The two PiP comments explained themselves as "what makes PiP work in the
  shipping configuration", which stopped being true when Android started
  shrinking the real ExoPlayer surface. They still describe the Linux path and
  the flag-off case, so they say that instead.
- `video_audio_codecs` justified its narrow codec list with "video does not play
  through ExoPlayer", which is no longer so on Android. The narrow list is still
  right, for a different reason now recorded: the flag is a user setting and a
  download outlives it, so only the intersection holds on both sides of the
  switch. DR-171 carries the same caveat.

No behaviour change.
2026-08-16 00:00:10 +02:00
dtourolle 0861523015 feat(library,home): lay libraries out as a mosaic, with favourites per category
The library overview and the home shortcut strip showed artwork of three
different shapes — square music covers, 16:9 library backdrops, 2:3 posters —
in grids that pick one box and crop everything to it. The home strip said so
in a comment: it forced `aspect="video"` on music libraries so the row would
line up, which lined it up by cutting the covers down.

Both surfaces are now justified mosaics: rows share one height and each tile is
as wide as its own artwork. `layoutMosaic` is a pure module — it packs tiles
until the height needed to fill the container drops to the target, justifies the
row by absorbing the rounding remainder into its widest tile, and deliberately
leaves the last row unstretched so one leftover tile does not inflate into a
banner. The component supplies only what the DOM knows: the measured container
width, and the artwork's *decoded* aspect ratio (via a new `onNaturalSize` on
CachedImage), committed in one debounced batch so the grid does not reshuffle
once per image as artwork lands.

Favourites gain a tile per category beside the library it belongs to, alongside
the existing cross-library entry. Which collection type maps to which category
is Jellyfin vocabulary, so it is derived in Rust — `SearchScope::for_collection_type`,
stamped onto every `Library` by a new constructor and carried over as an optional
`favoritesScope`. Deriving it in Svelte would have rebuilt the exact leak
`SearchScope::item_types` was extracted to close. A category shows one tile
however many libraries share it, and a library kind favourites do not carve up
(Live TV, channels, books) gets none.

Also corrects the requirements-count test, which the UR-074 commit left one
behind.

Spec: docs/specs/library-mosaic.md
TRACES: UR-075, UR-067 | DR-163, DR-164 | UT-158..UT-162
2026-08-15 23:57:09 +02:00
dtourolle ac4fccd499 fix(downloads,playback): re-encode undecodable audio and carry the media source through
Work from a parallel session in the same working tree, committed here so the
branch is not left half-written. Attribution note: authored in a concurrent
Claude session, not by the author of the preceding commit.

- DR-171: a downloaded video keeps audio the device can actually decode.
  `original` quality asked for a straight copy, so an E-AC-3/AC-3/DTS/TrueHD
  track came down untouched and the webview had nothing to play it with.
- `get_video_download_url` gains the media source, so the URL is built against
  the source actually chosen rather than the item's default.
- Device profile and repository plumbing updated to match.

Verified green as a whole: 656 Rust tests, 945 frontend tests, svelte-check clean.
2026-08-15 23:54:00 +02:00
dtourolle a5535f2941 fix(downloads): stop libraries mixing, make pause/resume real, reap partials, end bitrate corruption
Four defects behind "downloads still flaky", each with its own cause.

Libraries mixed their media (DR-167). Cached items carry no link back to their
library — library_id and parent_id are NULL on every row — so the library branch
of get_downloaded_items matched `EXISTS (SELECT 1 FROM libraries WHERE id = ?)`,
which asserts only that the library exists and never constrains the item to it.
Opening any downloaded library listed every downloaded top-level item on the
server: films under Music, albums under TV. The query deciding which libraries
appear already had the right rule, so the two disagreed about the same question;
that collection_type <-> item_type mapping is now one constant used by both.

Pause and resume did nothing (DR-168). pause_download wrote status = 'paused'
and stopped there — no cancellation existed anywhere in the download stack, so
the streaming task ran on and overwrote the row with completed/failed when it
finished. The row flicked to "paused" and undid itself. resume_download had the
mirror defect: it flipped the row to 'pending' without pumping, and the pump is
not a poller, so a resumed download sat until some unrelated event pumped the
queue. Adds a per-download stop flag the worker reads between chunks and on
retry, returning Stopped — not retryable, not recorded as a failure, and the
.part file is kept because that is what the resume continues from. Registering
returns a fresh flag so a resumed download does not inherit the pause that
stopped it. Cancel and clear_stale_downloads signal it too, so neither deletes a
file still being written.

Partial files were never reaped (DR-169). The worker named its sidecar with
with_extension("part"), which replaces: movie.mp4 became movie.part. Every
cleanup path deleted "{file_path}.part" — movie.mp4.part. They never matched, so
the partial of every cancelled or failed download stayed on disk forever,
invisible to disk-usage totals because no row pointed at it. One partial_path
helper now serves the writer and the cleaners.

Bitrate downloads corrupted themselves (DR-170). Only `original` asks for
Static=true; every other rung requests a transcode, which Jellyfin serves
chunked with no Content-Length and cannot byte-seek — it ignores Range and
answers 200 with the whole stream, not 206 with the tail. The worker sent the
header whenever a .part existed and appended the body regardless, so each retry
concatenated another full copy onto what was on disk. The file grew past its
real size and would not play, which is why bitrate downloads stayed broken after
the videoBitRate casing fix corrected the request. resume_offset now lets the
response decide: append only on 206, otherwise truncate and take it from the top.

docs/requirements.md also carries DR-171/UT-166, written by a parallel session
working in the same tree; its code lands separately.
2026-08-15 23:52:02 +02:00
dtourolle d49d027020 docs(player): allocate UR-074/DR-162 for the streaming bitrate cap
🏗️ Build and Test JellyTau / Run Tests (push) Failing after 5m10s
🏗️ Build and Test JellyTau / Android Compile Check (push) Skipped
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m27s
Traceability Validation / Check Requirement Traces (push) Successful in 16s
The feature shipped tagged against DR-160, which a parallel session had
claimed for picture-in-picture in the meantime. Renumbered to DR-162
across the Rust and frontend TRACES comments (the PiP tags in
VideoPlayer.svelte, pictureInPicture.ts and nativeVideo.ts keep DR-160)
and regenerated bindings.ts.

Adds the requirement rows the tags point at: UR-074 for the user need, and
DR-162 covering why the cap has to reach the PlaybackInfo negotiation and
not only the transcode URL, why the ceiling is process-wide, and why the
Settings default persists while the in-player override does not. Notes
that this gives UR-070 its resume-at-the-same-point mechanism while the
server-offered rendition list that requirement also asks for stays
proposed. UT-156/157 record what the tests pin.

docs/specs/streaming-bitrate-cap.md carries the layer assignment — the
step definitions, the video/audio split, the resolution pairing and the
reload decision are all Rust; the frontend holds a serde token and the
labels it was handed.

TRACES: UR-074 | DR-162 | UT-156, UT-157
2026-08-15 16:39:53 +02:00
dtourolle 9c352fdb77 Merge branch 'fix/android-versioncode-floor' 2026-08-15 16:35:27 +02:00
dtourolle dda2ff86a3 feat(player): cap streaming bandwidth with a user-chosen bitrate ceiling
Video streams were opened at a fixed allowance nobody could change:
MaxStreamingBitrate=20000000/VideoBitrate=18000000 on the HLS transcode
URL, 20 Mbps in the PlaybackInfo negotiation, and a 999999999 device
profile that let the server direct-play a source of any size. On a
metered or slow connection there was no way to spend less.

StreamingQuality is a ladder of bandwidth ceilings — Original, 20/10/8/
4/2/1 Mbps and 720 kbps — where a step bundles the total ceiling, the
audio share of it and the resolution that budget can carry. Those
numbers are Jellyfin encoding vocabulary, so they live in Rust and the
frontend only names a variant; labels and details come back over IPC
from player_get_streaming_qualities, the same arrangement as the EQ
presets.

The cap has to reach the *negotiation*, not just the transcode URL:
max_static_bitrate in the device profile is what makes the server refuse
to direct-play a file fatter than the cap, and without it a 30 Mbps
remux is handed over untouched and every URL parameter downstream is
moot. So it is applied at all four places that decide bandwidth — the
HLS URL builder, PlaybackInfo, the Live TV stream, and the
background-audio handoff (which takes the lower of the cap and its own
384 kbps). Video bitrate is the total minus the audio share so the two
together honour the ceiling rather than overshooting it.

The ceiling is process-wide rather than a repository field: it is a
preference about this device's connection, must survive a repository
rebuilt on re-login, and every URL builder plus the negotiation have to
agree on it or the cap leaks. Same shape as INCLUDE_CATALOG_BROWSE.

Two ways in. Settings holds the durable default, persisted to
app_settings and restored at startup — unlike the rest of VideoSettings,
because a limit set for a metered connection that silently reverts to
uncapped on the next launch spends the user's data with no changed
setting to see. The in-player menu is the "this film, this connection"
override: a cap is a property of the stream the server is producing, so
it cannot apply to one already in flight — player_set_stream_quality
re-opens the stream at the new quality and resumes at the current
position, reloading the native backend itself and handing HTML5 a URL
for the same reloadSource primitive the audio-track switch uses.

Tests pin the URL parameters at a capped and an uncapped step, the
handoff taking the lower of the two, the ladder's internal consistency
(video + audio == cap, resolution descending with bitrate) and the
persisted token's round trip. The ceiling is process-wide, so the tests
that depend on it serialise on a guard that restores the default.

TRACES: UR-074 | DR-160 | UT-156, UT-157
2026-08-15 16:34:56 +02:00
dtourolle 8ad3dc5c4f fix(android): raise the versionCode floor so 0.5.x can install over v0.5.2
v0.5.2 shipped Android versionCode 5002, from an earlier `minor*1000` scheme.
The `minor*100` formula that replaced it yields only 1502 for that same version,
and 1503 for 0.5.3 — lower than what is already installed, so Android refuses
the update as a downgrade. Every 0.5.x release built from this script was
un-installable for anyone already on v0.5.2.

This is the exact failure the block was written to prevent; its floor simply
went stale. The floor tracked "codes below 1000 are already in the field", which
was true when written, but a 5002 build has shipped since — and the highest code
this formula has *produced* is not the same as the highest code in the field.

Widen the multipliers and raise the floor past 5002:

    code = 10000 + major*1000000 + minor*1000 + patch

    0.0.14 -> 10014    0.5.2 -> 15002    0.6.0 -> 16000
    0.1.0  -> 11000    0.5.3 -> 15003    1.0.0 -> 1010000

Still strictly monotonic across the upgrade sequence. The guard test gains a
case pinning 0.5.3 above the 5002 in the field, so the floor is expressed as
"clears what shipped" rather than a literal that can silently go stale again.
2026-08-15 16:31:38 +02:00
dtourolle 9f5f57cba4 fix(ui,player): scroll restore, immersive fullscreen, watched toggle, handoff timeline, PiP
Batch of reported bugs and enhancements.

UI
- Pages no longer inherit the previous page's scroll position (DR-156, UR-072).
  The shell keeps its scrollers alive across navigation by design, so the
  element never remounts and its scrollTop survived the route change; SvelteKit
  restores window scroll, which this app never uses. ScrollMemory records the
  offset per route and per container: forward moves reset to the top, Back
  restores where the route was left.
- Season header stacks on narrow screens, and the title span gets min-w-0 so it
  actually truncates instead of overflowing under the action buttons.
- Favourites gets a labelled tile at the head of the library grid rather than
  only an unlabelled heart icon in the header.

Playback
- Full-screen video on Android hides the system bars (DR-157, UR-066).
  requestFullscreen() cannot touch the Activity window from inside a WebView, so
  the control did nothing visible while the bars stayed painted over the video.
  ImmersiveModeBridge hides them, restored on exit, Escape and teardown.
- Background-audio handoff stops leaking its relative timeline (DR-159).
  background_audio_base was a display-only correction applied in two places
  while progress reports to Jellyfin, the frontend and media3's own seeks all
  worked in the relative timeline treating it as absolute — each crossing losing
  exactly `base` seconds. The conversion now happens once, in the position tick,
  and inbound seeks resolve through seek_absolute, which re-opens the stream at
  the requested position because the handoff transcode cannot seek.
- Picture-in-picture works on the path that actually plays video (DR-160).
  canEnterPip demanded a native ExoPlayer surface, but that path is behind a
  flag defaulting to off, so PiP could never engage. It now accepts the WebView
  <video> too, keeping the WebView visible and routing play/pause to the element.
- Native video is now the default so PiP has a real surface (DR-161). The
  scrub-regression tests pinned the flag-off path implicitly; they now mock it
  off explicitly. The native scrub/seek path is not covered by the suite and
  needs device verification.

Watched state
- Watched toggle on the episode row, season header, series and movie hero, and
  the Episode Focus View (DR-158, UR-073). Both backend halves already existed
  with no caller. storage_set_watched covers a container's episodes so the
  toggle is honest offline, and QueuedOp::MarkUnplayed gives the sync queue the
  missing direction.

Release
- Fix the Android versionCode floor (set-version.sh). v0.5.2 shipped code 5002
  under an earlier minor*1000 scheme, but the current minor*100 formula yields
  1502 for that version and 1503 for 0.5.3 — so every 0.5.x release built from
  it was an un-installable downgrade for anyone already on v0.5.2. Widened to
  10000 + major*1000000 + minor*1000 + patch (0.5.3 -> 15003).
- Bump to 0.5.3.
2026-08-15 16:26:31 +02:00
dtourolleandClaude Opus 5 50934e2ac6 ci(android): ship Gradle in the builder image instead of downloading it
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 10m7s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m28s
Traceability Validation / Check Requirement Traces (push) Successful in 19s
Build & Release / Run Tests (push) Successful in 7m35s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 3m2s
Build & Release / Build Linux (push) Successful in 20m0s
Build & Release / Build Windows (push) Successful in 8m36s
Build & Release / Build Android (push) Successful in 30m30s
Build & Release / Create Release (push) Successful in 21s
The release APK job died at the Gradle wrapper step, after the 11-minute
Rust compile had already succeeded:

    Downloading https://services.gradle.org/distributions/gradle-8.14.3-bin.zip
    java.net.SocketException: Unexpected end of file from server

`tauri android init` regenerates gen/android with a wrapper pointing at
services.gradle.org, so every Android job re-downloaded ~130MB of Gradle at
build time. That is slow on a good day and a hard build failure when the CDN
drops the connection mid-transfer. It was also a standing violation of the
rule that every build tool must already live in the builder image.

Dockerfile.builder installs Gradle 8.14.3, keeping both the unpacked
distribution (on PATH) and the original zip under /opt/gradle/dist. A
`gradle --version` smoke-test fails the image build on a bad version rather
than letting CI discover it.

sync-android-sources.sh then repoints the regenerated wrapper at that local
zip, which is the established place for fixing up the generated project.
It parses the version the wrapper actually requests, so a future Tauri Gradle
bump logs "not in image, will download" instead of pointing at a missing
file. On dev machines /opt/gradle/dist does not exist and the properties file
is left untouched.

Verified by running the project's own wrapper jar inside a network namespace
with no connectivity: it resolved and unpacked the local zip to 100% and
proceeded into build-script evaluation.

Note: this is inert until the builder image is rebuilt and pushed
(scripts/build-builder-image.sh).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 07:47:43 +02:00
dtourolleandClaude Opus 5 8fbc080733 Merge branch 'fix/android-resume-position'
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 8m2s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m34s
Traceability Validation / Check Requirement Traces (push) Successful in 15s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 2m58s
Build & Release / Run Tests (push) Successful in 8m22s
Build & Release / Build Linux (push) Successful in 19m40s
Build & Release / Build Windows (push) Successful in 8m6s
Build & Release / Build Android (push) Failing after 13m33s
Build & Release / Create Release (push) Skipped
Resume-playback fixes across the three layers where the position was lost:

- DR-150 path: the Android native (ExoPlayer) surface never applied the
  resume seek, so resume always played from the start on device.
- DR-154: a stop-report the server could not be told about was logged and
  dropped, even though sync_queue and its drain were built and running.
- DR-155: the server's watch position was never mirrored into the local
  user_data row the resume check reads, so resume never crossed devices.

Also carries concurrent fixes merged in from parallel work: download
bitrate, series resume ordering, Recently Added grouping, remote-session
volume handoff, and home library card heights.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 22:14:44 +02:00
dtourolleandClaude Opus 5 ba5fd55204 fix(sync): mirror the server's watch position so resume crosses devices (DR-155)
The resume check reads the local user_data row and nothing else, but
mirror_user_data -- the only path by which server UserData lands in that
table -- mirrored is_favorite alone, and returned early whenever that
field was absent, which is exactly the shape of an ordinary watched
episode. playback_position_ticks was therefore write-only from this
device's perspective: watch 40 minutes in a browser, open JellyTau, and
it resumed from whatever this device last saw, or offered no resume at
all. Same user-visible symptom as the Android bug fixed earlier on this
branch, from an unrelated cause -- which is why resume read as broadly
flaky rather than as one defect.

The mirror now carries the position alongside the favourite flag under
the same pending_sync = 0 conflict rule, so a local position still
waiting to be pushed is never pulled backwards by a server that has not
yet heard where we got to. COALESCE(excluded.x, user_data.x) keeps the
stored value for a field the server omitted rather than nulling it, and
a row with neither field is still skipped rather than fabricated as
zeroes.

Mirroring alone was not sufficient. get_item -- the call the player route
makes -- returned the cached copy on a hit and never consulted the
server, so for an already-cached item the mirror never ran. It now
refreshes in the background on a cache hit via race_with_refresh, the
reusable form of what get_items already did inline. That asymmetry is
why browsing a season picked up other devices' state while opening the
episode directly did not. The refreshed value lands for the next read;
the cache-first race still answers immediately.

The DR/total counts in extract-traces.test.ts are updated for DR-154 and
DR-155 -- that edit is the test's intended signal that the CI gate's
denominator is live rather than frozen.

Verified red->green in the jellytau-builder image: both new tests failed
before the fix. Full Rust suite passes (634), cargo fmt clean, clippy
adds no new warnings; frontend suite (933) and svelte-check clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 22:14:20 +02:00
dtourolle fec4b7ae8c Merge branch 'fix/download-bitrate-param' into HEAD 2026-08-12 20:11:12 +02:00
dtourolleandClaude Opus 5 2ca2174cea fix(player): return volume control to the local speaker when a remote session stops
Stopping a remote session left Android stuck on the remote volume slider
with no way back to the device speaker.

Two causes:

1. `player_stop`'s remote branch sent "Stop" to the session and returned
   without touching the playback mode, so the manager stayed in Remote.
   It now drops to Idle, mirroring what the local branch already does.

2. Volume routing was torn down at a single call site
   (`transfer_to_local_inner`), so every *other* exit from remote mode
   leaked the Android VolumeProviderCompat. Routing is now derived from
   the transition inside `set_mode`: entering remote attaches control,
   any exit from remote hands it back to the local media stream. This
   also covers the frontend `disconnect()` path (Remote -> Idle) and the
   local-playback-start paths (Remote -> Local).

Adds a `RemoteVolumeControl` trait so the routing rule is unit-testable
off-device — the real implementation is Android JNI. Tests cover
remote->idle, remote->local, remote->remote (re-arms, never releases),
and that local/idle transitions leave routing untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 20:09:17 +02:00
dtourolleandClaude Opus 5 0ca2857c3a fix(catalog): show a new album once in Recently Added, not once per track
Recently Added listed every newly-added track individually, so importing a
14-track album filled the whole row with that one album and buried everything
else. Both code paths that build the row had the same symptom from separate
causes:

- Online: Jellyfin's /Items/Latest defaults to GroupItems=false, returning each
  new leaf on its own. Send GroupItems=true so the server collapses children
  into the container that was added.
- Offline: the downloaded-items CTE deliberately matches leaves *and* their
  container (right for browsing, wrong here), so a downloaded album returned the
  album plus each of its tracks. Drop a leaf only when its own container is in
  the same result.

Items with no container (movies, standalone tracks) are unaffected in both
paths. The online URL is extracted into build_latest_items_endpoint so it can be
asserted without an HTTP server, matching build_favorites_endpoint.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 20:07:57 +02:00
dtourolleandClaude Opus 5 1f32e4040b Merge branch 'fix/home-library-card-heights' from origin
Local and remote had both advanced two commits from 3619f71 with no
overlapping files:

  remote: uniform card heights; resume after furthest-watched episode
  local:  Android native-path resume; queued watch-position sync (DR-154)

Merged cleanly with no conflicts. The series_progress policy tests pass
against the merged file (19/19).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 19:21:18 +02:00
dtourolleandClaude Opus 5 e4632bb2b2 fix(sync): queue a watch position the server could not be told about (DR-154)
sync_queue and its drain (DR-131) were built, tested and running, but the
stop-report path never fed them, so closing a video while the server was
unreachable lost the resume point outright.

HybridRepository::report_playback_stopped is a bare pass-through to the
online repository ("Playback reporting goes directly to server"), and on
failure the error surfaced to a frontend catch whose own comment read
"Server error - could queue, but for now just log". Both producers that
would have queued it -- PlaybackReporter::queue_for_sync in Rust and
syncService.queuePlaybackProgress on the frontend -- have no callers on
the playback path. user_data.pending_sync was dutifully set to 1, but
nothing drains that flag for positions the way favourites do (DR-120).

The command layer now enqueues a report_playback_stopped row whenever the
push fails; the existing drain already parses and replays that operation.

The pending row for an item is superseded in place rather than appended
to: progress is reported every 10s, so a server that stays down would
otherwise add a row per tick, all obsoleted by the newest -- the
unbounded queue DR-131 exists to prevent. Only pending/failed rows are
superseded, since reviving an abandoned row restores that same growing
counter. Queueing is best-effort and never fails the command: the local
position is already saved, so a failed queue write must not be reported
as a lost position.

Verified red->green in the jellytau-builder image: the four new tests
failed to compile (enqueue_playback_stopped not found) before the fix.
Full Rust suite passes (627 tests), cargo fmt clean, clippy adds no new
warnings; frontend suite (933) and svelte-check also clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 19:18:11 +02:00
dtourolleandClaude Opus 5 2d50744320 fix(player): resume at saved position on the Android native path
The native (ExoPlayer) video path never applied the resume position, so
"resume from where you left off" always played from the start on Android.

Two layers each assumed the other did the seek:

- The only code acting on `initialPosition` was handleCanPlay, an HTML5
  <video> event handler. The native path has no <video> element, so
  `canplay` never fires and that seek never ran.
- NativePlayerAdapter.load() had an initialPosition branch, but it only
  recorded the number, claiming "the native backend performs the actual
  seek internally". It does not: PlayItemRequest carries no start
  position, and loadWithMetadata -> prepare() always starts ExoPlayer at 0.
- VideoPlayer never called adapter.load() at all, so even that branch was
  unreachable.

The frontend therefore believed it had resumed (the seek bar showed the
resume point) while ExoPlayer played from the beginning.

NativePlayerAdapter.load() now issues the backend seek, excluding live
streams (no resume point; seeking knocks the HLS window off its live
edge). VideoPlayer calls it on the native branch and marks the initial
seek as performed so the existing $effect does not fire a duplicate.
The HTML5 path is untouched: seeking before metadata is clamped to 0,
which is exactly what handleCanPlay waits for.

Verified red->green: the new test failed with "Number of calls: 0"
before the fix. Full frontend suite passes (933 tests); svelte-check
and check:boundary are clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 18:51:37 +02:00
dtourolleandClaude Opus 5 adc460f35d fix(downloads): honor the selected bitrate (videoBitRate, capital R)
Downloading at a specific quality silently returned the full-size
original. The download URL builder spelled the transcode params
`videoBitrate`/`audioBitrate`, but Jellyfin binds `videoBitRate`/
`audioBitRate` — with a capital R.

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

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

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

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

TRACES: UR-071 | DR-123

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 18:44:54 +02:00
dtourolleandClaude Opus 5 9d7cb085e9 fix(series): resume after the furthest-watched episode, not the first gap
`pick_current_episode` rung 3 returned the first unwatched episode in
series order. A viewer who skipped the pilot but is three seasons deep
was sent back to S1E1: the gap was a deliberate skip, not the place they
stopped.

This read as flaky rather than consistently wrong because rung 3 only
fires when the server's Next Up (rung 2) yields nothing, and
`resolve_current_episode` swallows that call's errors with
`.unwrap_or_default()`. `HybridRepository::get_next_up_episodes`
delegates unconditionally to the online repo, so any unreachable-server
moment silently degraded to the empty vec — same series, same watch
state, different answer depending on one request's outcome.

Rung 3 now scans the ordered list from the end with `rposition(is_played)`
and returns the episode after the furthest-watched one, falling back to
the previous first-unwatched behaviour when nothing is watched or the
series is finished. Season crossing comes free from the already-flat
series ordering, and `season_rank` keeps specials last so a watched
special cannot mark a show finished.

Tests written first and confirmed failing (S1E1 where S3E4 was
expected), covering the skipped-pilot case, rolling into the next season
past a skipped episode, and the watched-special case. All 17 existing
tests still pass.

Note: cargo test could not run locally (javascriptcoregtk-4.1 /
webkit2gtk-4.1 absent on this host). The pure policy half plus its
verbatim test module were extracted into a standalone crate to get real
red/green; the full crate suite still needs a run on a complete
toolchain.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 18:23:26 +02:00
dtourolleandClaude Opus 5 85bd227714 fix(home): uniform card heights in the Your Libraries row
MediaCard derives its artwork aspect ratio from the item, so a music
library rendered aspect-square (144px tall at w-36) next to video
libraries at aspect-video (81px), leaving the home row ragged.

Add an optional `aspect` prop that overrides the derived ratio, and pass
aspect="video" from the home Libraries strip. Unset, behaviour is
unchanged, so the /library overview grid and the media carousels keep
their per-type ratios. Artwork already uses object-cover, so square music
art crops rather than distorts.

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

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

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

Two edge cases the previous inline version got wrong:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 20:57:58 +02:00
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
dtourolle 1ef6180776 chore(release): bump to 0.4.1
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 18m39s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m40s
Traceability Validation / Check Requirement Traces (push) Successful in 16s
Build & Release / Run Tests (push) Successful in 6m1s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 9m26s
Build & Release / Build Linux (push) Successful in 19m41s
Build & Release / Build Windows (push) Successful in 13m57s
Build & Release / Build Android (push) Successful in 30m6s
Build & Release / Create Release (push) Successful in 24s
2026-08-05 12:31:16 +02:00
dtourolle 878ac5fa59 fix(player): make lockscreen transport reach background audio (DR-097)
Pausing from the lockscreen did nothing while a video's audio played in
the background. The handoff starts native ExoPlayer audio and only then
tears the WebView <video> down, and that teardown fires a DOM `pause`
the frontend reports like any other — leaving html5_playing = Some(false).
Transport therefore stayed aimed at the element: the lockscreen pause
emitted a ControlCommand into a <video> that no longer existed while the
native player carried on.

The controller now tracks a background-audio handoff explicitly. Entering
one hands transport authority to the native backend and drops the dying
element's state/position/media-loaded reports, which also stop flipping
the UI to paused and dragging the position backwards. Exiting restores
the element as the player.

A lockscreen pause also has to survive the return to the foreground: the
video used to resume from a snapshot taken at handoff time, undoing the
pause on the way back in. shouldResumeOnForeground() lets an explicit
`paused` from the player override that snapshot.

TRACES: UR-040, UR-005 | DR-052, DR-097
2026-08-05 12:26:25 +02:00
dtourolle 6aaa80ff92 chore(release): bump to 0.4.0
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 17m21s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m46s
Traceability Validation / Check Requirement Traces (push) Successful in 16s
Build & Release / Run Tests (push) Successful in 5m38s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 8m54s
Build & Release / Build Linux (push) Successful in 19m22s
Build & Release / Build Windows (push) Successful in 13m55s
Build & Release / Build Android (push) Successful in 30m12s
Build & Release / Create Release (push) Successful in 15s
2026-08-04 20:22:01 +02:00
dtourolle f7bcfe521d fix(favorites): save server favourites through to the cache (DR-115)
The hybrid favourites read went straight to the online repository on a
cache miss and dropped the result on the floor. Every other read path
persists what it fetches, so this one made the favourites page re-query
the server on every visit — and left the offline mirror (DR-114) empty on
a fresh install, since this is the path that fills it.

It now goes through get_favorites_server_only, which saves through on the
way back.

The command had a matching hole: with nothing cached it returned the empty
result, painting "Nothing favourited yet" at a viewer whose favourites
were simply marked on another client. It now asks the repository for a
real answer instead of an empty state it would correct a round trip later.

TRACES: UR-067 | DR-115
2026-08-04 20:20:36 +02:00
dtourolle 30dc3ba7f6 fix(player): recover a failed stream on Linux instead of stopping (DR-130)
A recoverable player error meant "playback is over": the frontend's error
handler stopped the player unconditionally, so a wifi blip killed the
track. Android already decides in its JNI callback, but MpvBackend is
constructed before PlayerController exists, so its event thread has no
controller to ask.

So MPV reports the failure and the frontend echoes it into the new
player_recover_stream command — the same shape as PlaybackEnded ->
player_on_playback_ended, keeping the decision in Rust. The command
re-opens the stream where it stopped, with the existing attempt budget
and backoff, and returns whether it handled it; only a false answer
falls through to the old stop path.

Android now reports the errors it has already declined as
*unrecoverable*, so the echo never asks the same question twice.

TRACES: UR-004, UR-040 | DR-130 | UT-117
2026-08-04 20:15:28 +02:00
dtourolle 32f8de5c91 fix(player): survive a flaky stream, and don't read 0:00 at EOF
Two MPV-side fixes for the same failure story — a wifi blip during
playback.

The demuxer gave up the moment a read failed and MPV raised
EndFile(ERROR), so a momentary outage killed the track outright. Enabling
ffmpeg's reconnect options handles the common case entirely below our
level, so most outages never reach the recovery path at all. Set
non-fatally: their availability varies with the libmpv/ffmpeg build, and
losing resilience is not a reason to refuse to play anything.

Separately, `time-pos` and `duration` are live properties of the *loaded*
file: at EOF MPV unloads it and both stop resolving. Reading them straight
through returned 0.0/unknown at exactly the moment end-of-file handling
needed to know where playback had reached, so the player appeared to
rewind to 0:00 as a track ended. `ObservedTime` records the last reading
seen while media was loaded and the accessors fall back to it.
2026-08-04 19:39:14 +02:00
dtourolle 62873cab3d feat(search): answer search from a local index; tier downloads by lifetime
Search's instant leg read only downloaded items, so with no downloads it
returned nothing and every keystroke fell through to a full Recursive=true
server query. It now reads the whole synced catalog through the same
availability CTE get_items uses, gated on the same include_catalog_browse
flag so search and browse cannot diverge. (UR-065, DR-108)

Also fixes three defects found while confirming that:

- items_fts grew by a full duplicate index every catalog pass. INSERT OR
  REPLACE fires no AFTER DELETE trigger without recursive_triggers, so the
  old index row was orphaned, and a TEXT PRIMARY KEY meant the replacement
  took a fresh rowid and inserted a second entry. Now a real upsert, with
  migration 021 rebuilding existing indexes. (DR-110)
- DELETE FROM items existed nowhere, so server-side deletions never
  propagated. Adds a post-crawl mark-and-sweep, scoped to crawled types,
  skipping downloaded items, and refusing to run after a partial crawl
  because items.parent_id cascades. (DR-110)
- The index omitted MusicArtist, Playlist and People, which search groups
  results by. Adds them plus people_fts (migration 022). (DR-111)

Re-indexing moves from a frontend startup call to a Rust background task
with a 6h TTL, so a long session no longer searches a stale catalog and a
restart no longer forces a crawl regardless of freshness. (DR-109, IR-030)

Downloads gain a lifetime tier. Eviction selected every completed row by
age with no download_source filter, so hitting the storage limit deleted
the oldest download -- typically one saved deliberately for offline -- to
make room for a precached track. It now reclaims only 'auto' rows, and
expired ones are reclaimed first, before live cache is evicted.
(DR-126, DR-127)

Downloaded video and audio-only handoffs now play from disk instead of
streaming; the video path had never consulted downloads at all. No
transcode is involved: MPV runs video=no and ExoPlayer has no surface for
an Audio item. (DR-123 in part, DR-128)

FTS queries are built as quoted phrases so apostrophes, hyphens and
slashes are data rather than operator syntax, and the item-type filter is
bound rather than interpolated.

Specs: docs/specs/catalog-index-search.md,
docs/specs/read-through-media-cache.md

Includes concurrently-developed favourites browsing and background-audio
stream-end handling; the two workstreams share offline.rs, lib.rs and
online.rs, so no subset of files builds independently.
2026-08-04 17:35:17 +02:00
dtourolle c55ff45692 fix(android): clear the system bars and display cutout (UR-066)
The bottom nav rendered under the Android navigation bar, and full-screen
playback controls spilled into unusable screen edges. It looked device-specific
(Motorola bad, Fairphone fine) but every device was equally unpadded — only the
intrusion differed: a tall opaque 3-button bar swallows the nav, a thin
translucent gesture pill overlaps harmlessly.

None of the app's safe-area handling was ever active, for two independent
reasons:

  1. app.html had no `viewport-fit=cover`, so every `env(safe-area-inset-*)`
     resolved to 0px — the padding in app.css and BottomUi was a no-op.
  2. Android WebView maps only the *display cutout* into `env()`; the status bar
     and navigation bar are never reported. With enableEdgeToEdge() and
     targetSdk 36 (enforced from 35, opt-out ignored from 36) the WebView always
     spans them, so CSS could not learn about them by any route.

WindowInsetsBridge now reads `systemBars() | displayCutout()` and publishes
`--jt-inset-*` CSS custom properties, both pushed on every inset change
(rotation, nav-mode switch, PiP) and pullable via `AndroidInsets.get()` — the
pull is required because the first inset pass lands before the document exists
and a page load wipes the pushed inline style. app.css folds them with `env()`
via `max()` into `--safe-*`, the only thing components may pad from.

Exactly one element owns each edge: the shell takes top/left/right, BottomUi
takes bottom (inside its surface box, so the colour extends behind the gesture
bar), and shellReservesBottomInset hands bottom back to the shell on routes with
no bottom UI. The full-screen players inset their control layers only, leaving
video and artwork edge-to-edge.

The theme's `fitsSystemWindows=true` claimed the opposite of what actually
happened — overridden at runtime, ignored at this target SDK — and is removed.

Also converts six nested `h-screen`/`min-h-screen` boxes to `h-full`: the shell
is `h-screen` *and* inset-padded, so its content box is `100vh - safe-top` and
any nested 100vh box overflows by exactly the inset (the library column would
have clipped its own BottomUi). A test guards against reintroduction.
2026-08-04 14:45:10 +02:00
dtourolle 58f2506966 feat(series): land on the current episode, not season 1 (UR-062, UR-063, UR-064)
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 16m59s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m36s
Traceability Validation / Check Requirement Traces (push) Successful in 18s
Build & Release / Run Tests (push) Successful in 5m9s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 8m54s
Build & Release / Build Linux (push) Successful in 18m49s
Build & Release / Build Windows (push) Successful in 14m4s
Build & Release / Build Android (push) Successful in 30m17s
Build & Release / Create Release (push) Successful in 18s
Opening a series dumped the viewer at the top of season 1, and its Play
button played nothing at all: it resolved `$libraryItems[0]` — the first
*season* by SortName — and navigated to `/player/<seasonId>`, which the
player route bounced straight back to `/library/<seasonId>`.

The backend could already answer "where is this viewer in this show":
`repository_get_next_up_episodes` has accepted a `series_id` since it was
written and no caller had ever passed one.

Backend (DR-101, DR-106)
- `repository/series_progress.rs`: `pick_current_episode` — in progress,
  else Next Up, else first unwatched, else the premiere. The third rung is
  the offline path, where Next Up is always empty. `sort_series_order` puts
  specials (season 0) after the numbered seasons.
- `repository_get_series_episodes` takes over the season fan-out and the
  flat-series fallback, which were domain knowledge living in the frontend.
- `clear_watch_history` maps to Jellyfin's mark-unplayed (recursive over a
  container, also zeroes resume). Offline it refuses rather than diverging
  state the next sync would undo.

Frontend (DR-102, DR-103, DR-104, DR-107)
- Seasons collapse; only the current one is expanded, and the current
  episode is badged and scrolled into view.
- Hero button reads `Resume S2E4` / `Play S1E1` and opens that episode's
  focus view, where Play commits (ux-flows §5B.5).
- Seasons are no longer a destination: `/library/<seasonId>` redirects to
  `/library/<seriesId>#season-N`, and every inbound link follows.
- The "More Episodes" strip spans the whole series, so a season finale
  offers the next premiere instead of dead-ending (§5B.2).
- Clear-history buttons on the series hero and each season header.

Routes (DR-105)
- `/library/tv` and `/library/movies` absorb their all-titles and genres
  pages as `?view=` tabs; the four legacy routes redirect. 6 video routes
  become 2, and `/library/shows/genres` stops being the odd one out.

Logic extracted to `seriesNavigation.ts`, `episodeStrip.ts` and
`libraryView.ts` so it is unit-tested rather than buried in components.
Spec: docs/specs/series-current-episode-navigation.md
2026-08-03 20:37:43 +02:00
dtourolle a818fee297 fix(player): re-entering a video no longer opens the audio player (DR-100)
Leaving a video and returning to it rendered the movie/episode in
AudioPlayer. Closing a webview-rendered video deliberately emits no
"stopped" state (that would break the autoplay handoff), and the
direct-play path does not stop the backend on unmount, so the Rust
controller still reported that item as its loaded media. Re-entering the
route therefore took the "already playing, just show the UI" shortcut,
which returns before a stream URL is fetched, and the render fell
through to the audio surface. Mostly visible on Android, where video
direct-plays; Linux transcodes and stops the backend on unmount.

Both decisions move into playerSurface.ts as pure functions:
shouldReuseActivePlayback excludes video, so video always takes the full
load path and gets its stream URL and resume position;
resolvePlayerSurface maps video-without-a-stream-URL to "pending"
(spinner) rather than falling through to audio.
2026-08-03 18:12:37 +02:00
dtourolle a26a853f01 fix(player): advance background audio-only episodes in the backend (UR-040)
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m33s
Traceability Validation / Check Requirement Traces (push) Successful in 25s
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 17m25s
Build & Release / Run Tests (push) Successful in 6m7s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 8m38s
Build & Release / Build Linux (push) Successful in 19m23s
Build & Release / Build Windows (push) Successful in 13m43s
Build & Release / Build Android (push) Successful in 29m47s
Build & Release / Create Release (push) Successful in 19s
An episode played audio-only while the app was backgrounded stalled at the
episode boundary instead of advancing, and ExoPlayer parked in STATE_ENDED —
where any later play intent (lockscreen, headset, Bluetooth reconnect) replays
the ended item, surfacing as the episode randomly restarting.

End-of-playback is dispatched from two places and they disagreed. The Android
JNI callback carried the background-audio branch but can never reach it:
load_and_play sets EndReason::NewTrackLoaded at every load and nothing clears
it, so the first real end consumes it and the decision is always Stop. The call
that actually decides is the frontend's echo of the resulting PlaybackEnded into
player_on_playback_ended — and that path had no background-audio case at all, so
it started a countdown whose advance is a webview goto() that cannot start audio
while backgrounded.

Both dispatchers now share PlayerController::auto_advance_to_next_episode, so
they cannot drift apart again.

The handoff base offset moves from the BackgroundAudioOffset Tauri state onto
the controller, and the advance clears it: the next episode's stream is built
without StartTimeTicks, so its timeline is already absolute and a stale base
made player_exit_background_audio return old_base + position_in_new_episode.
Unreachable until the advance actually worked.

Tests (red before the fix):
- test_auto_advance_background_audio_episode_advances_in_backend
- test_auto_advance_foreground_video_episode_uses_countdown
- test_advance_to_next_episode_audio_only_clears_handoff_base

Bump to 0.2.9.
2026-08-02 18:10:18 +02:00
dtourolle 9d099268b9 fix(player): make the video seek bar work by touch (DR-099)
🏗️ Build and Test JellyTau / Run Tests (push) Failing after 6m30s
🏗️ Build and Test JellyTau / Android Compile Check (push) Has been skipped
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m25s
Traceability Validation / Check Requirement Traces (push) Successful in 18s
Build & Release / Run Tests (push) Failing after 6m4s
Build & Release / Build Linux (push) Has been skipped
Build & Release / Build Windows (push) Has been skipped
Build & Release / Build Android (push) Has been skipped
Build & Release / Create Release (push) Has been skipped
On Android, dragging or tapping the progress bar moved the thumb but
playback stayed where it was. Two separate defects, both touch-only,
which is why the mouse-driven scrub tests never caught either.

1. Gesture hijack. DR-098 taught handleTouchStart to ignore touches that
   land on a control, but handleTouchMove kept running. It measures
   against touchStartX/Y, which that early return leaves at the PREVIOUS
   gesture's values, so a seek-bar drag produced a huge bogus vertical
   delta: read as a brightness swipe, it dimmed the screen to the 0.3
   floor and fired a spurious play/pause "correction" mid-drag. A gesture
   is now latched at touchstart (playerGestureActive) and touchmove
   ignores anything unlatched — re-checking the move target cannot
   recover a start point that was never recorded.

2. Commit signal. The seek was committed only from `change`, which
   Android's WebView does not reliably fire for a touch interaction on a
   range input, so the thumb moved to the tapped position and no seek
   ever ran. touchend/mouseup now commit too; `input` arms a one-shot
   latch so whichever release signal arrives first commits and the other
   is a no-op. seekRelative shares the same commitSeek entry point
   instead of fabricating a synthetic change event.

Tests drive the slider with real touch events (UT-089, UT-090) and fail
against the pre-fix component.
2026-08-01 10:41:23 +02:00
dtourolle e381d626c1 docs(requirements): UR-061/DR-092 no longer describe the removed deferral
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m20s
Traceability Validation / Check Requirement Traces (push) Successful in 16s
Build & Release / Run Tests (push) Failing after 7m23s
Build & Release / Build Linux (push) Has been skipped
Build & Release / Build Windows (push) Has been skipped
Build & Release / Build Android (push) Has been skipped
Build & Release / Create Release (push) Has been skipped
Both still described the 300ms deferred-tap design that DR-098 replaced
with immediate action, so the generated release notes advertised
behaviour the code no longer has.
2026-07-30 16:13:29 +02:00
dtourolle b12e99b7e1 fix(player): keep double-tap seek working over the play overlay (DR-098)
Publish Documentation / Build & publish docs to gitea-pages (push) Has been cancelled
Traceability Validation / Check Requirement Traces (push) Has been cancelled
🏗️ Build and Test JellyTau / Run Tests (push) Failing after 7m5s
🏗️ Build and Test JellyTau / Android Compile Check (push) Has been skipped
The control-surface guard added in the previous commit killed
double-tap-to-seek. The first tap pauses, which renders the full-screen
<button> play overlay over the video, so the SECOND tap lands on a
button — and the guard discarded it as "a tap on a control".

Mark that overlay `data-player-surface`: visually it IS the video, so it
must keep taking tap gestures despite being a <button>. The marker wins
over the interactive-tag check in isControlSurfaceTouch.

Adds VideoPlayer.tapSurface.test.ts, which renders the REAL component
and dispatches real touch/click events at whatever element is genuinely
on top. This is the gap that let four bugs ship in a row: the pure-unit
tests over registerTap/isControlSurfaceTouch/isSynthesizedTouchClick all
passed throughout, because each helper behaved exactly as specified —
every bug was in the composition, i.e. which element actually receives a
tap after Svelte re-renders. Modelling that DOM by hand in a test would
just re-encode the same wrong assumption, so these render it instead.

The new double-tap test was verified to fail with the fix reverted and
pass with it applied, in both directions.
2026-07-30 15:44:38 +02:00
dtourolle dc8b732465 fix(player): controls bar taps are not player gestures (DR-098)
The bottom play/pause button did nothing. The gesture listener lives on
the outer container and touch events bubble, so tapping the button ran
handleTouchStart (toggle #1) and then the button's own onclick (toggle
#2). The two cancelled out, leaving the control apparently dead.

Ignore container-level gestures for touches that land on an interactive
control: buttons, links, inputs (the seek bar), or anything inside the
controls bar, now marked `data-player-controls`. The rule itself is a
pure function over the ancestor chain (isControlSurfaceTouch), so it is
unit tested without a DOM.

Same root shape as the play-overlay bug in the previous commit: a second
click target over the video that the gesture layer did not account for.
2026-07-30 15:27:01 +02:00
dtourolle b98a530f48 fix(player): guard the play overlay against the synthesized touch click
After the DR-098 tap rewrite, pausing became impossible while unpausing
always worked — an asymmetry that pointed straight at the overlay.

Pausing renders a full-screen play-overlay button over the video. The
compatibility click Android synthesizes from the tap arrives ~30-130ms
later, by which time that button exists, so the click lands on the
OVERLAY rather than the <video>. Its onclick called togglePlayPause with
no guard at all, resuming immediately. Unpausing was unaffected because
it removes the overlay, leaving nothing to intercept the click.

The suppression rule was only wired into the video element's handler.
Extract it as isSynthesizedTouchClick() in tapGestures.ts (unit-tested)
and use it from every click target layered over the video, the overlay
included.

Verified: 724 frontend tests pass, svelte-check clean. Bumped to 0.2.5
so the APK installs over 2004.
2026-07-30 15:10:59 +02:00
dtourolle b565c4ae6f fix(player): tap gestures act immediately, no deferral timer (DR-098)
Tapping the video surface pause-looped: it would unpause and bounce
straight back to paused about a second later. Long-press unpaused fine,
which is what pinned it to the tap path rather than the media pipeline.

The gesture handler deferred the first tap's play/pause behind a 300ms
timer so a second tap could cancel it and seek instead. But the timer
callback cleared its own handle *before* invoking the toggle, and
handleVideoClick used exactly that handle (`tapTimeout !== null`) to
suppress the compatibility click Android's WebView synthesizes after a
touch. So the guard was already open when the late click arrived, and it
toggled a second time.

Replace the deferral with immediate action — there are only first and
second taps:

  1st tap: toggle play/pause
  2nd tap: seek, then toggle play/pause again

The second toggle undoes the first, so a double tap seeks while leaving
the play state exactly as it was: playing jumps and keeps playing,
paused jumps and stays paused. No timer, no window race, no loop.

Click suppression no longer depends on the timer: ignore detail === 0
and any click within 700ms of a touch tap, since Android can deliver the
synthesized click late and with a real detail value.

A swipe now undoes the touchstart toggle (latched on swipeGestureActive
so it happens once, not per touchmove frame), keeping brightness swipes
from changing the play state.

UT-085..087 described the old deferred behaviour and are updated to the
new contract. UT-091 is used for the DR-097 facade tests, since UT-089
and UT-090 were already claimed by extract-traces.test.ts.
2026-07-30 14:53:20 +02:00
dtourolle 79e10d7485 chore(release): bump to 0.2.3
Android versionCode derives from this (0.2.3 -> 2003); required for the
APK to install over the 2002 build already on the device.
2026-07-30 13:56:04 +02:00
dtourolle a2dbde5492 debug(player): log pause reason and flatten the debug tick
An unexplained pause/resume loop was invisible over adb: handlePause
logged nothing at all, so only the "playing" half of each cycle showed
up, and the 1s debug tick logged an object — which the Android WebView
console bridge renders as "[object Object]", discarding every field.

Log the element state on pause (readyState, networkState, seeking,
ended, plus the component's own isSeeking/isBuffering/handoff flags) and
emit the debug tick as a flat string. This is what identified DR-097:
the element was fully buffered and healthy at every pause, ruling out a
stall and pointing at a competing controller instead.
2026-07-30 13:55:06 +02:00
dtourolle 75cd07a5c0 fix(player): decide transport in Rust for webview media (DR-097)
Video on Android/Linux renders in a webview <video> element, and the
frontend facade short-circuited play/pause/toggle straight into the
adapter whenever one was registered. Html5PlayerAdapter.toggle() then
decided play-vs-pause by reading el.paused off the DOM, so the Rust
controller never saw the intent and could not serialise competing ones.

el.paused flips transiently while an element buffers or settles a seek.
Two intents ~150ms apart therefore read *different* values and performed
*opposing* actions — one playing, one pausing — which self-sustained a
play/pause loop that needed no further input. On device this showed up
as a fully healthy element (readyState=4, networkState=1, not seeking,
not buffering, not ended) pausing itself roughly once a second, so
unpausing or skipping ahead bounced straight back to paused.

The root cause was that Rust held NO state for webview-rendered media:
report_html5_state only re-emitted its argument, despite the comment
above it claiming the controller was the single source of truth. It had
nothing to decide a toggle from.

Now report_html5_state tracks the reported state, and play/pause/toggle
consult it and drive the element by emitting a ControlCommand — the same
"backend decides, adapter executes the primitive" split player_seek_video
already uses. A stopped/idle report clears the tracking so MPV/ExoPlayer
regain authority for music playback.

Tests cover the loop signature directly (repeated toggles must alternate,
never repeat or oppose) plus a guard that one intent yields exactly one
ControlCommand — which matters on Windows, where the backend is itself
webview-based and could otherwise be driven twice.
2026-07-30 13:54:41 +02:00
dtourolle 64d07b8940 chore(release): bump to 0.2.2
Android versionCode is derived from this (0.2.2 -> 2002), so the bump is
required for the APK to install over the 2001 build already on device.
2026-07-30 13:17:29 +02:00
dtourolle 5b810f7fc3 build(android): add --device/--abi to build only the needed architecture
An on-device test build compiled all four ABIs (arm64/arm/x86/x86_64),
so three of the four Rust compiles were thrown away. That dominated the
build time when iterating against a connected phone.

--device resolves the attached device's ABI via adb and targets just
that triple; --abi <target> selects one explicitly; ABI= works as an
env var. Default behaviour is unchanged (all four), since a
distributable universal APK genuinely needs them.

  bun run android:build:device
  bun run android:build:release:device
2026-07-30 13:16:52 +02:00
dtourolle 1ae213ff39 fix(player): stop AbortError storm from HLS stall recovery (DR-096)
🏗️ Build and Test JellyTau / Run Tests (push) Failing after 7m14s
🏗️ Build and Test JellyTau / Android Compile Check (push) Has been skipped
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m20s
Traceability Validation / Check Requirement Traces (push) Successful in 14s
Html5PlayerAdapter.play() reported every interrupted play attempt as a
player error. While an HLS stream stalls, hls.js' gap-controller nudges
the element to recover, which cancels the pending play() promise and
raises AbortError ("play() request was interrupted by a call to
pause()"). That is transient — the element is still trying to play — but
it hit host.onError roughly once a second for the whole stall, leaving
the UI stuck reporting paused.

Treat an interrupted play as a debug-level non-event, and memoise the
in-flight attempt so the UI and recovery paths share one element.play()
rather than stacking calls that abort each other.

This is the loop amplifier, complementing DR-095 which removed the
dead-segment stall that triggered it.

Note: webviewAudioAdapter.play() has the same raw shape but is not
implicated — audio playback does not go through hls.js — so it is left
unchanged rather than widening this fix.
2026-07-30 12:55:49 +02:00
dtourolle 98a6bca645 fix(player): clamp seeks inside media to stop end-of-stream pause loop (DR-095)
Seeking near the end of a transcoded video locked the player into a
stall/pause loop: unpausing or skipping bounced straight back to paused.

Both seek paths clamped the target to exactly `duration`. hls.js then
requested the segment whose start time lies *past* the end of the media
(a 6330.324s item asks for segment 1055, starting at 6336.33s). Jellyfin
never produces that segment, the fetch times out, and the gap-controller
stalls forever at the last buffered position — retrying ~1x/second and
firing an endless stream of AbortErrors as play() lands mid-nudge.

Clamp strictly inside the media instead, keeping one segment length
(6s) of margin, floored at 0 so short media still seeks to the start.
The seek-bar drag path needed this too: its range input `max` is the
duration itself, so dragging fully right produced the same dead target.

Also bumps the requirement-count fixture for the new DR-095 row.
2026-07-30 12:52:03 +02:00
dtourolle 984e594006 chore(release): bump to 0.2.1
🏗️ Build and Test JellyTau / Run Tests (push) Failing after 7m4s
🏗️ Build and Test JellyTau / Android Compile Check (push) Has been skipped
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m18s
Traceability Validation / Check Requirement Traces (push) Successful in 15s
Build & Release / Run Tests (push) Failing after 6m58s
Build & Release / Build Linux (push) Has been skipped
Build & Release / Build Windows (push) Has been skipped
Build & Release / Build Android (push) Has been skipped
Build & Release / Create Release (push) Has been skipped
2026-07-30 10:39:07 +02:00
dtourolle f49e6e4648 fix(boundary): detect item-type arrays anywhere in src/ (DR-094)
check:boundary passed on the very leak it was written for. The pattern was
anchored to `includeItemTypes:` at the query site, so searchScope.ts
assigning the same array to a named const and dereferencing it one
indirection away was invisible — through every green CI run.

The check now matches an array literal naming two or more Jellyfin item
types anywhere in src/, catching a const, a Record value, a function
return, and an inline query alike. Deliberate limits kept: two adjacent
literals required (single-type presentation stays legal), string literals
required (item.type === "Audio" is display logic), explicit type list
(so ["High","Low"] produces no noise).

Verified all five cases: reintroducing the original SCOPE_ITEM_TYPES
fails; a new const ["Movie","Series"] fails; the same array in a
.test.ts passes; itemType: "Movie" / item.type === / ["High","Low"] pass;
a 5th allowlist entry fails on the new cap.

Allowlist 1→3 entries, capped at 4 so the next exception forces a
conversation rather than a one-line append:
- GenericMediaListPage: grid styling over a self-declared itemType —
  presentation, changes only with a UI redesign.
- DownloadedBrowse: borderline, leans domain (the container set grows
  when Jellyfin adds a container type). Allowlisted with a TODO for a
  backend MediaItem.isContainer flag.

The header now names what the check still cannot see — run-time-built
sets, types split across variables, switch/|| taxonomy — and CLAUDE.md
states that a green check:boundary is not proof. That matters given this
check passed on its own founding violation for months.

Also: both gates wired into test-all.sh, which called `bun run test`
without --run and would have hung in watch mode. Corrected the Dockerfile
comment describing the Windows toolchain as mingw/GNU — it is MSVC via
cargo-xwin (GNU cannot bundle NSIS from Linux).
2026-07-30 10:30:55 +02:00
dtourolle 105cc082ea fix(search): move scope→item-type taxonomy into Rust (UR-049, DR-063)
Stage 1 of scoped-search-boundary-implementation.md — the query side.

scoped-search-boundary.md diagnosed this leak, specified the fix in
detail, and became the justification for the boundary rule in CLAUDE.md,
the check:boundary tripwire, and the spec-review checklist. The fix was
never built: SCOPE_ITEM_TYPES was still live in searchScope.ts, called by
library.ts, and no SearchScope existed anywhere in src-tauri/. The rule's
own founding violation was still shipping.

Rust now owns the taxonomy:

  pub enum SearchScope { All, Music, Movies, Tv }
  impl SearchScope { pub fn item_types(self) -> Option<Vec<String>> }

- SearchOptions gains `scope`, resolved by resolve_scope(). Scope wins
  over include_item_types, which stays for the non-search get_items
  callers that legitimately request one concrete type.
- repository_search resolves the scope ONCE, before the cache/server
  paths diverge, so online and offline filter identically — the failure
  mode most likely to go unnoticed.
- All expands to None (no filter), not the union of the other scopes:
  an explicit includeItemTypes list would silently drop People, folders,
  and any type nobody enumerated.
- searchScope.ts re-exports SearchScope from generated bindings instead
  of a hand-written union, and no longer names an item type for search.
- library.ts sends { scope }.

8 Rust tests written first, confirmed failing on "use of undeclared type
SearchScope" before the implementation existed.

The frontend tests that asserted includeItemTypes contents were rewritten
to assert the opaque scope is sent and includeItemTypes is absent —
keeping the old assertions would require the frontend to know the
taxonomy again, defeating the fix. The expansion is now asserted in Rust.

Verified the spec's headline criterion by hashing every src/ file, adding
"AudioBook" to the Music scope in Rust, and re-hashing: zero frontend
files change. That criterion failed before this commit.

Stage 2 (result-side grouping: GROUP_ITEM_TYPES, GroupedSearchResult on
both search payloads) remains open.
2026-07-30 10:30:38 +02:00
dtourolle 0a3ee0791f chore(scripts): remove three broken, orphaned traceability scripts
All three shared one root cause: an unscoped `grep -r src-tauri/`, which
walks ~40GB of target/ build artifacts.

- check-req-coverage.sh: also read README.md, which has held zero
  requirement rows since they moved to docs/requirements.md. Reported
  "Total Requirements: 1", zeros in every category, then printed
  "All requirements have implementations!" — the opposite of a warning,
  from an empty result set.
- check-test-coverage.sh: hung indefinitely, no output at all.
- find-req-implementations.sh: same hang.

None was referenced by CI, package.json, or the docs.

They were salvageable — the greps just needed scoping — but they read an
undocumented `@req:` / `@req-test:` tag convention parallel to `TRACES:`
(146 and 76 occurrences, described in no doc; CLAUDE.md documents only
TRACES). Repairing them would re-establish the second source of truth
that let "1 requirement" and "211 requirements" coexist unnoticed.
extract-traces.ts is now the single owner of coverage reporting.

The existing @req:/@req-test: comments are left in place: harmless as
prose, several encode useful test intent, and stripping 222 comments is a
large diff with no functional gain. They are simply no longer read.
2026-07-30 10:30:20 +02:00
dtourolle 0da0a9f16c fix(ci): derive traceability denominators from requirements.md (DR-093)
The coverage gate divided traced counts by hardcoded literals (UR/39,
IR/24, DR/48, JA/3, TOTAL_REQS=114) that had fallen out of date as
requirements grew to 211. It reported 158% coverage — JA alone printed
800% — so the 50% threshold was mathematically unreachable and the job
could not fail. Coverage could have collapsed to 30% and CI would still
have printed a green tick.

Real coverage is 86%. The number was fine; the gate was dead.

extract-traces.ts now owns both sides of the fraction:

- countDefinedRequirements() counts an ID only where it leads a markdown
  table row, ignoring the "Traces To" column and prose. IDs are
  deduplicated because requirements.md lists every UR twice (§1
  definition + §3 matrix), which would otherwise report UR as 121/61.
- computeCoverage() uses the intersection of traced and defined IDs, so
  a TRACES comment naming a deleted or typo'd requirement is reported as
  `orphaned` rather than inflating the ratio past 100%. UT/IT test
  identifiers are excluded as a separate taxonomy.
- CI reads .coverage.percent and fails on <50% or >100%; a >100% reading
  is now a hard error rather than the condition that hid this bug.
- New `bun run traces:coverage` runs the same computation locally.
- scripts/ added to the scan roots — the coverage tool was invisible to
  the matrix it generates.

Tests written first (15, over fixtures so they don't drift as
requirements are added). vitest include widened to scripts/** so build
tooling is covered by the normal suite.

Verified empirically rather than by inspection: forcing the threshold to
99% fails; adding a requirement lowers coverage 86%→85%; a TRACES: DR-999
lands in `orphaned` without changing `covered`.

traceability-ci.md documented the same stale numbers and would have let
the broken arithmetic be reconstructed — replaced with a pointer to the
live command.
2026-07-30 10:30:08 +02:00
dtourolle 75bae2556c docs(specs): design-principles audit — five remediation specs
Audit of the principles in CLAUDE.md and docs/architecture/ against the
actual code. Principles with a working automated check (poison-tolerant
locking, Android source sync, one-directional playback state, graceful
backend init, reachability-from-traffic) all held up. The two that drifted
are exactly the two whose checks were broken or too narrow:

- traceability-gate-repair: CI divided by hardcoded denominators
  (UR/39, IR/24, DR/48, JA/3, total 114) while requirements.md had grown
  to 211, reporting 158% coverage — the 50% threshold was unreachable and
  the job could not fail.
- req-coverage-script-removal: check-req-coverage.sh reports
  "1 requirement" and prints "all requirements have implementations".
- scoped-search-boundary-implementation: the founding boundary incident
  was specced but never built; the leak is still live.
- boundary-tripwire-hardening: check:boundary passes on that same leak —
  the pattern is anchored to the query site, so a named const evades it.
- player-facade-enforcement: 52 direct commands.player* call sites
  outside the facade, and no automated check at all.

Each spec follows SPEC-TEMPLATE.md with a filled-in Layer assignment
table and is checked against SPEC-REVIEW-CHECKLIST.md.
2026-07-30 10:29:54 +02:00
dtourolle 48f63dd763 docs(spec): build provenance — git describe + build profile
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m15s
Traceability Validation / Check Requirement Traces (push) Successful in 18s
A running JellyTau currently reports no version anywhere: not in the UI, not in
the logs. When a user reports "the equalizer does nothing on my device" there is
no way to tell whether they are on the v0.2.0 tag, master, or a three-week-old
local debug build — a live gap given v0.2.0's Android audio settings are not yet
device-verified.

Specifies a build.rs-emitted `git describe --tags --always --dirty`, a typed
BuildKind (Release/Untagged/Development/Unknown) classified in Rust rather than
pattern-matched in the UI, a get_build_info command, startup logging, and a
Settings > About block with copy-to-clipboard for bug reports.

Explicitly does NOT derive the release version from git: Cargo needs a literal
semver at manifest-parse time, so sourcing it from a tag would trade a
reviewable bump for a build-time dependency that fails in CI's shallow Docker
clones. The release version stays authored; only the provenance is derived —
they answer different questions.

Two constraints found while writing this:
- Only publish-docs.yml sets fetch-depth: 0. build-release.yml has five
  checkouts and build-and-test.yml two, all of which would stamp "unknown"
  as-is. Flagged as an acceptance criterion.
- tauri.conf.json's version field can be dropped to fall back to Cargo (three
  hand-bumped files becomes two), but gen/android/app/build.gradle.kts reads
  versionName/versionCode from generated Tauri properties, so that must be
  verified before adopting rather than assumed.
2026-07-28 23:19:34 +02:00
dtourolle 36ef231e2f chore(release): bump to 0.2.0
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 11m0s
Publish Documentation / Build & publish docs to gitea-pages (push) Has been cancelled
Traceability Validation / Check Requirement Traces (push) Has been cancelled
Build & Release / Run Tests (push) Successful in 11m20s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 8m20s
Build & Release / Build Linux (push) Failing after 16m33s
Build & Release / Build Windows (push) Successful in 13m46s
Build & Release / Build Android (push) Successful in 29m50s
Build & Release / Create Release (push) Has been skipped
Minor bump rather than patch: Android gains working equalizer, volume
normalization and gapless playback, which are new user-facing capabilities.

CHANGELOG entry written by hand rather than from `bun run release:notes`. The
generated draft lists "Crossfade between audio tracks (UR-031)" as a feature of
this release, which is false — the trace graph cannot distinguish code that
plumbs a setting (settings.rs clamping, the backend.rs trait method, both
legitimately tagged DR-034) from code that implements it, and crossfade is
implemented nowhere. The release-notes tool documents its output as a reviewed
draft; this is a concrete case of why.

v0.1.3-v0.1.5 have no CHANGELOG entries; noted in the file rather than
backfilled.
2026-07-28 23:08:05 +02:00
dtourolle cb79a376b3 feat(android): implement audio settings (EQ, normalization, gapless)
🏗️ Build and Test JellyTau / Android Compile Check (push) Has been cancelled
Publish Documentation / Build & publish docs to gitea-pages (push) Has been cancelled
Traceability Validation / Check Requirement Traces (push) Has been cancelled
🏗️ Build and Test JellyTau / Run Tests (push) Has been cancelled
ExoPlayerBackend was the only backend not overriding the PlayerBackend trait's
set_audio_settings/audio_settings defaults, so the Settings > Audio controls
rendered on Android and silently did nothing — the default returns Ok(()) while
applying nothing, so the failure was invisible.

Rust owns what the values are (canonical 10-band ISO layout, preset curves,
normalization presets); Kotlin owns when the AudioEffect objects exist, since
that needs the live audio session id.

- settings.rs: audio_settings_jni_payload() sanitises (crossfade clamped, band
  vector normalised) before serialising, so a malformed vector cannot reach the
  Kotlin parser. JSON rather than a wide JNI signature, matching how load()
  already passes subtitles — adding a field will not change the signature.
- ExoPlayerBackend: set_audio_settings/audio_settings over JNI; ExoPlayerState
  gains the first command-side field (settings are pushed out, never reported).
- JellyTauPlayer.kt: Equalizer, LoudnessEnhancer, and gapless via
  pauseAtEndOfMediaItems.

Three details that are easy to get wrong:
- Effects re-attach on onAudioSessionIdChanged. ExoPlayer rebuilds its audio
  sink on a format change, which invalidates effects bound to the old session;
  without this the EQ silently stops applying mid-queue.
- All effect work is posted to mainHandler rather than run inline. AudioEffect
  construction from a player callback can re-enter the player and deadlock —
  the same shape as the AutoplayDecision lock-scrutinee bug.
- Device equalizers expose a device-dependent band count (commonly 5) at fixed
  centres, so the canonical 10 bands are resampled by nearest centre frequency.
  resampleBands() is a pure @JvmStatic function so that mapping is testable
  without a device.

Normalization is approximate, not parity: LoudnessEnhancer is a gain stage, not
a true EBU R128 normalizer like MPV's dynaudnorm. Recorded as such rather than
claimed as equivalent.

Crossfade is deliberately excluded — unimplemented on every platform and
blocked on mpv, so building it on Android alone would invert the parity gap.

Tests written first and observed failing (cannot find function
audio_settings_jni_payload) before the implementation: the payload contract is
pinned by tests because a serde rename would otherwise silently break the
Kotlin parser.

Not yet verified on a physical device — AudioEffect availability and band
layouts are device-specific. Requirements matrix marks these rows accordingly,
and flipping the trait default to Err(not_implemented()) is deferred until that
verification lands.
2026-07-28 23:03:31 +02:00
dtourolle b11188e9dd docs(player): backend unification findings + correct false parity claims
Investigation into unifying the playback backends (Linux/MPV, Android/ExoPlayer,
Windows/webview) onto one engine with hardware acceleration. Conclusion: video
cannot be unified onto a native engine; audio can.

The blocker is not mpv-specific. WebKitGTK, WebView2 and Android WebView each
draw into their own compositor surface, so a native video surface sits either
entirely above or entirely below the webview and cannot interleave with HTML.
GStreamer and libVLC fail identically. mpv would additionally regress streaming:
it has no adaptive bitrate, while the current hls.js path does.

Six specs added:
- playback-backend-unification: the analysis and decision, with evidence
- android-audio-settings-parity: set_audio_settings on ExoPlayerBackend
- android-native-video-spike: timeboxed test of SurfaceView compositing
- windows-native-audio-backend: replace the webview <audio> shim with libmpv
- libmpv2-migration: dead libmpv git pin -> libmpv2, plus a LICENSE file
- playback-docs-corrections: the requirement-status fixes applied here

Corrections to requirements.md, all verified against source:
- UR-031/DR-034 claimed crossfade was "Done (Linux only)". It is implemented
  nowhere (mpv_backend.rs has a bare TODO) and is architecturally blocked on
  mpv, whose single-stream audio chain cannot feed acrossfade's two inputs.
- Parity matrix listed crossfade as a Linux/Android gap; it is neither.
- The matrix omitted the equalizer, which has the same Linux-only shape.
- The suggested ConcatenatingMediaSource is deprecated in current Media3.

nativeAdapter.ts cited tauri#10152 as an upstream blocker for native Android
video. That issue is a stale feature request, dead since 2024-07-01; the
capability shipped in tauri 27d01834 (2024-09-02), and the related
black-screen bug was fixed in wry 0.39.4 (we ship 0.55.x). What is genuinely
unproven is SurfaceView-behind-WebView compositing, which the spike now tracks.
2026-07-28 23:03:17 +02:00
dtourolle f636b6b151 Merge: Android WebView bridge + audio-focus fixes, tap gestures, sleep timer (v0.1.5)
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 13m55s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m20s
Traceability Validation / Check Requirement Traces (push) Successful in 18s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 8m44s
2026-07-28 01:33:58 +02:00
dtourolle 37ffabee06 chore(release): bump to 0.1.5; regenerate traceability matrix
Build & Release / Run Tests (push) Successful in 4m35s
Build & Release / Build Linux (push) Successful in 18m25s
Build & Release / Build Windows (push) Successful in 13m27s
Build & Release / Build Android (push) Successful in 29m9s
Build & Release / Create Release (push) Successful in 16s
Registers UR-061/DR-092 (tap gestures) and UT-062 (background-audio
bridge reporting), and regenerates the matrix — 313 TRACES across 299
files.
2026-07-28 01:33:20 +02:00
dtourolle 13e0860401 fix(player): expired sleep timer stops without triggering autoplay
Stopping the backend makes the native player fire its ended callback,
which lands in on_playback_ended. The timer thread cancels the timer
first, so by the time the callback inspects it the mode reads Off — the
sleep-timer branch is skipped and the episode path runs, showing a
next-episode popup (or advancing outright) right after the user's sleep
timer expired.

Record EndReason::UserStop before the stop reaches the backend. That is
the honest label: the stop was user-initiated, just via the timer they
set rather than the stop button.

TRACES: UR-023, UR-026 | DR-029
2026-07-28 01:33:10 +02:00
dtourolle d1c01a6bc3 feat(player): defer single tap so a double tap doesn't also toggle pause
A tap cannot be classified when it lands — it may still turn out to be
the first half of a double tap. Play/pause is therefore deferred until
the 300ms double-tap window closes, and cancelled outright if a second
tap arrives, so a double tap seeks without also toggling pause.

Forward skip moves from 10s to 30s (back stays 10s), for both double tap
and the keyboard arrows.

The timing rules live in tapGestures.ts so they are unit-testable
without mounting the player. Rapid double taps now chain off a
still-in-flight seek target instead of all resolving against the same
not-yet-updated position.

TRACES: UR-005, UR-061 | DR-092 | UT-085, UT-086, UT-087, UT-088
2026-07-28 01:33:04 +02:00
dtourolle e5d3cc06f2 fix(android): register WebView JS bridges once; stop audio-focus fight
Locking the screen killed audio on video playback even with the
background-audio toggle armed.

configureWebViewForMedia() ran from onCreate's delayed post AND from
every onResume, re-calling addJavascriptInterface on each pass — five
times in a 45s session. WebView binds injected objects at page-load
time, so re-injecting over a live page leaves JS holding a stale proxy:
the object stays truthy (passing the `bridge()?.` optional chain) while
its methods vanish. Logcat showed 66 "WebView: Unknown object" errors
and, in JS, "TypeError: setEnabled is not a function".

So the toggle turned blue but never reached native. backgroundAudioEnabled
stayed false, onStop never dispatched 'jellytau-background', the handoff
never ran, and audio stopped the instant the screen locked. PiP and audio
focus broke identically.

- Register the bridges exactly once per WebView (identity-compared), and
  split the idempotent settings/chrome-client work into
  configureWebViewSettings() so it still runs on every resume.
- Forward WebView console output to logcat as "JellyTauWeb". The frontend
  was previously invisible to adb, which is what made this bug so hard to
  place; keep it for the next boundary-spanning diagnosis.
- setBackgroundAudioEnabled now reports whether native was actually
  reached instead of silently no-oping, so a dead bridge can never again
  masquerade as an armed toggle.

Removing the re-injection revived a latent conflict it had been masking:
the focus calls started working, and three AUDIOFOCUS_GAIN requesters
inside one uid began fighting — MainActivity, ExoPlayer, and Chromium's
own AudioFocusDelegate. The grant was followed ~45ms later by
AUDIOFOCUS_LOSS, whose handler paused playback, so arming background
audio (or just pressing play) paused the video in a loop.

WebView already manages focus for <video>. Drop the redundant
AndroidAudioFocus bridge, its listeners and its helpers entirely, and
leave focus to whichever engine is actually rendering — consistent with
the player-is-authoritative principle.

Also drops the dead AndroidBackgroundAudio.isSupported() probe, unused
since the button gate moved to platform().

TRACES: UR-040 | IR-025, DR-051 | UT-062
2026-07-28 01:32:57 +02:00
dtourolle 5759a97289 chore: ignore Arch packaging build artifacts
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 12m26s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m18s
Traceability Validation / Check Requirement Traces (push) Successful in 17s
Build & Release / Run Tests (push) Successful in 4m15s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 7m49s
Build & Release / Build Linux (push) Successful in 17m46s
Build & Release / Build Windows (push) Successful in 13m20s
Build & Release / Build Android (push) Successful in 29m14s
Build & Release / Create Release (push) Successful in 15s
A local `scripts/build-arch.sh` run leaves a vendored cargo cache
(`.cargo-arch/`), a makepkg workdir (`packaging/arch/pkg/`, `src/`) and the
built package in the tree — tens of thousands of untracked files that bury real
changes in `git status`.
2026-07-25 15:53:10 +02:00
dtourolle b9f026e215 chore(release): bump to 0.1.2
Adds CHANGELOG.md, which the release-notes template in docs/release-checklist.md
already linked to but which had never been created.
2026-07-25 15:21:51 +02:00
dtourolle b7a7037194 docs: add UR-060 search relevance requirement; regenerate matrix
Records the search relevance and grouping behaviour as UR-060, with DR-090
(Rust relevance ranking) and DR-091 (Shows/Episodes split, People group,
stored-order migration). DR-066 now points at DR-091 for the current group set
instead of restating a default order that has since changed.
2026-07-25 15:13:52 +02:00
dtourolle 124da29fc7 fix(search): route the library header search to /search
Typing in the desktop header search bar ran library.search() in place and
relied on /library rendering the results inline. On every other /library/**
route nothing rendered them, so the search bar looked broken: results were
fetched and never shown.

Make /search the single surface that renders results. The header bar becomes a
navigator — it hands the query and route-derived scope to /search via ?q= and
?scope=, which seed the page and run the search on arrival. The inline result
block and the header's scope chips are removed; the chips live on /search,
which owns the results. The empty `all` scope is omitted from the URL, and
typing while already on /search does not push a history entry per keystroke.
2026-07-25 15:13:45 +02:00
dtourolle 5927299c0f feat(search): rank results by match quality and split TV/People groups
Neither search backend orders by *where* the query matched, so a mid-word hit
could outrank a prefix one — typing "parks" surfaced "Sparks of Love" above
"Parks and Recreation".

Add `domain/search_rank.rs`, which sorts by match position (prefix →
word-start → mid-word substring → no name match), then by media kind so a
container outranks its own contents. The sort is stable, so each backend's own
relevance still breaks ties it was never overruled on. `repository_search`
applies it to both the instant cache result and the merged cache+server union,
so the list does not reshuffle when server results land. Ranking lives in Rust
because "a better match" is domain vocabulary, not presentation.

On the frontend, the combined `tvShows` result group splits into separate
Shows and Episodes groups so a show no longer competes with its own episodes
for a slot, and a People group is added so searching an actor's name reaches
their bio. A stored `tvShows` order expands in place, keeping the position an
upgrading user chose for it.
2026-07-25 15:13:32 +02:00
dtourolle 7650efcb7f fix(player): scale video to fill the player viewport
The <video> element used `max-w-full max-h-full`, which only ever shrinks
oversized media. A source smaller than the window (480p on a 1080p display)
rendered at its intrinsic size — a small picture floating in a black frame.

Fill the container and let `object-contain` do the scaling, so the picture
fits whichever axis constrains it in both directions while preserving aspect
ratio. The sizing rules move to `videoFit.ts` so they are unit-testable
outside the component.
2026-07-25 15:12:53 +02:00
dtourolle 4b9350c949 chore(release): bump to 0.1.1
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 12m25s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m17s
Traceability Validation / Check Requirement Traces (push) Successful in 16s
Build & Release / Run Tests (push) Successful in 3m52s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 9m52s
Build & Release / Build Linux (push) Successful in 17m32s
Build & Release / Build Windows (push) Successful in 13m14s
Build & Release / Build Android (push) Successful in 29m7s
Build & Release / Create Release (push) Successful in 13s
2026-07-25 09:25:52 +02:00
dtourolle d01c1216b8 docs: red-green rule for bug fixes; regenerate traceability matrix
CLAUDE.md now states the failing-test-first rule explicitly: write a test
that reproduces the bug and watch it fail before applying the fix, and
extract buried logic into a plain .ts module so it can be unit-tested. A
test written against already-fixed code can pass for the wrong reason.
2026-07-25 09:21:22 +02:00
dtourolle fb967433f0 fix(library): populate "More Episodes" for series without season folders
The Episode Focus View's episode strip collapsed to just the current
episode on some series. Two causes:

- Series that expose episodes directly as children rather than under
  season folders yielded an empty season fetch, leaving allEpisodes
  empty. The library page now groups those flat episode children by
  their season number and synthesizes minimal season headers.
- isCurrentEpisode over-matched: episodes with no season/episode number
  compared equal (undefined === undefined) and every one of them looked
  like the focused episode.

Extracts the strip's pure logic into episodeStrip.ts so both behaviours
are unit-tested, per the failing-test-first rule.

TRACES: UR-058 | DR-087
2026-07-25 09:21:15 +02:00
dtourolle ee584aced2 fix(autoplay): advance to the next episode in background audio mode
An episode handed off to the audio-only path for background playback is a
MediaType::Audio item, so autoplay's video-only checks stopped
recognising it as an episode: playback simply ended at the episode
boundary instead of continuing to the next one.

- Carry episode identity (item_type, series_id) through the
  background-audio handoff so the backend queue item still knows it's an
  episode; is_episode_item now trusts item_type over the media_type
  heuristic, and the sleep timer's episode counter follows.
- The frontend normally performs the advance by navigating to
  /player/<id>, which is unavailable while the WebView is suspended.
  advance_to_next_episode_audio_only drives it entirely in the backend:
  fetch the next episode, build its audio-only stream URL, and load it
  into the native audio player, preserving episode identity so the
  following boundary advances too.
- Android's autoplay dispatch routes background-audio episodes to that
  backend advance and keeps the countdown path for the foreground.
- get_audio_only_stream_url_for_video joins the MediaRepository trait
  (online delegates to the existing builder, offline errors) so the
  controller can reach it without a frontend round-trip.

TRACES: UR-040, UR-023 | DR-052 | JA-032
2026-07-25 09:21:08 +02:00
dtourolle eb76c96e94 feat(player): skipping an episode marks it watched, not paused
Skipping to the next episode left a mid-episode resume point behind, so
the skipped episode reappeared in Continue Watching with a partial
progress bar. Skipping means "done with this one", not "stopped here".

- reportSkippedEpisode marks the outgoing episode played instead of
  reporting a stop position, and arms a one-shot suppression consumed by
  the player's stop handler, so VideoPlayer's post-navigation unmount
  stop report can't overwrite the 100% progress with the partial one.
- Continue Watching drops resume entries superseded by Next Up: an
  in-progress episode whose series has a next-up entry strictly later in
  series order (season, then episode) is hidden from the Home and TV
  rows. Movies, series without a next-up entry, and items with unknown
  or mixed ordering are always kept.

Adds UR-059, DR-088, DR-089.

TRACES: UR-059 | DR-088, DR-089
2026-07-25 09:20:56 +02:00
dtourolle c3ead64748 ci(release): build Windows NSIS installer on tag
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 18m37s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m13s
Traceability Validation / Check Requirement Traces (push) Successful in 17s
Build & Release / Run Tests (push) Successful in 4m48s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 4m36s
Build & Release / Build Linux (push) Successful in 17m58s
Build & Release / Build Windows (push) Successful in 22m16s
Build & Release / Build Android (push) Successful in 29m7s
Build & Release / Create Release (push) Successful in 15s
Adds a build-windows job to the release workflow, cross-compiling the
Windows NSIS installer from Linux via the builder image (MSVC target +
cargo-xwin, no toolchain installs). Wires its artifacts into
create-release alongside Linux and Android.

TRACES: UR-003 | DR-004
2026-07-25 00:02:33 +02:00
dtourolle 742ad88a29 feat(build): cross-platform desktop packaging; bump to 0.1.0
Build & Release / Run Tests (push) Has been cancelled
Traceability Validation / Check Requirement Traces (push) Has been cancelled
Build & Release / Build Linux (push) Has been cancelled
Build & Release / Build Android (push) Has been cancelled
Publish Documentation / Build & publish docs to gitea-pages (push) Has been cancelled
🏗️ Build and Test JellyTau / Android Compile Check (push) Has been cancelled
Build & Release / Create Release (push) Has been cancelled
🏗️ Build and Test JellyTau / Run Tests (push) Has been cancelled
Adds Docker-based packaging for Linux desktop (deb/rpm), Arch
(.pkg.tar.zst via makepkg), and Windows. Windows cross-compiles from
Linux via the official Tauri path — the x86_64-pc-windows-msvc target
driven by cargo-xwin — and produces an NSIS installer (nsis via
tauri.conf.json targets, since the CLI rejects --bundles nsis on a Linux
host). Verified end to end: builds jellytau.exe + jellytau_x64-setup.exe.

Unifies everything on one registry builder image (Dockerfile.builder):
Android SDK/NDK, rpm/file, clang(+clang-cl)/lld/llvm/nsis, cargo-xwin and
the msvc target. Packaging tools sit in a trailing layer so tool changes
rebuild in ~1min instead of ~15. Desktop stages are thin FROM
${BUILDER_IMAGE} layers; Arch uses a separate archlinux image.

CLAUDE.md: CI must install no system toolchains — everything lives in the
image. Bumps version 0.0.18 -> 0.1.0 (Windows support + webview audio +
equalizer).

TRACES: UR-003, UR-005 | DR-004
2026-07-24 23:49:50 +02:00
dtourolle d4e2cd120c feat(player): webview audio backend for platforms without a native one
Adds WebviewAudioBackend, used on non-Linux/non-Android targets (e.g.
Windows) where there is no libmpv/ExoPlayer. Instead of decoding, it
emits a WebviewAudioLoad event with the stream URL; a frontend <audio>
element (WebviewAudioAdapter + webviewAudio service) plays it and reports
state/position back through the existing player_report_* round-trip, so
the Rust PlayerController stays the single source of truth. Play/pause/
seek reach the element via the existing ControlCommand event.

All video already renders in the webview on every platform, so this
completes audio-only playback for Windows (video via WebView2, audio via
<audio>). Pure Rust + Tauri events, so it still cross-compiles from Linux.

Regenerates bindings.ts (adds webview_audio_load; also carries the
equalizer EQ bindings).

TRACES: UR-003, UR-004, UR-005 | DR-004
2026-07-24 23:49:23 +02:00
dtourolle c543f90ad3 feat(audio): graphic equalizer with presets and custom bands
Adds a 10-band graphic equalizer to AudioSettings (enabled flag +
per-band dB gains, normalised to 10 entries and clamped to range).
Presets return gain curves; the settings page gains EQ UI. libmpv
applies the filter on Linux (Android parity pending). Old persisted
settings without EQ fields load as disabled + flat.

Also includes the requirements/traceability/ux-flows doc updates for
this feature and the home long-press routing (UR-058/DR-087).

TRACES: UR-027 | IR-020, DR-030 | UT-079, UT-080, UT-081, UT-082
2026-07-24 23:49:13 +02:00
dtourolle 589f08b873 feat(home): tap opens detail, long-press plays from home cards
Home carousel cards route a tap to the item's detail / Episode Focus
View and a ~500ms long-press to a confirm-then-play flow. MediaCard gains
an onLongPress prop with pointer-based detection (cancelled on >10px move
so carousel scroll is unaffected, trailing click suppressed). Episode
taps route to /library/<seriesId>?episode=<id>; the bare-episode detail
page links back to its parent series/season.

TRACES: UR-058 | DR-087
2026-07-24 23:49:03 +02:00
dtourolle e2c9d68311 docs(downloads): mark UR-055/056 Done; fix colliding UT ids
The browsable Downloaded library + Transfers split + on-disk usage
(f25deba, plus today's grouping/perf fixes) fully implement UR-055 and
UR-056, but the requirements doc still listed them and DR-081..085 as
Planned. Flip to Done.

Also fix UT-id collisions: the downloaded-browse and formatBytes tests
reused UT-046..050 (already assigned to smart-cache/playlist tests in the
matrix). Reassign to UT-071..078 and register them in §4, including the
new music/TV container-rollup and orphan-leaf regression tests.
2026-07-24 22:22:43 +02:00
dtourolle 57b24f8c74 fix(offline): Downloaded browse groups by container and loads on large libraries
Two bugs on the Downloaded browse surface (UR-055/UR-056):

1. Grouping — browsing a downloaded *library* listed individual leaves
   (songs, episodes) instead of their containers. The library-level match in
   `get_downloaded_items` selected every downloaded item on the server; add a
   NOT EXISTS clause so the top level shows only albums/series/movies, with
   leaves still reachable by drilling in. Regression tests for music + TV.

2. "Loading your downloads…" hung on large libraries. The disk-usage
   partiality query did an OR-based self-join over the entire synced catalog
   (O(items^2), unindexable). Narrow it to downloaded containers first via a
   CTE, and add the missing idx_items_season index (migration 020 + base
   schema) — parent_id/album_id/series_id were already indexed.

Also annotate the existing backend tests that cover the IT-016/IT-017
end-to-end offline-listing scenarios with their trace IDs.
2026-07-24 21:42:12 +02:00
dtourolle 6391720d23 docs(offline): mark UR-052 offline-listing feature and its tests Done
The DR-079/DR-080 root-cause fixes for issue #10 landed in 8f4f651; the
requirements doc still listed UR-052 (and DR-078/079/080, UT-068/069/070,
IT-016/017) as Broken/Partial/Pending. Flip them to Done and regenerate the
traceability matrix. Existing backend tests already cover the IT-016/017
end-to-end scenarios (annotated with their IDs in the code commits).
2026-07-24 21:41:57 +02:00
dtourolle 90f03dd142 fix(player): show background-audio button on all Android video playback
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 11m49s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 10m18s
Traceability Validation / Check Requirement Traces (push) Successful in 1m2s
Build & Release / Run Tests (push) Successful in 10m46s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 7m16s
Build & Release / Build Linux (push) Successful in 24m59s
Build & Release / Build Android (push) Successful in 33m5s
Build & Release / Create Release (push) Successful in 17s
The audio-only (background-audio) button was gated on the
AndroidBackgroundAudio JS-bridge probe, resolved once as a const at mount.
The bridge is injected into the WebView asynchronously and races component
mount, so on some loads the probe returned false and never recovered,
hiding the button on 'some videos' at random.

Gate on platform() === 'android' instead (synchronous, stable), matching
the convention in VolumeControl. toggleBackgroundAudio() already no-ops if
the bridge is momentarily absent.

Bump version to 0.0.18.
2026-07-24 20:32:08 +02:00
dtourolle 514e42fccb Merge branch 'frontend-domain-model' into ci-docs-publish-fix
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 8m34s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m20s
Traceability Validation / Check Requirement Traces (push) Successful in 15s
Build & Release / Run Tests (push) Successful in 5m18s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 4m38s
Build & Release / Build Linux (push) Successful in 17m22s
Build & Release / Build Android (push) Successful in 22m44s
Build & Release / Create Release (push) Successful in 14s
2026-07-23 22:18:59 +02:00
dtourolle 9b1c9b3c91 feat(settings): rework settings page; remove unused SkeletonLoader/StorageManagement
Settings page refactor plus supporting docs (requirements, ux-flows,
traceability) and the frontend-domain-model spec with implementation-status
banner. Removes SkeletonLoader and StorageManagement components (no remaining
references).
2026-07-23 22:18:37 +02:00
dtourolle 1780109fb1 domain: flip last missed catalog .type reader (movies page) to .kind
library/movies/+page.svelte handleItemClick still read item.type === "Folder"
(missed in the phase-2a sweep). Now item.kind === "folder". Final sweep
confirms zero catalog .type/runTimeTicks/primaryImageTag/playbackPositionTicks
reads remain anywhere in src/.

Decision on dropping the dual-carry legacy Rust fields: KEPT. They are now
internal-only (no frontend reader), but internal Rust still depends on
item_type (SQL WHERE item_type='Audio', player audio filter, player-queue
passthrough) and the DB stores ticks. Removing them needs a DB/query-layer
migration with real regression risk and zero frontend benefit — out of scope
for the frontend-model goal, which is met.

Frontend 626, check clean.
2026-07-23 22:15:15 +02:00
dtourolle 3a18ad060b domain: delete dead jellyfinFieldMapping; scope playbackUnits to session boundary (phase 4e)
jellyfinFieldMapping.ts (SORT_FIELD_MAP friendly->Jellyfin sort names) had
zero consumers — sort code passes raw Jellyfin field names directly — so it
and its test are deleted.

playbackUnits.ts can't be removed: its tick<->seconds helpers are still the
correct converters for the remote Jellyfin *session* boundary
(SessionInfo.playState.positionTicks, NowPlayingItem.runTimeTicks), which
legitimately arrives in ticks. Documented that narrowed role; formatTime/
calculateProgress remain neutral seconds-based presentation helpers.

Note (out of scope): sortBy still passes raw Jellyfin field names
("SortName", "CommunityRating") — a separate sort-taxonomy leak that would
need its own Rust SortKey, like the search-scope work.

Frontend 626 tests (jellyfinFieldMapping's 18 removed with it), check clean.
2026-07-23 22:13:54 +02:00
dtourolle 1968c06172 domain: neutral StreamKind for media streams (phase 4d)
Add StreamKind enum (audio/video/subtitle/other) to the domain module with
a total stream_kind_from_jellyfin mapper. MediaStream gains a kind field
(dual-carry), populated at the mapping seam. Frontend VideoPlayer track/
subtitle selection and the channel-video check now use stream.kind instead
of the Jellyfin stream.type string.

Rust 456 (+ stream_kinds_map test), frontend 644, check clean.
2026-07-23 22:11:31 +02:00
dtourolle ec8a7610f5 domain: player/reporting ticks -> milliseconds (phase 4c)
Playback position now crosses the IPC boundary in milliseconds. Ticks
survive only inside Rust (DB storage, Jellyfin API) and at the genuine
remote-session boundary (session seek / transfer / RemoteControls).

Rust command signatures (ms in, converted to ticks internally):
- storage_update_playback_progress / _context: position_ms
- repository_report_playback_start / _progress / _stopped: position_ms
- PlaybackProgress.position_ticks -> position_ms (converted in the query)

Frontend:
- playbackReporting, playerEvents, VideoPlayer, Queue, player/[id] resume:
  seconds*1000 / durationMs/1000 instead of tick math.
- repository-client + syncService param names -> positionMs.
- Tests updated to ms fixtures/assertions.

Out of scope (legitimately ticks): NowPlayingItem, PlayState.positionTicks,
sessionSeek, playbackModeTransferToLocal, RemoteControls, SessionCard — the
remote Jellyfin session API.

Rust 456, frontend 644, check clean.
2026-07-23 22:02:29 +02:00
dtourolle 93d198ce21 domain: primaryImageTag -> imageId end-to-end (phase 4a/4b)
Rust: PlayerMediaItem and MergedMediaItem gain image_id (dual-carry),
populated from primary_image_tag at every construction/conversion site.
Regenerated bindings.

Frontend: all catalog + player + merged readers now use imageId. The
NowPlayingItem->MediaItem bridge (player.ts) properly maps the remote
session's Jellyfin fields (Type, runTimeTicks, primaryImageTag) onto the
neutral kind/durationMs/imageId. Types that are genuinely out of scope
(Person, NowPlayingItem, PlayItemRequest) keep primaryImageTag.

Rust 456, frontend 644, check clean.
2026-07-23 21:40:58 +02:00
dtourolle 2b42b74912 domain: replace user-facing Jellyfin type badge with kind label (phase 3b)
The library detail page showed the raw Jellyfin item_type string
("MusicAlbum") to users. Add utils/mediaKind.ts with kindLabel(), a
presentation-only MediaKind -> human label map, and use it for the badge.
Flip the two remaining .type debug logs to .kind.

This removes the last user-visible Jellyfin vocabulary on the catalog
surface. primaryImageTag -> imageId rename (naming-only, ~40 sites across
catalog + player/merged types needing a Rust round-trip) intentionally
deferred as the lowest-value slice.

Frontend 644 tests, check clean.
2026-07-23 21:31:51 +02:00
dtourolle 7660a33dfc domain: catalog frontend off Jellyfin ticks -> milliseconds (phase 3a)
The catalog surface now speaks milliseconds, the app's neutral time unit.
Ticks no longer reach library/home components.

Rust:
- UserData gains playback_position_ms (dual-carry), populated from ticks
  at the offline mapping seam via domain::ticks_to_ms.

Frontend:
- formatDuration(duration.ts) and the two local copies now take ms, not
  ticks; all callers pass item.durationMs.
- Progress bars (EpisodeRow, EpisodeFocusView, MediaCard, LibraryListView)
  compute playbackPositionMs / durationMs — unit-consistent, no tick math.
- PlaylistDetailView totalDuration sums durationMs.
- duration.test.ts + TrackList.test.ts fixtures updated to ms.

Deferred: player/session/reporting tick math (Queue, SessionCard,
RemoteControls, playbackReporting, playerEvents) — those cross the
storage/Jellyfin command boundary in ticks and need command-signature
changes (phase 3b). Display {item.type} badge -> kind label (phase 4).

Rust 456, frontend 644, check + check:boundary clean.
2026-07-23 21:30:04 +02:00
dtourolle 3e962a202c test: update playerVisibility fixtures to .kind (phase 2a)
isVideoItem now reads item.kind, so the mini-player visibility fixtures
must set kind (track/movie/liveChannel) instead of the old Jellyfin
type strings. Renames channelItem -> liveChannelItem to match its kind.

All 644 frontend tests pass.
2026-07-23 21:16:42 +02:00
dtourolle 43ddc5a889 domain: flip isVideoItem in player store to .kind (phase 2a completion)
The catalog MediaItem check in stores/player.ts isVideoItem() was missed
in the phase 2a sweep (excluded by a player-path filter). It reads the
catalog MediaItem, so it flips like the rest: type Movie/Episode/TvChannel
-> kind movie/episode/liveChannel.

Verified: no catalog .type === "<JellyfinType>" comparisons remain in the
frontend (only stream.type in VideoPlayer, deferred to phase 4). check clean.
2026-07-23 21:14:45 +02:00
dtourolle 772e9ca6d5 domain: flip catalog frontend off Jellyfin item-type strings (phase 2a)
Migrate catalog MediaItem consumers from stringly item.type ("Audio",
"MusicAlbum", …) to the neutral item.kind enum across all classification
logic: home, library detail, player routing, artist/person/related/genre
components, tv store.

Model refinements found during migration (each a real distinction the
flat item_type collapsed):
- MediaKind::LiveChannel — live TV (playable, non-seekable) vs
- MediaKind::ChannelItem — channel VOD leaf (playable, seekable) vs
- MediaKind::Channel — channel container (drill-in).
  TvChannel->LiveChannel, non-folder ChannelFolderItem->ChannelItem.

RelatedItemsSection and GenreTags props migrated from Jellyfin type
strings to MediaKind; MediaKind re-exported from api/types.

Deferred by design: display {item.type} text, ResultsCounter labels,
Person.type (role), stream.type (phase 4), and all runTimeTicks/tick math
(coupled to playbackPositionTicks — phase 3). Old fields still dual-carried
so nothing breaks.

Rust 456 + 7 domain tests, frontend 644 tests, check clean.
2026-07-23 21:12:20 +02:00
dtourolle 55fa26377a domain: introduce provider-neutral media model (phase 1)
Establish src-tauri/src/domain/ as the single source of truth for the
media model, with all Jellyfin translation isolated in from_jellyfin.rs.
Adds MediaKind enum and neutral duration_ms/image_id fields to MediaItem
as additive, defaulted dual-carry alongside the legacy Jellyfin-named
fields, so nothing breaks while the frontend migrates off them.

- domain/media.rs: canonical MediaKind (closed enum, replaces stringly
  item_type), Default = Other so unknown/defaulted items are inert.
- domain/from_jellyfin.rs: total, panic-free item_type -> MediaKind
  classification (all audited types + person subroles) and ticks->ms.
- MediaItem gains kind/duration_ms/image_id, populated at both mapping
  seams (online to_media_item, offline cached_item_to_media_item) and
  the synthesized-album/person sites.
- Regenerated bindings.ts: frontend now HAS the neutral model available.

Phase 1 of docs/specs/frontend-domain-model.md. No frontend behaviour
change yet; wire shape is a superset of before.

Rust 456 tests, frontend 644 tests, check + check:boundary all green.
2026-07-23 20:53:47 +02:00
dtourolle f89b241ad6 docs: document frontend/backend boundary rule and spec workflow
Add the "domain vocabulary lives in Rust" principle, the
check:boundary pre-commit gate, and a spec-writing section pointing at
the spec template and review checklist.
2026-07-23 20:04:54 +02:00
dtourolle 8b028b6b60 docs: specs, requirements, ux-flows and traceability for new features
Add specs for the account menu, downloads-as-offline-library, offline
downloaded-only filter, and scoped search (+ boundary revision). Add the
new UR/DR entries to requirements.md, update ux-flows, and regenerate the
traceability matrix.

TRACES: UR-049, UR-050, UR-052, UR-053, UR-054, UR-055, UR-056
2026-07-23 20:04:35 +02:00
dtourolle dd9d4191f1 chore(ci): add frontend boundary tripwire script
Grep-based tripwire flagging multi-type includeItemTypes literals in the
Svelte frontend — the machine-detectable signature of the item-type
taxonomy leaking into presentation. Wire it into the Gitea build-and-test
workflow and a check:boundary package script. Motivated by
docs/specs/scoped-search-boundary.md.
2026-07-23 20:04:35 +02:00
dtourolle bacb9ca0bb docs(traces): tag episode focus view and library detail with TRACES
Add TRACES comments linking the episode focus view and library detail
page to their existing requirements.

TRACES: UR-035, UR-038, UR-048 | DR-043, DR-061, DR-062
2026-07-23 20:03:16 +02:00
dtourolle cf9472f04f feat(chrome): shared account menu and global app header
Move account actions (Settings, Downloads, Display preferences, Sign
out) out of the library-only header into a shared AccountMenu anchored in
a global AppHeader, available on every authenticated non-immersive
screen. Add a layoutShell helper deciding where chrome shows, expose
serverName/serverUrl auth stores, and a display view-mode preference. The
settings page also gains the UR-053 WiFi-only toggle.

TRACES: UR-054 | DR-075, DR-076, DR-077
2026-07-23 20:03:12 +02:00
dtourolle f25deba824 feat(downloads): browsable downloaded library with on-disk usage
Replace the flat download list with a Downloaded browse surface that
reuses the online grids/cards/detail pages, filtered to on-device media,
plus a demoted Transfers tab. Add repository browse commands
(getDownloadedLibraries/Items, disk usage) with offline/hybrid
implementations, a downloadedCatalog service, formatBytes helper, and
per-item/device disk-usage labels on cards and grids. Regenerated
bindings.

Also carries the inseparable UR-052 offline-filter hunks in
offline.rs/hybrid.rs.

TRACES: UR-055 | DR-081, DR-082, DR-083, DR-084; UR-056 | DR-085
2026-07-23 20:02:55 +02:00
dtourolle 8f4f651bac fix(offline): gate library listing to downloaded-only when offline (#10)
The connectivity store now drives the "downloaded only" view so an
offline library page shows just on-device media, with the server catalog
revealed only when "Show all server media" is toggled.

TRACES: UR-052 | DR-078, DR-079
2026-07-23 20:02:33 +02:00
dtourolle c175378f38 feat(search): context-scoped search with filter chips and group order
Add a search scope (all/music/shows/movies) resolved from the entry
route and adjustable via filter chips, threaded through the library
store's search() into includeItemTypes. Results group by type in a
user-configurable order, editable from settings.

TRACES: UR-049 | DR-063, DR-064, DR-065; UR-050 | DR-066, DR-067
2026-07-23 20:02:15 +02:00
dtourolle e083b53ee8 feat(downloads): WiFi-only network-type-aware download gating
Add a metered/cellular network detector so downloads honour a "WiFi
only" preference. Android reports network type via NetworkTypeMonitor;
Rust exposes it through download/network.rs and holds the queue pump when
on a metered connection, emitting a queue-wide waitingForNetwork event.
The frontend surfaces this via the networkType service and a
waitingForNetwork store flag.

TRACES: UR-053 | DR-074
2026-07-23 20:02:07 +02:00
dtourolle 8f8433eebe ci: remove superseded traceability workflow
traceability.yml duplicated the coverage check now owned by
traceability-check.yml, running the same extraction on every push and PR
to master/main/develop. Dead CI: nothing references it, and keeping both
doubled runner time for one result.
2026-07-23 19:17:40 +02:00
dtourolle 6f057ad14a ci: fix Bad substitution in docs publish step
The gitea-pages push step used ${GITHUB_SHA::8}, a bash-only substring
expansion. The Gitea runner executes run: blocks with /bin/sh (dash),
which rejects it with "Bad substitution" and exits 2, failing the job
after the site had already built successfully.

Use cut(1) to shorten the SHA instead, which is POSIX sh compatible.
2026-07-23 19:13:29 +02:00
dtourolle bebe13eb62 ci: drop redundant setup-bun step that stalls Gitea runner
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 4m59s
Publish Documentation / Build & publish docs to gitea-pages (push) Failing after 5m23s
Traceability Validation / Check Requirement Traces (push) Successful in 15s
Build & Release / Run Tests (push) Successful in 5m5s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 4m41s
Build & Release / Build Linux (push) Successful in 17m20s
Build & Release / Build Android (push) Successful in 22m29s
Build & Release / Create Release (push) Successful in 5s
bun is already baked into the jellytau-builder image (Dockerfile.builder),
so oven-sh/setup-bun@v1 was redundant. Fetching that GitHub-hosted action
from the self-hosted Gitea runner hangs the job before any steps run.
Removed from traceability-check, traceability, and publish-docs workflows;
build-and-test and build-release never used it and never stalled.
2026-07-23 09:45:07 +02:00
dtourolle a8adbe25cc Merge pull request 'android-picture-in-picture' (#12) from android-picture-in-picture into master
🏗️ Build and Test JellyTau / Run Tests (push) Failing after 3h14m1s
Publish Documentation / Build & publish docs to gitea-pages (push) Has been cancelled
Traceability Validation / Check Requirement Traces (push) Has been cancelled
Build & Release / Run Tests (push) Successful in 10m40s
Build & Release / Build Linux (push) Successful in 17m14s
Build & Release / Build Android (push) Successful in 22m26s
Build & Release / Create Release (push) Successful in 12s
🏗️ Build and Test JellyTau / Android Compile Check (push) Has been cancelled
Reviewed-on: #12
2026-07-22 20:29:04 +00:00
dtourolle acf1bb200d fix resuming video playback after background audio only mode.
Traceability Validation / Check Requirement Traces (pull_request) Failing after 3h14m1s
🏗️ Build and Test JellyTau / Android Compile Check (pull_request) Has been cancelled
🏗️ Build and Test JellyTau / Run Tests (pull_request) Has been cancelled
2026-07-22 22:28:07 +02:00
dtourolle 3fbf6afdbc Background-audio handoff for video + repository/player refactor
Hand video playback off to a native audio-only stream when the app is
backgrounded or locked, with no on-device video decode (UR-040). Adds
player_enter/exit_background_audio commands, an audio-only stream URL
for video items across the repository layer, and the frontend handoff
state machine wired into VideoPlayer. Includes accompanying
repository/offline/player refactoring and regenerates the traceability
matrix.
2026-07-22 21:52:07 +02:00
dtourolle 4e6ab017d4 docs: add mdBook docs-site, publish workflow, and release-notes tooling
Add a docs-site (mdBook) with a Gitea publish-docs workflow, a
release-notes generator script (release:notes) that turns a commit
range's TRACES into grouped notes, the background-audio feature spec,
and CLAUDE.md. Ignore docs-site build artifacts.
2026-07-22 21:51:56 +02:00
dtourolleandClaude Opus 4.8 027054a200 Bump version to 0.0.16
Needed to deploy over the CI-installed build on device: CI derives
versionCode as 1000 + major*10000 + minor*100 + patch, so the field is
already at 1000, while a local `tauri android build` writes the raw
patch number (15) and is rejected as a downgrade.

Cargo.toml is versioned independently (0.1.0) and is left alone.

Note: local builds still emit the raw code (16) - only CI applies the
1000+ formula, so deploying to a device with a CI build installed needs
gen/android/app/tauri.properties patched after Tauri regenerates it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 15:57:23 +02:00
dtourolleandClaude Opus 4.8 1fa5aa46f9 Android picture-in-picture, and fix three dead Android config files
Add PiP for native (ExoPlayer) video on Android. Video renders into a
SurfaceView behind the WebView, so PiP is driven by the Activity shrinking
into a floating window rather than the HTML5 PiP API (which WebKitGTK does
not implement, hence Android-only).

- PictureInPictureManager.kt: enter PiP with the video's aspect ratio
  (clamped to the 1:2.39-2.39:1 range Android accepts, outside which it
  throws), plus a play/pause RemoteAction. Hides the WebView while in PiP -
  it is opaque and sits above the surface, so it would otherwise occlude the
  video entirely - and re-fits the surface on exit.
- MainActivity.kt: onUserLeaveHint auto-PiP, onPictureInPictureModeChanged,
  and an AndroidPictureInPicture JS interface following the existing
  AndroidAudioFocus pattern.
- pictureInPicture.ts + VideoPlayer.svelte: PiP button, rendered only when
  the native bridge reports support.
- proguard: keep rules for @JavascriptInterface methods, which are only
  referenced from JS and would be stripped in minified release builds.

Casting needs no special handling: canEnterPip() checks natively that a
local video surface is attached and playing, which a remote session lacks.

While wiring the manifest, found that three tracked files under
src-tauri/android/ were never reaching any build. Gradle reads only
gen/android/app/src/main/, and sync-android-sources.sh did not copy them:

- src/main/AndroidManifest.xml was a partial <application> fragment written
  as if Tauri merged it. It does not - there is no manifest-merger hook
  here, so its hardwareAccelerated flag never reached an APK. Promoted to
  the complete authoritative manifest (folding in that flag) and synced.
- src/main/res/values/themes.xml (transparent status bar, fitsSystemWindows)
  was never copied; the sync only globbed mipmap-*. Now synced.
- build.gradle.kts was a leftover com.android.library module config with
  stale media3 1.5.1 deps. The live deps are in app/build.gradle.kts at
  1.5.0. Deleted.

Verified: merged manifest now carries hardwareAccelerated,
supportsPictureInPicture, resizeableActivity and the density configChange;
themes.xml compiles into merged resources; Kotlin builds warning-free;
svelte-check clean; 537 frontend tests pass.

Not verified: PiP behaviour on a device, and the release keep rules against
a minified build. assembleUniversalDebug cannot complete in this
environment - the Rust step wants a dev-server addr file that only exists
under `tauri android dev`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 12:59:39 +02:00
dtourolleandClaude Opus 4.8 7b8a8f66e5 CI: make versionCode step POSIX sh compatible
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 4m24s
Traceability Validation / Check Requirement Traces (push) Successful in 24s
Build & Release / Run Tests (push) Successful in 5m24s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 4m31s
Build & Release / Build Linux (push) Successful in 17m40s
Build & Release / Build Android (push) Successful in 22m33s
Build & Release / Create Release (push) Successful in 14s
The runner executes workflow steps with /bin/sh (dash), which has no
here-strings: `IFS='.' read -r MAJ MIN PAT <<< "$VERSION"` failed with
"Syntax error: redirection unexpected" and aborted the Android release build.

Parse the semver with `cut` instead, drop the GNU-only `\s` from the sed
expression in favour of [[:space:]], and default any missing component to 0 so a
malformed version can never emit versionCode 0. Verified under sh:
0.0.14 -> 1014, 0.0.15 -> 1015, 0.1.0 -> 1100, 1.0.0 -> 11000 (monotonic).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 12:23:04 +02:00
dtourolleandClaude Opus 4.8 2e479d05b3 Navigation up/back split, faster startup, and CI versionCode fix
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 4m57s
Traceability Validation / Check Requirement Traces (push) Successful in 22s
Build & Release / Run Tests (push) Successful in 5m13s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 4m30s
Build & Release / Build Linux (push) Successful in 17m52s
Build & Release / Build Android (push) Failing after 58s
Build & Release / Create Release (push) Has been skipped
Navigation:
- Split conflated "back" into navigateUp (deterministic route parent) and a
  history-safe navigateBack that tracks in-app depth via afterNavigate instead
  of history.length. Fixes the resume-from-background trap where a stale WebView
  stack left the header arrow stuck on the current page.
- /library self-corrects for music/tv/movies (which have dedicated landing
  pages): a leftover currentLibrary no longer forces the inline content-list
  view, so "up"/back shows the libraries overview. Live TV / channels / other
  types still render inline.

Startup (unblock first paint):
- auth.initialize() no longer awaits security-status, player-config, or session
  verification before flipping isInitialized. These run fire-and-forget after the
  session is restored, so the library overview paints without waiting on several
  serial IPC round-trips.

Versioning / CI:
- tauri.conf.json + package.json aligned to 0.0.15 (the tag series had drifted to
  0.1.0, whose formula-derived versionCode 1000 outran the v0.0.x tags).
- Release workflow now pins a monotonic Android versionCode
  (1000 + major*10000 + minor*100 + patch) so tagged builds never downgrade
  below prior installs and always increase in semver order.

Tests: navigation (4), auth (29), playbackMode (23) green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 21:12:36 +02:00
dtourolle 1992a8187d layout and remote fix
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 4m31s
Traceability Validation / Check Requirement Traces (push) Successful in 20s
Build & Release / Run Tests (push) Successful in 5m24s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 4m29s
Build & Release / Build Linux (push) Successful in 17m27s
Build & Release / Build Android (push) Successful in 22m14s
Build & Release / Create Release (push) Successful in 12s
2026-07-16 22:53:03 +02:00
dtourolle 532ffa661a Fix tests
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 4m29s
Traceability Validation / Check Requirement Traces (push) Successful in 19s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 5m4s
Build & Release / Run Tests (push) Successful in 4m52s
Build & Release / Build Linux (push) Successful in 17m55s
Build & Release / Build Android (push) Successful in 22m13s
Build & Release / Create Release (push) Successful in 13s
2026-07-11 22:09:33 +02:00
dtourolle 2a1f1689b4 Layout and search fix
🏗️ Build and Test JellyTau / Run Tests (push) Failing after 2m4s
🏗️ Build and Test JellyTau / Android Compile Check (push) Has been skipped
Traceability Validation / Check Requirement Traces (push) Successful in 23s
Build & Release / Run Tests (push) Failing after 2m45s
Build & Release / Build Linux (push) Has been skipped
Build & Release / Build Android (push) Has been skipped
Build & Release / Create Release (push) Has been skipped
2026-07-11 19:55:55 +02:00
dtourolle a2cd9978f0 build uses android signing key
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 5m18s
Traceability Validation / Check Requirement Traces (push) Successful in 21s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 5m13s
Build & Release / Run Tests (push) Successful in 5m4s
Build & Release / Build Linux (push) Successful in 17m29s
Build & Release / Build Android (push) Successful in 21m44s
Build & Release / Create Release (push) Successful in 15s
2026-07-07 18:05:17 +02:00
dtourolle 36be192d44 offline mode fixes
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 4m39s
Traceability Validation / Check Requirement Traces (push) Successful in 24s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 5m11s
2026-07-07 16:22:12 +02:00
dtourolle acb7e5f221 fix offline mode and layout bugs
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 4m32s
Traceability Validation / Check Requirement Traces (push) Successful in 22s
Build & Release / Run Tests (push) Successful in 5m21s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 4m25s
Build & Release / Build Linux (push) Successful in 17m31s
Build & Release / Build Android (push) Successful in 22m5s
Build & Release / Create Release (push) Successful in 16s
2026-07-06 20:24:46 +02:00
dtourolle 68c8602230 Merge pull request 'fix-launcher-offline-mode' (#9) from fix-android-launcher-icon-conflict into master
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 5m18s
Traceability Validation / Check Requirement Traces (push) Successful in 21s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 4m25s
Reviewed-on: #9
2026-07-03 17:58:30 +00:00
dtourolle 2d141e5bf4 Fix for offline mode
🏗️ Build and Test JellyTau / Run Tests (pull_request) Successful in 4m21s
Traceability Validation / Check Requirement Traces (pull_request) Successful in 20s
🏗️ Build and Test JellyTau / Android Compile Check (pull_request) Successful in 4m54s
2026-07-03 19:37:34 +02:00
dtourolleandClaude Opus 4.8 c58cc0cf46 CI: replace broken per-commit Android APK build with a fast compile check
build-and-test.yml built a full APK on every master push without running
sync-android-sources.sh, so it used the wrong (Tauri-default) sources, was
unsigned, and duplicated the ~15min build that build-release.yml does properly
on tags. Replace it with cargo check --target aarch64-linux-android (~1min),
which catches Android Rust breakage without linking, bundling, or signing.
The signed release APK remains a tag-only artifact from build-release.yml.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 18:48:14 +02:00
dtourolleandClaude Opus 4.8 8938e3fdba Android launcher: drop monochrome (themed) icon, keep color only
The monochrome adaptive-icon layer produced a poor themed-icon rendering.
Remove the <monochrome> reference from mipmap-anydpi-v26/ic_launcher.xml and
delete the ic_launcher_monochrome.png files so Android always uses the color
adaptive icon (background + foreground). sync-android-sources.sh also drops any
monochrome layer Tauri regenerates.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 18:47:50 +02:00
dtourolle e2c12615c5 Fix CI apk build
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 6m14s
Traceability Validation / Check Requirement Traces (push) Successful in 27s
🏗️ Build and Test JellyTau / Build Android APK (push) Successful in 29m11s
Build & Release / Run Tests (push) Successful in 4m35s
Build & Release / Build Linux (push) Successful in 17m28s
Build & Release / Build Android (push) Successful in 21m35s
Build & Release / Create Release (push) Successful in 11s
2026-07-02 21:57:28 +02:00
dtourolle 0b5a3aa176 Merge pull request 'player-adapter-contract' (#8) from player-adapter-contract into master
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 3m52s
Traceability Validation / Check Requirement Traces (push) Successful in 24s
🏗️ Build and Test JellyTau / Build Android APK (push) Successful in 18m41s
Reviewed-on: #8
2026-07-02 18:02:17 +00:00
dtourolle 37455bc470 Use incremental build
🏗️ Build and Test JellyTau / Run Tests (pull_request) Successful in 3m57s
Traceability Validation / Check Requirement Traces (pull_request) Successful in 21s
🏗️ Build and Test JellyTau / Build Android APK (pull_request) Successful in 19m5s
2026-07-02 20:01:43 +02:00
dtourolleandClaude Opus 4.8 a64e1b1fb4 Introduce PlayerAdapter contract; decision logic shared in Rust backend
Establish a decoupled player boundary so UI and backend interact with video
through one contract, with the HTML5 (Linux/interim-Android) and native
(ExoPlayer) providers as interchangeable primitive-executor adapters.

- PlayerAdapter interface + AdapterHost callback bag (adapters/types.ts): the
  adapter owns only decision-free element PRIMITIVES (seekElement, reloadSource,
  play/pause, setVolume, selectSubtitle); it never branches on strategy.
- Seek/audio-track DECISIONS stay in Rust (player_seek_video / _switch_audio_track
  return a strategy); the facade dispatches the chosen primitive to the active
  adapter. Both providers share the one decision path — logic lives once, in Rust.
- Facade holds the active adapter; a new ControlCommand PlayerStatusEvent lets
  backend control (lockscreen/remote/sleep) drive the webview <video> element.
- Html5PlayerAdapter resolves the LIVE element via the bridge (fixes play/pause
  silently no-opping when the element was re-bound).
- Do not emit a "stopped" player state on natural end-of-video: it flipped the
  player/mode to idle mid-handoff and suppressed next-episode auto-advance under
  a sleep timer. Jellyfin progress reporting is preserved; the backend's
  on_video_playback_ended owns the transition.
- VideoPlayer net -300 lines (strategy/HLS-reload logic relocated to the adapter).
- Adds 20 adapter unit tests; existing suites stay green (vitest 457, cargo 416).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 19:56:20 +02:00
dtourolle 1f6977cd01 Playback fix
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 4m28s
Traceability Validation / Check Requirement Traces (push) Successful in 22s
🏗️ Build and Test JellyTau / Build Android APK (push) Successful in 18m37s
Build & Release / Run Tests (push) Successful in 4m12s
Build & Release / Build Linux (push) Successful in 16m20s
Build & Release / Build Android (push) Successful in 18m57s
Build & Release / Create Release (push) Successful in 13s
2026-07-02 18:13:55 +02:00
dtourolle 6af7f7dcca Fix android playback issue
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 4m13s
Traceability Validation / Check Requirement Traces (push) Successful in 20s
🏗️ Build and Test JellyTau / Build Android APK (push) Successful in 18m46s
2026-07-02 00:19:07 +02:00
dtourolle 75014ee00f Fix sleep bug, fix menu return
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 4m1s
Traceability Validation / Check Requirement Traces (push) Successful in 23s
Build & Release / Run Tests (push) Successful in 4m7s
🏗️ Build and Test JellyTau / Build Android APK (push) Successful in 19m5s
Build & Release / Build Linux (push) Successful in 16m20s
Build & Release / Build Android (push) Successful in 19m12s
Build & Release / Create Release (push) Successful in 8s
2026-07-01 23:49:51 +02:00
dtourolleandClaude Opus 4.8 342f95cac1 Wire up playback reporting, fix duration flash, hide video from audio mini player
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 4m14s
Traceability Validation / Check Requirement Traces (push) Successful in 21s
🏗️ Build and Test JellyTau / Build Android APK (push) Successful in 19m3s
Playback reporting (position sync / resume-on-another-device):
- player_configure_jellyfin now builds a PlaybackReporter sharing the player
  controller's Arc, so Start/Progress/Stopped actually reach Jellyfin on every
  auth path (login/restore/reauth); previously they never did.
- The PlaybackReporterWrapper now shares the same Arc the controller and MPV
  progress loop report through, instead of a dead parallel Option.
- Android position callbacks now emit throttled progress reports (30s/item),
  mirroring the MPV backend.

Duration flash on pause:
- resolveDuration() prefers the live store duration for the already-loaded
  track over the runTimeTicks estimate, so pausing no longer clobbers the
  slider's max to 0 when runTimeTicks is missing.

Video leaking into audio mini player:
- isVideoItem() also checks the backend PlayerMediaItem mediaType
  discriminator, so a video started via player_play_item (no Jellyfin `type`,
  mediaType "video") no longer surfaces in the audio mini player.

Middle-truncation of long media names:
- New truncateMiddle util applied to track/episode/card/mini-player titles so
  distinguishing tails (episode numbers, suffixes) stay visible.

Adds regression tests for the duration and mini-player fixes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 21:52:27 +02:00
dtourolle dcee342c47 Jray mugshots of actors shown
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 14m55s
Traceability Validation / Check Requirement Traces (push) Successful in 51s
🏗️ Build and Test JellyTau / Build Android APK (push) Successful in 26m44s
2026-06-28 21:09:06 +02:00
dtourolle 78f5cd9db9 Fix playback regression
🏗️ Build and Test JellyTau / Run Tests (push) Has been cancelled
🏗️ Build and Test JellyTau / Build Android APK (push) Has been cancelled
Traceability Validation / Check Requirement Traces (push) Has been cancelled
2026-06-28 21:07:00 +02:00
dtourolle 0eae81ec59 Add JRay support
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 17m59s
Traceability Validation / Check Requirement Traces (push) Successful in 1m48s
🏗️ Build and Test JellyTau / Build Android APK (push) Has been cancelled
2026-06-28 20:38:58 +02:00
dtourolle 8eae4ae253 layout improvements
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 9m2s
Traceability Validation / Check Requirement Traces (push) Successful in 2m30s
🏗️ Build and Test JellyTau / Build Android APK (push) Has been cancelled
2026-06-28 20:14:17 +02:00
dtourolle ef7be645b3 Merge pull request 'fix/lockscreen-mediasession-sync' (#7) from fix/lockscreen-mediasession-sync into master
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 3m47s
Traceability Validation / Check Requirement Traces (push) Successful in 21s
🏗️ Build and Test JellyTau / Build Android APK (push) Successful in 18m40s
Reviewed-on: #7
2026-06-27 21:57:22 +00:00
dtourolle b9249f72e9 rescale logo
🏗️ Build and Test JellyTau / Run Tests (pull_request) Successful in 4m27s
Traceability Validation / Check Requirement Traces (pull_request) Successful in 21s
🏗️ Build and Test JellyTau / Build Android APK (pull_request) Successful in 18m42s
2026-06-27 23:56:36 +02:00
dtourolleandClaude Opus 4.8 385d2270c9 fix(android): keep lockscreen/media controls in sync with playback
The lockscreen controls drifted out of sync, especially while casting, and
couldn't control remote playback. Two media sessions were competing (a Media3
MediaSession driving transport vs a MediaSessionCompat driving the notification),
position was only pushed on play/pause so the scrubber froze mid-track, and
remote mode showed stale local metadata with dead buttons.

- Make MediaSessionCompat the single source of truth; route all transport
  commands (both the Compat callback and the Media3 wrappedPlayer) through Rust
  via nativeOnMediaCommand instead of touching ExoPlayer directly.
- Push position on every 250ms tick via a lightweight updatePlaybackPosition,
  and report 0.0 playback speed when paused so Android stops extrapolating.
- Mirror the remote session's now-playing onto the lockscreen from the native
  session poller (works while the screen is locked, unlike WebView timers) via
  a new player::update_lockscreen_metadata JNI bridge.
- Make MediaSessionHandler mode-aware: in remote mode forward play/pause/next/
  prev/seek to the remote Jellyfin session; Stop while casting emits
  RemoteDisconnectRequested, which the frontend handles by transferring to local.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 23:55:26 +02:00
dtourolle 1e599627b5 Fix tracability check
🏗️ Build and Test JellyTau / Run Tests (pull_request) Successful in 3m16s
Traceability Validation / Check Requirement Traces (pull_request) Successful in 21s
🏗️ Build and Test JellyTau / Build Android APK (pull_request) Successful in 18m9s
2026-06-23 23:05:30 +02:00
dtourolle fa7cb6e908 fix: Autoplay now resets time to zero and ignores trigger if episode already started
🏗️ Build and Test JellyTau / Run Tests (pull_request) Successful in 3m12s
Traceability Validation / Check Requirement Traces (pull_request) Failing after 18s
🏗️ Build and Test JellyTau / Build Android APK (pull_request) Successful in 18m42s
2026-06-23 21:10:50 +02:00
431 changed files with 67553 additions and 7786 deletions
+25 -39
View File
@@ -49,6 +49,13 @@ jobs:
run: |
bun install
# Tripwire for domain-taxonomy leaks into the presentation layer (a
# multi-type includeItemTypes query defining a category in the frontend).
# See scripts/check-frontend-boundary.sh and
# docs/specs/scoped-search-boundary.md.
- name: Check frontend/backend boundary
run: bash scripts/check-frontend-boundary.sh
- name: Run frontend tests
run: |
bunx svelte-kit sync
@@ -60,16 +67,24 @@ jobs:
cargo test
cd ..
build:
name: Build Android APK
# Fast per-commit Android compile check. This does NOT build a shippable APK:
# the full signed release APK is built only on tag pushes by build-release.yml
# (which runs sync-android-sources.sh + signing). Running the full bundle here
# too would duplicate a ~15min build and, without the sync step, produced an
# unsigned APK missing our custom sources/icons/proguard rules anyway.
# `cargo check` for the Android target (~1min) catches Android-specific Rust
# breakage without linking, bundling, or signing.
android-check:
name: Android Compile Check
runs-on: linux/amd64
needs: test
container:
image: gitea.tourolle.paris/dtourolle/jellytau-builder:latest
env:
ANDROID_HOME: /opt/android-sdk
NDK_VERSION: 27.0.11902837
ANDROID_SDK_ROOT: /opt/android-sdk
NDK_HOME: /opt/android-sdk/ndk/27.0.11902837
ANDROID_NDK_HOME: /opt/android-sdk/ndk/27.0.11902837
steps:
- name: Checkout repository
@@ -97,42 +112,13 @@ jobs:
${{ runner.os }}-bun-
- name: Install dependencies
run: |
bun install
run: bun install
- name: Build frontend
run: bun run build
- name: Ensure Android NDK
run: |
if [ ! -d "$NDK_HOME" ]; then
echo "NDK not found at $NDK_HOME, installing ndk;$NDK_VERSION"
yes | "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --sdk_root="$ANDROID_HOME" "ndk;$NDK_VERSION"
fi
echo "Using NDK at $NDK_HOME"
ls "$NDK_HOME"
- name: Initialize Android project
- name: Cargo check (aarch64-linux-android)
run: |
TC="$NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin"
export CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER="$TC/aarch64-linux-android24-clang"
export CC_aarch64_linux_android="$TC/aarch64-linux-android24-clang"
export AR_aarch64_linux_android="$TC/llvm-ar"
cd src-tauri
echo "" | bunx tauri android init
cd ..
- name: Build Android APK
id: build
run: |
mkdir -p artifacts
bun run tauri android build --apk true --target aarch64
# Find the generated APK file
ARTIFACT=$(find src-tauri/gen/android/app/build/outputs/apk -name "*.apk" -type f -print -quit)
echo "artifact=${ARTIFACT}" >> $GITHUB_OUTPUT
echo "Found artifact: ${ARTIFACT}"
- name: Upload build artifact
uses: actions/upload-artifact@v3
with:
name: jellytau-apk
path: ${{ steps.build.outputs.artifact }}
retention-days: 30
if-no-files-found: error
cargo check --target aarch64-linux-android --lib
+85 -11
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:
@@ -121,6 +127,62 @@ jobs:
path: dist/linux/
retention-days: 30
build-windows:
name: Build Windows
runs-on: linux/amd64
needs: test
# Cross-compiled from Linux via the official Tauri path (MSVC + cargo-xwin),
# baked into the builder image. No toolchain installs here — the image has
# cargo-xwin, clang/clang-cl, lld, llvm, nsis and the msvc target.
container:
image: gitea.tourolle.paris/dtourolle/jellytau-builder:latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Cache Rust dependencies
uses: actions/cache@v3
with:
path: |
~/.cargo/registry
~/.cargo/git
~/.cache/cargo-xwin
src-tauri/target
key: ${{ runner.os }}-cargo-windows-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-windows-
- name: Cache Node dependencies
uses: actions/cache@v3
with:
path: |
~/.bun/install/cache
node_modules
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}
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: ./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
- name: List Windows artifacts
run: ls -lah dist/windows/
- name: Upload Windows build artifact
uses: actions/upload-artifact@v3
with:
name: jellytau-windows
path: dist/windows/
retention-days: 30
build-android:
name: Build Android
runs-on: linux/amd64
@@ -159,20 +221,23 @@ 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: |
REF="${GITHUB_REF#refs/tags/v}"
VERSION="${REF#refs/heads/}"
# On non-tag runs keep whatever is in tauri.conf.json
if echo "$GITHUB_REF" | grep -q '^refs/tags/v'; then
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: ./scripts/set-version.sh "${GITHUB_REF#refs/tags/}"
- name: Sync custom Android sources & gradle config
run: ./scripts/sync-android-sources.sh
@@ -210,7 +275,7 @@ jobs:
create-release:
name: Create Release
runs-on: linux/amd64
needs: [build-linux, build-android]
needs: [build-linux, build-windows, build-android]
if: startsWith(github.ref, 'refs/tags/v')
container:
image: gitea.tourolle.paris/dtourolle/jellytau-builder:latest
@@ -230,6 +295,12 @@ jobs:
name: jellytau-linux
path: artifacts/linux/
- name: Download Windows artifacts
uses: actions/download-artifact@v3
with:
name: jellytau-windows
path: artifacts/windows/
- name: Download Android artifacts
uses: actions/download-artifact@v3
with:
@@ -248,6 +319,9 @@ jobs:
echo "- **AppImage** - Run directly on most Linux distributions" >> release_notes.md
echo "- **DEB** - Install via \`sudo dpkg -i jellytau_*.deb\` (Ubuntu/Debian)" >> release_notes.md
echo "" >> release_notes.md
echo "#### Windows" >> release_notes.md
echo "- **Installer (.exe)** - Run \`jellytau_*-setup.exe\` (NSIS). Unsigned — SmartScreen may warn on first run." >> release_notes.md
echo "" >> release_notes.md
echo "#### Android" >> release_notes.md
echo "- **APK** - Install via \`adb install jellytau-release.apk\` or sideload via file manager" >> release_notes.md
echo "- **AAB** - Upload to Google Play Console or testing platforms" >> release_notes.md
@@ -329,7 +403,7 @@ jobs:
fi
echo "Release id=$RELEASE_ID"
for f in artifacts/android/* artifacts/linux/*; do
for f in artifacts/android/* artifacts/linux/* artifacts/windows/*; do
[ -f "$f" ] || continue
echo "⬆️ Uploading $(basename "$f")"
curl -fsS -X POST \
+124
View File
@@ -0,0 +1,124 @@
name: Publish Documentation
# Renders the markdown docs (docs/*.md) into an mdBook site, builds the Rust
# API reference with cargo doc, and force-pushes the combined output to the
# orphan `gitea-pages` branch that the Gitea Pages server serves.
#
# The published matrix is regenerated during the build, so it is never stale.
on:
push:
branches:
- master
concurrency:
# Only one docs publish at a time; a newer push supersedes an in-flight run.
group: publish-docs
cancel-in-progress: true
jobs:
publish-docs:
name: Build & publish docs to gitea-pages
runs-on: linux/amd64
container:
image: gitea.tourolle.paris/dtourolle/jellytau-builder:latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
# bun is baked into jellytau-builder (see Dockerfile.builder); no setup-bun
# action needed — fetching it stalls on this Gitea runner.
- name: Install dependencies
run: bun install
- name: Install mdBook
run: |
set -e
MDBOOK_VERSION=v0.4.40
URL="https://github.com/rust-lang/mdBook/releases/download/${MDBOOK_VERSION}/mdbook-${MDBOOK_VERSION}-x86_64-unknown-linux-gnu.tar.gz"
echo "⬇️ Downloading mdBook ${MDBOOK_VERSION}"
curl -fsSL "$URL" | tar -xz -C /usr/local/bin
mdbook --version
- name: Regenerate traceability matrix (keep published copy current)
run: bun run traces:markdown
- name: Assemble mdBook sources
run: |
set -e
# mdBook's src is docs/. Drop in the SUMMARY and the generated
# intro + API redirect pages (build artifacts, not committed).
cp docs-site/SUMMARY.md docs/SUMMARY.md
cat > docs/README.md <<'EOF'
# JellyTau Documentation
Cross-platform Jellyfin client — business logic in a Rust backend,
SvelteKit + TypeScript frontend, talking over Tauri v2 IPC.
- **[Requirements Specification](requirements.md)** — user, integration, and development requirements.
- **[Traceability Matrix](traceability.md)** — generated map from requirements to code (regenerated on every publish).
- **[Architecture](architecture/README.md)** — backend, frontend, data flow, platform backends.
- **[Rust API Reference](api/index.html)** — rustdoc for the `src-tauri` backend.
_This site is published automatically from `master` by the `publish-docs` CI job._
EOF
cat > docs/api-redirect.md <<'EOF'
# Rust API Reference
The full backend API reference is generated by `cargo doc` (rustdoc).
👉 **[Open the Rust API Reference](api/index.html)**
EOF
- name: Build mdBook site
run: mdbook build docs-site --dest-dir "$GITHUB_WORKSPACE/site"
- name: Build Rust API docs (cargo doc)
working-directory: src-tauri
# --no-deps keeps it to our own crate (fast, focused); document private
# items so internal modules/commands appear.
run: |
cargo doc --no-deps --document-private-items
# The backend modules/commands live in the LIB crate (jellytau_lib);
# the bin crate (jellytau) is a near-empty shim. Land on the lib.
echo '<meta http-equiv="refresh" content="0; url=jellytau_lib/index.html">' \
> target/doc/index.html
- name: Assemble published output
run: |
set -e
mkdir -p "$GITHUB_WORKSPACE/site/api"
cp -r src-tauri/target/doc/. "$GITHUB_WORKSPACE/site/api/"
# Disable Jekyll processing on the pages branch.
touch "$GITHUB_WORKSPACE/site/.nojekyll"
ls -la "$GITHUB_WORKSPACE/site"
- name: Push to gitea-pages branch
env:
# PAT preferred; falls back to the auto-provided token (same pattern
# as build-release.yml).
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
AUTO_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -e
TOKEN="${GITEA_TOKEN:-$AUTO_TOKEN}"
REPO="${GITHUB_REPOSITORY}"
HOST="$(echo "$GITHUB_SERVER_URL" | sed -E 's#^https?://##')"
REMOTE="https://oauth2:${TOKEN}@${HOST}/${REPO}.git"
cd "$GITHUB_WORKSPACE/site"
git init -q
git config user.name "gitea-actions"
git config user.email "actions@gitea.tourolle.paris"
git checkout -q -b gitea-pages
git add -A
# POSIX sh has no ${VAR::N} substring expansion — cut instead.
SHORT_SHA="$(printf '%s' "$GITHUB_SHA" | cut -c1-8)"
git commit -q -m "docs: publish site from ${SHORT_SHA}"
echo "🚀 Force-pushing to gitea-pages"
git push -f "$REMOTE" gitea-pages
+32 -18
View File
@@ -25,9 +25,8 @@ jobs:
with:
fetch-depth: 0
- name: Setup Bun
uses: oven-sh/setup-bun@v1
# bun is baked into jellytau-builder (see Dockerfile.builder); no setup-bun
# action needed — fetching it stalls on this Gitea runner.
- name: Install dependencies
run: bun install
@@ -43,30 +42,45 @@ jobs:
echo "📊 Validating requirement traceability..."
echo ""
# Parse JSON
# Denominators come from docs/requirements.md at run time — NEVER
# hardcode them here. This step previously divided by frozen literals
# (UR/39, IR/24, DR/48, JA/3, total 114) while the file had grown to
# 211 requirements, so it reported 158% coverage and the threshold
# below could never trip. See docs/specs/traceability-gate-repair.md.
TOTAL_TRACES=$(jq '.totalTraces' traces-report.json)
UR=$(jq '.byType.UR | length' traces-report.json)
IR=$(jq '.byType.IR | length' traces-report.json)
DR=$(jq '.byType.DR | length' traces-report.json)
JA=$(jq '.byType.JA | length' traces-report.json)
COVERED=$(jq '.coverage.covered' traces-report.json)
TOTAL_REQS=$(jq '.coverage.total' traces-report.json)
COVERAGE=$(jq '.coverage.percent' traces-report.json)
# Print coverage report
echo "✅ TRACES Found: $TOTAL_TRACES"
echo ""
echo "📋 Coverage Summary:"
echo " User Requirements (UR): $UR / 39 ($(( UR * 100 / 39 ))%)"
echo " Integration Requirements (IR): $IR / 24 ($(( IR * 100 / 24 ))%)"
echo " Development Requirements (DR): $DR / 48 ($(( DR * 100 / 48 ))%)"
echo " Jellyfin API Requirements (JA): $JA / 3 ($(( JA * 100 / 3 ))%)"
echo "📋 Coverage Summary (traced / defined):"
for T in UR IR DR JA; do
TRACED=$(jq --arg t "$T" '[.byType[$t][] | select(. != null)] | length' traces-report.json)
DEFINED=$(jq --arg t "$T" '.defined[$t]' traces-report.json)
echo " $T: $TRACED / $DEFINED"
done
echo ""
COVERED=$((UR + IR + DR + JA))
TOTAL_REQS=114
COVERAGE=$((COVERED * 100 / TOTAL_REQS))
echo "📈 Overall Coverage: $COVERED / $TOTAL_REQS ($COVERAGE%)"
echo ""
# Traced IDs that requirements.md does not define (typo, or a deleted
# requirement). These do not count toward coverage.
ORPHANED=$(jq -c '.coverage.orphaned' traces-report.json)
if [ "$ORPHANED" != "[]" ]; then
echo "⚠️ Traced but not defined in requirements.md: $ORPHANED"
echo ""
fi
# A ratio above 100% means the computation is broken — the exact
# condition that hid the stale-denominator bug. Fail loudly.
if [ "$COVERAGE" -gt 100 ]; then
echo "❌ ERROR: Coverage ($COVERAGE%) exceeds 100% — the gate is miscomputing."
echo " Orphaned IDs: $ORPHANED"
exit 1
fi
# Check minimum threshold
MIN_THRESHOLD=50
if [ "$COVERAGE" -lt "$MIN_THRESHOLD" ]; then
-175
View File
@@ -1,175 +0,0 @@
name: Requirement Traceability Check
on:
push:
branches:
- master
- main
- develop
pull_request:
branches:
- master
- main
- develop
jobs:
traceability:
name: Validate Requirement Traces
runs-on: linux/amd64
container:
image: gitea.tourolle.paris/dtourolle/jellytau-builder:latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v1
with:
bun-version: latest
- name: Install dependencies
run: bun install
- name: Extract requirement traces
run: bun run traces:json > traces.json
- name: Validate trace format
run: |
if ! jq empty traces.json 2>/dev/null; then
echo "❌ Invalid traces.json format"
exit 1
fi
echo "✅ Traces JSON is valid"
- name: Check requirement coverage
run: |
set -e
# Extract coverage stats
TOTAL_TRACES=$(jq '.totalTraces' traces.json)
UR_COUNT=$(jq '.byType.UR | length' traces.json)
IR_COUNT=$(jq '.byType.IR | length' traces.json)
DR_COUNT=$(jq '.byType.DR | length' traces.json)
JA_COUNT=$(jq '.byType.JA | length' traces.json)
echo "## 📊 Requirement Traceability Report"
echo ""
echo "**Total TRACES Found:** $TOTAL_TRACES"
echo ""
echo "### Requirements Covered:"
echo "- User Requirements (UR): $UR_COUNT / 39 ($(( UR_COUNT * 100 / 39 ))%)"
echo "- Integration Requirements (IR): $IR_COUNT / 24 ($(( IR_COUNT * 100 / 24 ))%)"
echo "- Development Requirements (DR): $DR_COUNT / 48 ($(( DR_COUNT * 100 / 48 ))%)"
echo "- Jellyfin API Requirements (JA): $JA_COUNT / 3 ($(( JA_COUNT * 100 / 3 ))%)"
echo ""
# Set minimum coverage threshold (50%)
TOTAL_REQS=114
MIN_COVERAGE=$((TOTAL_REQS / 2))
COVERED=$((UR_COUNT + IR_COUNT + DR_COUNT + JA_COUNT))
COVERAGE_PERCENT=$((COVERED * 100 / TOTAL_REQS))
echo "**Overall Coverage:** $COVERED / $TOTAL_REQS ($COVERAGE_PERCENT%)"
echo ""
if [ "$COVERED" -lt "$MIN_COVERAGE" ]; then
echo "❌ Coverage below minimum threshold ($COVERAGE_PERCENT% < 50%)"
exit 1
else
echo "✅ Coverage meets minimum threshold ($COVERAGE_PERCENT% >= 50%)"
fi
- name: Check for new untraced code
run: |
set -e
# Find files modified in this PR/push
if [ "${{ github.event_name }}" = "pull_request" ]; then
CHANGED_FILES=$(git diff --name-only origin/${{ github.base_ref }}...HEAD | grep -E '\.(ts|tsx|svelte|rs)$' || true)
else
CHANGED_FILES=$(git diff --name-only HEAD~1 | grep -E '\.(ts|tsx|svelte|rs)$' || true)
fi
if [ -z "$CHANGED_FILES" ]; then
echo "✅ No source files changed"
exit 0
fi
echo "### Files Changed:"
echo "$CHANGED_FILES" | sed 's/^/- /'
echo ""
# Check if changed files have TRACES
UNTRACED_FILES=""
while IFS= read -r file; do
if [ -f "$file" ]; then
# Skip test files and generated code
if [[ "$file" == *".test."* ]] || [[ "$file" == *"node_modules"* ]]; then
continue
fi
# Check if file has TRACES comments
if ! grep -q "TRACES:" "$file" 2>/dev/null; then
UNTRACED_FILES+="$file"$'\n'
fi
fi
done <<< "$CHANGED_FILES"
if [ -n "$UNTRACED_FILES" ]; then
echo "⚠️ New files without TRACES:"
echo "$UNTRACED_FILES" | sed 's/^/ - /'
echo ""
echo "💡 Add TRACES comments to link code to requirements:"
echo " // TRACES: UR-001, UR-002 | DR-003"
else
echo "✅ All changed files have TRACES comments"
fi
- name: Generate traceability report
if: always()
run: bun run traces:markdown
- name: Upload traceability report
if: always()
uses: actions/upload-artifact@v3
with:
name: traceability-report
path: docs/traceability.md
retention-days: 30
- name: Comment PR with coverage report
if: github.event_name == 'pull_request'
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const traces = JSON.parse(fs.readFileSync('traces.json', 'utf8'));
const urCount = traces.byType.UR.length;
const irCount = traces.byType.IR.length;
const drCount = traces.byType.DR.length;
const jaCount = traces.byType.JA.length;
const total = urCount + irCount + drCount + jaCount;
const coverage = Math.round((total / 114) * 100);
const comment = `## 📊 Requirement Traceability Report
**Coverage:** ${coverage}% (${total}/114 requirements traced)
### By Type:
- **User Requirements (UR):** ${urCount}/39 (${Math.round(urCount/39*100)}%)
- **Integration Requirements (IR):** ${irCount}/24 (${Math.round(irCount/24*100)}%)
- **Development Requirements (DR):** ${drCount}/48 (${Math.round(drCount/48*100)}%)
- **Jellyfin API (JA):** ${jaCount}/3 (${Math.round(jaCount/3*100)}%)
**Total Traces:** ${traces.totalTraces}
[View full report](artifacts) | [Format Guide](https://github.com/yourusername/jellytau/blob/master/scripts/README.md#extract-tracests)`;
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: comment
});
+12
View File
@@ -58,3 +58,15 @@ android-keystore/
# Local machine-specific Android NDK toolchain paths (do not commit)
src-tauri/.cargo/config.toml
# Docs site build artifacts (generated by the publish-docs CI job into docs/)
/docs/SUMMARY.md
/docs/README.md
/docs/api-redirect.md
/docs-site/book/
# Arch packaging build artifacts (vendored cargo cache, makepkg workdir, output package)
/.cargo-arch/
/packaging/arch/pkg/
/packaging/arch/src/
/packaging/arch/*.pkg.tar.zst
+1069
View File
File diff suppressed because it is too large Load Diff
+313
View File
@@ -0,0 +1,313 @@
# JellyTau
A cross-platform Jellyfin client. Business logic lives in a Rust backend
(`src-tauri/`); a SvelteKit + TypeScript frontend (`src/`) handles presentation
and talks to it over Tauri v2 IPC. Targets **Linux** (libmpv, WebKitGTK HTML5
`<video>` for transcoded playback) and **Android** (ExoPlayer).
Package manager is **bun**.
## Build / Run / Test
All routine tasks go through `package.json` scripts and helper scripts in
`scripts/`:
```bash
bun install # install deps
bun run dev # vite dev server (frontend)
bun run tauri dev # run the desktop app
bun run check # svelte-check (types)
bun run test # vitest (frontend unit/integration)
bun run test:rust # cargo test (scripts/test-rust.sh)
bun run test:all # full suite (scripts/test-all.sh)
bun run test:e2e # webdriverio e2e
# Android — canonical entry points (see scripts/):
bun run android:build # debug APK
bun run android:build:release # release APK
bun run android:deploy # install to connected device
bun run android:dev # build + deploy
bun run android:logs # logcat
```
The **debug** build type carries `applicationIdSuffix ".debug"`, so
`com.dtourolle.jellytau.debug` ("JellyTau Debug") installs *alongside* a release
build with its own data dir — never uninstall the release app to test a debug
one. `./scripts/build-and-deploy.sh release --device --debug` puts an
R8-minified *release* build in that same slot, signed with the local debug
keystore, for validating minification without the real key. Only the
applicationId is suffixed; Kotlin classes stay in the `namespace` package
`com.dtourolle.jellytau`, so JNI lookups and R8 keep rules are unaffected. See
[README_ANDROID_BUILD.md](src-tauri/android/README_ANDROID_BUILD.md).
CI runs on **Gitea Actions** (`.gitea/workflows/`), not GitHub. Use the `gh` CLI
only against the mirror if one exists; the canonical remote is
`gitea.tourolle.paris`.
> **🔴 CI installs no system tools.** Never add an `apt-get`, `rustup`,
> `sdkmanager`, mingw/nsis, or any other *toolchain/system-package* install to a
> CI workflow step. Every build, test, and packaging **tool** must already live
> in the Docker image the job runs in — the unified builder (`Dockerfile.builder`
> → `gitea.tourolle.paris/dtourolle/jellytau-builder`) for Android/Linux/Windows,
> or `Dockerfile.arch` for Arch. If a job needs a tool the image lacks, **add it
> to the image, rebuild + push it** (`scripts/build-builder-image.sh`), and use
> it from CI — do not install it at job time. This keeps builds reproducible and
> fast, and is why the packaging stages are thin `FROM ${BUILDER_IMAGE}` layers.
>
> `bun install` (fetching the project's own JS deps per the lockfile) is **not**
> a violation — that's project dependencies, not a toolchain. The rule is about
> system tools, not npm/bun/cargo *packages* declared by the project.
## Before Committing
- Frontend: `bun run check` and `bun run test` must pass.
- Rust: `cd src-tauri && cargo fmt` then `cargo clippy`, plus `bun run test:rust`.
- **Boundary**: `bun run check:boundary` must pass — no domain taxonomy (Jellyfin
item-type category sets) leaked into the frontend. See below.
- **Traceability**: new requirement-implementing code must carry a `// TRACES:`
comment (see below).
- **Android source edits**: edit `src-tauri/android/src` (the canonical tree),
then run `scripts/sync-android-sources.sh` to sync into the `gen/` tree.
Never edit the generated `gen/` sources directly.
## Traceability (TRACES)
This project practices requirement-driven development: code that implements a
requirement is tagged with a `TRACES:` comment linking it to requirement IDs, and
an extraction tool builds the traceability matrix. **When you add or change code
that implements a requirement, add/update its TRACES comment.** Internal helpers
and requirement-less code stay untraced.
Format — `// TRACES: <URs> | <DRs> | <tests>`, e.g.:
```rust
/// TRACES: UR-005 | DR-001
pub enum PlayerState { }
```
```typescript
// TRACES: UR-005, UR-026 | DR-029
export function autoplayNextEpisode() { }
```
ID types: **UR** user requirement, **IR** integration, **DR** development, **JA**
Jellyfin API, **UT** unit test, **IT** integration test. Requirements are defined
in [docs/requirements.md](docs/requirements.md); the generated matrix is
[docs/traceability.md](docs/traceability.md).
Tooling:
```bash
bun run traces # extract traces (default format)
bun run traces:json # JSON — e.g. | jq '.byType' or '.requirements."UR-005"'
bun run traces:markdown # regenerate docs/traceability.md
git diff --name-only | xargs grep -L "TRACES:" # find untraced changed files
```
**CI is Gitea Actions** (`.gitea/workflows/`, remote `gitea.tourolle.paris`), not
GitHub. `traceability-check.yml` fails the build if coverage drops below
**50%** (`MIN_THRESHOLD`); `build-and-test.yml` runs frontend + Rust tests and an
Android `cargo check`. See [docs/traceability-ci.md](docs/traceability-ci.md) and
[docs/traces-quick-ref.md](docs/traces-quick-ref.md).
### Traces drive release notes
Prefer traceability over raw commit subjects when writing release notes for
[docs/release-checklist.md](docs/release-checklist.md). Raw `git log` subjects are
noisy; the TRACES graph gives a semantic summary of *what capabilities* the
release touched.
```bash
bun run release:notes # <latest tag>..HEAD
bun run release:notes v0.0.15..HEAD # explicit range
```
[scripts/release-notes.ts](scripts/release-notes.ts) resolves a commit range's
changed files → their `TRACES:` IDs → descriptions in
[docs/requirements.md](docs/requirements.md), then groups **UR** into *Features*
and **DR/IR** into *Improvements* (deduped, so many commits touching one
requirement collapse to one line). It also lists changed files that carry no
TRACES so nothing is silently dropped — those still need a manual line. Treat the
output as a reviewed draft, not a final changelog.
## Architecture
- **Rust backend** (`src-tauri/src/`) — all business logic: auth, catalog,
sessions, downloads, offline cache, playback control. Commands grouped by
domain in `src-tauri/src/commands/` (`auth.rs`, `catalog.rs`, `player/`,
`download/`, `offline.rs`, `sessions.rs`, …).
- **Svelte frontend** (`src/`) — presentation only. Stores in
`src/lib/stores/`, API wrappers in `src/lib/api/`, components in
`src/lib/components/`.
- **Playback layers** — Linux uses libmpv for direct playback and a WebKitGTK
HTML5 `<video>` element for HLS-transcoded (h264) streams; Android uses
ExoPlayer with a foreground media service + `MediaSessionCompat`.
- **tauri-specta** generates TypeScript bindings and typed events from the Rust
command/event definitions (registered via the Builder in `src-tauri/src/lib.rs`).
**Read the architecture docs before making structural changes** — they are the
canonical, maintained source; this file only summarizes. See
[docs/architecture/README.md](docs/architecture/README.md) and:
| Doc | Contents |
|-----|----------|
| [01-rust-backend.md](docs/architecture/01-rust-backend.md) | Player/session state machines, playback mode, queue, commands |
| [02-svelte-frontend.md](docs/architecture/02-svelte-frontend.md) | Stores, repository architecture, MiniPlayer, autoplay, nav guard |
| [03-data-flow.md](docs/architecture/03-data-flow.md) | Cache-first query flow, playback initiation, mode transfer |
| [04-type-sync-and-threading.md](docs/architecture/04-type-sync-and-threading.md) | **Rust↔TS type sync, the IPC camelCase convention + param table, locking** |
| [05-platform-backends.md](docs/architecture/05-platform-backends.md) | MpvBackend (Linux), ExoPlayerBackend (Android), MediaSession, HTML5 adapter |
| [06-downloads-and-offline.md](docs/architecture/06-downloads-and-offline.md) | Download manager/worker, smart cache, offline commands |
| [07-connectivity.md](docs/architecture/07-connectivity.md) | HTTP retry, ConnectivityMonitor, reachability model |
| [08-database-design.md](docs/architecture/08-database-design.md) | Tables, relationships, key queries |
| [09-security.md](docs/architecture/09-security.md) | Token storage, secure storage, network security |
Release process lives in [docs/release-checklist.md](docs/release-checklist.md)
and [docs/build-release.md](docs/build-release.md).
### Core principles (from the architecture docs)
- **Playback state is one-directional.** The player (ExoPlayer on Android, MPV on
Linux, session poller in remote mode) is the **authoritative source** of state
— position, pause, seeking, rate, track changes. The Svelte UI, OS
`MediaSession`/lockscreen, and MPRIS are **consumers**; they reflect what the
player reports and never determine it.
- **Unified player boundary.** UI controls playback *only* through the frontend
facade `src/lib/player/index.ts` (`playerController`) — never by calling
`commands.player*` directly. Webview HTML5 `<video>` reports its state back
into Rust via `src/lib/player/html5Adapter.ts` and the `player_report_*`
commands, so the controller stays the single source of truth in both native
and HTML5 modes.
- **Reachability from real traffic.** Server online/offline is derived from the
outcome of actual repository requests (reported to `ConnectivityMonitor`), not
a side-channel poller. The `/System/Info/Public` probe runs *only while
offline*, as a recovery detector.
- **Poison-tolerant locking.** Access shared `std::sync` state via the
`MutexSafe`/`RwLockSafe` helpers in `utils/lock.rs`, which recover a poisoned
lock instead of cascading a panic across the player.
- **Graceful backend init.** If a native player backend fails to initialize, the
app falls back to a no-op backend and emits `backend-init-failed` rather than
crashing.
- **Domain vocabulary lives in Rust.** The frontend is presentation-only and must
not encode Jellyfin's *taxonomy* — e.g. the set of item types that defines a
category like "Music". Send an opaque scope/enum across the boundary and let the
backend expand it. Single-type presentation (`itemType: "Movie"`, "this page
shows albums") is fine; a *category → set of types* mapping in `src/` is a leak.
`bun run check:boundary` is the tripwire; the real gate is the spec's layer
assignment. The canonical example lives in Rust:
`SearchScope::item_types()` in `repository/types.rs` expands an opaque scope the
frontend sends. See [scoped-search-boundary.md](docs/specs/scoped-search-boundary.md)
for the incident this rule came from — note the tripwire missed that leak for
months because the mapping was assigned to a named const rather than written
inline at the query, so **a green `check:boundary` is not proof**; it flags
item-type array literals only, not run-time-built sets or `switch`/`||`
taxonomy.
## Writing specs
New feature specs go in [docs/specs/](docs/specs/). **Start from
[SPEC-TEMPLATE.md](docs/specs/SPEC-TEMPLATE.md)** — its "Layer assignment" section
forces each piece of *logic* to be placed in the correct layer (Rust = domain,
frontend = presentation) *with a reason*, which is what prevents boundary leaks.
Before accepting a spec, run it past
[SPEC-REVIEW-CHECKLIST.md](docs/specs/SPEC-REVIEW-CHECKLIST.md). Do **not** frame
a spec around "no Rust changes required" — correct layer placement is the goal,
not minimal backend churn.
## Conventions
### Rust Backend
- Use `#[tauri::command]` for all IPC handlers.
- Prefer `async` commands for I/O-bound work.
- Return `Result<T, String>` from commands (the established convention here).
- Use `tauri::State<>` for shared state.
- Group related commands in domain modules under `commands/`.
- Use official Tauri plugins before writing custom native code.
### Frontend
- Use `invoke<T>()` from `@tauri-apps/api/core`, or the tauri-specta bindings.
- Define TS types matching the Rust structs; prefer the generated bindings.
- Handle IPC errors with try/catch.
- Use `@tauri-apps/api/path` for paths (never hardcode).
- Use `@tauri-apps/api/event` for backend→frontend events.
### 🔴 IPC parameter naming (Tauri v2)
The command **name** must match the Rust function name exactly
(`invoke("player_play_queue", …)`). But **parameter names do NOT** — Tauri v2's
`#[tauri::command]` macro auto-converts snake_case Rust params to **camelCase**
on the frontend:
```rust
#[tauri::command]
pub async fn cmd(repository_handle: String) { }
```
```typescript
await invoke("cmd", { repositoryHandle: "…" }); // camelCase, auto-converted
```
Nested struct fields need `#[serde(rename_all = "camelCase")]`; tagged unions use
`#[serde(tag = "type")]` and both sides must match the tag. Note: tauri-specta
tagged responses keep the Rust field names as-is (e.g. `new_url`, not `newUrl`).
### Events
- Backend events use **kebab-case** names (`download-event`, `search-event`).
- Emit from Rust via `emit(...)`; consume on the frontend via
`@tauri-apps/api/event` or the tauri-specta typed event bindings.
### Security
- Declare minimum permissions in `src-tauri/capabilities/`.
- Keep the CSP restrictive in `tauri.conf.json`.
- Validate all inputs in Rust command handlers.
- **Never read credentials** (tokens/keys from keyring, env, or stores) without
asking the user first.
## Gotchas (hard-won)
- **Never call sync/blocking APIs from event callbacks** that can re-enter the
player or hold a lock — it deadlocks. On Android, bind a locked
`AutoplayDecision` to a `let` *before* matching; a tokio `MutexGuard` held in
the `match` scrutinee deadlocks the `AdvanceToNext` arm.
- **VideoPlayer native mode**: no lifecycle calls after an `await` in `onMount`
(it flips to HTML5 mode and breaks Android seek).
- **Transcoded resume/seek**: `get_video_stream_url` must return the HLS
`master.m3u8`, not `stream.mp4`, or transcoded playback never starts.
- **Downloads** cap at 3 concurrent; the backend pump auto-starts pending rows.
Don't loop `startDownload` from the frontend.
- **Parallel Claude sessions**: the user may run concurrent sessions. Unexpected
file changes may be another session — check `git diff` before "repairing".
## Testing
### 🔴 Bug fixes: failing test FIRST, then the fix
When fixing a bug, **write a test that reproduces it and watch it fail before
touching the fix.** Red → green, in that order:
1. Write a test that exercises the broken behavior and **run it — it must fail**,
proving the test actually catches the bug (a test that passes before the fix
proves nothing).
2. Apply the fix.
3. Re-run — the test now passes, and so does the rest of the suite.
Never fix first and backfill the test afterward: a test written against
already-fixed code can pass for the wrong reason and silently fails to guard the
regression. If the logic is buried in a component, extract the pure part into a
plain `.ts` module (e.g. `episodeStrip.ts`) so it can be unit-tested — the same
pattern as `TrackList.logic.test.ts`.
```bash
# Rust
cd src-tauri && cargo test
cd src-tauri && cargo test test_name # single test
# Frontend
bun run test
bun run test:coverage
# Tauri IPC param-naming integration tests (guard the camelCase rule):
bun run test -- tauriIntegration.test.ts
```
+35
View File
@@ -1,4 +1,11 @@
# Multi-stage build for JellyTau - Tauri Jellyfin client
#
# The desktop packaging stages (desktop-linux-build, windows-cross) build FROM
# the unified registry builder image, which carries every packaging tool. Declared
# here (before the first FROM) so it's in scope for those stages' FROM lines.
# Override for local iteration: --build-arg BUILDER_IMAGE=jellytau-builder:latest
ARG BUILDER_IMAGE=gitea.tourolle.paris/dtourolle/jellytau-builder:latest
FROM ubuntu:24.04 AS builder
ENV DEBIAN_FRONTEND=noninteractive \
@@ -108,6 +115,34 @@ RUN cd src-tauri && cargo fetch && cd .. && \
bun run tauri android build --apk true && \
echo "APK build complete!"
# Desktop packaging stages build FROM the unified registry builder image (see the
# BUILDER_IMAGE ARG at the top), which already carries every packaging tool
# (rpm/file for Linux, cargo-xwin + nsis + the x86_64-pc-windows-msvc rust
# target for Windows). ONE source of dependency truth, shared with CI — no
# per-stage apt/rustup here.
#
# NOTE: Windows uses the MSVC target via cargo-xwin, NOT mingw/GNU — the GNU
# toolchain cannot bundle an NSIS installer from Linux. See
# scripts/build-windows-cross.sh.
# Linux desktop packaging environment (deb + rpm; Arch is Dockerfile.arch).
# Thin layer over the builder — the actual build runs at container-run time on
# the bind-mounted source (see docker-compose.yml / scripts/build-desktop-linux.sh),
# matching the `dev` service model. Run standalone with:
# docker run --rm -v "$PWD:/app" -v "$PWD/dist:/app/dist" <img> \
# bash -c "OUTPUT_DIR=/app/dist scripts/build-desktop-linux.sh"
FROM ${BUILDER_IMAGE} AS desktop-linux-build
WORKDIR /app
CMD ["bash", "-c", "OUTPUT_DIR=/app/dist scripts/build-desktop-linux.sh"]
# Windows cross-compile environment (MSVC target via cargo-xwin). Video works via
# WebView2 and audio via the webview <audio> backend; NSIS installer is produced
# from Linux by cargo-xwin. Default bundles NSIS; override WIN_BUNDLES=none for
# exe-only. Build runs at container-run time like above.
FROM ${BUILDER_IMAGE} AS windows-cross
WORKDIR /app
CMD ["bash", "-c", "OUTPUT_DIR=/app/dist WIN_BUNDLES=${WIN_BUNDLES:-nsis} scripts/build-windows-cross.sh"]
# Final output stage
FROM ubuntu:24.04 AS final
RUN apt-get update && apt-get install -y --no-install-recommends \
+37
View File
@@ -0,0 +1,37 @@
# JellyTau Arch Linux package builder.
#
# Tauri has no pacman bundle target, so we build a real .pkg.tar.zst with makepkg
# from packaging/arch/PKGBUILD. makepkg refuses to run as root, so we create a
# non-root `builder` user with passwordless sudo (for `makepkg -s` pacman calls).
#
# docker build -f Dockerfile.arch -t jellytau-arch .
# docker run --rm -v "$PWD/dist:/out" jellytau-arch
FROM archlinux:latest
RUN pacman -Syu --noconfirm \
base-devel git sudo \
rust cargo nodejs \
webkit2gtk-4.1 mpv gtk3 libayatana-appindicator \
libsoup3 pkgconf openssl \
&& pacman -Scc --noconfirm
# Bun is not in the official repos; install the upstream binary.
RUN curl -fsSL https://bun.sh/install | bash && \
ln -s /root/.bun/bin/bun /usr/local/bin/bun
# Non-root build user with passwordless sudo for makepkg's dependency step.
RUN useradd -m builder && \
echo 'builder ALL=(ALL) NOPASSWD: ALL' > /etc/sudoers.d/builder && \
ln -sf /root/.bun/bin/bun /usr/local/bin/bun
WORKDIR /app
COPY . .
RUN chown -R builder:builder /app
USER builder
ENV OUTPUT_DIR=/out
RUN mkdir -p /out
VOLUME ["/out"]
# Default: build the package. Output lands in /out (mount it to collect the pkg).
CMD ["bash", "-c", "OUTPUT_DIR=/out scripts/build-arch.sh"]
+49 -1
View File
@@ -1,5 +1,9 @@
# JellyTau Builder Image
# Pre-built image with all dependencies for building and testing
# Pre-built image with all dependencies for building, testing, and packaging:
# - Android APK (SDK/NDK), Linux desktop (deb/rpm),
# - Windows cross via the official Tauri path: MSVC target + cargo-xwin + NSIS
# Arch packages build in a separate archlinux image (Dockerfile.arch) since
# makepkg is Arch-specific.
# Push to your registry: docker build -f Dockerfile.builder -t gitea.tourolle.paris/dtourolle/jellytau-builder:latest .
FROM ubuntu:24.04
@@ -83,6 +87,50 @@ RUN $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --sdk_root=$ANDROID_HOME \
# Set NDK environment variable
ENV NDK_HOME=$ANDROID_HOME/ndk/$NDK_VERSION
# Gradle distribution. `tauri android init` regenerates gen/android with a
# wrapper pointing at services.gradle.org, so every Android job would otherwise
# download ~130MB of Gradle at build time — slow, and a hard failure when the
# CDN hiccups ("Unexpected end of file from server"). Ship the distribution in
# the image instead; scripts/sync-android-sources.sh repoints the regenerated
# wrapper at this local copy. Keep GRADLE_VERSION in sync with the version
# Tauri's generated wrapper requests.
ENV GRADLE_VERSION=8.14.3 \
GRADLE_HOME=/opt/gradle/gradle-8.14.3
RUN mkdir -p /opt/gradle/dist && \
wget -q "https://services.gradle.org/distributions/gradle-${GRADLE_VERSION}-bin.zip" \
-O "/opt/gradle/dist/gradle-${GRADLE_VERSION}-bin.zip" && \
unzip -q "/opt/gradle/dist/gradle-${GRADLE_VERSION}-bin.zip" -d /opt/gradle && \
"$GRADLE_HOME/bin/gradle" --version
ENV PATH="$GRADLE_HOME/bin:$PATH"
# ---------------------------------------------------------------------------
# Desktop packaging tools — kept in a trailing layer ON PURPOSE so that adding
# or changing a packaging tool doesn't invalidate the expensive apt/rust/Android
# layers above (a tool tweak becomes a ~1-2 min rebuild, not ~15). Covers Linux
# (deb/rpm) and Windows cross (MSVC via cargo-xwin + NSIS).
RUN apt-get update && apt-get install -y --no-install-recommends \
# Linux desktop packaging: rpmbuild for the .rpm bundle (deb needs nothing extra)
rpm \
file \
# Windows cross-compile (official Tauri path: MSVC target via cargo-xwin).
# clang provides clang-cl, the MSVC-compatible C compiler cc-rs uses to build
# C deps (bundled sqlite, ring, ...); lld = linker; llvm = llvm-lib/ar etc;
# nsis = installer generator.
clang \
lld \
llvm \
nsis \
&& rm -rf /var/lib/apt/lists/* \
# Ubuntu's clang package ships clang but NOT the clang-cl alias that cc-rs
# invokes for MSVC targets. clang-cl is the same binary in MSVC-compat mode,
# so provide it as a symlink.
&& ln -sf /usr/bin/clang /usr/local/bin/clang-cl
# Windows rust target + cargo-xwin (downloads the MSVC CRT/SDK at build time).
RUN . $HOME/.cargo/env && \
rustup target add x86_64-pc-windows-msvc && \
cargo install --locked cargo-xwin
WORKDIR /app
ENTRYPOINT ["/bin/bash"]
+60 -19
View File
@@ -18,6 +18,7 @@
"@tailwindcss/vite": "^4.1.18",
"@tauri-apps/cli": "^2",
"@testing-library/svelte": "^5.3.1",
"@vitest/coverage-v8": "^4.0.18",
"@vitest/ui": "^4.0.16",
"@wdio/cli": "^9.5.0",
"@wdio/local-runner": "^9.5.0",
@@ -30,7 +31,7 @@
"tailwindcss": "^4.1.18",
"typescript": "~5.6.2",
"vite": "^6.0.3",
"vitest": "^4.0.16",
"vitest": ">=1.0.0 <5.0.0",
"webdriverio": "^9.5.0",
},
},
@@ -46,10 +47,18 @@
"@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="],
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="],
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="],
"@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="],
"@babel/runtime": ["@babel/runtime@7.28.4", "", {}, "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ=="],
"@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="],
"@bcoe/v8-coverage": ["@bcoe/v8-coverage@1.0.2", "", {}, "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA=="],
"@csstools/color-helpers": ["@csstools/color-helpers@5.1.0", "", {}, "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA=="],
"@csstools/css-calc": ["@csstools/css-calc@2.1.4", "", { "peerDependencies": { "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-tokenizer": "^3.0.4" } }, "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ=="],
@@ -348,6 +357,8 @@
"@types/yauzl": ["@types/yauzl@2.10.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q=="],
"@vitest/coverage-v8": ["@vitest/coverage-v8@4.1.10", "", { "dependencies": { "@bcoe/v8-coverage": "^1.0.2", "@vitest/utils": "4.1.10", "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", "istanbul-reports": "^3.2.0", "magicast": "^0.5.2", "obug": "^2.1.1", "std-env": "^4.0.0-rc.1", "tinyrainbow": "^3.1.0" }, "peerDependencies": { "@vitest/browser": "4.1.10", "vitest": "4.1.10" }, "optionalPeers": ["@vitest/browser"] }, "sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g=="],
"@vitest/expect": ["@vitest/expect@4.0.16", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.0.16", "@vitest/utils": "4.0.16", "chai": "^6.2.1", "tinyrainbow": "^3.0.3" } }, "sha512-eshqULT2It7McaJkQGLkPjPjNph+uevROGuIMJdG3V+0BSR2w9u6J9Lwu+E8cK5TETlfou8GRijhafIMhXsimA=="],
"@vitest/mocker": ["@vitest/mocker@4.0.16", "", { "dependencies": { "@vitest/spy": "4.0.16", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0-0" }, "optionalPeers": ["msw", "vite"] }, "sha512-yb6k4AZxJTB+q9ycAvsoxGn+j/po0UaPgajllBgt1PzoMAAmJGYFdDk0uCcRcxb3BrME34I6u8gHZTQlkqSZpg=="],
@@ -362,7 +373,7 @@
"@vitest/ui": ["@vitest/ui@4.0.16", "", { "dependencies": { "@vitest/utils": "4.0.16", "fflate": "^0.8.2", "flatted": "^3.3.3", "pathe": "^2.0.3", "sirv": "^3.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.0.3" }, "peerDependencies": { "vitest": "4.0.16" } }, "sha512-rkoPH+RqWopVxDnCBE/ysIdfQ2A7j1eDmW8tCxxrR9nnFBa9jKf86VgsSAzxBd1x+ny0GC4JgiD3SNfRHv3pOg=="],
"@vitest/utils": ["@vitest/utils@4.0.16", "", { "dependencies": { "@vitest/pretty-format": "4.0.16", "tinyrainbow": "^3.0.3" } }, "sha512-h8z9yYhV3e1LEfaQ3zdypIrnAg/9hguReGZoS7Gl0aBG5xgA410zBqECqmaF/+RkTggRsfnzc1XaAHA6bmUufA=="],
"@vitest/utils": ["@vitest/utils@4.1.10", "", { "dependencies": { "@vitest/pretty-format": "4.1.10", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA=="],
"@wdio/cli": ["@wdio/cli@9.23.2", "", { "dependencies": { "@vitest/snapshot": "^2.1.1", "@wdio/config": "9.23.2", "@wdio/globals": "9.23.0", "@wdio/logger": "9.18.0", "@wdio/protocols": "9.23.2", "@wdio/types": "9.23.2", "@wdio/utils": "9.23.2", "async-exit-hook": "^2.0.1", "chalk": "^5.4.1", "chokidar": "^4.0.0", "create-wdio": "9.21.0", "dotenv": "^17.2.0", "import-meta-resolve": "^4.0.0", "lodash.flattendeep": "^4.4.0", "lodash.pickby": "^4.6.0", "lodash.union": "^4.6.0", "read-pkg-up": "^10.0.0", "tsx": "^4.7.2", "webdriverio": "9.23.2", "yargs": "^17.7.2" }, "bin": { "wdio": "bin/wdio.js" } }, "sha512-D6KZGomfNmjFhSWYdfR7Ojik5qWEpPoR4g5LQPzbFwiii/RkTudLcMFcCO6s7HTMLDQDWryOStV2KK6KqrIF8A=="],
@@ -422,6 +433,8 @@
"ast-types": ["ast-types@0.13.4", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w=="],
"ast-v8-to-istanbul": ["ast-v8-to-istanbul@1.0.5", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.31", "estree-walker": "^3.0.3", "js-tokens": "^10.0.0" } }, "sha512-UPAgKJFSEGMWSDr3LX4tqnAb4f7KGT8O40Tyx8wbYmmZ/yn58lNCm8h3svs3eXgiGd5AXxz8NDOvXWvicq+rJA=="],
"async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="],
"async-exit-hook": ["async-exit-hook@2.0.1", "", {}, "sha512-NW2cX8m1Q7KPA7a5M2ULQeZ2wR5qI5PAbw5L0UOMxdioVk9PMZ0h1TmyZEkPYrCvYjDlFICusOu1dlEKAAeXBw=="],
@@ -498,6 +511,8 @@
"concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="],
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
"cookie": ["cookie@0.6.0", "", {}, "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw=="],
"core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="],
@@ -686,6 +701,8 @@
"html-encoding-sniffer": ["html-encoding-sniffer@6.0.0", "", { "dependencies": { "@exodus/bytes": "^1.6.0" } }, "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg=="],
"html-escaper": ["html-escaper@2.0.2", "", {}, "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg=="],
"htmlfy": ["htmlfy@0.8.1", "", {}, "sha512-xWROBw9+MEGwxpotll0h672KCaLrKKiCYzsyN8ZgL9cQbVumFnyvsk2JqiB9ELAV1GLj1GG/jxZUjV9OZZi/yQ=="],
"htmlparser2": ["htmlparser2@10.1.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "entities": "^7.0.1" } }, "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ=="],
@@ -738,6 +755,12 @@
"isexe": ["isexe@3.1.1", "", {}, "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ=="],
"istanbul-lib-coverage": ["istanbul-lib-coverage@3.2.2", "", {}, "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg=="],
"istanbul-lib-report": ["istanbul-lib-report@3.0.1", "", { "dependencies": { "istanbul-lib-coverage": "^3.0.0", "make-dir": "^4.0.0", "supports-color": "^7.1.0" } }, "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw=="],
"istanbul-reports": ["istanbul-reports@3.2.0", "", { "dependencies": { "html-escaper": "^2.0.0", "istanbul-lib-report": "^3.0.0" } }, "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA=="],
"jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="],
"jake": ["jake@10.9.4", "", { "dependencies": { "async": "^3.2.6", "filelist": "^1.0.4", "picocolors": "^1.1.1" }, "bin": { "jake": "bin/cli.js" } }, "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA=="],
@@ -756,7 +779,7 @@
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"js-tokens": ["js-tokens@10.0.0", "", {}, "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q=="],
"js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
@@ -828,6 +851,10 @@
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"magicast": ["magicast@0.5.3", "", { "dependencies": { "@babel/parser": "^7.29.3", "@babel/types": "^7.29.0", "source-map-js": "^1.2.1" } }, "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw=="],
"make-dir": ["make-dir@4.0.0", "", { "dependencies": { "semver": "^7.5.3" } }, "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw=="],
"mdn-data": ["mdn-data@2.12.2", "", {}, "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA=="],
"micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
@@ -1020,7 +1047,7 @@
"stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
"std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="],
"std-env": ["std-env@4.2.0", "", {}, "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw=="],
"stream-buffers": ["stream-buffers@3.0.3", "", {}, "sha512-pqMqwQCso0PBJt2PQmDO0cFj0lyqmiwOMiMSkVtRokl7e+ZTRYgDHKnuZNbqjiJXgsg4nuqtD/zxuo9KqTp0Yw=="],
@@ -1042,7 +1069,7 @@
"strnum": ["strnum@2.1.2", "", {}, "sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ=="],
"supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="],
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
"svelte": ["svelte@5.48.0", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/estree": "^1.0.5", "acorn": "^8.12.1", "aria-query": "^5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.6.2", "esm-env": "^1.2.1", "esrap": "^2.2.1", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-+NUe82VoFP1RQViZI/esojx70eazGF4u0O/9ucqZ4rPcOZD+n5EVp17uYsqwdzjUjZyTpGKunHbDziW6AIAVkQ=="],
@@ -1068,7 +1095,7 @@
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
"tinyrainbow": ["tinyrainbow@3.0.3", "", {}, "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q=="],
"tinyrainbow": ["tinyrainbow@3.1.0", "", {}, "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw=="],
"tldts": ["tldts@7.0.19", "", { "dependencies": { "tldts-core": "^7.0.19" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-8PWx8tvC4jDB39BQw1m4x8y5MH1BcQ5xHeL2n7UVFulMPH/3Q0uiamahFJ3lXA0zO2SUyRXuVVbWSDmstlt9YA=="],
@@ -1166,6 +1193,10 @@
"zip-stream": ["zip-stream@6.0.1", "", { "dependencies": { "archiver-utils": "^5.0.0", "compress-commons": "^6.0.2", "readable-stream": "^4.0.0" } }, "sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA=="],
"@babel/code-frame/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="],
"@inquirer/core/wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="],
"@inquirer/external-editor/iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
@@ -1190,10 +1221,24 @@
"@testing-library/dom/aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="],
"@vitest/expect/@vitest/utils": ["@vitest/utils@4.0.16", "", { "dependencies": { "@vitest/pretty-format": "4.0.16", "tinyrainbow": "^3.0.3" } }, "sha512-h8z9yYhV3e1LEfaQ3zdypIrnAg/9hguReGZoS7Gl0aBG5xgA410zBqECqmaF/+RkTggRsfnzc1XaAHA6bmUufA=="],
"@vitest/expect/tinyrainbow": ["tinyrainbow@3.0.3", "", {}, "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q=="],
"@vitest/pretty-format/tinyrainbow": ["tinyrainbow@3.0.3", "", {}, "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q=="],
"@vitest/runner/@vitest/utils": ["@vitest/utils@4.0.16", "", { "dependencies": { "@vitest/pretty-format": "4.0.16", "tinyrainbow": "^3.0.3" } }, "sha512-h8z9yYhV3e1LEfaQ3zdypIrnAg/9hguReGZoS7Gl0aBG5xgA410zBqECqmaF/+RkTggRsfnzc1XaAHA6bmUufA=="],
"@vitest/snapshot/@vitest/pretty-format": ["@vitest/pretty-format@2.1.9", "", { "dependencies": { "tinyrainbow": "^1.2.0" } }, "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ=="],
"@vitest/snapshot/pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="],
"@vitest/ui/@vitest/utils": ["@vitest/utils@4.0.16", "", { "dependencies": { "@vitest/pretty-format": "4.0.16", "tinyrainbow": "^3.0.3" } }, "sha512-h8z9yYhV3e1LEfaQ3zdypIrnAg/9hguReGZoS7Gl0aBG5xgA410zBqECqmaF/+RkTggRsfnzc1XaAHA6bmUufA=="],
"@vitest/ui/tinyrainbow": ["tinyrainbow@3.0.3", "", {}, "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q=="],
"@vitest/utils/@vitest/pretty-format": ["@vitest/pretty-format@4.1.10", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q=="],
"@wdio/reporter/diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="],
"anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
@@ -1260,6 +1305,8 @@
"mocha/glob": ["glob@8.1.0", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^5.0.1", "once": "^1.3.0" } }, "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ=="],
"mocha/supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="],
"mocha/yargs": ["yargs@16.2.0", "", { "dependencies": { "cliui": "^7.0.2", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.0", "y18n": "^5.0.5", "yargs-parser": "^20.2.2" } }, "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw=="],
"mocha/yargs-parser": ["yargs-parser@20.2.9", "", {}, "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w=="],
@@ -1298,6 +1345,12 @@
"vitest/@vitest/snapshot": ["@vitest/snapshot@4.0.16", "", { "dependencies": { "@vitest/pretty-format": "4.0.16", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-sf6NcrYhYBsSYefxnry+DR8n3UV4xWZwWxYbCJUt2YdvtqzSPR7VfGrY0zsv090DAbjFZsi7ZaMi1KnSRyK1XA=="],
"vitest/@vitest/utils": ["@vitest/utils@4.0.16", "", { "dependencies": { "@vitest/pretty-format": "4.0.16", "tinyrainbow": "^3.0.3" } }, "sha512-h8z9yYhV3e1LEfaQ3zdypIrnAg/9hguReGZoS7Gl0aBG5xgA410zBqECqmaF/+RkTggRsfnzc1XaAHA6bmUufA=="],
"vitest/std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="],
"vitest/tinyrainbow": ["tinyrainbow@3.0.3", "", {}, "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q=="],
"wait-port/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"wait-port/commander": ["commander@9.5.0", "", {}, "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ=="],
@@ -1328,7 +1381,7 @@
"@jest/types/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"@jest/types/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
"@vitest/runner/@vitest/utils/tinyrainbow": ["tinyrainbow@3.0.3", "", {}, "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q=="],
"@vitest/snapshot/@vitest/pretty-format/tinyrainbow": ["tinyrainbow@1.2.0", "", {}, "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ=="],
@@ -1338,34 +1391,24 @@
"jest-diff/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"jest-diff/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
"jest-diff/pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="],
"jest-matcher-utils/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"jest-matcher-utils/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
"jest-matcher-utils/pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="],
"jest-message-util/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"jest-message-util/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
"jest-message-util/pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="],
"jest-util/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"jest-util/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
"jszip/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
"lazystream/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
"log-symbols/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"log-symbols/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
"mocha/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
"mocha/find-up/locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
@@ -1432,8 +1475,6 @@
"wait-port/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"wait-port/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
"mocha/chokidar/readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"mocha/find-up/locate-path/p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="],
+52
View File
@@ -33,6 +33,58 @@ services:
ports:
- "5172:5172" # In case you want to run dev server
# Linux desktop packages - deb + rpm + pacman into ./dist
desktop-linux-build:
build:
context: .
dockerfile: Dockerfile
target: desktop-linux-build
args:
# Defaults to the registry builder (Dockerfile's ARG). Point at a locally
# built builder with: BUILDER_IMAGE=jellytau-builder:latest docker compose ...
BUILDER_IMAGE: ${BUILDER_IMAGE:-gitea.tourolle.paris/dtourolle/jellytau-builder:latest}
container_name: jellytau-desktop-linux-build
volumes:
- .:/app
- cargo-cache:/root/.cargo
- bun-cache:/root/.bun
environment:
- RUST_BACKTRACE=1
- OUTPUT_DIR=/app/dist
command: bash -c "OUTPUT_DIR=/app/dist scripts/build-desktop-linux.sh"
# Arch Linux package (.pkg.tar.zst via makepkg) into ./dist
arch-build:
build:
context: .
dockerfile: Dockerfile.arch
container_name: jellytau-arch-build
volumes:
- ./dist:/out
environment:
- RUST_BACKTRACE=1
- OUTPUT_DIR=/out
# Windows cross-compile (MSVC via cargo-xwin). Emits NSIS installer + .exe to
# ./dist. Override WIN_BUNDLES=none for exe-only.
windows-cross:
build:
context: .
dockerfile: Dockerfile
target: windows-cross
args:
BUILDER_IMAGE: ${BUILDER_IMAGE:-gitea.tourolle.paris/dtourolle/jellytau-builder:latest}
container_name: jellytau-windows-cross
volumes:
- .:/app
- cargo-cache:/root/.cargo
- bun-cache:/root/.bun
environment:
- RUST_BACKTRACE=1
- OUTPUT_DIR=/app/dist
- WIN_BUNDLES=${WIN_BUNDLES:-nsis}
command: bash -c "OUTPUT_DIR=/app/dist WIN_BUNDLES=${WIN_BUNDLES:-nsis} scripts/build-windows-cross.sh"
# Development container - for interactive development
dev:
build:
+39
View File
@@ -0,0 +1,39 @@
# Summary
[Introduction](README.md)
# Requirements & Traceability
- [Requirements Specification](requirements.md)
- [Traceability Matrix](traceability.md)
- [Traceability CI](traceability-ci.md)
- [Traces Quick Reference](traces-quick-ref.md)
# Architecture
- [Overview](architecture/README.md)
- [Rust Backend](architecture/01-rust-backend.md)
- [Svelte Frontend](architecture/02-svelte-frontend.md)
- [Data Flow](architecture/03-data-flow.md)
- [Type Sync & Threading](architecture/04-type-sync-and-threading.md)
- [Platform Backends](architecture/05-platform-backends.md)
- [Downloads & Offline](architecture/06-downloads-and-offline.md)
- [Connectivity](architecture/07-connectivity.md)
- [Database Design](architecture/08-database-design.md)
- [Security](architecture/09-security.md)
# UX & Specs
- [UX Flows](ux-flows.md)
- [Video Background Audio](specs/video-background-audio.md)
# Build & Release
- [Build & Release](build-release.md)
- [Release Checklist](release-checklist.md)
- [Docker](build/docker.md)
- [Builder Image](build/build-builder-image.md)
---
[Rust API Reference (rustdoc)](api-redirect.md)
+25
View File
@@ -0,0 +1,25 @@
# mdBook config for the published JellyTau documentation site.
# The book's `src` is the repo `docs/` directory (see [build] below); this file
# and SUMMARY.md live in docs-site/ to avoid cluttering docs/. The publish-docs
# CI job copies SUMMARY.md into docs/ at build time, renders, and pushes the
# result (plus the rustdoc API under /api/) to the orphan `gitea-pages` branch.
[book]
title = "JellyTau Documentation"
description = "Requirements, traceability, and architecture for the JellyTau Jellyfin client."
authors = ["Duncan Tourolle"]
language = "en"
# Sources live in the repo docs/ dir (one level up from this book root).
src = "../docs"
[output.html]
default-theme = "navy"
preferred-dark-theme = "navy"
git-repository-url = "https://gitea.tourolle.paris/dtourolle/jellytau"
edit-url-template = "https://gitea.tourolle.paris/dtourolle/jellytau/_edit/master/docs/{path}"
[output.html.fold]
enable = true
level = 1
[output.html.search]
enable = true
+20 -8
View File
@@ -51,14 +51,19 @@ graph TD
**View Enforcement:**
Ordinal content (where position carries meaning) is always a list. Everything
else honours the user's persisted grid/list preference — see
[ux-flows.md §5A.2](../ux-flows.md).
| Content Type | View Mode | Toggle Visible | Component Used |
|--------------|-----------|----------------|----------------|
| Tracks | List (forced) | No | `TrackList` |
| Artists | Grid (forced) | No | `LibraryGrid` with `forceGrid={true}` |
| Albums | Grid (forced) | No | `LibraryGrid` with `forceGrid={true}` |
| Playlists | Grid (forced) | No | `LibraryGrid` with `forceGrid={true}` |
| Genres | Grid (both levels) | No | `LibraryGrid` with `forceGrid={true}` |
| Album Detail Tracks | List (forced) | No | `TrackList` |
| Tracks | List (forced — ordinal) | No | `TrackList` |
| Artists | User preference | Yes | `LibraryGrid` |
| Albums | User preference | Yes | `LibraryGrid` |
| Playlists | User preference | Yes | `LibraryGrid` |
| Genres | User preference (both levels) | Yes | `LibraryGrid` |
| Album Detail Tracks | List (forced — ordinal) | No | `TrackList` |
| Season Episodes | List (forced — ordinal) | No | `SeasonSection` |
**TrackList Component:**
@@ -80,9 +85,16 @@ The `TrackList` component (`src/lib/components/library/TrackList.svelte`) is a d
/>
```
**LibraryGrid forceGrid Prop:**
**LibraryGrid view mode:**
The `forceGrid` prop prevents the grid/list view toggle from appearing and forces grid view regardless of user preference. This ensures visual content (artists, albums, playlists) is always displayed as cards with artwork.
`LibraryGrid` reads the global `viewMode` store (persisted to `localStorage`)
and renders `LibraryListView` or the card grid accordingly. The `showViewToggle`
prop controls whether the toggle buttons appear in the page header; the grid
itself always follows the stored preference.
A `forceGrid` prop previously existed to pin pages to grid regardless of
preference. No caller ever passed it, so it was removed — pages that were
documented as "forced grid" have in practice always honoured the toggle.
## Playback Reporting Service
+44
View File
@@ -90,6 +90,50 @@ flowchart LR
**Important**: The command is `player_get_queue` (returns `QueueStatus` with `hasNext`/`hasPrevious`). There is no `player_get_queue_status` command.
## HTML5 Video Adapter (webview-rendered video)
**Location**: `src/lib/player/html5Adapter.ts`, `src/lib/player/index.ts`, report commands in
`src-tauri/src/commands/player/timers.rs`
Video on desktop (Linux WebKitGTK) — and, per current interim behavior, Android — is rendered by an
HTML5 `<video>`/HLS element **inside the webview**. libmpv is initialized audio-only (`vo=null`,
`video=false`), so the native backend cannot render or observe this element. The `<video>` is therefore
the real player, living outside Rust's reach.
To keep the `PlayerController` the single source of truth (matching the audio path), the HTML5 element
is treated as **a dumb output device that reports back into Rust**, rather than an independent state
authority:
```mermaid
flowchart LR
subgraph Webview["Webview"]
Video["HTML5 <video> / HLS.js"]
Adapter["html5Adapter.ts<br/>(reports DOM events)"]
end
subgraph Backend["Rust"]
Cmds["player_report_state<br/>player_report_position<br/>player_report_media_loaded"]
Controller["PlayerController"]
Emitter["TauriEventEmitter"]
end
subgraph Frontend["Frontend"]
Events["playerEvents.ts"]
Store["player store"]
end
Video -->|DOM events| Adapter --> Cmds --> Controller --> Emitter --> Events --> Store
```
**Key points:**
- The adapter re-emits the *same* `PlayerStatusEvent`s (`StateChanged`, `PositionUpdate`, `MediaLoaded`)
the native backends emit, so `playerEvents.ts` needs **no** HTML5-specific branch — HTML5 is just
another event source feeding the existing pipeline.
- Position reports are throttled (~250ms) to match the MPV cadence and avoid flooding IPC from the
60fps RAF loop.
- **Boundary rule**: UI components never touch the report commands or `videoElement` state directly.
Playback *control* goes through the unified facade `src/lib/player/index.ts` (`playerController`);
HTML5 *state reporting* goes through `html5Adapter.ts`. This restores the documented invariant
("frontend only displays state and invokes commands") for the video path.
## MpvBackend (Linux)
**Location**: `src-tauri/src/player/mpv/`
+4
View File
@@ -13,6 +13,7 @@ JellyTau uses a client-server architecture: business logic lives in a comprehens
- **Business Logic in Rust**: Core logic — playback, repository, sync, downloads, connectivity — lives in Rust for performance, reliability, and type safety.
- **Presentation in Svelte**: The frontend (~20.5k non-test lines) owns UI, layout, navigation, and interaction state and invokes Rust commands. It is intentionally UI-heavy, **not** a thin wrapper. Largest pieces: components + routes (~14.6k lines), stores (~3.4k), api/services/utils (~2.4k); `VideoPlayer.svelte` alone is ~1.6k lines.
- **Events + Polling hybrid**: Rust emits events the frontend listens to, and the UI also polls status on short intervals in a few hot spots (e.g. queue status in `library/+layout.svelte`, playback progress in `VideoPlayer.svelte`).
- **Unified player boundary**: UI components control playback only through the frontend facade `src/lib/player/index.ts` (`playerController`), never by calling `commands.player*` directly. Webview-rendered HTML5 video reports its state back into Rust via `src/lib/player/html5Adapter.ts` and the `player_report_*` commands, so the `PlayerController` stays the single source of truth in both native (MPV/ExoPlayer) and HTML5 modes (see [05-platform-backends.md](05-platform-backends.md)).
- **Handle-Based Resources**: UUID handles for stateful Rust objects.
- **Cache-First**: Parallel queries with intelligent fallback.
- **Single source of truth for reachability**: Server reachability is derived from the outcome of *real repository traffic*, not a side-channel poller. The `OnlineRepository` reports each server result to the `ConnectivityMonitor` (classified via `RepoError`), which applies a time-window debounce before declaring the server offline and recovers instantly on the first success. The standalone `/System/Info/Public` probe runs *only while offline*, as a recovery detector for idle sessions.
@@ -166,6 +167,9 @@ src/lib/
│ ├── repository-client.ts # RepositoryClient wrapper (~100 lines)
│ ├── client.ts # JellyfinClient (helper for streaming)
│ └── sessions.ts # SessionsApi (remote session control)
├── player/ # Unified player boundary (frontend)
│ ├── index.ts # playerController facade — the only write-side entry point for playback
│ └── html5Adapter.ts # Reports webview <video> DOM events back into Rust (player_report_*)
├── services/
│ ├── playerEvents.ts # Tauri event listener for player events
│ └── playbackReporting.ts # Thin wrapper (~50 lines)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 338 KiB

After

Width:  |  Height:  |  Size: 142 KiB

+91
View File
@@ -0,0 +1,91 @@
# Desktop packaging (Linux, Arch, Windows)
How to produce distributable desktop packages for JellyTau. All three flows can
run in Docker so no host toolchain setup is required. Outputs land in `./dist`.
## One builder image (shared with CI)
The deb/rpm and Windows-cross flows build on the **unified registry builder**
([../Dockerfile.builder](../Dockerfile.builder) →
`gitea.tourolle.paris/dtourolle/jellytau-builder`), the same image CI uses. It
carries every packaging tool: Android SDK/NDK, `rpm`/`file` (Linux bundler),
`cargo-xwin` + `lld` + `llvm` + `nsis` + the `x86_64-pc-windows-msvc` rust target
(Windows). There is **one** dependency source of truth — no per-stage tool
installs.
The desktop stages in [../Dockerfile](../Dockerfile) are thin `FROM
${BUILDER_IMAGE}` environments; the actual build runs at container-run time on
your bind-mounted source (like the `dev` service), so source edits need no image
rebuild.
**If you changed `Dockerfile.builder`** (e.g. added a tool), rebuild and push it
first, or the packaging flows use the stale registry image:
```bash
scripts/build-builder-image.sh # build + push :latest to the registry
# ...or iterate locally without pushing:
docker build -f Dockerfile.builder -t jellytau-builder:latest .
BUILDER_IMAGE=jellytau-builder:latest bun run docker:build:windows
```
Arch uses a separate `archlinux` image ([../Dockerfile.arch](../Dockerfile.arch))
because `makepkg` is Arch-specific — it is not part of the unified builder.
| Target | Format | Docker command | Functional? |
|--------|--------|----------------|-------------|
| Debian/Ubuntu, Fedora | `.deb`, `.rpm` | `bun run docker:build:linux` | ✅ yes |
| Arch Linux | `.pkg.tar.zst` | `bun run docker:build:arch` | ✅ yes |
| Windows | NSIS installer + `.exe` | `bun run docker:build:windows` | ✅ yes (unsigned) |
## Linux: deb + rpm
Tauri's bundler produces these natively. The build runs on the existing Ubuntu
builder image ([../Dockerfile](../Dockerfile), `desktop-linux-build` stage):
```bash
bun run docker:build:linux # deb + rpm -> ./dist
# or, on a host with the Tauri Linux deps installed:
BUNDLES="deb,rpm" scripts/build-desktop-linux.sh
```
Runtime dependency: the app links libmpv (audio) and WebKitGTK (webview + HTML5
transcoded video). The deb/rpm declare these.
> Note: `appimage` is also a valid Tauri target if you want a portable bundle —
> add it to `BUNDLES`.
## Arch Linux: pacman package
**Tauri has no `pacman` bundle target** (as of tauri-cli 2.9.x — valid targets
are deb/rpm/appimage/msi/nsis/app/dmg). So we ship a hand-written PKGBUILD in
[../packaging/arch/PKGBUILD](../packaging/arch/PKGBUILD) and build it with
`makepkg` on an Arch base image ([../Dockerfile.arch](../Dockerfile.arch)):
```bash
bun run docker:build:arch # .pkg.tar.zst -> ./dist
```
The PKGBUILD is AUR-ready: swap its `source=()` for a release tarball/VCS URL to
publish. Runtime deps: `webkit2gtk-4.1`, `mpv`, `gtk3`, `libayatana-appindicator`.
`makepkg` refuses to run as root, so the Docker stage builds as a non-root
`builder` user. Because the image `COPY`s the source at build time, the
`arch-build` compose service does **not** bind-mount the repo — rebuild the image
to pick up source changes.
## Windows: NSIS installer cross-compiled from Linux
Produces a working (unsigned) NSIS installer + `.exe` via the official Tauri
cross-compile path — the `x86_64-pc-windows-msvc` target driven by `cargo-xwin`.
Video plays via WebView2 and audio via the webview `<audio>` backend. See
[build-windows.md](build-windows.md) for the full explanation.
```bash
bun run docker:build:windows # NSIS installer + .exe -> ./dist
WIN_BUNDLES=none bun run docker:build:windows # exe only, skip bundling
```
The Docker `windows-cross` stage is a thin layer over the builder, which carries
`cargo-xwin` + `lld` + `llvm` + `nsis` + the `x86_64-pc-windows-msvc` target.
Cross-compilation is Tauri's "last resort" path (less tested than building on
Windows); a `windows-latest` CI job is the fallback if it misbehaves.
+78
View File
@@ -0,0 +1,78 @@
# Windows build
JellyTau targets Linux and Android primarily, but a working Windows build —
including an **NSIS installer cross-compiled from Linux** — is produced by the
Docker tooling. It is not yet a first-class release target (no code signing / CI
job / SMTC lockscreen), but it runs and plays media.
## How playback works on Windows
- **Video** — renders through the webview HTML5 `<video>` element (hls.js) on
*every* platform; on Windows that is WebView2 (Chromium/Edge), which plays HLS +
h264 fine. No Windows-specific code.
- **Audio-only (music)** — the native audio backends are libmpv (Linux) and
ExoPlayer (Android); neither exists on Windows. Instead
`create_player_backend()` in [../src-tauri/src/lib.rs](../src-tauri/src/lib.rs)
uses `WebviewAudioBackend` on non-Linux/non-Android targets: it hands the stream
URL to a webview `<audio>` element (see
[../src/lib/services/webviewAudio.ts](../src/lib/services/webviewAudio.ts)),
which reports state back through the same `player_report_*` round-trip the video
path uses. Pure Rust + Tauri events.
## Cross-compiling from Linux (MSVC + cargo-xwin)
We use the [official Tauri cross-compile path](https://v2.tauri.app/distribute/windows-installer/):
the **MSVC** target (`x86_64-pc-windows-msvc`) driven by
[`cargo-xwin`](https://github.com/rust-cross/cargo-xwin), which downloads the MSVC
CRT / Windows SDK headers and links with `lld`. MSVC is the target Tauri
officially supports for Windows (mingw/GNU is not), and — unlike GNU — it lets the
Tauri CLI bundle the **NSIS installer from a Linux host**.
> Why not mingw/GNU? The GNU target *does* link a valid `.exe`, but the Tauri CLI
> gates `--bundles` by the host OS unless it recognizes a real Windows build.
> `--runner cargo-xwin --target x86_64-pc-windows-msvc` is what flips it into
> Windows mode and enables the `nsis`/`msi` bundlers on Linux.
The builder image ([../Dockerfile.builder](../Dockerfile.builder)) bakes in the
whole toolchain: the `x86_64-pc-windows-msvc` rust target, `cargo-xwin`, `lld`,
`llvm`, and `nsis`.
```bash
bun run docker:build:windows # NSIS installer + .exe -> ./dist
WIN_BUNDLES=none bun run docker:build:windows # exe only, skip bundling
```
Or directly on a host that has the toolchain:
```bash
scripts/build-windows-cross.sh # nsis installer + exe
WIN_BUNDLES=none scripts/build-windows-cross.sh # exe only
```
Under the hood the build runs:
```bash
tauri build --runner cargo-xwin --target x86_64-pc-windows-msvc --bundles nsis
```
Outputs:
- `.exe``src-tauri/target/x86_64-pc-windows-msvc/release/jellytau.exe`
- NSIS installer — `.../release/bundle/nsis/*-setup.exe`
(both copied to `./dist` when `OUTPUT_DIR` is set).
## Caveats
- **Cross-compilation is a last resort** per Tauri's own docs — it's less tested
than building on Windows. If it misbehaves, a `windows-latest` CI job or a
Windows VM building natively (`tauri build --bundles nsis`) is the fallback.
- **Code signing is not wired up** — the installer is unsigned, so Windows
SmartScreen will warn on first run.
## Outstanding for a first-class Windows release
1. Gapless/crossfade + SMTC (lockscreen) — currently no-ops in the webview audio
path.
2. Downloaded (`Local` source) file playback needs `convertFileSrc` on the
frontend; streaming works today.
3. Code signing + a Windows packaging CI job.
+150
View File
@@ -0,0 +1,150 @@
# Defect windows — which bugs were present when
For each fixed defect, the releases it was actually present in. Companion to
[CHANGELOG.md](../CHANGELOG.md), which says what changed; this says how long each
fault had been shipping before it did.
**"Present since"** is the first *release* containing the defective code, not the
first release where a user could hit it — those differ, sometimes by months, and
the gap is called out where it matters. **"How dated"** records the evidence, so a
row can be re-checked or disputed:
| Method | Meaning |
|--------|---------|
| `pickaxe` | `git log -S<token>` on the defective token — the commit that introduced the exact string, then the earliest tag containing it. Strongest evidence. |
| `feature` | The defect is inseparable from a feature that landed whole (bad rung in a new algorithm, missing caller in new plumbing), dated to that feature's release. |
| `absence` | The fix *adds* something that was never there. Dated to when the surrounding code was built, since there is no introducing commit to find. Weakest — treat as "no later than". |
## Present since the first release
Nine defects date to the initial proof of concept (v0.0.1, 2026-06-23) and shipped
for between two weeks and seven weeks short of two months before anyone hit them.
That is the dominant pattern here: not regressions, but original assumptions that
went unexercised until a later feature leaned on them.
| Defect | Present since | Fixed in | Shipped broken for | How dated |
|---|---|---|---|---|
| `AudioStreamIndex=0` pinned the video stream as the audio track (DR-140) | v0.0.1 | **v0.4.6** | ~7 weeks | pickaxe |
| Download URL spelled `videoBitrate`, which Jellyfin does not bind (DR-123) | v0.0.1 | **v0.5.1** | ~7 weeks | pickaxe |
| `pause_download` / `resume_download` were no-ops (DR-168) | v0.0.1 | **v0.5.3** | ~7.5 weeks | pickaxe |
| `.part` sidecar named by `with_extension`, so no cleanup path matched it (DR-169) | v0.0.1 | **v0.5.3** | ~7.5 weeks | pickaxe |
| `Range` sent on every retry regardless of the response (DR-170) | v0.0.1 | **v0.5.3** | ~7.5 weeks | pickaxe |
| `/Items/Latest` requested with the default `GroupItems=false` | v0.0.1 | **v0.5.1** | ~7 weeks | pickaxe |
| `SubtitleStreamIndex` omitted from PlaybackInfo, letting the server burn in (DR-176) | v0.0.1 | **v0.5.5** | ~8 weeks | pickaxe |
| No `PlaySessionId`, and one hardcoded `DeviceId`, on every stream URL (DR-177) | v0.0.1 | **v0.5.5** | ~8 weeks | pickaxe |
| `download_item` never recorded `media_type`; NULL read as `'audio'` (DR-135) | v0.0.1 | **v0.4.6** | ~7 weeks | pickaxe |
| `download_album` read its track list from the local cache (DR-173) | v0.0.1 | **v0.5.5** | ~8 weeks | pickaxe |
| Device profile carried no `MaxAudioChannels` (DR-141) | v0.0.1 | **v0.4.6** | ~7 weeks | absence |
| Streaming ceiling fixed at 20 Mbps with no way to lower it (UR-074) | v0.0.1 | **v0.5.3** (as a feature) | ~7.5 weeks | pickaxe |
### Why they took so long to surface
Four of these were **latent until a later feature exercised them**, which is why
the fix lands so far from the cause:
- The `videoBitrate` casing was harmless while every download was `original`. It
became visible only once a quality picker existed to select against — and then
produced no error, just a full-size file, because Jellyfin discards an unbound
query key silently.
- The unconditional `Range` header was inert for the same reason: `original` is
the one rung served with a `Content-Length` and real byte-range support. It
started corrupting files in **v0.5.1**, the moment the casing fix made
transcoded downloads actually transcode. So the *code* dates to v0.0.1 and the
*corruption* to v0.5.1 — a one-release window for the visible symptom.
- The missing `PlaySessionId` only bites when a stream is re-opened for the same
item. Nothing re-opened one until quality switching, transcoded seek and
audio-track switching existed.
- The omitted `SubtitleStreamIndex` only bites on sources whose own default
subtitle track is image-based, since that is what forces the server from
sidecar to burn-in.
Two were **masked by soft failure**: the asset protocol being disabled (DR-134)
was hidden by the thumbnail cache falling back to the server copy whenever the
server was reachable, and `AudioStreamIndex=0` was hidden by servers that
silently correct an out-of-range index — which is exactly why it was reported as
"*some* videos have no audio" rather than as a bug in the client.
## Introduced by a feature, fixed later
| Defect | Present since | Fixed in | How dated |
|---|---|---|---|
| Native-path resume position never applied (both layers assumed the other seeked) | v0.0.9/v0.0.10 | **v0.5.1** | feature (`PlayerAdapter` contract) |
| `get_downloaded_items` matched "this library exists" rather than constraining the item to it (DR-167) | v0.0.17 | **v0.5.3** | feature (browsable downloaded library) |
| `SCOPE_ITEM_TYPES` — the frontend/backend boundary leak (DR-063) | v0.0.17 | **v0.2.1** | pickaxe |
| `check:boundary` anchored to the query site, blind to a named const (DR-094) | v0.0.17 | **v0.2.1** | feature (tripwire landed with the leak it missed) |
| Coverage gate divided by hardcoded denominators, reporting 158% (DR-093) | v0.0.1 | **v0.2.1** | pickaxe |
| Tap deferral raced the WebView's synthesized click (DR-092 → DR-098) | v0.1.5 | **v0.2.7** | feature (the deferral itself) |
| Transport for webview media decided from `el.paused` in the DOM (DR-097) | v0.0.9/v0.0.10 | **v0.2.7** | feature (`Html5PlayerAdapter`) |
| `pick_current_episode` rung 3 returned the first *gap*, not the furthest watched | v0.3.0 | **v0.5.1** | feature |
| `mirror_user_data` mirrored `is_favorite` alone and returned early (DR-155) | v0.4.0 | **v0.5.1** | pickaxe |
| Stop-report path never fed the sync queue that existed for it (DR-154) | v0.4.6 | **v0.5.1** | feature (queue + drain landed with no producer) |
| Background-audio base applied in two display-only places (DR-159) | v0.2.9 | **v0.5.3** | pickaxe |
| Positions reported as 0 before the first tick, and always 0 for webview media (DR-178/179/180) | v0.5.3 | **v0.5.5** | feature (DR-159's tick boundary) |
Three of these are worth separating out, because the defect is not a mistake in
the code so much as **plumbing that was built and never connected**:
- `repository_get_next_up_episodes` accepted a `series_id` from the day it was
written, and no caller passed one until v0.3.0.
- The sync queue and its drain were built, tested and running in v0.4.6 with
neither of its two would-be producers ever called.
- Both halves of the watched-state backend existed with no caller before v0.5.3.
An automated check cannot see any of these — the code is present, tested and
reachable in principle. Only tracing a requirement to a *call site* catches it.
## Short windows (one release or less)
| Defect | Present since | Fixed in | Note |
|---|---|---|---|
| `experimentalNativeVideo` defaulted on, shipping audio with a blank screen (DR-161 → DR-172) | v0.5.3 | **v0.5.4** | One release. The decode path was fine; the compositing step never ran. |
| Webview-shaped audio profile insufficient — server ignores a profile's audio codec (DR-149) | v0.4.7 | **v0.4.8** | The v0.4.7 fix for DR-148 was necessary and not sufficient. |
| Android `versionCode` floor went stale (`minor*100` yielding less than the 5002 already in the field) | v0.5.0 | **v0.5.3** | Caught before a broken APK shipped; no released build was un-installable. |
| Subtitle sidecar work reverted by a commit assembled from a stale tree | v0.5.5 | **v0.5.5** | Never released broken — both commits are in v0.5.5. |
## Fixed twice / never actually broken
- **Autoplay time reset (v0.0.2).** Two commit objects carry this identical
change: `dcf08f30` (merged via Gitea PR #3, tagged v0.0.2) and `fa7cb6e9` (the
local original). Both have the same parent `674c8e5c` and the same diff. A merge
chain pulled `fa7cb6e9` and its follow-up `1e599627` into master's history
during v0.5.5, so `git log v0.5.4..v0.5.5` lists an autoplay fix that changed no
file in that release — `nextEpisodeService.ts` is byte-identical across the tag
boundary. The fix shipped in **v0.0.2** and has not regressed.
This is the one case where reading the changelog off `git log` subjects would
have produced a false entry, and it is a good argument for the project's
practice of deriving release notes from TRACES rather than commit subjects.
## Recurring shapes
Four causes account for most of the table:
1. **An omitted parameter is not a neutral default.** `SubtitleStreamIndex`,
`AudioStreamIndex`, `GroupItems` and `MaxAudioChannels` all had a server-side
default that was actively wrong, and in three of the four the server's choice
was more expensive than the one intended — burn-in forcing a full re-encode
being the extreme case.
2. **Silent binding failures.** `videoBitRate` produced no error, no warning and a
plausible-looking file. So did an unbound `Range`, and so did the coverage gate
dividing by a stale denominator.
3. **Two layers each assuming the other acts.** Native resume (adapter recorded
the position, backend never seeked), end-of-playback dispatch (two paths, one
unreachable), and the surface/attach split in v0.5.0's native video.
4. **A guard keyed on state that moves.** The tap deferral keyed suppression on a
timer handle the callback had already cleared; the HTML5 toggle keyed
play-vs-pause on `el.paused`, which flips while buffering.
## Reproducing this
The pickaxe rows can be re-derived directly:
```bash
git log --oneline --reverse -S'<defective token>' -- src-tauri/src # introducing commit
git tag --contains <sha> | sort -V | head -1 # first release with it
```
Blaming the lines a fix removed (`git blame` at the fix's parent) is faster to run
across many commits but was **not** used for the rows above: it reliably lands on
whichever commit last touched the adjacent lines, which is usually not the commit
that introduced the defect. It was used only to shortlist candidates.
+381 -22
View File
@@ -37,11 +37,11 @@ For a narrative overview of the system design, see
| UR-024 | View recently added content on server | Medium | Done |
| UR-025 | Sync watch history and progress back to Jellyfin | High | Done |
| UR-026 | Sleep timer for audio and video playback (roller UI, time/track/episode modes) | Low | Done |
| UR-027 | Audio equalizer for sound customization | Low | Planned |
| UR-027 | Audio equalizer for sound customization | Low | Done (Linux only) |
| UR-028 | Navigate to artist/album by tapping names in now playing view | High | Done |
| UR-029 | Toggle between grid and list view in library | Medium | Done |
| UR-030 | Quick genre browsing and filtering | Medium | Done |
| UR-031 | Crossfade between audio tracks | Low | Done (Linux only) |
| UR-031 | Crossfade between audio tracks | Low | Not implemented (blocked — see DR-034) |
| UR-032 | Gapless playback for seamless album listening | Medium | Done (Linux only) |
| UR-033 | Volume normalization to prevent volume jumps between tracks | Low | Done (Linux only) |
| UR-034 | Rich home screen with hero banners, carousels, and personalized sections | High | Done |
@@ -50,6 +50,42 @@ For a narrative overview of the system design, see
| UR-037 | Visually appealing video library with poster grids and metadata | High | Done |
| UR-038 | Movie/show detail page with backdrop, ratings, and rich metadata | High | Done |
| UR-039 | Navigate between main sections via bottom navigation bar | High | Done |
| UR-040 | Keep a video's audio playing when the app is backgrounded or the screen is locked, stopping video decode until the app returns to the foreground (per-player toggle; Android) | Medium | Done (pending device verification) |
| UR-041 | Continue watching *locally-playing video* in a floating picture-in-picture window when leaving the app (Android) — PiP applies to video only, never to audio playback, library/menu browsing, or remote/cast sessions | Medium | Done |
| UR-042 | Authenticate to a server and manage the session lifecycle (connect, log in, Quick Connect, background session verification, re-authenticate, log out) | High | Done |
| UR-043 | Automatically detect server reachability and switch between online and offline operation without user intervention | High | Done |
| UR-044 | Pin downloaded media so it is protected from automatic cache eviction | Low | Done |
| UR-045 | Predictively pre-cache likely-next media (queue lookahead and album affinity) within a storage budget | Low | Done |
| UR-046 | Group multiple remote players into a synchronized playback group (LMS SyncGroups) | Low | Done |
| UR-047 | Manage multiple Jellyfin servers (add, list, remove) and switch the active server/account | Medium | Planned (backend store done; switcher UI pending) |
| UR-048 | See the next episodes of a series directly below the episode/series being viewed, above cast and similar-shows content, so continuing a show is the shortest path (see [ux-flows.md §5B](ux-flows.md)) | High | Done |
| UR-049 | Search is scoped by where it was started — inside a library it searches that library, from Home/library-root/search-tab it searches everything — with the scope shown as filter chips under the search bar that preselect from context and can be changed without retyping (see [ux-flows.md §6.1](ux-flows.md)) | High | Implemented |
| UR-050 | Reorder search result groups (Songs, Albums, Artists, Movies, TV Shows) by drag and drop in settings, so the media a user cares about most appears first (see [ux-flows.md §6.3](ux-flows.md)) | Medium | Implemented |
| UR-051 | Browse library pages in a consistent layout where card shape signals media type (square music, poster video, thumbnail episode), ordinal content stays listed, and the grid/list preference persists across pages (see [ux-flows.md §5A](ux-flows.md)) | Medium | Partial (implemented; toggle not reachable from settings) |
| UR-052 | While offline, library pages show only media available on the device by default, with an opt-in toggle that additionally reveals the cached server catalog as greyed-out entries which can be queued for download on the next reconnect | High | Done |
| UR-053 | Restrict media downloads to unmetered networks via a "WiFi Only" setting: when enabled, queued downloads are held while the device is on cellular or a metered connection (including metered WiFi hotspots) and resume automatically once an unmetered network is available | Medium | Done (pending device verification) |
| UR-054 | Reach account actions (Settings, Downloads, Display preferences, Sign out) from every authenticated screen via a single account menu anchored to the user's name, identical on desktop and mobile (see [ux-flows.md §1.2](ux-flows.md)) | High | Done |
| UR-055 | Browse downloaded media as an offline-scoped library — reusing the same library grids, cards, and detail pages as online browsing, showing only libraries/containers with downloaded content — with the transfer-progress list demoted to a secondary "Transfers" view (see [ux-flows.md §7.2](ux-flows.md)) | High | Done |
| UR-056 | See how much disk each downloaded item/album/series consumes, in familiar rounded units shown on the card and detail page, with a device total on the Downloaded surface and a reclaim amount stated at the point of removal (see [ux-flows.md §7.3.1](ux-flows.md)) | Medium | Done |
| UR-057 | Settings apply the instant a control is changed — no "Save" button and no save/dirty state — so leaving the page never loses a change; sliders show a live readout while dragging but persist on release (see [ux-flows.md §8.1](ux-flows.md)) | Medium | Done |
| UR-058 | On the home screen, a tap on a media card opens the item (movie/episode detail page, or the series Episode Focus View for episodes) rather than starting playback; a long-press starts "play now" after a confirm; an episode detail/focus page links back to its parent series and season (see [ux-flows.md §5B.5](ux-flows.md) and [§5B.1](ux-flows.md)) | Medium | Done |
| UR-059 | Skipping to the next episode records the episode left behind as **fully watched** rather than saving a mid-episode resume point — skipping means "done with this one", not "stopped here" — and Continue Watching hides episodes the viewer has already moved past (a partial position behind that series' next-up episode), so the row only ever offers genuinely unfinished media | Medium | Done |
| UR-060 | Search results are ordered by how well they match: a name that *starts* with the query outranks one matching mid-word (typing "parks" finds "Parks and Recreation" before "Sparks of Love"), and at equal match quality a container outranks its contents (a series before its episodes). Results are grouped into distinct categories — TV Shows, Episodes, Movies, Songs, Albums, Artists and People — so a show never competes with its own episodes for the same slot, and searching an actor's name reaches their bio | High | Done |
| UR-061 | Double tapping the video skips within it — right half jumps **forward 30 seconds**, left half jumps **back 10 seconds** — with an on-screen indicator naming the amount. A double tap leaves the play state unchanged — playing jumps and keeps playing, paused jumps and stays paused — because the second tap re-toggles what the first tap toggled (see DR-098); the skip lands relative to the position the player actually reports, and repeated double taps accumulate rather than all skipping from the same spot | Medium | Done |
| UR-062 | Opening a TV series lands the viewer **where they are in it**, not at season 1: the series page scrolls the current season into view and highlights the current episode, and the hero button opens that episode (labelled `Resume S2E4` / `Play S1E1`). "Current" means the episode in progress, else the server's Next Up for that series, else the first unwatched episode, else the first — resolved by the backend so it also works offline. A season is **never a page of its own**: every route that names a season lands on the series with that season in view, so the episodes of all seasons are always one continuous scrollable list | High | Done |
| UR-063 | Each video library is **one page**, not three. Browsing (hero, Continue Watching, Next Up, Recently Added, genre rows), the full title grid, and the genre browser are tabs of `/library/tv` and `/library/movies` rather than separate routes with inconsistent names (`/library/tv/shows` vs `/library/movies/all`, `/library/shows/genres` vs `/library/movies/genres`). The old routes redirect so existing links keep working | Medium | Done |
| UR-064 | Watch history can be **erased**, per series and per season, from the series page. Clearing marks every episode inside unwatched and clears resume positions, so the show returns to "never watched" and reopens on its premiere. It asks for confirmation first (it cannot be undone) and requires a connection to the server, since history cleared only locally would be undone by the next sync | Medium | Done |
| UR-065 | Search answers from a **locally indexed copy of the library**, so results appear as fast as the device can query rather than at the speed of a round trip to the server, and the same results are found with the server unreachable. A background job keeps the index current — refreshing on a schedule rather than only at app start, dropping media removed from the server, and covering everything the result groups can show (including artists and people). The server is still queried in the background so media added since the last index still turns up, merged in without reordering what is already on screen | High | Implemented |
| UR-066 | The app's own chrome stays clear of the device's system chrome. On Android the bottom navigation sits above the navigation/gesture bar instead of underneath it, the header clears the status bar, and full-screen video and audio playback keep their controls inside the usable screen — clear of the gesture bar and, in landscape, of the display notch. This must hold across navigation modes (gesture and 3-button) and rotation, not only on the handsets it happened to be tested on | High | Done |
| UR-067 | Favourited media can be **found again**. A Favourites page lists everything favourited across all libraries, scoped by tabs (All / Movies / Shows / Music); the home screen carries favourite rows for movies, shows and music, hidden when a category is empty; and each library page can be filtered to favourites in place. Without this the like button writes to a store nothing reads | Medium | Done |
| UR-068 | Anything the app shows can be favourited where it is shown — from a movie, series, episode, album, artist or playlist page, and from any card in a grid or carousel — not only from the player while the item happens to be playing | Medium | Done |
| UR-069 | Favourite state agrees with the server in both directions. An item favourited in another Jellyfin client shows as favourited here without being touched, and an item favourited here while the server is unreachable reaches the server once it returns — without the user going back to the screen where they marked it | Medium | Done |
| UR-070 | Playback quality is the viewer's choice: the player offers the bitrates the server can produce for what is playing, and changing one resumes at the same point with the same audio and subtitle tracks. Because the chosen rendition can change at any moment, nothing that streams for playback is treated as a stored copy unless it happens to be byte-identical to the real file | Medium | Proposed |
| UR-071 | Media the viewer is watching can be **kept**, by a whole-file download that runs in the background independently of playback and at its own quality, so it is unaffected by bitrate changes. Where the streamed bytes already are that file (direct play), they are kept rather than fetched twice. A completed download is then played from disk rather than streamed again | Medium | Proposed |
| UR-073 | Watched state is something the viewer can **set**, not only something playback records. Any episode, season, series or movie can be marked watched — or unwatched again — from where it is shown, without sitting through it or erasing its history wholesale. Marking a season or series covers the episodes inside it, and works with the server unreachable | Medium | Done |
| UR-072 | Each page opens where a page should open. Moving to a new screen starts at the top of it, and going Back returns the viewer to the place they left — their position in a long library grid or home screen, not the top of it. A page never inherits the scroll position of the page before it | Medium | Done |
| UR-075 | Artwork is shown at the shape it was made in. Where a screen presents a set of things side by side — the libraries on the library page and on home — they are laid out as a mosaic: rows of a common height in which each tile is as wide as its own picture, rather than a grid that crops every cover to one box. Favourites are reachable per category from that same mosaic, beside the library they belong to, not only as one undifferentiated list | Medium | Done |
| UR-074 | Video streaming can be held to a **bandwidth budget the viewer sets**, rather than spent at whatever rate the server would otherwise send. A ceiling chosen once — from the source's own bitrate down to a rung that still plays on a poor connection — governs every video the app opens, live TV included, and survives a restart, so a metered connection is not quietly drained by the next thing played. A single video can be moved to a different ceiling from the player, resuming where it was, without disturbing that default | Medium | Done |
---
@@ -81,10 +117,18 @@ External system integrations and platform-specific implementations.
| IR-017 | Jellyfin API client for transcoding parameters | API | UR-022 | Planned |
| IR-018 | libmpv subtitle rendering and selection | Playback | UR-020 | Planned |
| IR-019 | libmpv audio track selection | Playback | UR-021 | Planned |
| IR-020 | libmpv/ExoPlayer equalizer integration | Playback | UR-027 | Planned |
| IR-020 | libmpv/ExoPlayer equalizer integration | Playback | UR-027 | Done (Linux/MPV; Android parity pending) |
| IR-022 | Jellyfin API client for person/cast data | API | UR-035, UR-036 | Done |
| IR-023 | Database schema for person/cast caching | Storage | UR-035, UR-036 | Done |
| IR-024 | Jellyfin API client for home screen data (featured, continue watching) | API | UR-034 | Done |
| IR-025 | Android background-audio handoff: WebView `<video>` → native ExoPlayer foreground service on background/lock, and back on foreground (audio continues, video decode stops) | Platform | UR-040 | Done (pending device verification) |
| IR-026 | Android picture-in-picture: auto-enter on user-leave-hint via `enterPictureInPictureMode`, **only while a local video surface is actively rendering** (never for audio-only playback, menu/library browsing, or remote/cast sessions — enforced by the native `canEnterPip` guard, re-checked at leave time); aspect-ratio sizing; a play/pause RemoteAction that **reflects live player play/pause state** (updated whenever playback state changes, not only on button press); WebView hide/restore on mode change | Platform | UR-041 | Done |
| IR-027 | Jellyfin `/System/Info/Public` reachability probe used as an offline→online recovery detector | API | UR-043 | Done |
| IR-028 | Jellyfin/LMS SyncGroups API client (list, create, join, unsync, dissolve sync groups) | API | UR-046 | Done |
| IR-029 | Android `ConnectivityManager`/`NetworkCapabilities` transport probe with a `NetworkCallback` change subscription, surfaced to the frontend via the `AndroidNetworkType` JS bridge and the `jellytau-network-changed` WebView event (requires `ACCESS_NETWORK_STATE`) | Platform | UR-053 | Done (pending device verification) |
| IR-030 | Scheduled full-catalog crawl of every library (`Recursive=true`, paged) feeding the local index, driven by a Rust background task and the `ConnectivityMonitor` reconnect signal rather than by the frontend | Storage | UR-065 | Implemented |
| IR-031 | Android `WindowInsets` bridge: an `OnApplyWindowInsetsListener` on the decor view reports `systemBars() | displayCutout()` in CSS pixels, pushed into the WebView as `jt-inset` CSS custom properties plus a `jellytau-insets-changed` event, and pullable via the `AndroidInsets` JS bridge | Platform | UR-066 | Done (pending device verification) |
| IR-032 | Whole-file background download of the item being played, reusing the existing resumable download worker and the Range-capable `/Videos/{id}/stream.mp4` endpoint; plus per-platform read-through caching hooks (ExoPlayer `CacheDataSource`, mpv `stream-record`) for direct-play sessions only | Storage | UR-071 | Proposed |
### 2.2 Jellyfin API Requirements
@@ -123,6 +167,10 @@ API endpoints and data contracts required for Jellyfin integration.
| JA-029 | Get cast/crew for item (actors, directors) | Items | UR-035 | Done |
| JA-030 | Get person details and filmography | Persons | UR-036 | Done |
| JA-031 | Get items by person (actor/director filmography) | Items | UR-036 | Done |
| 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
@@ -161,11 +209,11 @@ Internal architecture, components, and application logic.
| DR-029 | Sleep timer with roller UI, time/track/episode modes, and auto-stop (audio + video players) | Player | UR-026 | Done |
| DR-049 | Auto-play episode limit (configurable max episodes per session) | Player | UR-023 | Done |
| DR-050 | Reusable scroll picker (roller) component | UI | UR-026 | Done |
| DR-030 | Equalizer UI with presets and custom bands | UI | UR-027 | Planned |
| DR-030 | Equalizer UI with presets and custom bands | UI | UR-027 | Done |
| DR-031 | Clickable artist/album links in now playing view | UI | UR-028 | Done |
| DR-032 | List view option for library browsing (albums, artists) | UI | UR-029 | Done |
| DR-033 | Genre browsing screen with quick filters | UI | UR-030 | Done |
| DR-034 | Crossfade engine with configurable duration (0-12s) | Player | UR-031 | Done (Linux only) |
| DR-034 | Crossfade engine with configurable duration (0-12s) | Player | UR-031 | Not implemented (blocked on MPV: single-stream audio chain; `acrossfade` needs 2 inputs — see docs/specs/playback-backend-unification.md) |
| DR-035 | Gapless playback between sequential tracks | Player | UR-032 | Done (Linux only) |
| DR-036 | Volume normalization with preset levels (Loud/Normal/Quiet) | Player | UR-033 | Done (Linux only) |
| DR-037 | Remote session browser and control UI | UI | UR-010 | Done |
@@ -180,6 +228,141 @@ Internal architecture, components, and application logic.
| DR-046 | Dedicated search page with input and results | UI | UR-039 | Done |
| DR-047 | Next episode auto-play popup with configurable countdown and episode limit | Player | UR-023 | Done |
| DR-048 | Video settings (auto-play toggle, countdown duration, episode limit) | Settings | UR-023, UR-026 | Done |
| DR-051 | Background-audio toggle button in the video player controls (suppresses auto-PiP while enabled) | UI | UR-040 | Done (pending device verification) |
| DR-052 | Background-audio handoff state machine: on background/lock tear down the WebView `<video>`/HLS decode and start native audio-only playback at the current position; on foreground return position and resume `<video>`; exactly one audio source active at every transition (no dual audio) | Player | UR-040 | Done (pending device verification) |
| DR-053 | PictureInPictureManager: `canEnterPip` gate (local video surface actively rendering — false for audio, browsing, and remote/cast), aspect-ratio clamp, a RemoteAction play/pause receiver whose icon reflects live player state (refreshed on every playback-state change while in PiP, not only on button press), WebView hide/restore, surface re-fit on exit; plus the `AndroidPictureInPicture` JS bridge and the PiP button (shown only when PiP is supported) in the video player | UI | UR-041 | Done |
| DR-054 | Auth manager and session lifecycle: connect-to-server, login, Quick Connect verification poll (start/stop), session get/set, background session verifier, re-authenticate, logout | Auth | UR-042 | Done |
| DR-055 | ConnectivityMonitor deriving reachability from real repository traffic, with online/offline state, mark-reachable/unreachable reporting, and a probe-based recovery poller active only while offline | Connectivity | UR-043 | Done |
| DR-056 | Download pinning (pin/unpin/is-pinned) that excludes an item from smart-cache eviction | Storage | UR-044 | Done |
| DR-057 | Smart cache manager: album-affinity tracking, queue-lookahead pre-cache, storage-limit enforcement, config, stats, and recommendations | Storage | UR-045 | Done |
| DR-058 | Remote sync-group control (LMS SyncGroups): list, create, unsync a player, dissolve a group | Player | UR-046 | Done |
| DR-059 | Playback-mode transfer state machine: get/set current mode, transferring guard, transfer-to-remote / transfer-to-local, remote session status | Player | UR-010 | Done |
| DR-060 | Multi-server store and active-account selection: save/get/delete server, save/get user, set/get active user (per-server), active-session resolution | Storage | UR-047 | Partial (store done; server-switcher UI pending) |
| DR-061 | Episode Focus View: episode hero followed *immediately* by the "More Episodes" strip — a forward-biased window (~3 before / ~6 after) around the current episode, spanning season boundaries in series order, with the current episode present and badged, per-card resume progress and watched state, and click-to-swap focus (no playback) | UI | UR-048 | Done |
| DR-062 | Detail-page section ordering: continuation content precedes discovery content — Episode Focus View renders hero → episode strip → cast → similar; Series renders hero → seasons/episodes → cast → similar | UI | UR-048 | Done |
| DR-063 | Search scope taxonomy owned by Rust: `SearchScope` (All / Music / Movies / TV) crosses IPC as an opaque enum and `SearchScope::item_types()` expands it to Jellyfin item types, resolved once in `repository_search` before the cache and server paths diverge so online and offline filter identically; `All` expands to *no* filter rather than the union of the other scopes (which would drop People and folders). The frontend maps the originating route to a scope (`resolveSearchScope`, presentation) and never names an item type for search | Backend | UR-049 | Implemented |
| DR-064 | Scope chip row rendered under the search bar on both the search page and the in-library header search: preselected from context, horizontally scrollable, re-runs the search preserving the query on change | UI | UR-049 | Implemented |
| DR-065 | Thread `SearchOptions.includeItemTypes` through `library.search()` so the global/header search honours scope (backend online + offline paths already support it) | UI | UR-049 | Implemented |
| DR-066 | Persisted search result group order with a drag-and-drop settings list, keyboard-accessible reordering, a shipped default (see DR-091 for the current group set and order), and empty-group omission | Settings | UR-050 | Implemented |
| DR-067 | `SearchResults` renders groups in the user-configured order rather than hardcoded markup order, without altering intra-group ranking | UI | UR-050 | Implemented |
| DR-068 | Library card shape by media type: 1:1 square for music (circular mask for artists), 2:3 poster for movies/series/seasons, 16:9 for episodes and collection folders | UI | UR-051 | Done |
| DR-069 | Responsive library grid (2/3/4/5/6 columns across base→xl) with two-line truncated card text and artwork-overlay progress/watched state | UI | UR-051 | Done |
| DR-070 | Global persisted grid/list view preference honoured by browse pages, suppressed for ordinal content (album tracks, season episodes) | UI | UR-051, UR-029 | Partial (persisted store + page-header toggle; no settings entry) |
| DR-075 | Shared `AccountMenu` component: identity header (user + server), Downloads / Settings / Display entries, divider, Sign out last; anchored to the username/avatar trigger and identical on desktop and mobile | UI | UR-054 | Done |
| DR-076 | App shell exposes the header (and therefore the account menu) on every authenticated non-immersive route, including `/`, `/search`, and `/downloads`; only `/player/*` and `/login` remain chrome-free | UI | UR-054 | Done |
| DR-077 | Display section in Settings binding the existing persisted grid/list `viewMode` store, giving the preference a discoverable home | Settings | UR-054, UR-029 | Done |
| DR-078 | Catalog-visibility gate spanning the "Show all server media" toggle → `set_show_server_catalog``INCLUDE_CATALOG_BROWSE` → the synced-catalog UNION branch of offline `get_items`. Visibility resolves to `serverReachable \|\| showServerCatalog`, so offline with the toggle off lists downloaded/local media only | Storage | UR-052, UR-002 | Done |
| DR-079 | `isConnected` derives from backend-reported server reachability alone; `navigator.onLine` is advisory and may only trigger a recheck, never force or clear the offline state (a reachable LAN server while the browser reports offline, and an unreachable server on a live link, must both resolve correctly) | Connectivity | UR-052, UR-043 | Done |
| DR-080 | With the catalog-browse gate off, an empty offline `get_items` result is authoritative "no downloads here" and must be returned as-is; the hybrid repository must not treat it as a cache miss and fall through to the server | Storage | UR-052, UR-013 | Done |
| DR-074 | WiFi-only download gate: `NetworkState`/`NetworkType` transport model reported from the platform via `set_network_state`, checked in `pump_download_queue` before starting any pending row (cellular/metered/unknown fail closed, WiFi and Ethernet require `NOT_METERED`); blocked rows stay `pending` and re-pump on network change, with a `waitingForNetwork` event driving the "Waiting for WiFi" notice. Also wires the previously inert Smart Caching / Queue Pre-caching / WiFi Only settings toggles to `CacheConfig` | Downloads | UR-053 | Done (pending device verification) |
| DR-081 | `/downloads` split into a default **Downloaded** browse view and a secondary **Transfers** activity view, with a view switch and a Transfers badge shown only while transfers are active | UI | UR-055 | Done |
| DR-082 | Offline-scoped browse entry point in the repository client: browse downloaded content only (offline repository `get_items`/`get_libraries` — downloaded items plus their containers) independent of server reachability, without merging server catalog | Storage | UR-055 | Done |
| DR-083 | Downloaded browse reuses library grids, cards, and detail pages via the offline-scoped source; omits libraries/containers with no downloaded content; badges partially- vs fully-downloaded containers; play uses the local file; remove available at item/album/season/series level | UI | UR-055 | Done |
| DR-084 | Transfers view renders only in-flight rows (downloading/queued/paused/failed/waiting-for-WiFi) with Pause/Resume/Cancel/Retry; completed transfers leave the view and appear in Downloaded | UI | UR-055 | Done |
| DR-085 | Per-item on-disk size: stat downloaded files, aggregate to album/season/series subtotals and a device total, format in consistent rounded human units; surface size on cards and detail pages, the device total on the Downloaded surface, and a reclaim figure in the remove confirmation | Downloads | UR-056 | Done |
| DR-086 | Settings page persists each control on change via per-group writers (`playerSetAudioSettings` / `playerSetVideoSettings` / `updateCacheConfig`) rather than a batch Save action; slider controls persist on `change` (pointer release) not each `input` tick; no Save button, `saving`, or `saveMessage` state | Settings | UR-057 | Done |
| DR-087 | `MediaCard` gains an `onLongPress` prop with pointer-based long-press detection (~500 ms hold, cancelled on >10 px move so carousel scroll is unaffected, trailing click suppressed); home carousels wire tap→detail/focus routing and long-press→confirm→player; episode taps route to `/library/<seriesId>?episode=<id>`; the bare-episode detail page links to its parent series/season | UI | UR-058 | Done |
| DR-088 | Skip-to-next-episode marks the outgoing episode played (`markAsPlayed`) instead of reporting a stop position, and arms a one-shot suppression consumed by the player's stop handler so `VideoPlayer`'s post-navigation unmount stop report cannot overwrite the 100% progress with the partial position | UI | UR-059 | Done |
| DR-089 | Continue Watching suppresses resume entries superseded by Next Up: an in-progress episode whose series has a next-up entry strictly later in series order (season, then episode) is dropped from the Home and TV rows; movies, series without a next-up entry, and items with unknown/mixed episode ordering are always kept | UI | UR-059 | Done |
| DR-090 | Relevance ranking in Rust (`domain/search_rank.rs`): results sort by match position (prefix → word-start → mid-word substring → no name match) then by media kind (containers before their contents), stably so the backend's own relevance breaks ties. Applied in `repository_search` to both the instant cache result and the merged cache+server union, so the list does not reshuffle when server results land | Backend | UR-060 | Done |
| DR-091 | Search result groups split TV into separate Shows and Episodes groups and add a People group (default order: Shows → Episodes → Movies → Songs → Albums → Artists → People); a stored `tvShows` order from before the split expands in place to shows+episodes so an upgrading user keeps their arrangement | UI | UR-060 | Done |
| DR-092 | Video tap gestures resolve in `tapGestures.ts` (pure, unit-tested) rather than inline in `VideoPlayer.svelte`: `registerTap` classifies each tap and the component acts on it immediately — `togglePlayPause` for a first tap, or `seek` (+30 s right / 10 s left) plus a re-toggle for a second tap inside `DOUBLE_TAP_WINDOW_MS` (300 ms). A consumed pair resets the state, and a swipe forgets the tap. The deferral this originally used was removed in DR-098, which also covers suppressing the compatibility `click` the browser synthesizes after a touch tap. `resolveSeekTarget` converts the delta to the absolute position the facade requires, clamped per DR-095 and chained off a still-in-flight `pendingSeekTarget` so back-to-back skips accumulate instead of all resolving against a not-yet-updated position | UI | UR-061 | Done |
| DR-094 | Frontend boundary tripwire (`scripts/check-frontend-boundary.sh`) detects Jellyfin item-type array literals **anywhere** in `src/` rather than only inline at an `includeItemTypes:` query site, so a category→type mapping cannot evade the check by being assigned to a named const (the evasion that let the `scoped-search` leak pass CI); requires two adjacent type literals so single-type presentation and `item.type ===` inspection stay legal, and caps the allowlist to force taxonomy into Rust instead of accumulating exceptions | Tooling | - | Done |
| DR-098 | Video tap gestures act **immediately** — no deferral, no timer, and only first/second taps exist. A first tap toggles play/pause; a second tap inside `DOUBLE_TAP_WINDOW_MS` seeks *and* toggles again, so the two toggles cancel and a double tap preserves the play state (playing → jump and keep playing; paused → jump and stay paused). This replaces a design that deferred the first tap behind a 300 ms timer so a second tap could cancel it: the timer cleared its own handle *before* invoking the toggle, which reopened the `tapTimeout !== null` guard in `handleVideoClick` meant to suppress the compatibility `click` Android's WebView synthesizes after a touch — the late click then toggled a second time, producing a pause/unpause loop (long-press was unaffected, which is what identified the tap path). Click suppression no longer depends on the timer: `handleVideoClick` ignores `detail === 0` *and* any click within `TOUCH_CLICK_SUPPRESS_MS` of a touch tap. A swipe undoes the touchstart toggle exactly once (latched on `swipeGestureActive`) so brightness swipes never change play state. Click suppression is shared by **every** click target layered over the video via `isSynthesizedTouchClick`, not just the `<video>`: pausing renders a full-screen play-overlay button, so the synthesized click lands on *that* and an unguarded handler there resumed immediately — pausing appeared impossible while unpausing worked, because unpausing removes the overlay | UI | UR-061 | Done |
| DR-099 | The video seek bar is usable by touch. Two Android-only defects made dragging or tapping it move the thumb without moving playback. (a) *Gesture hijack*: the container-level gesture layer skips `touchstart` on a control (DR-098) but kept handling `touchmove`, so a seek-bar drag was measured against the **previous** gesture's start point — a huge bogus vertical delta that read as a brightness swipe, dimmed the screen to the 0.3 floor, and fired a spurious play/pause "correction" mid-drag. A gesture is now latched at `touchstart` (`playerGestureActive`) and `touchmove` ignores anything not latched, since re-checking the move target cannot recover a start point that was never recorded. (b) *Commit signal*: the seek was committed **only** from `change`, which Android's WebView does not reliably fire for a touch interaction on a range input — the thumb moved to the tapped position and no seek ever ran. `touchend`/`mouseup` now commit as well; `input` arms a one-shot latch so whichever release signal arrives first commits and the other is a no-op. `seekRelative` shares the same `commitSeek` entry point instead of fabricating a synthetic `change` event | UI | UR-005, UR-061 | Done |
| DR-097 | Transport authority (play/pause/toggle) lives in Rust for **webview-rendered** media, not just native. The controller tracks the state the HTML5 element reports (`html5_playing`, fed by `report_html5_state`, which now *stores* rather than only re-emitting); `play`/`pause`/`toggle_playback` consult it and drive the element by emitting a `ControlCommand` that `playerEvents.handleControlCommand` executes against the active adapter. A `stopped`/`idle` report clears it so the native backend (MPV/ExoPlayer) regains authority for music. The frontend facade no longer short-circuits transport into the adapter: `adapter.toggle()` previously decided play-vs-pause by reading `el.paused` off the DOM, a value that flips transiently while an element buffers or settles a seek — so two intents ~150 ms apart read *different* values, performed *opposing* actions, and self-sustained a play/pause loop needing no further input (observed on Android with a fully-buffered `readyState=4 networkState=1` element). Same "backend decides, adapter executes the primitive" split as `player_seek_video` | Player | UR-005 | Done |
| DR-096 | `Html5PlayerAdapter.play()` is resilient to stall recovery: an in-flight attempt is memoised so concurrent callers (UI plus hls.js gap-controller recovery) share one `element.play()` instead of stacking calls, and an `AbortError` ("play() request was interrupted by a call to pause()") is logged at debug rather than pushed to `host.onError`. The browser raises it whenever a pending play promise is superseded by a pause/seek/source change, which hls.js does routinely while nudging past a stall — reporting it surfaced a player error roughly once per second for the whole stall and left the UI stuck showing paused | Player | UR-005 | Done |
| DR-095 | Seek targets clamp strictly *inside* the media (`clampSeekTarget`, `END_SEEK_MARGIN_SECONDS` = 6 s ≈ one HLS segment) instead of to the exact `duration`. Landing on the duration makes hls.js request the segment whose start time lies past the end of the media (e.g. a 6330.324 s item → segment 1055 starting at 6336.33 s), which Jellyfin never produces; the fetch times out and hls.js' gap-controller stalls at the last buffered position, presenting as "unpausing or skipping bounces straight back to paused". Applied on both seek paths — the relative-skip `resolveSeekTarget` and the seek-bar drag, whose range input `max` is the duration itself — and floored at 0 so media shorter than the margin still seeks to the start | UI | UR-061 | Done |
| DR-100 | Leaving a video and re-entering it renders the **video** player, never the audio one. Both halves of the `/player/[id]` decision are pure and unit-tested in `playerSurface.ts`. (a) `shouldReuseActivePlayback` excludes video: the "already playing, just show the UI" shortcut (added for expanding the audio mini player) returns *before* a stream URL is fetched, which is fine for audio — the backend owns the stream and the route only mirrors it — but leaves `<VideoPlayer>` with nothing to render. Closing a webview-rendered video deliberately emits no `stopped` state (that would break the autoplay handoff, see DR-047), so the Rust controller still reports that movie/episode as its loaded media and re-entering the same item hit the shortcut. (b) `resolvePlayerSurface` maps video-without-a-stream-URL to `pending` (spinner) instead of falling through to `<AudioPlayer>`, so no future path can put video content in the audio surface. Video now always takes the full load path, which fetches the stream URL and applies the stored resume position | UI | UR-005 | Done |
| DR-101 | "Where is this viewer in this series" is resolved in **Rust**, not the frontend. `repository_get_series_episodes` performs the season fan-out (`get_items(series_id)` → seasons → `get_items(season_id)`, plus the flat-series fallback for shows whose children are episodes rather than season folders) and returns them in series order — season index ascending, episode index ascending, specials (season 0) after every numbered season. `repository_get_series_current_episode` layers the pure policy `pick_current_episode` over that list: an **in-progress** episode wins (earliest in series order on a tie — it is literally where playback stopped, and Next Up would skip past it), then the server's **Next Up** for that series, then the **first unwatched** episode, then the first. The third rung is the offline path, not dead code: `OfflineRepository::get_next_up_episodes` returns an empty vec, so without it the feature would be online-only. A failing Next Up or resume lookup degrades to empty rather than failing the call. `repository_get_next_up_episodes` had accepted a `series_id` since it was written and **no caller had ever passed one** | Repository | UR-062 | Done |
| DR-102 | The series detail page anchors on that answer. It calls `repositoryGetSeriesEpisodes` once instead of fanning out over seasons in TypeScript (the fan-out *and* its flat-series fallback were domain knowledge in the presentation layer), groups the returned episodes under season headers by `parentIndexNumber`, and passes the resolved current episode to `SeasonSection``EpisodeRow`, which renders a highlight ring and scrolls itself into view. The hero button navigates to `/library/<seriesId>?episode=<currentId>` — the Episode Focus View, where an explicit Play/Resume commits — per ux-flows §5B.5: Play on a *container* is navigation, Play on a *leaf* commits. It previously resolved `$libraryItems[0]`, the first **season** by `SortName`, and navigated to `/player/<seasonId>`, which the player route bounced back to `/library/<seasonId>` — so Play on a series played nothing and landed on the season-1 page | UI | UR-062 | Done |
| DR-103 | A season is not a destination. `/library/<seasonId>` redirects to `/library/<seriesId>#season-<indexNumber>`, the anchor `SeasonSection` renders, so a season link scrolls the series' continuous episode list rather than opening a page. Every inbound link follows: the episode breadcrumb, `handleItemClick case "season"`, the TV landing page's `case "Season"`, and `DownloadedBrowse`. A season carrying no `seriesId` (deep link into a stale cache) still renders the generic view so the user is never stranded. This removes a surface that had no route of its own — it fell through the detail page's `kind` chain to the generic "Contents" poster grid, contradicting ux-flows §5A.2 (episodes must be a row list), and clicking an episode there opened a bare Episode page, which §5B.1 forbids | UI | UR-062 | Done |
| DR-104 | The "More Episodes" strip spans the **whole series** in series order, per ux-flows §5B.2's cross-season continuity rule: at the end of a season the window runs on into the next season's first episodes instead of dead-ending. `adjacentEpisodes` previously filtered the pool to `parentIndexNumber === current.parentIndexNumber` and sorted by `indexNumber` alone, so the window could never leave the current season — and, when episodes of several seasons did reach it, sorting by episode number alone interleaved them. Cards crossing a season boundary are labelled `SxEy` rather than a bare episode number so the jump is legible | UI | UR-062 | Done |
| DR-105 | Video library routes collapse to one per library. `/library/tv` and `/library/movies` render browse / all-titles / genres as in-page tabs driven by `?view=`, omitted for the default `browse` (the convention `searchRouteUrl` already uses for the `all` scope); `resolveLibraryView` is pure and unit-tested. The four legacy routes become redirect-only `+page.ts` loads rather than deletions, because `GenreTags` links to them and users have them in history; `resolveSearchScope` keeps its `/library/shows` branch for the same reason. The "Browse" tile grid at the bottom of both landing pages is removed — it was a second navigation affordance to the same destinations the carousels' "Show all" links already reach | UI | UR-063 | Done |
| DR-106 | Erasing watch history goes through the repository, not the local cache: `clear_watch_history(item_id)` maps to Jellyfin's `DELETE /Users/{userId}/PlayedItems/{itemId}`, which clears the played flag *and* zeroes the resume position, and which the server applies recursively to a folder — so one call handles a whole series or season. `OfflineRepository` returns `RepoError::Offline` rather than clearing locally, because history diverged only on the device would be silently undone by the next sync; the button disables itself while the server is unreachable. `ClearHistoryButton` is shared by the series hero and each `SeasonSection` header, confirms before acting (there is no undo), and reloads the page on success so the recomputed current episode — the premiere, for a fully cleared series — is what the viewer sees | Repository | UR-064 | Done |
| DR-107 | Seasons on the series page are collapsible, and **only the current season is expanded** on load — the one holding the episode DR-101 resolved. A show with ten seasons otherwise renders every episode of every season at once, burying the one episode the viewer came for under hundreds of rows. Expansion state is per season and pure (`initialExpandedSeasons` in `seriesNavigation.ts`): the current season, or the first season when there is no current episode, so a never-watched show still opens on season 1 rather than fully collapsed. A `?episode=` deep link expands that episode's season too. Toggling is local and not persisted — it is a reading position, not a preference | UI | UR-062 | Done |
| DR-108 | The instant (cache) leg of `repository_search` searches the **synced catalog**, not just downloads. `OfflineRepository::search` replaces its `downloaded_items` CTE with the `available_items` CTE `get_items` already uses — the same downloads branches plus a `synced_at IS NOT NULL` branch gated on the same `include_catalog_browse()` flag — so search and browse cannot diverge on what is visible. Online (flag true) search reads the whole index and answers before any HTTP request completes; offline with "Show all server media" off (flag false) it stays downloads-only, unchanged. Requires no frontend change, since the flag is already set correctly for all three states. The `include_item_types` filter is switched from string interpolation to bound parameters, as `SearchOptions` is settable from the frontend and not only from `SearchScope` | Backend | UR-065 | Implemented |
| DR-109 | Index freshness is a Rust-owned policy, not a frontend startup call. A tokio task ticks every 30 min and runs a full pass when a repository is active, the server is reachable, and `last_catalog_sync` (already persisted to `app_settings`, previously read only for a UI hint) is older than `CATALOG_INDEX_TTL` (6 h); the `ConnectivityMonitor` reconnect signal re-evaluates the same condition immediately. An `AtomicBool` prevents concurrent passes, replacing `offlineCatalog.ts`'s `syncInProgress` — the frontend trigger is removed rather than left alongside, since two triggers with one guard each is how double-crawls happen. `RepositoryManager` gains an active-handle slot so the task has something to run against. Progress is emitted as the kebab-case `catalog-index-event` | Backend | UR-065 | Implemented |
| DR-110 | Index hygiene. `save_to_cache` switches from `INSERT OR REPLACE INTO items` to `ON CONFLICT(id) DO UPDATE`: REPLACE fires no `AFTER DELETE` trigger unless `recursive_triggers` is on (it is not — only `foreign_keys` and `journal_mode` are set), so `items_ad` never ran, and because `items.id` is a `TEXT PRIMARY KEY` each replacement also took a fresh rowid and appended a second `items_fts` entry — a duplicate index per sync, invisible in results but permanently degrading `MATCH`. The upsert preserves the rowid `items_fts` keys on and fires `items_au`; migration `021_rebuild_items_fts` clears orphans on existing installs. Separately, a post-crawl sweep deletes synced-but-not-downloaded rows a successful library crawl did not return, so media removed from the server stops being searchable; it skips items with completed downloads and skips any library whose crawl errored, because `items.parent_id` is `ON DELETE CASCADE` and a partial crawl would cascade away a whole series | Storage | UR-065 | Implemented |
| DR-111 | The index covers what the result groups render: `CATALOG_ITEM_TYPES` gains `MusicArtist` and `Playlist`, and migration `022_people_fts` adds a `people_fts` virtual table over the existing `people` table (which had no FTS, and is populated incidentally by item-detail fetches) with the same trigger pattern as `items_fts`. `OfflineRepository::search` UNIONs `people_fts` matches in as `Person` items when the resolved scope admits them — i.e. `SearchScope::All`, which expands to no filter (DR-063). Without this, the Artists and People groups UR-060 mandates can only ever be filled by the server leg | Storage | UR-065, UR-060 | Implemented |
| DR-112 | Safe-area insets come from **native**, not from `env()` alone. `env(safe-area-inset-*)` is 0px without `viewport-fit=cover` (missing from `app.html`, so every safe-area rule in the app was already a no-op), and even with it Android WebView maps only the *display cutout* — never the status bar or navigation bar. Since `enableEdgeToEdge()` plus `targetSdk 36` make edge-to-edge unconditional, the WebView always spans the system bars, so CSS could not learn about them by any route. `WindowInsetsBridge` reads the real insets and publishes `jt-inset` custom properties; `app.css` folds them with `env()` via `max()` into `--safe-*`, which is the only thing components may pad from. Ownership is exactly one element per edge: the app shell takes top/left/right, and BottomUi takes bottom wherever it renders (`shellReservesBottomInset` hands it back to the shell on routes with no bottom UI) so the padding sits inside BottomUi's surface box and the colour extends behind the gesture bar. The full-screen players inset their control layers only, leaving video and artwork edge-to-edge. The theme's `fitsSystemWindows=true` — which claimed the opposite and was overridden at runtime and ignored at this target SDK — is removed | UI | UR-066 | Done |
| DR-113 | `MediaItem.user_data` is populated from the server instead of being hardcoded `None`. `JellyfinItem` gains a `UserData` field (`#[serde(alias = "UserData")]` → the existing `UserData` type) and `to_media_item` maps it, so every list and detail response carries favourite/played/resume state. `UserData` is named explicitly in the `Fields=` list rather than relying on Jellyfin's default. Without this no card or detail page can render a favourite it did not itself set, and the mini player's per-track `storageGetPlaybackProgress` fetch is the only way to colour one heart | Repository | UR-069 | Done |
| DR-114 | Server favourite state is mirrored into the local `user_data` table by `OfflineRepository::save_to_cache` — the single choke point every cached server result passes through — so offline browsing and the offline Favourites page see the same favourites as the server. The upsert carries `pending_sync = 0` and is guarded by `WHERE user_data.pending_sync = 0`, which is the conflict rule: a toggle made offline is never overwritten by a stale server value before it has been pushed | Storage | UR-069 | Done |
| DR-115 | Cross-library favourites query: a `get_favorites(scope, options)` repository method plus the `repository_get_favorites` command. Online issues `Filters=IsFavorite&Recursive=true` with `IncludeItemTypes` expanded from `SearchScope::item_types()` in Rust (the frontend sends the opaque scope, never a type list — DR-063); offline reads `items ⨝ user_data (is_favorite = 1)` under the same `include_catalog_browse()` gate as browsing; hybrid races cache against server like `get_items` — saving server results through to the cache on a miss, so the favourites page does not re-query the server every visit and the DR-114 mirror is filled on a fresh install — and applies the DR-080 rule that an empty offline result is authoritative when the gate is off. The command falls back to this read when nothing is cached, rather than painting an empty state it will correct a round trip later. A separate method rather than `get_items` because favourites span libraries and `get_items` is `ParentId`-shaped | Repository | UR-067 | Done |
| DR-116 | `GetItemsOptions.favorites_only` filters an existing library listing in place — online by appending `Filters=IsFavorite`, offline by joining `user_data` into the existing `available_items` CTE so the downloads-only gate still applies. This is what backs the per-library favourites toggle, and composes with the genre and item-type filters already there | Repository | UR-067 | Done |
| DR-117 | The Favourites page (`/library/favorites`) renders favourites across libraries with All / Movies / Shows / Music scope tabs, reusing `LibraryViewTabs` + `LibraryGrid` + `MediaCard` so card shape still follows the media (§5A.1) and a mixed All tab reads as posters, squares and thumbnails side by side. Each tab sends a `SearchScope` value and nothing else. Reached from the library overview and from "See all" on the home rows | UI | UR-067 | Done |
| DR-118 | Home carries favourite rows for movies, shows and music, loaded via `repository_get_favorites` per scope and rendered below Recently Added. A row with no items does not render at all, so a fresh install shows no empty favourite rows | UI | UR-067 | Done |
| DR-119 | `FavoriteButton` is mounted wherever a whole item is shown — movie/series/episode detail heroes, album/artist/playlist headers, and as a `MediaCard` artwork overlay — and a `favorites` store holds in-session optimistic state so un-hearting on one surface updates every other without a refetch. Resolution order is `store override ?? item.userData?.isFavorite ?? false`. On a card the heart is its own button and stops propagation, so hearting never also opens, plays, or triggers the §5B.5 long-press; it is suppressed on server-only (greyed) cards | UI | UR-068 | Done |
| DR-120 | Favourite toggles made while offline reach the server. A Rust drain, triggered by the `ConnectivityMonitor` offline→online transition, pushes every `user_data` row with `pending_sync = 1` and clears the flag on success, leaving failures pending for the next transition. It lives in Rust rather than the frontend because a frontend drain dies with the component that started it. Both the drain and the hybrid background refresh emit the kebab-case `favorites-changed` event (`{ itemIds }`) so open views update — without it a favourite marked on another client appears only on the *second* visit to a page, since the cache-first read returns local rows and the server refresh is invisible to the frontend. Supersedes the unused `syncService.queueFavorite`, which is deleted rather than left as a second queue | Backend | UR-069 | Done |
| DR-121 | Player quality selector: Rust reports the bitrates available for the current media source and owns the quality→transcode-parameter mapping (the one `get_video_download_url` already holds — playback calls into it rather than restating it, or the two tables drift). Changing quality re-negotiates the stream URL and resumes at the current position with audio/subtitle selection preserved. On Linux, video re-negotiates *within* HLS: returning `stream.mp4` is the documented cause of transcoded playback never starting. The frontend renders the list and remembers the choice; it does not decide what the choice resolves to | UI | UR-070 | Proposed |
| DR-122 | The playback path is ephemeral. Streamed bytes are never persisted unless DR-124 rules them keepable, and any in-flight capture is abandoned — partial file deleted, never promoted — the moment the viewer changes quality, because a capture spanning a rendition change is a splice of two encodings rather than a playable file | Playback | UR-070 | Proposed |
| DR-123 | The download path is independent of playback: a whole-file fetch through the existing download manager at one canonical quality (default `original`, the direct static copy) over the Range-capable `/Videos/{id}/stream.mp4`, unaffected by bitrate changes and completing into an ordinary `downloads` row so offline browsing and `refresh_queue_local_sources` pick it up unchanged. Prerequisite: downloaded video is currently never played locally — `repository_get_video_stream_url` goes straight to the online repo and the player route calls it with no local check, so a completed video download is still streamed. Without that fix nothing in this spec is observable for video | Repository | UR-071 | In Progress |
| DR-124 | Streamed bytes are kept only where they *are* the download artifact — a direct-play session. Android uses ExoPlayer `SimpleCache`/`CacheDataSource` keyed by item **and** media-source id so renditions cannot collide, sharing the existing smart-cache storage budget rather than opening a second one over the same disk; Linux audio uses mpv `stream-record`, abandoned on seek because it is documented as intended for linear streams and seeking breaks the recording. Transcoded Linux video is **not** captured: HLS segments are not a file, and assembling one needs ffmpeg, which is not a dependency and which CI may not install at job time — DR-123 covers that case instead | Playback | UR-071 | Proposed |
| DR-125 | A capture is promoted to a completed `downloads` row only when it covers the whole resource; partials stay evictable cache. A new `downloads.source_rendition` column records the negotiated quality/container/codec (`NULL` for the existing paths, which are always `original`) so a captured transcode and a real download are distinguishable rows and an "upgrade to original" remains possible. A quality change never touches a file that already exists — not a permanent download, and not a completed temporary one, both of which stay valid copies of the rendition they hold. It invalidates only an **in-flight** capture or background download of cached media, which is abandoned and restarted at the newly chosen quality, because a capture spanning a rendition change is a splice of two encodings rather than a playable file | Storage | UR-071 | Proposed |
| DR-126 | Cache eviction only reclaims the *temporary* tier. `evict_lru_async` selected every completed download ordered by `completed_at ASC` with no `download_source` filter, so hitting the 10 GB storage limit deleted the **oldest** download — typically a film saved deliberately for offline — to make room for a newly precached track. It now evicts only `COALESCE(download_source, 'user') = 'auto'` rows; `COALESCE` rather than a bare equality because rows predating migration 012 can be NULL and unknown provenance must be treated as the user's, never as disposable. Freeing less than requested is the correct outcome when only user downloads remain — the caller reports "unable to free enough space" instead of silently deleting them | Storage | UR-071 | Done |
| DR-127 | A cache entry *is* a download with a shorter life: same `downloads` row and same file handling, distinguished by `download_source = 'auto'` plus an expiry, so there is one storage model rather than a cache and a download library that can disagree. Temporary rows are reclaimed on whichever comes first — the life limit elapsing, or eviction under space pressure (DR-126). Permanent (`'user'`) rows have no expiry. A temporary row can be promoted to permanent by the user choosing to keep it, which only clears the expiry and flips the source; the bytes never move | Storage | UR-071 | Done |
| 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-154 | A watch position that cannot reach the server is queued, not dropped. `sync_queue` and its drain (DR-131) were built, tested and running, but the stop-report path never fed them: `HybridRepository::report_playback_stopped` is a bare pass-through to the online repository ("Playback reporting goes directly to server"), and on failure the error surfaced to a frontend `catch` whose own comment read "Server error - could queue, but for now just log". Both producers that *would* have queued it — `PlaybackReporter::queue_for_sync` in Rust and `syncService.queuePlaybackProgress` on the frontend — have no callers on the playback path, so closing a video while the server was unreachable lost the resume point outright even though `user_data.pending_sync` was dutifully set to 1 and nothing ever drains that flag for positions (unlike favourites, DR-120). The command layer now enqueues a `report_playback_stopped` row whenever the push fails, which the existing drain already knows how to parse and replay. The pending row for an item is **superseded in place** rather than appended to: progress is reported every 10s, so a server that stays down would otherwise add a row per tick, all of them obsoleted by the newest — the unbounded queue DR-131 exists to prevent. Only `pending`/`failed` rows are superseded, because an `abandoned` row has been given up on and reviving it would restore that same growing counter. Queueing is best-effort and never fails the command: the local position is already saved, so a failed *queue* write must not be reported as a lost position | Backend | UR-025, UR-002 | Done |
| DR-155 | A watch position set on another device reaches this one. The resume check reads the local `user_data` row and nothing else, but `mirror_user_data` — the only path by which server `UserData` lands in that table — mirrored `is_favorite` alone, and returned early whenever that field was absent, which is exactly the shape of an ordinary watched episode. So `playback_position_ticks` was write-only from this device's perspective: watch 40 minutes in a browser, open JellyTau, and it resumed from whatever *this* device last saw or offered no resume at all — the same user-visible symptom as DR-150's Android bug, from an unrelated cause, which is why resume read as broadly flaky. The mirror now carries the position alongside the favourite flag under the same `pending_sync = 0` conflict rule, so a local position still waiting to be pushed is never pulled *backwards* by a server that has not yet heard where we got to; `COALESCE(excluded.x, user_data.x)` means a field the server omitted keeps its stored value rather than being nulled, and a row with neither field is still skipped rather than fabricated as zeroes. Mirroring alone was not sufficient: `get_item` — the call the player route makes — returned the cached copy on a hit and never consulted the server, so for an already-cached item the mirror never ran. It now refreshes in the background on a cache hit (`race_with_refresh`, the reusable form of what `get_items` already did inline), which is why browsing a season picked up other devices' state while opening the episode directly did not. The refreshed value lands for the next read, the cache-first race still answering immediately | Backend | UR-025, UR-002 | Done |
| DR-156 | A page no longer inherits the previous page's scroll position. The shell keeps its scrollers alive across navigation by design — the root layout, the home page and the library layout each own a `flex-1 overflow-y-auto` box that outlives the route inside it, which is what lets `BottomUi` be a flex sibling rather than a measured overlay — but the element therefore never remounts and its `scrollTop` survives the route change. SvelteKit's own scroll restoration could not help: it saves and restores `window` scroll, and in this app the window never scrolls at all, so there was no scroll handling of any kind. The symptom was that opening an item from half-way down a library grid dropped the viewer half-way down the detail page, and returning to the grid landed at the top of it — exactly backwards. `ScrollMemory` (pure, one instance per container, keyed on path + query so a genre-filtered grid keeps its own place) records the offset a route is left at in `beforeNavigate` and decides in `afterNavigate`: `link`/`goto`/`form` reset to the top, `popstate` restores that route's saved offset, and the initial `enter` is left alone. Deciding does not consume the offset, so a route returned to more than once restores each time. Applied via the `scrollContainer` action on all three scrollers | UI | UR-072 | Done |
| DR-160 | Picture-in-picture works on the path that actually plays video. PiP shrinks the whole *Activity*, so `canEnterPip` demanded a native ExoPlayer `SurfaceView` be attached and rendering — `isPlayingVideo() && getSurfaceView() != null && isVideoSurfaceAttached()`. But the native path sits behind `experimentalNativeVideo`, which defaulted to **off**, so in the shipping configuration video played in the WebView's `<video>` element and all three conditions were false. `enterPip` bailed with "Not entering PiP: no local video playing" every single time: the button was offered (gated only on OS capability) and could not work, however it was pressed. The manager now accepts either surface. The frontend reports the element through `AndroidPictureInPicture.setHtml5VideoState(active, width, height, playing)` — intrinsic size because the PiP window's aspect ratio came from the letterboxed surface's measured bounds, which do not exist here, and play state because `ExoPlayer.isPlaying` is false on this path and the PiP play/pause action would be frozen on "Play" mid-playback. Two behaviours invert when the WebView *is* the video: it must stay visible in PiP rather than be hidden (`hideWebView` is now gated on the native path — hiding it would leave an empty black window), and the play/pause `RemoteAction` has to reach the element, so the receiver dispatches `jellytau-pip-play`/`jellytau-pip-pause` DOM events instead of driving ExoPlayer. `jellytau-pip-entered`/`-exited` let the player strip its own chrome, since controls, title and gradients would otherwise be rendered into a window a couple of inches wide. The `<video>` is deregistered on teardown so PiP is never offered over a video that has gone | UI | UR-041 | Done (pending device verification) |
| DR-167 | Each downloaded library shows only its own media. Cached items carry no link back to their library — `library_id` and `parent_id` are NULL on every row ([[offline-libraries-never-cached]]) — so `get_downloaded_items` matched the library branch with `EXISTS (SELECT 1 FROM libraries l WHERE l.id = ?)`, which asserts only that the requested library *exists* and never constrains the item to it. Opening any downloaded library therefore listed every downloaded top-level item on the server: films under Music, albums under TV. The sibling query that decides which libraries *appear* already carried the right rule — a `collection_type``item_type` mapping — so the two disagreed about the same question. That mapping is now the named constant `LIBRARY_HOLDS_ITEM`, used by both, and a library of unknown collection type still keeps everything rather than being emptied by a rule that cannot classify it. The taxonomy stays in Rust, never the frontend | Downloads | UR-055 | Done |
| DR-168 | Pause and resume actually stop and restart the bytes. `pause_download` wrote `status = 'paused'` and did nothing else, and no cancellation existed anywhere in the download stack — no token, no flag, no abort — so the streaming task ran on, kept writing, and overwrote the row with `completed`/`failed` when it finished: the row flicked to "paused" and undid itself. `resume_download` had the mirror defect, flipping the row to `pending` without calling `pump_download_queue`; the pump runs when something calls it rather than polling, so a resumed download sat untouched until an unrelated event happened to pump the queue. A per-download stop flag (`download::stop`) is the missing half — a module-level registry because the two sides never meet, the command holding Tauri state and the worker running detached in `async_runtime::spawn`. The worker reads it between chunks and on retry (so a pause is not swallowed by a 45-second backoff), flushes, and returns `Stopped`, which is deliberately **not** retryable and **not** recorded as a failure: the `.part` file is left intact because that is exactly what the resume's Range request continues from. Registering returns a *fresh* flag, or a resumed download would inherit the pause that stopped it and halt instantly. Cancel and `clear_stale_downloads` signal it too, so neither deletes a file still being written | Downloads | UR-055 | Done |
| DR-169 | Partial files are actually reaped. The worker named its sidecar with `Path::with_extension("part")`, which *replaces* the extension — `movie.mp4` became `movie.part` — while every cleanup path deleted `"{file_path}.part"`, i.e. `movie.mp4.part`. The two never matched, so the partial file of every cancelled or failed download stayed on disk indefinitely, invisible to the disk-usage totals because no `downloads` row pointed at it. `partial_path` appends instead, is the single definition both the writer and the cleaners use, and incidentally removes a collision the old form had, where `movie.mp4` and `movie.mkv` mapped to one `movie.part` | Downloads | UR-055 | Done |
| DR-173 | Downloading an album queues the **whole** album, and every track it queued is findable offline afterwards. Two independent gaps left an album with a handful of its tracks on the device while the button reported the album as downloaded. First, `download_album` took its track list from `items WHERE album_id = ?` — the local catalog cache. Jellyfin does not return `AlbumId` on every listing endpoint, so tracks cached by one of those endpoints sit in `items` with a NULL `album_id` and are invisible to that query; on the reporter's database three whole albums (18, 12 and 9 tracks) had it NULL on *every* track, so "download album" would have queued nothing for them, and a partially-linked album queued only the linked subset. Second, the frontend then resolved one stream URL per track from its own list and paired it with the returned row ids **by position** — a pairing with no basis, since the ids came back in the backend's `index_number` order over a different set of rows, so a row could be handed another track's URL and any track past the end of the shorter list was never started at all; on Android that loop also stopped wherever the webview was suspended. The same `album_id` is what `OfflineRepository::get_items` joins a track to its album on, so a track that did download stayed invisible under its album offline — the two halves of the same missing link. The operation now belongs to Rust end to end: `HybridRepository::get_album_tracks` asks the **server** what the album contains (cache-first `get_items` is right for browsing and wrong for deciding what to download) and errors offline so the caller falls back to the ungated local catalog, keeping the queue-while-offline flow; `queue_album_tracks` writes the album link onto every track it queues — queuing a track *is* the statement that it belongs to the album, rather than something to hope a listing endpoint recorded — and the stream URLs are resolved here through the existing reconnect resolver, now scoped to the rows just queued so one album cannot start every unrelated pending row. Nothing crosses the IPC boundary but the album id. Re-queuing a broken album heals it: the missing tracks are added and the tracks already on disk get their link. `download_series`/`download_season` still derive their episode lists from the cache the same way and want the same treatment | Downloads | UR-018, UR-055 | Done |
| DR-170 | Downloads at a chosen bitrate are no longer corrupted by their own retries. Only the `original` preset asks for `Static=true`; every other rung requests a **transcode**, which Jellyfin serves chunked, with no `Content-Length`, and cannot byte-seek — so it ignores `Range` and answers `200` with the whole stream from the beginning rather than `206` with the requested tail. The worker sent the Range header whenever a `.part` existed and appended the body unconditionally, so each retry and each resume concatenated a fresh copy of the entire transcode onto the bytes already on disk: the file grew past its real size and would not play, which is why "downloads for different bitrates" stayed broken after the `videoBitRate` casing fix (DR-adc460f3) corrected the *request*. `resume_offset` makes the response decide — append only on a `206`, otherwise truncate and take the stream from the top — and the total size is computed from that offset rather than from a partial length the server never agreed to | Downloads | UR-071 | Done |
| DR-172 | Native Android video is opt-in again, because as a default it shipped as **audio with no picture**. DR-161 flipped `experimentalNativeVideo` on so picture-in-picture could shrink a real video surface; on a device that produced sound and a blank screen. The decode path was never the problem — logcat showed ExoPlayer running (`Position update` ticks) and feeding a live `SurfaceView` with an active BufferQueue. The compositing was: the SurfaceView sits *behind* the WebView, and the step that clears the opaque layers above it never took effect, with `WebView transparent = false` logged and `= true` never appearing. So the video rendered correctly the whole time, behind an opaque page. This is exactly the defect the flag existed to contain — `VideoPlayer.scrubRegression.test.ts` had recorded that "the native SurfaceView has never been visible through the webview" — and enabling it by default shipped a verified decode path on top of an unverified display path. Reverting costs nothing that matters: PiP does not depend on it (DR-160 drives PiP from the WebView `<video>`), and working video outranks PiP showing a native surface. The flag stays available in Settings, now described as incomplete rather than as a performance win, and the scrub-regression mocks that were made explicit under DR-161 are kept explicit so those tests state which path they guard rather than inheriting a default that has now moved twice. Fixing the compositing is the prerequisite for trying this default again | UI | UR-003, UR-004, UR-041 | Done |
| DR-171 | A downloaded video keeps audio the device can actually decode. `original` quality asked for `Static=true`, which hands back the source file byte-for-byte — E-AC-3/AC-3/DTS/TrueHD track included — and video is rendered on both platforms by the webview `<video>` element, which decodes none of them. Streaming already knew this: DR-149 judges the track the server would serve against `WEBVIEW_AUDIO_CODECS` and forces a transcode over Jellyfin's own direct-play offer, because 10.11.5 honours a `DirectPlayProfile`'s container and video codec but ignores its audio codec. The download path never consulted that policy, so the *same film* had sound when streamed and played as picture in silence once downloaded — and offline a download is the only source a video has, so there was no working path left to fall back to. The rule is now one rule: `served_audio_codec` picks the track the server will serve (the default, or the first when none is marked) and both callers judge it, the streaming verdict staying a bool and the download path needing the codec itself so it can say what to re-encode. Only the audio is re-encoded — `allowVideoStreamCopy=true` keeps an h264 source's picture byte-for-byte and no bitrate or resolution cap is added, so `original` still means original quality; a source the webview could not have rendered anyway (HEVC) becomes h264 as a side effect, which is the only form of it that would have played. The decision is per item rather than blanket because the transcode costs the byte-range resumability `Static=true` gives the download worker (see DR-170 for what a chunked, length-less response does to a resume), so a file whose audio already plays keeps the direct copy. An unknown codec — item not fetchable, or the server named none — changes nothing: the policy only ever *adds* a transcode, so it cannot make a working download worse. The codec set judged against is the **webview's**, not the platform's, even though DR-161 made ExoPlayer the Android default: `experimentalNativeVideo` is a user setting, a downloaded file outlives whatever it was set to when the file arrived, and the narrow list is the only one that holds on both sides of it — at the cost of a Dolby-licensed device re-encoding a track its ExoPlayer could have played. `resolve_video_download_url` is the single entrance for all three resolution sites (the frontend's per-item command, the bulk series/season enqueue, and the offline-queued resume), since the pure builder cannot look a codec up and a caller that forgets to is exactly how the silent downloads shipped. **Files already downloaded stay silent** — the bytes on disk are the wrong bytes and only a re-download replaces them | Downloads | UR-071, UR-004 | Done |
| DR-162 | Video streams are opened against a **bandwidth ceiling the user chose**, instead of a fixed allowance nobody could change. Every video URL carried `MaxStreamingBitrate=20000000`/`VideoBitrate=18000000`, `PlaybackInfo` negotiated at 20 Mbps, and the device profile advertised `999999999` — so on a metered or slow connection the only lever was not watching. `StreamingQuality` is a ladder of ceilings (Original, 20/10/8/4/2/1 Mbps, 720 kbps) in which a step is not a label but a bundle of transcode parameters: the total ceiling, the audio share of it, and the resolution that budget can carry. It lives in Rust because those numbers are Jellyfin encoding vocabulary — the frontend names a variant and reads labels back over `player_get_streaming_qualities`, the same arrangement as the EQ preset curves. The video bitrate is the total *minus* the audio share, so the two together honour the cap rather than overshooting it by the size of the audio track, and `MaxHeight` falls with the ladder so a small budget is not spent on pixels it cannot afford. The cap has to reach the **negotiation**, not only the transcode URL: `max_static_bitrate` in the device profile is what makes the server refuse to direct-play a source fatter than the ceiling, and without it a 30 Mbps remux is handed over untouched and every URL parameter downstream is moot — which is why it is applied at all four places that decide bandwidth (the HLS builder, `PlaybackInfo`, `open_live_stream`, and the background-audio handoff, which takes the lower of the cap and its own 384 kbps). The ceiling is process-wide rather than a field on `OnlineRepository`, mirroring `INCLUDE_CATALOG_BROWSE`: it is a preference about *this device's connection*, it must survive a repository rebuilt on re-login, and every builder plus the negotiation have to agree on it or the cap leaks. Settings owns the durable default and is the only writer to `app_settings` — persisted unlike the rest of `VideoSettings`, because a limit set for a metered connection that silently reverts to uncapped on the next launch spends the user's data with no changed setting to show for it — and it is restored at startup from the async runtime, defaulting to uncapped if the read fails so a database problem degrades to the old behaviour rather than to an arbitrary limit. The in-player menu is the per-video override: a cap is a property of the stream the server is producing, so it cannot be applied to one already in flight — `player_set_stream_quality` re-opens the stream at the new quality and resumes at the current position, reloading a native backend itself and handing HTML5 a URL for the same `reloadSource` primitive the audio-track switch uses, so no strategy branch lives in the UI. It deliberately does not persist. This gives UR-070 its resume-at-the-same-point mechanism; the server-offered per-item rendition list that requirement also asks for remains proposed | Playback | UR-074, UR-070 | Done |
| DR-174 | Tiles of mixed shapes are laid out **justified** rather than gridded. A CSS grid gives every cell one box, so on a page holding square music covers, 16:9 library backdrops and 2:3 posters at once, everything that is not the chosen shape is cropped to it — the home shortcut strip was explicitly forcing `aspect="video"` on music libraries for exactly this reason, which lined the row up by cutting the covers down. `layoutMosaic` packs tiles into rows of a **shared height** and gives each its own width from its own aspect ratio: it adds tiles to a row until the height needed to fill the container has fallen to the target, closes the row there (so rows land at or below the target, never above), and justifies the row to the container width by absorbing the rounding remainder into its widest tile, where a pixel is least visible. The last row is deliberately *not* justified — with one tile left over, filling the width would inflate it to a banner — so it sits at the target height, left-aligned. Ratios are clamped to a band, which costs a crop on genuine outliers and stops one panorama owning a row or one very tall image shrinking to a sliver. It is a pure module with no DOM: the component supplies only the two things the DOM knows — the measured container width, and the artwork's *decoded* aspect ratio, reported by `CachedImage` so the layout uses the shape an image actually has rather than the one its item type implies. Those measurements are committed in one debounced batch rather than per image, because artwork arrives over several hundred milliseconds and re-packing on each arrival would shuffle the grid under the pointer repeatedly. Labels are drawn *over* the bottom of each tile rather than beneath it: a caption below sits outside the computed box, and one that wraps to two lines would break the row alignment the layout exists to provide | UI | UR-075 | Done |
| DR-175 | A library knows which favourites category it belongs to, and the frontend does not work it out. The mosaic offers a favourites tile per category beside its library, which needs a collection-type → category answer; deriving it in Svelte would have re-created the exact leak `SearchScope::item_types` was extracted to close (docs/specs/scoped-search-boundary.md) — one table of Jellyfin vocabulary, differing only in which vocabulary. `SearchScope::for_collection_type` maps `movies`/`tvshows`/`music` and returns `None` for everything else, so a Live TV or books library gets no tile at all rather than one opening an unfiltered list; `All` is never derived from a library, being the cross-library entry offered beside them rather than a property of one. `Library::new` stamps the result onto every library at construction — a constructor rather than a struct literal precisely so a derived field cannot be forgotten at one of the four sites — and it rides to the frontend as an optional `favoritesScope`, absent rather than null when there is none. The UI's remaining share is presentation only: what to call the tile, where to put it, and showing a category's tile **once** however many libraries share it, since two movie libraries have one favourites list between them | UI | UR-075, UR-067 | Done |
| DR-176 | The server is never asked to burn a subtitle into the picture. `PlaybackInfo` omitted `SubtitleStreamIndex`, which does not mean "none" — the server then honours the source's default/forced flag and picks a track itself. On a source whose default subtitle is image-based (PGS/DVD/DVB) that track cannot go out as a sidecar, so the server falls back to `SubtitleMethod=Encode` and composites it into the video. The cost lands on the *video*, not the subtitle: burn-in rules out remuxing, so an HEVC stream the device could have taken untouched is re-encoded frame by frame. Observed on an HEVC + E-AC-3 + PGSSUB episode, where only the audio actually needed transcoding: the server could not sustain the re-encode in real time, the buffer never grew past a single segment, and playback stalled every few seconds — taking seeking with it, since each seek restarted the encoder and cost seconds before the first frame. The fix is to request `SubtitleStreamIndex=-1` explicitly and to advertise every *text* format we can render (`srt`/`subrip`/`ass`/`ssa`/`vtt`) as `External`, so a subtitle can only ever arrive as a sidecar. Nothing is lost, because the app already fetches subtitle tracks itself and draws them over the video (UR-020) — the server's composited copy was always redundant. Image-based tracks are consequently not offered, which is honest rather than a regression: the renderer cannot composite a bitmap, and the previous behaviour paid for them by making the whole stream unwatchable. Both halves of that hold at the layer that can enforce them. The sentinel travels on the stream URL as well as in the negotiation, because the negotiation is not what opens most streams — a quality switch, a transcoded seek and an audio-track switch each rebuild the URL on their own, and an omitted index there lets the server pick the default track back up out of whatever session state it still holds. And "not offered" is enforced where the offer is made: each subtitle stream crosses the boundary carrying the backend's verdict on whether it can arrive as a sidecar, so the picker lists only tracks the app can draw instead of showing an entry that ticks and displays nothing. Only an explicit "no" hides a track, so a stream carrying no verdict behaves as before | Playback | UR-020, UR-004 | Done |
| DR-177 | Each video transcode this device opens is its own server-side job, and the one it replaces is stopped. Jellyfin keys a transcode job by device **and** play session, and every stream URL the app built carried the same hardcoded `DeviceId` with no `PlaySessionId` at all — so the second stream for an item was indistinguishable from the first. Re-opening a stream is not rare: a mid-playback quality switch (UR-074), a transcoded seek and an audio-track switch all do it, each leaving the previous ffmpeg running. Observed on-device when switching bitrate mid-film: the server served the new playlist, then rejected the new job's segments with `400 hls1/main/0.ts` while the two jobs contended for one transcode path, and playback stalled — reproducible against the server, where a second stream for a live job's item alternates between serving bytes and 400ing per attempt, which is what made it read as flaky rather than broken. `begin_video_play_session` mints a session id per open and reports the one it supersedes; the URL builder stops that job (`DELETE /Videos/ActiveEncodings`, un-retried and best-effort — a slow stop must not delay playback, and the new stream no longer collides either way) before returning. Placing it in the URL builder rather than in each caller means every re-open path is covered by construction. Two client faults made the same incident worse and are fixed with it: the fatal-HLS-error handler added the transcode seek offset to a position that already included it, so past roughly the halfway mark of a film any transient network error cleared the "near end" threshold and was reported as end-of-stream — turning a recoverable stall into a skip to the next item, exactly when a quality switch had just made the offset large; and the HTML5 reload primitive resolved on its own `canplay` timeout, so a reload the server never served reported success, leaving the picker showing a quality that was not playing and the caller with nothing to revert | Playback | UR-074, UR-004 | Done |
| DR-178 | Every position that leaves the app is read from the controller, not from a backend that may not be playing anything. `PlayerController::position()` forwards to the native backend, which is authoritative for exactly one of the three ways this app renders media. On the **webview** path — the shipping default for video on both platforms — nothing is loaded into that backend at all: the `<video>` element is the player, its ticks were re-emitted to the frontend and then dropped, and the backend answered 0 forever. During a **background-audio handoff** the base that converts the stream's relative timeline to the episode's is applied once at the native tick boundary (DR-159), so before ExoPlayer's first tick nothing has applied it and the reading is 0 there too. Both holes surfaced as the same user-visible bug through different doors: returning to the foreground while the audio-only transcode was still opening handed the frontend `0.0`, and the video reloaded at `StartTimeTicks=0` — the episode restarting from the beginning — while the `Stopped` report that followed wrote that zero to Jellyfin as the resume point. `absolute_position()` answers for all three paths: the maximum of the backend's reading, the last position webview-rendered media reported, and the handoff base. The maximum is exact rather than a heuristic, because at most one term is ever meaningful at a time and the base is a floor the stream cannot physically be behind. `duration()` gains the same fallback for the same reason. The element's reading is cleared wherever it stops being the player — teardown, a handoff taking over, a different item loading — so it can never be attributed to what plays next | Player | UR-005, UR-025, UR-040 | Done (pending device verification) |
| DR-179 | Jellyfin is told what was played: progress while it plays, and a stop when it ends. A device trace of 35 minutes' playback requested `/Sessions/Playing/Progress` **zero** times and sent 14 `Stopped` reports, every one of them at position 0. Three faults, one subject. *Progress never left the device*: the frontend service writes it to the local DB by design, and nothing on the Rust side reported it for webview-rendered media — so the server learned a position only when the player was closed, and a crash or a swipe-away cost the session. It is now reported from the controller's own position ticks, through the 30s throttler it already owned and shares with the native audio path, which covers all three rendering paths in one place instead of adding a second frequent IPC caller. *Zero-position stops were sent*: Jellyfin stores the reported position as the resume point, so a zero does not merely fail to inform, it instructs the server to forget — and no zero was ever real, each one coming from asking a player that was not rendering the media (see DR-178). They are withheld; one landed 40s after the frontend had correctly reported 15:22 for the same episode, overwriting it. *A finished episode reported nothing at all*: Jellyfin decides "watched" from the stop report and its percentage, and in background audio-only mode nobody sends one — the webview is suspended and its element was torn down at the handoff, while the backend advances to the next episode without a word about the one that ended, so an episode listened to end-to-end on the lockscreen never counted as watched. `on_playback_ended` now reports it stopped at its **runtime** (not the last tick, which can be seconds short or, on a handoff whose ticks stopped early, nowhere near the end) before any advance, since after one the queue's current item is the next episode. Scoped to the audio-only handoff, the case the frontend provably cannot cover, so foreground playback keeps its single existing report; music ending natively remains unreported and wants its own change. The reporting seam is a `PlaybackReportSink` the controller sends to, which also collapses three copies of the spawn-a-task-and-hope block into one and is what let all of this be written as failing tests rather than found on a device a second time | Player | UR-025, UR-005, UR-040 | Done (pending device verification) |
| DR-180 | A background-audio handoff of a **downloaded** episode starts where the video left off. The handoff prefers a local file over the audio-only stream (DR-128), but the two begin in different places and were treated alike: a stream is built with `StartTimeTicks`, so the server makes the handoff point that stream's zero and the base is the handoff position with no seek — while a file has no such parameter and begins at the episode's own zero, so basing it at the handoff position claimed minutes of audio that were about to play from the beginning. Backgrounding a downloaded episode therefore restarted it while the lockscreen scrubber, dutifully adding the base, showed the position it should have been at. `background_audio_plan` splits the two: a file gets no base and a real seek, a stream keeps the base and no seek (seeking one would skip *past* the content by the handoff position again). The same distinction settles an inbound seek — `seek_absolute` re-opens a *streamed* handoff at the requested position because a chunked length-less transcode cannot honour a seek, which is not true of local media, and `resume_stream_at` refuses a non-remote source outright, so routing a lockscreen scrub of a downloaded episode through it failed the seek rather than performing it | Player | UR-040, UR-071 | Done (pending device verification) |
| DR-181 | A resumed transcode plays. Every video stream URL carried the resume position as `StartTimeTicks`, which is correct for a progressive response and fatal for an HLS one: Jellyfin builds each segment URI by echoing the **master playlist's** query string into it, and its segment handler opens by rejecting any request carrying `StartTimeTicks > 0` (`ArgumentException``400`). One position on the playlist therefore 400s every `hls1/main/N.ts` behind it, so hls.js exhausted its retries and gave up — presenting as an episode that will not resume while the same episode from the beginning is fine, the `> 0` being exactly why the beginning survived. The parameter is also unnecessary there: a playlist spans the whole item and asking for segment N *is* the seek, which the server transcodes from. So it is removed from the URL builder entirely rather than conditionalised — the builder has one caller shape and no way to know whether the response will be segmented — and the position becomes what it always was for HLS, a seek issued once the player has loaded: the seek path reloads at zero and seeks the element, and the resume path lets the player seek itself. The progressive `/Audio/universal` builder used by the background-audio handoff is a different endpoint with no segments and keeps its `StartTimeTicks`, which is why an audio-only handoff resumes correctly and a video one did not | Playback | UR-004, UR-074 | Done |
| DR-182 | Native video shows a picture. The poster/title card is an opaque `bg-black` overlay drawn over the whole video area while `isMediaReady` is false, and **every** signal that clears it is emitted by the HTML5 `<video>` element — `canplay`, `loadedmetadata`, hls.js `FRAG_BUFFERED`, the `playing` event, and two `readyState` timeouts. The native path renders no such element (`{#if !!useHtml5Element}`), so on Android nothing could ever clear it: ExoPlayer decoded to a live SurfaceView behind a black div for the entire session. That is DR-172's "audio with no picture" report, and it is indistinguishable on screen from the compositing failure DR-172 attributed it to — which is why the flag was reverted rather than fixed. Both the overlay and the native branch date from the original POC commit, so the native path has never been able to reveal itself; the 2026-08-11 device verification predates neither and does not contradict this, since a spike run that never reached a steady state would not have shown it. The backend's own events are the equivalent signals and `nativeSignalRevealsVideo` is the rule for reading them: `state === "playing"` mirrors the element's `playing` event, and a position tick carrying a real position or duration mirrors the `readyState` backstops, covering a first state event that is dropped or arrives before the listener is attached. `buffering`/`paused`/`stopped`/`error` deliberately do not qualify — revealing on `error` would replace the title card with a transparent hole showing the launcher through the app. The rule is a pure module rather than a branch inside the component because the decision that was missing is exactly the part worth guarding, and the component needs a DOM and a mounted player to exercise | UI | UR-003, UR-004, UR-041 | Done |
| DR-183 | The JavaScript bridges are installed before the page that uses them loads. WebView binds an injected object into JS at **page-load time**: an `addJavascriptInterface` call landing after the page has loaded does not appear to that page. They were installed from `configureWebViewForMedia`, which finds the WebView by walking the view tree 500 ms after `onCreate` — a race against Tauri's own page load, and one that is *permanent* when lost, because the identity guard added for DR-097's stale-proxy bug then declines to re-inject on every later resume pass. The whole set (`AndroidVideoSurface`, `AndroidPictureInPicture`, `AndroidBackgroundAudio`, `AndroidNetworkType`, `AndroidImmersive`, `AndroidInsets`) would simply be absent from `window`, and silently: every call site optional-chains the bridge, so a missing one is a no-op rather than an error. This is a candidate explanation for DR-172's other piece of evidence — `WebView transparent = false` logged, `= true` never appearing, i.e. the enable call never reaching Kotlin at all. `WryActivity.setWebView()` calls the `onWebViewCreate` hook immediately before wry issues the first `loadUrl` (confirmed in wry 0.55's `main_pipe.rs`, where the `setWebView` JNI call precedes `load_url`), so a bridge installed there is bound by the time any page runs. The hook can fire during `super.onCreate()`, before the rest of our own `onCreate`, so only work needing nothing but the WebView moves into it — insets stay in `configureWebViewForMedia`, which runs later and on every resume. The tree-walk path is kept as a fallback, and `enableNativeVideoCompositing` now logs an explicit error when the bridge is missing, so the ambiguity that left DR-172 unresolved cannot recur silently | Android | UR-003, UR-004, UR-040, UR-041 | Done |
| DR-184 | The video SurfaceView leaves the view hierarchy when the video does. `VideoOverlayManager.detachVideoSurface` had **no callers anywhere in the tree** — the mirror of the DR-151 defect, where `setActivity` had none — so `attachVideoSurface` was one-way: `JellyTauPlayer.clearVideoSurface()` dropped its `surfaceView` reference and cleared ExoPlayer's without removing the view, leaving it parented to the content view for the life of the process, with the next native video adding another SurfaceView beneath it. The stack was invisible while the WebView was opaque, which is why it went unnoticed. Two consequences outlive the leak: `isVideoSurfaceAttached()` gates `PictureInPictureManager.canEnterPip` through `isNativeVideoPath()`, so it reported an attached surface forever after the first native video (saved from offering PiP over nothing only by the `isPlayingVideo()` check beside it), and every abandoned surface held its `OnLayoutChangeListener` on the content view. Detach is called from `clearVideoSurface`, which covers stop, the switch to audio, and the background-audio handoff, and always runs on the main thread because every caller is already inside a `mainHandler.post`. It removes the view from its *own* parent rather than looking the content view up from an Activity reference, so an Activity recreated underneath it cannot strand the view | Android | UR-003, UR-041 | Done |
| DR-185 | The app shell stops painting over the video surface. `app.css` clears the page's opaque layers for native video through three selectors, and one of them — `html[data-native-video="active"] [data-app-shell]` — was written against an attribute **no component has ever set, in any commit**. The shell is `+layout.svelte`'s root `div`, which paints `--color-background` across the entire viewport; VideoPlayer is `fixed inset-0 z-50` and correctly makes *itself* transparent on the native path, but it stacks *above* the shell, so the WebView still composited the shell's opaque background over the whole screen and the SurfaceView behind it could never be seen. This is the missing half of the compositing DR-172 went looking for: the spec's own layer table lists this layer as "cleared by `data-native-video` → app.css", which was written but never wired, and `html`/`body` being genuinely transparent made the CSS look correct in isolation. The failure is invisible three ways over — the CSS is valid, the selector is plausible, and a rule matching nothing looks exactly like a rule matching something already transparent — while the symptom (black screen, audio fine) is identical to a real compositing failure, which is how it survived DR-150 through DR-172. Fixed by setting the attribute the rule was written for, and guarded by asserting the *relationship* rather than the rule: every attribute the compositing block targets must be set somewhere in the app, so a selector aimed at nothing fails the suite instead of failing silently on a device | UI | UR-003, UR-004, UR-041 | Done |
| DR-186 | The play overlay comes down when the backend plays. `isPlaying` was assigned once from the `player_play_item` response and thereafter only by the `player://state-changed` listener — a channel the backend never emits, the same dead wire that DR-182's first fix was mistakenly hung on. On the native path the flag therefore froze at whatever the initial response said: with ExoPlayer playing, the UI still believed it was paused, so the `bg-black/30` play-button overlay stayed raised across the whole video area and the transport button kept showing ▶. The video was simultaneously dimmed and covered while it played, which reads as "the overlay never goes away" and is easily mistaken for a second compositing fault. The mirror reads the same `player` store `playerEvents.ts` feeds, which is what the architecture already says is authoritative — the player reports state, the UI consumes it — and is gated to the native path so HTML5 keeps its element-event wiring, which is authoritative there | UI | UR-003, UR-005 | Done |
| DR-187 | The system bars go away with the player, not only with the fullscreen button. `enterImmersive()` had exactly one caller, `toggleFullscreen()`, so opening the player left the status and navigation bars painted over it until the user pressed a button most never press. On the native path this is worse than cosmetic: the SurfaceView fills the content view, so the bars sit directly on top of the video. The player is a full-screen surface by construction — `fixed inset-0 z-50` over a `MATCH_PARENT` surface — so entry is the right moment. Called synchronously in `onMount` before any `await`, per the native-mode pitfall, and paired with the `exitImmersive()` already unconditional in `onDestroy`, so a player torn down while immersive cannot leave the rest of the app without bars | UI | UR-066, UR-003 | Done |
| DR-188 | Native Android video is **ready to be the default except for the background-audio handoff**, and the flip therefore waits. The picture defects behind DR-172 are all found, fixed and device-verified — DR-185 (the app shell painted over the surface through a CSS rule targeting an attribute nothing set), DR-182 (nothing could lift the poster card on a path with no `<video>` element), DR-183 (the JS bridges raced the page load, so `setTransparent(true)` could never arrive), DR-184 (the SurfaceView was never detached), plus DR-186 and DR-187, the two UI defects only this path could reveal. On a device logcat now carries `WebView transparent = true` and `Marking media ready` with video on screen, which is the pair DR-172 went looking for and could not find, and skip, seek and rotation were exercised by hand. Turning the default on then surfaced a *different* unverified sub-path: returning from background audio is HTML5-only (DR-190), so on the native path playback simply stays dead. Shipping it would have repeated DR-161 exactly — a verified sub-path made default over an unverified one — so the default stays off and the flip is gated on DR-190 rather than on more confidence | UI | UR-003, UR-004, UR-041 | Blocked by DR-190 |
| DR-191 | Forcing the WebView overlay to redraw from the Activity, because with the ExoPlayer **SurfaceView** beneath it the overlay's ordinary damage stopped reaching the screen: the page kept mutating — the clock text every second, the control bar's opacity going to 0 — while the display held whatever frame it last presented, over video that animated perfectly. Not a state defect; the live DOM showed the slider advancing 476 → 479 across three seconds behind a screen showing neither. Only **structural** changes got through, which is why the play overlay always appeared to work (an `{#if}` block, added and removed) while the progress bar never did, and why rotation lost the transport UI. A CSS animation cannot help, since opacity animates on the compositor without repainting the layer. **Superseded by DR-192**: this drove `postInvalidateOnAnimation` in a loop, which treats the symptom — the cause is the SurfaceView's separate layer, and removing that removes the need. Kept as the record of how the mechanism was identified | Android | UR-003, UR-004 | Superseded by DR-192 |
| DR-195 | Play/pause works on the native path, because the frontend stops claiming a webview element is playing when there is none. `html5_playing` is Rust's record of "a webview `<video>` is active and in this state", and `toggle_playback`, `play` and `pause` all route transport to that element whenever it is set. The player route mirrored element state into it **unconditionally** — from `handleReportStart` and, fatally, from `handleReportProgress`, which VideoPlayer calls on a 10-second interval — so on the native path the frontend re-declared every ten seconds that an element was playing when none existed, and every transport intent was emitted into the void. The pause button was dead from the on-screen tap, from the control bar, and from a direct `player_toggle` invocation, while seek and skip kept working because `player_seek_video` decides elsewhere; that asymmetry is the signature. It also explains the flashing, since the control bar and the JRay overlay both key off `isPlaying`, which was being contradicted on every interval tick. DR-193 clearing the flag at load was necessary but insufficient on its own — the interval put it straight back. The mirror now lives in `mirrorElementStateToRust` in VideoPlayer, gated on `useHtml5Element`, which is the only place that knows whether an element renders at all; the route cannot tell the two paths apart, which is precisely how it came to lie. Confirmed on device by ADB: surface tap and control bar each pause (position frozen across repeated samples, transport label flipped) and resume | Playback | UR-005, UR-003 | Done |
| DR-194 | The previous frame flashing on rotation. It reads as a TextureView artefact — the view retains its last frame, so between a rotation and `fitSurfaceToScreen()` landing that frame sits at the old size — and two fixes were built on that reading: revealing after two `postOnAnimation` hops, then revealing on `onSurfaceTextureUpdated`, which required owning the `SurfaceTextureListener` and handing ExoPlayer the Surface directly rather than via `setVideoTextureView`. **Neither stopped the flash.** The mechanism is the *window's* rotation animation: Android cross-fades a **screenshot of the old orientation**, that screenshot contains the old video frame at the old size, and no TextureView bookkeeping can reach it — nor can the app pre-empt the screenshot, since `onConfigurationChanged` fires after it is taken. The only lever is to stop the animation: `ROTATION_ANIMATION_JUMPCUT`. That was accepted and silently ignored at first, and the platform said why out loud — `VRI[MainActivity]: setLayoutParams: not fullscreen` — because the attribute is honoured only for a fullscreen window. `FLAG_FULLSCREEN` (deprecated for hiding system bars, which immersive mode does instead, but still what marks the window fullscreen for this decision) is therefore set alongside it, scoped to while native compositing is active so the rest of the app keeps its normal animation. The frame-arrival reveal is kept: it replaced a fixed-timeout guess with a real signal, and its timeout is required rather than defensive, since a resize while paused means no new frame is ever coming. **The flash is not confirmed fixed on device** — the forced-rotation harness (`settings put system user_rotation`) proved unreliable, and `screenrecord` fixes its canvas at start so a rotation inside a recording never changes frame dimensions, which defeated two attempts at measuring it | Android | UR-003, UR-066 | Needs device verification |
| DR-193 | Play/pause reaches the player that is actually rendering. `toggle_playback`, `play` and `pause` all route to the webview element when `is_html5_active()`, which is `html5_playing.is_some()` — a flag written **only** by the element's own state reports and cleared only when it reports "stopped"/"idle" (or on a background-audio handoff). An element that went away without that final report, or webview-rendered music earlier in the same process, therefore left the flag set, and on Android's native video path every transport intent was emitted as a `ControlCommand` at an element that no longer existed: the pause button did nothing, from the on-screen tap and from the control bar alike, while seek and skip kept working because `player_seek_video` decides elsewhere. Whether it happened at all depended on what had played before, which is exactly what made it read as flaky rather than broken. `load_and_play` — the native load path, and the one the HTML5 video path deliberately avoids via `set_current_item` — now clears the flag, because loading into the native backend *is* the statement that native renders this item. Nothing is lost on the webview path: an element re-establishes its own authority the moment it reports again, so this is the existing "element is gone" semantics applied where it can be known directly rather than inferred from a report that may never arrive | Playback | UR-005, UR-003 | Done |
| DR-192 | Native video presents through a **TextureView**, not a SurfaceView. A SurfaceView renders on its own layer *outside* the app window and punches a transparent region through it; everything drawn above that hole — for us the entire Svelte UI in a transparent WebView — depends on that composition path, and Android's own graphics documentation states that "overlays do not currently work correctly with SurfaceView or TextureView". The consequences were four symptoms of one cause (DR-191): a frozen progress bar, controls that would not fade, rotation losing the transport UI, and overlays that lingered after the DOM removed them. A TextureView is an ordinary view whose frames are drawn as a texture in the window's normal rendering pass, so there is no second layer and no transparent region, and the WebView above composites like it would over any other view — which is why media3 offers `surface_type="texture_view"` and why it is the standard remedy for ExoPlayer overlay problems. The trade is accepted rather than hidden: TextureView costs more power and memory than SurfaceView and adds a frame of latency, but hardware decode through MediaCodec is untouched, so the reason native video exists survives it. `setVideoTextureView` installs ExoPlayer's own `SurfaceTextureListener`, so the old `SurfaceHolder.Callback` wiring is deleted rather than ported — adding a listener of ours would displace it and the video would never appear. PiP needs no change, since a TextureView is a View and the aspect-ratio probe reads its measured bounds | Android | UR-003, UR-004, UR-041 | Done |
| DR-190 | The background-audio handoff can return to the native path. Everything that restores playback on the way back is written around the WebView `<video>`: `applyPendingForegroundSeek` returns early on `!videoElement`, the HLS re-init `$effect` returns early on `!useHtml5Element`, and `pendingForegroundSeek`/`pendingForegroundPlay` — which own the post-handoff position and play/pause — are consumed only by `handleCanPlay` and `markMediaReady`, an element event and a path that reaches the same guard. On the native path there is no element, so `exitBackgroundAudioHandoff` completes, clears `handoffState`, blanks and reassigns `currentStreamUrl` to force an effect that will not run, and nothing ever restarts ExoPlayer: the user returns from the lockscreen to a dead player. This never showed while the path was opt-in and its picture was invisible anyway. The return needs the native equivalent of the element reload — re-issue the item to the backend, seek to the position `player_exit_background_audio` reports, then honour `wasPlaying` — routed through the adapter rather than the element, so both paths restore through one contract | Playback | UR-040, UR-003 | Proposed |
| DR-161 | Native video is the default, so picture-in-picture has a real surface. DR-160 makes PiP work on the HTML5 path, but that path can only ever shrink the *UI* into the PiP window; showing the video itself needs the SurfaceView behind the WebView, which is what `experimentalNativeVideo` gates. The flag now defaults to on when the user has never chosen, with an explicit stored choice still winning in both directions so anyone who turned it off keeps it off. This is a deliberate acceptance of risk: the flag existed because the native path was an unfinished spike, and `VideoPlayer.scrubRegression.test.ts` documents its history — a native init that flipped to HTML5 mid-lifecycle and left seeks going down one path while ExoPlayer played on another. Those tests pin the **flag-off** interim override (native response overridden to HTML5, backend stopped once), which the default no longer selects, so they now mock the flag off rather than inherit it: they still guard that path, but they no longer describe what ships. The native scrub/seek path is consequently not covered by the suite and needs device verification | UI | UR-041, UR-003 | Needs device verification |
| DR-159 | The background-audio handoff stops leaking its relative timeline. The handoff plays the episode as a *relative* stream — the audio-only URL is built with `StartTimeTicks` = the position the screen was locked at, so ExoPlayer's zero is the handoff point — and `background_audio_base` holds the offset that turns one back into a real position. The base was a **display-only** correction, applied in exactly two places (the lockscreen scrubber and the internal truncation maths) while every other consumer worked in the relative timeline treating the number as absolute. Each crossing threw away exactly `base` seconds, which is why the jump-back distance varied with where the screen was locked and read as random. Three crossings were live: progress reporting to Jellyfin sent the relative position every 30s, so the server was told `real base` — and since DR-155 now mirrors the server's position back and refreshes on a cache hit, that regressed value returned as the resume point (lock at 40 min, listen to 90, reopen at 50); lockscreen seeks went out absolute and came back relative, against a chunked length-less transcode that cannot honour a seek at all, so a clamped seek landed at stream zero; and media3's own `seekToDefaultPosition`/`seekBack`/`seekForward` bypassed the `ForwardingPlayer` wrapper entirely, reaching the real ExoPlayer — `Util.handlePlayButtonAction` seeking an ended player to the relative zero being the same mechanism as DR-129's truncation bug through a different door. The fix converts **once, at the boundary**: `JellyTauPlayer`'s position tick adds the base (and shifts the duration with it, since the stream's own length is only what remains) before either `nativeOnPositionUpdate` or the lockscreen sees it, so position updates, progress reports, the frontend and the truncation check all speak the episode's timeline and none needs to know a handoff happened. The base is consequently *removed* from `claim_stream_resume`, `truncated_stream_resume_position` and `player_exit_background_audio`, where adding it now double-counts, and the lockscreen's `positionOffsetMs` addition goes with it (the field remains, read-only, as the tick's input). Inbound seeks go the other way: `seek_absolute` is the new boundary for every outside seek, re-opening the stream at the requested position via `resume_stream_at` when a handoff is active — which is what `onSeekTo` had claimed for months in a comment describing code that did not exist — and an ordinary seek otherwise. `seekToDefaultPosition` is swallowed rather than forwarded, since Rust already owns what "play after the stream ended" means and the `play()` that follows reaches it. Exit reads the position *before* clearing either base, or a tick landing in between hands back a relative one | Player | UR-040, UR-005, UR-025 | Done (pending device verification) |
| DR-158 | A watched toggle, on the episode row, the season header, the series and movie hero, and the Episode Focus View. Both halves of the backend already existed and neither had a caller: `mark_played` (`POST /PlayedItems`) was reachable only from the sync drain replaying rows the *reporter* had queued, and `clear_watch_history` (`DELETE /PlayedItems`) only from the destructive "erase this series' history" button — so the sole way to mark something watched was to play it. Jellyfin applies both recursively over a season or series, so the container case needs no client-side fan-out *online*. Offline it does: `storage_set_watched` writes the item **and its descendants** (drawn from `items` by `parent_id`/`album_id`/`season_id`/`series_id`, so an uncached id selects nothing and the statement no-ops instead of raising a foreign-key error), because otherwise marking a season watched with no server would tick the season and leave every episode inside it unwatched. It is deliberately separate from `storage_mark_played`, which stays the single-item "this finished playing" path that increments `play_count`. Un-marking clears the resume position as well as the flag, matching the server. `QueuedOp::MarkUnplayed` gives the queue the missing direction — pushing as `clear_watch_history` — so the toggle works offline both ways rather than only one; without it un-marking would have been the half that needed a connection. The button is an everyday toggle, so unlike `ClearHistoryButton` it does not confirm, and it holds an optimistic state because the caller's `watched` prop only catches up after a reload (a season means a round trip, during which the button would otherwise appear to ignore the tap) | UI | UR-073 | Done |
| DR-157 | Full-screen video on Android actually goes full screen. `toggleFullscreen` called `document.documentElement.requestFullscreen()` and nothing else, which inside an Android WebView does not touch the Activity window — it expands the element within a viewport that already spans the whole screen, because `enableEdgeToEdge()` is called in `onCreate` and SDK 36 ignores the opt-out. So the control did nothing visible while the status bar and navigation/gesture bar stayed painted over the video, and (unlike DR-112's chrome-clearance work, which is about *reserving* space for the bars) here the bars should not be there at all. `ImmersiveModeBridge` hides them via `WindowInsetsControllerCompat` with `BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE`, so an edge swipe brings them back transiently over the video instead of resizing the window mid-playback, and the system's own gestures stay reachable. Exposed as the `AndroidImmersive` bridge and posted to the main thread, since `@JavascriptInterface` methods arrive on a WebView binder thread. `requestFullscreen()` is kept for the platforms where it does work, but its rejection is caught rather than allowed to abort the immersive call. Restoring is wired to three paths, not one: leaving fullscreen, Escape (which previously called `document.exitFullscreen()` directly, bypassing the flag and the bars), and `onDestroy` — the bars belong to the Activity, so a player torn down while immersive would strand every screen behind it without them. The `--jt-inset-*` properties need no special handling: hiding the bars fires the decor view's inset listener with zeroes and `WindowInsetsBridge` republishes them | UI | UR-066 | 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 |
---
@@ -191,14 +374,14 @@ Internal architecture, components, and application logic.
|----------|-------------------------|-------------------------|
| UR-001 | IR-001, IR-002 | - |
| UR-002 | IR-013 | DR-003, DR-012, DR-013, DR-014 |
| UR-003 | IR-003, IR-004, IR-011 | DR-002, DR-004, DR-010 |
| UR-004 | IR-003, IR-004, IR-008, IR-011 | DR-002, DR-004, DR-006 |
| UR-005 | - | DR-001, DR-005, DR-009 |
| UR-003 | IR-003, IR-004, IR-011 | DR-002, DR-004, DR-010, DR-182, DR-183, DR-184, DR-185, DR-186, DR-187, DR-188, DR-190, DR-191, DR-192, DR-193, DR-194, DR-195 |
| UR-004 | IR-003, IR-004, IR-008, IR-011 | DR-002, DR-004, DR-006, DR-129, DR-171, DR-176, DR-177, DR-181, DR-182, DR-183, DR-185, DR-188 |
| UR-005 | - | DR-001, DR-005, DR-009, DR-178, DR-179, DR-186, DR-193, DR-195 |
| UR-006 | IR-005, IR-006, IR-007, IR-008 | - |
| UR-007 | IR-010 | DR-007, DR-008, DR-016 |
| UR-008 | IR-010 | DR-007, DR-011 |
| UR-009 | IR-009, IR-010, IR-011 | - |
| UR-010 | IR-012, IR-021 | DR-037 |
| UR-010 | IR-012, IR-021 | DR-037, DR-059 |
| UR-011 | IR-013 | DR-003, DR-015, DR-018 |
| UR-012 | IR-009, IR-014 | - |
| UR-013 | IR-013 | DR-017 |
@@ -206,14 +389,14 @@ Internal architecture, components, and application logic.
| UR-015 | - | DR-005, DR-020 |
| UR-016 | - | - |
| UR-017 | - | DR-014, DR-021 |
| UR-018 | IR-013 | DR-015, DR-018 |
| UR-018 | IR-013 | DR-015, DR-018, DR-173 |
| UR-019 | IR-015 | DR-022 |
| UR-020 | IR-016, IR-018 | DR-023 |
| UR-020 | IR-016, IR-018 | DR-023, DR-176 |
| UR-021 | IR-016, IR-019 | DR-024 |
| 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, DR-178, DR-179 |
| UR-026 | - | DR-029, DR-048, DR-050 |
| UR-027 | IR-020 | DR-030 |
| UR-028 | - | DR-031 |
@@ -228,6 +411,41 @@ Internal architecture, components, and application logic.
| UR-037 | IR-010 | DR-042 |
| UR-038 | IR-010 | DR-043 |
| UR-039 | - | DR-045, DR-046 |
| UR-040 | IR-025 | DR-051, DR-052, DR-129, DR-130, DR-159, DR-178, DR-179, DR-180, DR-183, DR-190 |
| UR-041 | IR-026 | DR-053, DR-160, DR-161, DR-172, DR-182, DR-183, DR-184, DR-185, DR-188 |
| UR-042 | IR-009, IR-014 | DR-054 |
| UR-043 | IR-027 | DR-055 |
| UR-044 | - | DR-056 |
| UR-045 | - | DR-057 |
| UR-046 | IR-028 | DR-058 |
| UR-047 | IR-013 | DR-060 |
| 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, DR-143 |
| UR-053 | IR-029 | DR-074 |
| UR-054 | - | DR-075, DR-076, DR-077, DR-147 |
| UR-055 | - | DR-081, DR-082, DR-083, DR-084, DR-167, DR-168, DR-169, DR-173 |
| UR-056 | - | DR-085 |
| UR-057 | - | DR-086 |
| UR-058 | - | DR-087, DR-142 |
| UR-060 | - | DR-090, DR-091, DR-111 |
| UR-061 | - | DR-092 |
| UR-062 | - | DR-101, DR-102, DR-103, DR-104, DR-107 |
| UR-063 | - | DR-105 |
| UR-064 | - | DR-106 |
| UR-065 | IR-030 | DR-108, DR-109, DR-110, DR-111 |
| UR-066 | IR-031 | DR-112, DR-157, DR-187, DR-194 |
| UR-067 | - | DR-115, DR-116, DR-117, DR-118 |
| UR-068 | - | DR-119 |
| UR-069 | - | DR-113, DR-114, DR-120 |
| UR-070 | - | DR-121, DR-122 |
| UR-071 | IR-032 | DR-123, DR-124, DR-125, DR-126, DR-127, DR-128, DR-133, DR-134, DR-135, DR-136, DR-137, DR-138, DR-170, DR-171, DR-180 |
| UR-072 | - | DR-156 |
| UR-073 | - | DR-158 |
| UR-074 | - | DR-162, DR-177, DR-181 |
| UR-075 | - | DR-174, DR-175 |
---
@@ -295,6 +513,130 @@ Internal architecture, components, and application logic.
| UT-056 | Playlist entry serialization | DR-019, JA-019 | Done |
| UT-057 | Playlist Tauri command param naming (camelCase) | DR-019, JA-019, JA-020 | Done |
| UT-058 | Playlist repository client methods | DR-019, JA-019, JA-020 | Done |
| UT-059 | Audio-only stream URL builder for a video item (selected audio-stream index) | JA-032, DR-052 | Pending |
| UT-060 | Background-audio handoff state machine (background→audio, foreground→video; no dual audio) | DR-052 | Pending |
| UT-061 | Background-audio Tauri command param naming (camelCase) | DR-052 | Pending |
| UT-062 | `setBackgroundAudioEnabled` reports whether the native bridge was actually reached (missing bridge, stale proxy, throwing method) so a dead bridge cannot look armed | UR-040, IR-025, DR-051 | Done |
| UT-067 | Offline `get_items` gates the synced-catalog UNION on the catalog-browse flag (downloads only when off, full catalog when on) | DR-078 | Done |
| UT-068 | Catalog visibility resolves to `serverReachable \|\| showServerCatalog`, and is pushed to the backend on every change of either input | DR-078, DR-079 | Done |
| UT-069 | `isConnected` follows backend reachability alone: false when the server is unreachable on a live link, true for a reachable server while `navigator.onLine` is false | DR-079 | Done |
| UT-070 | Hybrid `get_items` returns an empty offline result as-is when the catalog-browse gate is off, without querying the server | DR-080 | Done |
| UT-066 | WiFi-only download gate: cellular and metered WiFi blocked, unmetered WiFi/Ethernet allowed, unknown/none fail closed, desktop default ungated; plus the frontend network reporter (transport reporting, change subscription, teardown, fail-open queries) | DR-074 | Done |
| UT-071 | Byte-size formatter: zero/negative/non-finite → "0 B"; decimal unit thresholds; 23 significant-figure banding; trailing-zero trimming; largest-unit cap | DR-085 | Done |
| UT-072 | Downloaded-only browse returns a downloaded leaf and its container, filtered to the requested album parent; a non-downloaded sibling is omitted | DR-082, DR-083 | Done |
| UT-073 | An empty downloaded-only browse is authoritative — no rows, no error — regardless of the catalog-browse flag | DR-082 | Done |
| UT-074 | Only libraries with downloaded content are listed; an empty one is omitted | DR-082 | Done |
| UT-075 | Disk usage reports a leaf's own size, a container's summed descendants, and reconciles the device total with the sum of leaves | DR-085 | Done |
| UT-076 | Downloaded library browse lists album containers, not their individual tracks; drilling into the album returns the tracks | DR-082, DR-083 | Done |
| UT-077 | Downloaded TV library browse lists the series, not seasons/episodes; drilling returns the season then the episode | DR-082, DR-083 | Done |
| UT-078 | A downloaded leaf with no cached container (e.g. a movie) still surfaces at the library level | DR-082, DR-083 | Done |
| UT-079 | Each EQ preset returns a 10-band gain curve within range; Flat is all zeros; Bass Boost lifts lows and leaves highs flat | DR-030 | Done |
| UT-080 | `with_equalizer_normalised` clamps out-of-range gains and forces the band vector to exactly 10 entries (pad short, truncate long) | DR-030 | Done |
| UT-081 | Old persisted AudioSettings JSON without EQ fields loads as disabled + flat | DR-030 | Done |
| UT-082 | EQ fields serialize as camelCase (`equalizerEnabled`/`equalizerBands`) and round-trip | DR-030 | Done |
| UT-083 | EQ filter entries are empty when disabled or when the curve is flat (clears the `af` filter) | IR-020 | Done |
| UT-084 | Enabled EQ builds one peaking `equalizer` per non-zero band at the right frequency and gain inside a single `lavfi` chain | IR-020 | Done |
| UT-085 | A first tap resolves to `togglePlayPause` immediately — no deferral and no timer | DR-092, DR-098 | Done |
| UT-086 | A second tap inside the window seeks (+30 s right half, 10 s left half) with the matching feedback side **and** re-toggles play/pause, so the two toggles cancel and the play state is unchanged by a double tap | DR-092, DR-098 | Done |
| UT-087 | A tap after the window, and the tap following a consumed pair, are each fresh first taps that toggle (there is no third-tap case); repeated double taps keep seeking; `cancel()` makes the next tap a first tap so an interpreted swipe cannot seek | DR-092, DR-098 | Done |
| UT-088 | `resolveSeekTarget` applies the delta to the reported position, clamps into `[0, duration - END_SEEK_MARGIN_SECONDS]`, chains off an in-flight pending target so rapid skips accumulate, and ignores that target once the player reports past it | DR-092, DR-095 | Done |
| UT-089 | A touch drag on the video seek bar seeks to the dragged position, never toggles play/pause, and never alters brightness — the container gesture layer stays out of a control drag entirely | DR-098, DR-099 | Done |
| UT-090 | The seek bar commits its seek on `touchend` even when the engine never fires `change`, and commits exactly once when both signals arrive | DR-099 | Done |
| UT-091 | Transport intents (play/pause/toggle) reach the backend even while a video adapter is registered, and never call the adapter's own `play`/`pause`/`toggle` — the webview must not decide play-vs-pause from the DOM | DR-097 | Done |
| UT-092 | `shouldReuseActivePlayback` reuses backend playback for an already-loaded audio track but never for video, and never when an explicit start position or a next-episode restart was requested | DR-100 | Done |
| UT-093 | `resolvePlayerSurface` returns `video` only with a stream URL, `pending` for video whose stream URL is still missing (never `audio`), and `audio` for audio content | DR-100 | Done |
| UT-094 | `parseNativeInsets` accepts the bridge's JSON or a decoded object, and coerces missing/negative/non-finite edges to 0 rather than emitting `NaNpx` (which would invalidate the whole padding declaration) | DR-112 | Done |
| UT-095 | `safeAreaCssVars`/`applySafeAreaInsets` emit px-suffixed `jt-inset` custom properties for all four edges | DR-112 | Done |
| UT-096 | `readNativeInsets` returns null with no bridge and survives a stale WebView proxy (missing or throwing `get`) instead of throwing out of layout init | IR-031, DR-112 | Done |
| UT-097 | `initSafeArea` primes the document on start, re-applies on `jellytau-insets-changed` (rotation, nav-mode switch), unsubscribes on teardown, and writes nothing without a bridge so `env()` still wins on iOS/desktop | IR-031, DR-112 | Done |
| UT-098 | `shellReservesBottomInset` gives the bottom inset to BottomUi wherever one renders and to the app shell only on routes without one, so the gesture bar is never ignored nor double-padded | DR-112 | Done |
| UT-099 | A Jellyfin item payload carrying `UserData.IsFavorite` maps to `MediaItem.user_data.is_favorite` | DR-113, JA-034 | Done |
| UT-100 | `OnlineRepository::get_favorites` builds `Filters=IsFavorite` + `Recursive=true` + the scope's `IncludeItemTypes`, and omits the type filter entirely for `SearchScope::All` | DR-115, JA-033 | Done |
| UT-101 | `OfflineRepository::get_favorites` returns only `is_favorite = 1` rows, honours the scope type filter, and stays downloads-only when the catalog-browse gate is off | DR-115 | Done |
| UT-102 | The `save_to_cache` favourite mirror does not overwrite a row with `pending_sync = 1` | DR-114 | Done |
| UT-103 | The reconnect drain pushes pending favourites, clears `pending_sync`, and leaves failed rows pending | DR-120 | Done |
| UT-104 | `get_items` with `favorites_only` filters online (endpoint) and offline (SQL) | DR-116 | Done |
| UT-105 | `favorites` store precedence: override beats `userData.isFavorite` beats `false` | DR-119 | Done |
| UT-106 | Un-favouriting removes an item from a favourites listing view | DR-117, DR-119 | Done |
| UT-107 | The hybrid background refresh emits `favorites-changed` only for ids whose favourite state actually flipped | DR-120 | Done |
| UT-109 | Search covers synced-but-not-downloaded items when catalog browse is on, and stays downloads-only when off | DR-108 | Done |
| UT-110 | Search item-type filter is bound, not interpolated: a quote-bearing type neither errors nor widens results | DR-108 | Done |
| UT-111 | FTS prefix queries quote each token, so apostrophes/hyphens/slashes are data; empty or punctuation-only input returns no rows rather than erroring | DR-108 | Done |
| UT-112 | Repeated catalog passes leave one `items_fts` entry per item, not one per pass | DR-110 | Done |
| UT-113 | The stale-catalog sweep removes vanished synced rows, keeps downloaded ones, keeps uncrawled types, and stays scoped to one server | DR-110 | Done |
| UT-114 | Cached people are reachable from unscoped search and excluded from scoped search | DR-111 | Done |
| UT-115 | Re-index staleness policy: never-indexed and unparseable timestamps are due, fresh ones are not, future ones are not | DR-109 | Done |
| UT-116 | `resolve_local_media_path` returns a completed download's file, and `None` for an in-progress download, a row whose file has been deleted, or an unknown item | DR-123 | Done |
| UT-118 | `resolveVideoSource` prefers a downloaded file, never marks a local file as needing transcoding, and falls back to streaming for a blank path | DR-123 | Done |
| UT-119 | The audio-only handoff picks a downloaded file over the audio-only stream URL, preserving the Jellyfin id for progress sync | DR-128 | Done |
| 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-151 | An unreportable stop lands in the queue and is pushed by the existing drain; re-queueing the same item supersedes the earlier position rather than adding a row, distinct items keep their own positions, and an abandoned row is not revived by a later report | DR-154 | Done |
| UT-152 | Caching a server result mirrors its watch position locally — including for an item carrying a position but no favourite flag — without inventing a row for an item the server reported no user data for, and without pulling a still-unsynced local position backwards | DR-155 | Done |
| UT-162 | Each downloaded library lists only its own media: the music library shows the album and neither the film nor the series, the movie library only the film, the TV library only the series | DR-163 | Done |
| UT-163 | `partial_path` appends rather than replacing the extension, so it matches what the cleanup paths delete, keeps two sources for one title apart, and still produces a sidecar for an extension-less target | DR-165 | Done |
| UT-170 | `queue_album_tracks` queues a row for every track of the album — including tracks the cache holds without an `album_id` and tracks it has never seen at all — links each one to its album so offline browsing can find it, returns the row ids in track order, and is idempotent: re-queuing fills the gaps without duplicating rows or resetting a completed track. `cached_album_tracks` (the offline fallback) finds tracks by either album link and does not sweep in another album's | DR-173 | Done |
| UT-171 | `resolve_pending_download_urls` restricted to a set of row ids resolves only those rows and leaves other pending rows untouched, and an empty id set resolves nothing rather than sweeping everything | DR-173 | Done |
| UT-172 | `album_file_names` gives every track of an album its own file: a title repeated within the album (deluxe edition, two discs) is disambiguated by track number and item id instead of the second download overwriting the first, an unambiguous title keeps its own name, and path separators in a title are sanitised so a track cannot escape the album directory | DR-173 | Done |
| UT-164 | `resume_offset` appends only when the server answered `206`; a `200` after a Range request restarts the file, because that body is the whole stream | DR-166 | Done |
| UT-165 | A registered download starts unflagged, `signal` sets the flag its worker reads, signalling an unregistered id reports not-in-flight, `clear` forgets it, and re-registering drops a previous stop so a resumed download does not halt instantly | DR-164 | Done |
| UT-166 | `original` quality re-encodes audio the webview cannot decode (E-AC-3/AC-3/DTS/TrueHD) to AAC without capping bitrate or resolution, keeps the `Static=true` direct copy for audio that plays here (AAC/MP3/Opus/Vorbis/FLAC) and for an unknown codec, leaves the explicit quality presets untouched, and picks the served track by the same default-or-first rule the streaming verdict uses | DR-171 | Done |
| UT-155 | A seek during a background-audio handoff re-opens the stream at the requested absolute position (`StartTimeTicks`) and rebases the handoff to it, while a seek outside a handoff stays an ordinary seek and invents no base | DR-159 | Done |
| UT-154 | `mark_unplayed` parses to `QueuedOp::MarkUnplayed` and is rejected without an item id, and a queued un-mark drains to the server as `clear_watch_history` | DR-158 | Done |
| UT-156 | A capped step reaches the transcode URL as all four of its parts (total ceiling, the video/audio split summing to the cap, and a `MaxHeight`), the uncapped default keeps the historical 20/18 Mbps allowance and constrains no resolution, and the background-audio handoff takes the lower of the cap and its own 384 kbps | DR-162 | Done |
| UT-157 | The quality ladder is internally consistent — video + audio equals the cap at every step, audio never consumes the budget, only `Original` is uncapped — descends in bitrate, resolution and audio share together, and round-trips through the serde token it is persisted as | DR-162 | Done |
| UT-158 | Justified rows fill the container width exactly and never overflow it, every tile in a row shares one height, and each tile's width follows its own aspect ratio — a 16:9 tile coming out more than twice the width of a 2:3 tile at the same height | DR-174 | Done |
| UT-159 | The awkward cases of the packing: a short last row is left at the target height rather than stretched across the container, a last row that would overflow is brought down, an extreme ratio is clamped instead of taking a row to itself, a missing or nonsensical ratio falls back to square instead of collapsing the tile, an unmeasured container renders nothing rather than 1px tiles, and every tile is placed exactly once in order | DR-174 | Done |
| UT-160 | The default row height suits its container: it grows with the width, stays inside its bounds, and at phone width still fits two 16:9 tiles side by side | DR-174 | Done |
| UT-161 | A collection type maps to its favourites scope (`movies`/`tvshows`/`music`), every other kind — Live TV, channels, box sets, books, unknown — maps to none rather than to `All`, and a constructed library carries the scope across the wire as `favoritesScope`, omitted entirely when it has none | DR-175 | Done |
| UT-167 | The mosaic's composition: the cross-library favourites entry leads, each library is followed by its own category tile pointing at that category's tab, a category shared by two libraries still yields one tile, a library kind favourites do not carve up yields none, a scope the page offers no tab for is ignored, and every tile is uniquely keyed | DR-174, DR-175 | Done |
| UT-168 | Subtitles are negotiated as sidecars, never burned in: the requested `SubtitleStreamIndex` is the explicit "none" sentinel (`-1`) rather than omitted, every text format we can render (`srt`/`subrip`/`ass`/`ssa`/`vtt`) is advertised as `External`, and the burn-in verdict is by format — text never forces it, image formats (PGSSUB, dvdsub) always do, case-insensitively. The same sentinel rides the stream URL itself, so a stream re-opened without a fresh negotiation cannot inherit a subtitle. And the verdict reaches the picker: a subtitle stream carries `supportsExternalDelivery` — set only for subtitles, `false` for a bitmap format and for one the server left unnamed — which drops the tracks the app could never draw from the menu, the `<track>` children and the native play request alike, without even fetching their URLs, while a stream carrying no verdict at all is still offered | DR-176 | Done |
| UT-173 | Every video stream URL carries a `PlaySessionId`, each open mints a fresh one, and the open reports the session it superseded so that job can be stopped | DR-177 | Done |
| UT-174 | A fatal HLS network error is read against the *absolute* position: mid-film — including after a quality switch, where the seek offset carries the whole resume position — it is retried rather than reported as the end of the stream, the last tenth of a known runtime is treated as the end, an unknown runtime retries, and retries stop once the budget is spent | DR-177 | Done |
| UT-175 | A stream reload that never becomes playable is reported as a failure instead of resolving as success, so the caller can revert its selection rather than leave the UI claiming a stream that is not playing | DR-177 | Done |
| UT-176 | A handoff's position is floored at its base: with no tick yet landed the exit position is the point the screen was locked at rather than 0, and once ticks are flowing (the base already applied natively) it is not added twice | DR-178 | Done |
| UT-177 | Webview-rendered media's reported position and duration are the controller's, and are dropped the moment that element stops being the player — on teardown, and when a handoff takes over | DR-178 | Done |
| UT-178 | A stop report at position 0 is withheld rather than sent (it would clear the resume point), while a real position is still reported from either rendering path — the element's on the webview path, the backend's on the native one | DR-179 | Done |
| UT-179 | An audio-only episode that ends naturally is reported stopped at its runtime, so Jellyfin marks it played; a truncated stream, which is about to be re-opened, reports nothing | DR-179 | Done |
| UT-180 | Position ticks report progress to the server, throttled to one report per item per window rather than one per tick | DR-179 | Done |
| UT-181 | The handoff plan matches its source: a downloaded file takes no base and a seek, a stream takes the base and no seek, and a handoff at 0:00 takes neither; a downloaded handoff's absolute seek stays an ordinary seek instead of a stream rebuild | DR-180 | Done |
| UT-153 | Scroll handling per navigation kind: a forward move always lands at the top even when the previous page was scrolled and even when the target was visited before, Back restores that route's own saved offset (and the top when it has none), offsets are kept per route rather than shared, a repeated Back still restores, and the initial load leaves the container alone | DR-156 | Done |
| UT-142 | The audio codecs offered for video direct play: a Dolby device's real `MediaCodecList` output drops `ac3`/`eac3`, AMR and raw PCM are dropped too, a fully-supported list is passed through untouched, a list with nothing decodable still claims `aac`, and stray spacing or casing does not decide whether the user gets sound | DR-148 | Done |
| UT-143 | Subtitle URLs resolve to plain strings before they reach the markup (never a Promise), unresolvable tracks are dropped, a stale selection collapses to "Off", and a server-default track is never auto-selected | UR-020, DR-023 | Done |
| UT-144 | VideoPlayer actually renders `<track kind="subtitles">` children carrying `data-stream-index`, with no `default` attribute and no async `getSubtitleUrl()` bound to `src` | UR-020, DR-023 | Done |
| UT-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 |
| UT-182 | An HLS video URL never carries `StartTimeTicks` — with a position supplied or not — while the master playlist, codec, media source and chosen audio track still ride on it | DR-181 | Done |
| UT-183 | A reloaded stream is resumed by seeking the element to the absolute position with the transcode offset cleared to zero — never by carrying the position as an offset base, which since DR-181 would display the position while playing the item from its start — and a reload to 0:00 waits for no seek | DR-181 | Done |
| UT-184 | The native reveal rule fires on `state === "playing"` and on a position tick carrying a position or a duration, and on nothing else — not `buffering`, `paused`, `stopped`, `ended` or `error`, not an empty tick, and not a negative position | DR-182 | Done |
| UT-189 | On the native path the player never calls `player_report_state` — driven through the real 10-second progress interval under fake timers, which is the call site that mattered; asserting on a freshly mounted player passes with the guard deleted and guards nothing | DR-195 | Done |
| UT-187 | On the native path the play overlay follows the backend: it clears when the backend resumes after a pause and is raised again when the backend pauses, and the system bars are hidden on player entry rather than only by the fullscreen button | DR-186, DR-187 | Done |
| UT-186 | Every attribute the native-video compositing block in app.css targets is set somewhere in the app — `[data-app-shell]` in particular — so a selector aimed at nothing fails the suite instead of failing silently on a device | DR-185 | Done |
| UT-185 | Mounted on the native path (backend reports native, opt-in flag on, no `<video>` element rendered and the backend not stopped), VideoPlayer keeps the poster card up until the backend reports something, drops it on a playing state or a position tick with a duration, and keeps it up through `error` and `stopped` | DR-182 | Done |
### Integration Tests
@@ -312,6 +654,9 @@ Internal architecture, components, and application logic.
| IT-010 | Playback progress sync to Jellyfin | IR-015, UR-025 | Pending |
| IT-011 | Resume playback from server position | IR-015, UR-019 | Pending |
| IT-012 | Equalizer bands via libmpv | IR-020, UR-027 | Pending |
| IT-013 | Background-audio handoff on Android: background/lock continues audio via native service and stops video decode; foreground resumes video at position | IR-025, UR-040 | Pending |
| IT-016 | Offline library listing end-to-end: with the server unreachable, a library page lists only downloaded media with the toggle off, and additionally reveals greyed-out cached catalog entries with the toggle on | UR-052, DR-078, DR-079, DR-080 | Done |
| IT-017 | A download queued from a greyed-out offline catalog entry persists and is resolved and started on reconnect | UR-052, UR-011 | Done |
---
@@ -381,22 +726,36 @@ The `PlayerBackend` trait defines optional audio settings methods with default e
| Basic playback | ✅ | ✅ | Parity |
| Volume control | ✅ | ✅ | Parity |
| Seek | ✅ | ✅ | Parity |
| Crossfade | | ❌ | Gap |
| Gapless playback | ✅ | | Gap |
| Volume normalization | ✅ | | Gap |
| Crossfade | | ❌ | Not implemented (blocked on MPV) |
| Gapless playback | ✅ | ⚠️ | Implemented, pending on-device verification |
| Volume normalization | ✅ | ⚠️ | Implemented (LoudnessEnhancer — gain stage, approximate vs MPV's dynaudnorm), pending on-device verification |
| Equalizer (10-band) | ✅ | ⚠️ | Implemented (resampled onto device bands), pending on-device verification |
| Position updates | 250ms | On-demand | Inconsistent |
**Future Fix**:
1. Implement `set_audio_settings()` in `ExoPlayerBackend`
2. Add Kotlin-side ExoPlayer configuration for crossfade (using `ConcatenatingMediaSource` or `DefaultMediaSourceFactory`)
3. Implement gapless via ExoPlayer's built-in gapless support
4. Add volume normalization via ExoPlayer's `LoudnessEnhancer` or audio processor
5. Standardize position update frequency across platforms
**Status** (see docs/specs/android-audio-settings-parity.md):
1. `set_audio_settings()` implemented in `ExoPlayerBackend` (JSON over JNI)
2. ✅ Gapless via ExoPlayer's `pauseAtEndOfMediaItems`
3. ✅ Volume normalization via `LoudnessEnhancer`
4. ✅ Equalizer via `android.media.audiofx.Equalizer`, canonical 10 bands
resampled onto the device's band centres
5.**Not yet verified on a physical device** — the EQ/normalization effects
depend on device-specific `AudioEffect` availability and band layouts
6. ⬜ Flip the trait's `set_audio_settings` default from `Ok(())` to
`Err(not_implemented())` so a backend that omits it fails loudly instead of
silently reporting success. Deferred until (5) confirms the Android path works
7. ⬜ Standardize position update frequency across platforms
Crossfade is deliberately absent: it is unimplemented on every platform and
architecturally blocked on MPV, so building it on Android alone would invert the
parity gap. (The previously suggested `ConcatenatingMediaSource` is also
deprecated in current Media3.)
**Impact**:
- Medium - Android users lack audio enhancement features advertised in requirements
- User experience differs between platforms
- UR-031 (Crossfade), UR-032 (Gapless), UR-033 (Normalization) only work on Linux
- UR-032 (Gapless), UR-033 (Normalization) and UR-027 (Equalizer) are now
implemented on Android as well as Linux, pending on-device verification
- UR-031 (Crossfade) works nowhere — see DR-034
**Traces To**: IR-004, UR-031, UR-032, UR-033, DR-034, DR-035, DR-036
+68
View File
@@ -0,0 +1,68 @@
# Spec review checklist
Run a spec past this before accepting it. It exists because JellyTau's
backend/frontend boundary is a **stated rule with, historically, no gate** — the
rule lived in the architecture docs, but nothing forced a spec author to check a
new design against it, and a "minimal-change" spec quietly leaked domain
taxonomy into the frontend (see [scoped-search-boundary.md](scoped-search-boundary.md)).
This checklist is the human gate. The CI check
(`scripts/check-frontend-boundary.sh`) is only a crude tripwire for one leak
signature — it does **not** replace this.
Copy the boxes into the review comment (or the PR) and tick them.
## Boundary (the one that bites)
- [ ] **The spec has a filled-in "Layer assignment" table**, and it assigns
*logic*, not files. A spec without this section is not ready to review.
- [ ] **No domain vocabulary is placed in the frontend.** In particular: Jellyfin
item-type sets that define a *category* (what "Music"/"TV"/"Movies" means),
query-shaping rules, business rules, reachability/sync policy. If the
frontend names a *set* of item types to define a category, that is a leak —
it belongs behind an opaque enum the backend expands.
- [ ] **"The backend already accepts this parameter" was not used as the reason**
to place the deciding logic in the frontend. Accepting a parameter ≠ owning
the decision of its value.
- [ ] **The `Scope:` / effort framing is not optimizing for "least backend
change."** "Frontend only, no Rust changes" is a description, never a goal.
The goal is *correct layer placement*; sometimes that is more Rust work.
- [ ] Ran the litmus test on each borderline responsibility: *would it change if
Jellyfin's API changed?* → Rust. *Only if the UI were redesigned?*
frontend. Borderline defaults to Rust.
- [ ] Single-type presentation (`itemType: "Movie"`, "this page shows albums")
is **not** over-corrected into the backend. The rule targets category
*taxonomy*, not every mention of a type. Don't invent a backend enum per
list page.
## IPC contract
- [ ] Anything crossing the boundary has its wire shape specified.
- [ ] camelCase rule accounted for: top-level params auto-convert; nested structs
get `#[serde(rename_all = "camelCase")]`; tagged unions match tags on both
sides; events are kebab-case. (CLAUDE.md §IPC,
[04-type-sync-and-threading.md](../architecture/04-type-sync-and-threading.md).)
- [ ] Any result that arrives *twice* (command return **and** a later event —
e.g. the search cache/server merge) has **both** payloads in the new shape.
- [ ] `bindings.ts` is regenerated from Rust, not hand-edited.
## Requirements & traceability
- [ ] Linked to existing URs, or new URs/DRs are allocated in
[requirements.md](../requirements.md).
- [ ] Requirement-implementing code will carry `// TRACES:` comments (CLAUDE.md).
- [ ] Traceability coverage stays ≥ 50% (the CI gate).
## Conflicts & hygiene
- [ ] If this spec revises/supersedes another, the older spec gets a banner
pointing here — no two specs silently contradicting.
- [ ] Acceptance criteria include the standard gates: `bun run check`,
`bun run test`, `bun run check:boundary`, and (if Rust changed)
`cargo fmt`/`cargo clippy`/`bun run test:rust`.
- [ ] Notes flag that a parallel Claude session may be active in the repo.
---
**If any Boundary box can't be ticked, the spec is not ready** — fix the layer
assignment first. Every other section can be negotiated; that one is the whole
reason this file exists.
+105
View File
@@ -0,0 +1,105 @@
# Spec: <feature name>
<!--
Copy this file to docs/specs/<kebab-name>.md and fill it in. Delete the HTML
comments as you go. The section that matters most for this project is
"Layer assignment" — read its comment before writing it.
Before merging a spec, run it past docs/specs/SPEC-REVIEW-CHECKLIST.md.
-->
**Status:** Proposed <!-- Proposed | Accepted | Implemented | Superseded -->
**Requirements:** <!-- UR-xxx → DR-yyy; allocate new DRs in requirements.md. -->
**UX spec:** <!-- link to the relevant ux-flows.md section, or "n/a". -->
**Supersedes / revises:** <!-- link any spec this changes, or delete this line. -->
## Summary
<!-- 24 sentences. What changes for the user, in plain terms. -->
## Motivation
<!-- Why now. The problem being solved. -->
## Layer assignment
<!--
🔴 THIS IS THE SECTION THAT KEEPS THE ARCHITECTURE HONEST. Do not skip it, and
do NOT reframe it as "how little backend work can we get away with."
The project rule (CLAUDE.md, architecture/02-svelte-frontend.md): the Rust
backend owns ALL business logic — auth, catalog, sessions, downloads, offline,
playback, AND domain vocabulary (e.g. what Jellyfin item types the category
"Music" means). The Svelte frontend is PRESENTATION ONLY: rendering, layout,
navigation, view/order preferences, input handling.
For each distinct piece of *logic* this feature introduces, put it in the table
and name the layer it belongs to and WHY. "It's less work in the frontend" and
"the backend already accepts this parameter" are NOT reasons to place logic in
the frontend — the backend accepting a parameter does not make deciding that
parameter's value a presentation concern.
Litmus test for "does this belong in Rust?": Would this logic have to change if
Jellyfin changed its API, added an item type, or altered a business rule? If
yes, it is domain logic → Rust. Would it change if we redesigned the UI? If
yes (and only yes), it is presentation → frontend.
A past incident: scoped-search.md placed the item-type taxonomy (what "Music"
means as a set of Jellyfin types) in the frontend because the backend already
accepted an includeItemTypes filter. That was a boundary leak; see
scoped-search-boundary.md. This section exists to catch that class of mistake
at spec time, not in review three features later.
-->
| Logic / responsibility | Layer | Why it belongs there |
|------------------------|-------|----------------------|
| <!-- e.g. scope → item-types --> | Rust | <!-- domain vocabulary; changes with Jellyfin's API --> |
| <!-- e.g. group display order --> | Frontend | <!-- pure presentation; changes only if UI is redesigned --> |
<!--
If a row is genuinely borderline, say so and give the tie-breaker you used.
Borderline defaults to Rust for anything touching domain data or vocabulary.
-->
## Design
<!--
How it works. Wire shapes for anything crossing the IPC boundary. Remember:
- Command NAME must match the Rust fn name exactly.
- Top-level params auto-convert snake_case → camelCase (Tauri v2).
- Nested struct fields need #[serde(rename_all = "camelCase")].
- Events are kebab-case.
(See CLAUDE.md §IPC and architecture/04-type-sync-and-threading.md.)
Regenerate bindings.ts from Rust types; never hand-edit it.
-->
## Out of scope
<!-- What this spec deliberately does NOT do. -->
## Acceptance criteria
<!-- Checkable statements. Include the standard gates: -->
- [ ] `bun run check` and `bun run test` pass.
- [ ] `cargo fmt` clean, `cargo clippy` clean, `bun run test:rust` passes (if Rust changed).
- [ ] `bun run check:boundary` passes (no taxonomy leak into the frontend).
- [ ] New requirement-implementing code carries `// TRACES:` comments.
- [ ] `bindings.ts` regenerated if Rust types changed.
## Testing
<!-- Rust: cargo test. Frontend: vitest, src/lib/**/*.test.ts. What to cover. -->
## TRACES
<!-- Suggested tags per new/changed piece: UR-xxx | DR-yyy | tests. -->
## Notes for the implementer
<!--
- A parallel Claude session may be active in this repo — `git diff` before
"repairing" unexpected changes (see project memory / CLAUDE.md gotchas).
- Anything else non-obvious.
-->
+164
View File
@@ -0,0 +1,164 @@
# Spec: Account menu and global chrome availability
**Status:** Implemented
**Scope:** Frontend only. No Rust changes required.
**Requirements:** UR-054 → DR-075, DR-076, DR-077 (see
[requirements.md](../requirements.md)).
**UX spec:** [ux-flows.md §1.21.4](../ux-flows.md).
## Summary
Account actions — Settings, Downloads, Display preferences, Sign out — are
currently reachable **only from `/library/*`**. Move them into a single shared
account menu anchored to the user's name, and make that menu available on every
authenticated non-immersive screen.
## Motivation
A user sitting on the home screen cannot open Settings or sign out. The bottom
nav offers Home / Search / Library only, and the header that hosts those actions
belongs to the library layout. The user has to guess that account actions live
*inside* Library — an unrelated section — and navigate there first.
Desktop and mobile also disagree today: desktop shows an unlabeled logout icon
with no grouped menu, mobile shows a three-dot overflow with labelled items. The
same two actions are found two different ways.
## Background: verified current state
1. **The header is not global.** It is defined in
[library/+layout.svelte](../../src/routes/library/+layout.svelte). The root
layout [+layout.svelte](../../src/routes/+layout.svelte) renders no header at
all.
2. **`routeOwnsLayout`** in
[layoutShell.ts](../../src/lib/utils/layoutShell.ts) returns true for
`/library`, `/player/`, `/login` — those routes own their own full-height
flex column. Everything else renders into the root scroller with the root's
`BottomUi` below it.
3. **Bottom nav is Home / Search / Library only**
([BottomNav.svelte](../../src/lib/components/BottomNav.svelte)) — no Settings
or account entry.
4. **Net effect:** on `/`, `/search`, and `/downloads` there is no route to
Settings or Sign out.
5. **Desktop username is inert text** — a `<span>` next to the icons, not a
trigger.
6. **The mobile overflow menu already has the right contents** (Downloads,
Settings, divider, Sign out) and the right dismissal behaviour (backdrop
click, keyboard handler). **Extract and reuse it rather than rewriting it.**
7. **`viewMode` is already a persisted store** in
[library.ts](../../src/lib/stores/library.ts) (`jellytau-view-mode`,
`localStorage`). The Display setting is a second view onto it — **no new
state, no migration.**
## Design
### `AccountMenu` component (DR-075)
One component used by both breakpoints. Contents in fixed order:
```
Signed in as <name> ← identity block, not interactive
<server host>
────────────────────────
Downloads
Settings
Display ← grid/list preference
────────────────────────
Sign out ← destructive, last, after a divider
```
- **Trigger is the username/avatar**, not a bare three-dot icon. On mobile where
horizontal space is tight, the avatar (or initial) alone is acceptable; the
name shows inside the open menu regardless.
- **Same items, same order, both platforms.**
- Preserve the existing dismissal behaviour: click-outside backdrop, `Escape`,
and focus return to the trigger on close.
- Menu items are real links/buttons — keyboard reachable, correct roles,
`aria-expanded` on the trigger.
"Display" may either navigate to the Settings Display section or expose the
grid/list choice inline. Prefer navigating — it keeps one source of truth for
preferences and avoids a nested control inside a dropdown.
### Global chrome (DR-076)
Make the header — and therefore the account menu — available on `/`, `/search`,
and `/downloads`.
The cleanest route is to lift the header out of the library layout into a shared
component rendered by the root layout, with the library layout consuming the
same component rather than defining its own. **Do not duplicate the markup into
each route.**
Constraints that must survive the change:
- `/player/*` and `/login` stay chrome-free.
- `/settings` already owns its layout; it needs no account menu (the user is
already there), but must not double up on chrome.
- The root layout's flex/scroller structure is deliberate — the comments in
[layoutShell.ts](../../src/lib/utils/layoutShell.ts) and
[+layout.svelte](../../src/routes/+layout.svelte) explain why routes own their
own column. Preserve the scroll containment; a regression here reintroduces
the "last row hidden behind the nav" bug called out in those comments.
- Mini-player and bottom-nav visibility rules (`showGlobalMiniPlayer`,
`showBottomNav`) must be unchanged.
### Display section in Settings (DR-077)
Add a Display section to [settings/+page.svelte](../../src/routes/settings/+page.svelte)
with the grid/list control bound to the existing `viewMode` store via
`library.setViewMode(...)`. The page-header toggle in `LibraryGrid` stays — both
controls drive the same store, so they stay in sync for free.
## Out of scope
- Redesigning the Settings page or reorganising its existing sections.
- Multi-server / account switching (UR-047) — the identity block displays the
active server but offers no switcher.
- Changing the bottom nav's three destinations.
## Acceptance criteria
- [ ] Settings and Sign out are reachable from `/`, `/search`, and `/downloads`
without first navigating into Library.
- [ ] Desktop and mobile show the same account menu items in the same order.
- [ ] The username/avatar opens the menu; it is a real button with
`aria-expanded`.
- [ ] Sign out is last, after a divider, and still logs out + resets library
state + redirects as it does today.
- [ ] `/player/*` and `/login` remain chrome-free.
- [ ] Settings has a Display section that changes grid/list, and the change is
immediately reflected by the library page-header toggle (same store).
- [ ] No regression in scroll containment, mini-player visibility, or bottom-nav
visibility on any route.
- [ ] `bun run check` and `bun run test` pass.
## Testing
- Extend the existing `layoutShell` tests: chrome-visibility for `/`, `/search`,
`/downloads` (now true) and `/player/*`, `/login` (still false).
- `AccountMenu`: renders the documented items in order; trigger toggles
`aria-expanded`; `Escape` and backdrop click close it; Sign out invokes the
logout handler.
- Display setting: writes through to the `viewMode` store and persists.
New requirement-implementing code needs `TRACES:` comments — see
[CLAUDE.md](../../CLAUDE.md). Suggested: `AccountMenu``UR-054 | DR-075`,
shell/header changes → `UR-054 | DR-076`, Settings Display section →
`UR-054, UR-029 | DR-077`.
## Notes for the implementer
- Read [ux-flows.md §1.21.4](../ux-flows.md) first — behavioural spec; this is
the implementation plan.
- The layout shell is subtle and the existing comments record real bugs that
were fixed there. Read them before restructuring.
- Another session may be active in this repo, including in
`src/routes/settings/+page.svelte`. Check `git diff` before "repairing"
unexpected changes, and expect to coordinate on that file.
+209
View File
@@ -0,0 +1,209 @@
# Spec: Android audio settings parity (EQ, normalization, gapless)
**Status:** Proposed
**Requirements:** UR-031, UR-032, UR-033, UR-027 → DR-034, DR-035, DR-036, DR-030; IR-004
**UX spec:** n/a — no UI change; Settings Audio already renders these controls
**Supersedes / revises:** closes the audio half of the parity gap recorded in [playback-backend-unification.md](playback-backend-unification.md)
## Summary
Implement `set_audio_settings` / `audio_settings` on `ExoPlayerBackend` so the
equalizer, volume normalization, and gapless playback settings actually take
effect on Android. Today the Settings Audio panel renders these controls on
Android and they silently do nothing — `ExoPlayerBackend` is the only backend
that does not override the trait's no-op defaults.
Crossfade is explicitly **not** included; see Out of scope.
## Motivation
`PlayerBackend` declares `set_audio_settings` with a default `Ok(())` body.
`MpvBackend`, `NullBackend`, and `WebviewAudioBackend` all override it;
`ExoPlayerBackend` does not. The settings are persisted, pushed to the backend on
every track load, and displayed in the UI — and then dropped on the floor.
This is the single most user-visible platform divergence in the app: a user who
sets a "Rock" EQ preset on Android sees the sliders move and hears no change.
The backend-unification investigation ruled out fixing this by swapping engines
(video cannot be unified; see the sibling spec), so the fix is to implement the
trait methods where they are missing.
## Layer assignment
| Logic / responsibility | Layer | Why it belongs there |
|------------------------|-------|----------------------|
| Band count, centre frequencies, gain range, preset→curve map | Rust (existing) | Already domain-owned in `settings.rs` per [audio-equalizer.md](audio-equalizer.md). Android must consume the same `AudioSettings`, not define its own bands. Duplicating the band layout in Kotlin would be a taxonomy leak of exactly the kind `check:boundary` guards against. |
| Mapping `AudioSettings` → Android audio-effect parameters | Rust → JNI boundary | Platform playback detail, the direct analogue of `build_af_filter` in `mpv_backend.rs`. Belongs with the other `set_audio_settings` code. |
| Attaching/detaching `Equalizer` and `LoudnessEnhancer` to the ExoPlayer audio session | Kotlin (`JellyTauPlayer.kt`) | Android platform API mechanics; needs the live `audioSessionId`, which only the Kotlin layer holds. |
| Normalization preset (Loud/Normal/Quiet) → target gain | Rust (existing) | `VolumeLevel` is domain vocabulary; the same preset must mean the same loudness on every platform. |
| Rendering sliders / preset chips | Frontend (existing) | Pure presentation; unchanged by this spec. |
Borderline row: attaching the effects could arguably be driven entirely from
Rust via JNI property calls. It goes to Kotlin because `AudioEffect` construction
requires the audio session id and must be re-attached when ExoPlayer rebuilds its
audio sink — lifecycle state that lives in `JellyTauPlayer.kt`. Rust still owns
*what* the values are; Kotlin owns *when* the effect objects exist.
## Design
### Rust — `ExoPlayerBackend` (`src-tauri/src/player/android/mod.rs`)
Override the two defaulted methods, mirroring the shape of the existing
`set_audio_track` JNI call:
```rust
fn set_audio_settings(&mut self, settings: &AudioSettings) -> Result<(), PlayerError> {
let s = settings.clone().with_crossfade_clamped().with_equalizer_normalised();
// Serialize as JSON — the same pattern load() already uses for subtitles,
// avoiding a 6-arg JNI signature that has to change every time a field lands.
let json = serde_json::to_string(&s).map_err(|e| PlayerError { message: e.to_string() })?;
// Kotlin: fun setAudioSettings(json: String)
self.call_player_method_string("setAudioSettings", &json)?;
self.shared_state.lock_safe().audio_settings = s;
Ok(())
}
fn audio_settings(&self) -> AudioSettings {
self.shared_state.lock_safe().audio_settings.clone()
}
```
`ExoPlayerState` gains an `audio_settings: AudioSettings` field. Note
`ExoPlayerBackend` currently holds no such state — `position`/`state`/`volume` are
all pushed in by JNI callbacks — so this is the first *pull*-side field. That is
correct: audio settings are commanded downward, never reported upward.
### Kotlin — `JellyTauPlayer.kt`
```kotlin
fun setAudioSettings(json: String) {
val s = JSONObject(json)
applyEqualizer(s.getBoolean("equalizerEnabled"), s.getJSONArray("equalizerBands"))
applyNormalization(s.getBoolean("normalizeVolume"), s.getString("volumeLevel"))
exoPlayer.pauseAtEndOfMediaItems = !s.getBoolean("gaplessPlayback")
}
```
Three independent mechanisms:
- **Gapless** — nearly free. ExoPlayer is gapless by default for compatible
formats; honouring the setting means *disabling* it when the user turns it off,
via `pauseAtEndOfMediaItems`. Note this only applies within a loaded playlist;
our queue loads one item at a time, so verify behaviour before claiming DR-035
on Android (see Testing).
- **Equalizer**`android.media.audiofx.Equalizer` bound to
`exoPlayer.audioSessionId`. Android's EQ exposes a device-dependent band count
(commonly 5) at fixed centre frequencies, which will **not** match our 10-band
ISO layout. Rust owns the canonical 10 bands; Kotlin resamples them onto the
device's bands by nearest-centre-frequency interpolation. Gains are in
millibels (`setBandLevel` takes mB, we store dB → ×100), clamped to the
device's reported `getBandLevelRange()`.
- **Normalization**`android.media.audiofx.LoudnessEnhancer`, also bound to the
audio session, `setTargetGain(mB)` derived from `VolumeLevel`. This is a gain
booster, not a true EBU R128 normalizer like MPV's `dynaudnorm`; parity is
approximate and should be documented as such rather than overclaimed.
Lifecycle: build the effects lazily on first use, release them in `release()`,
and re-attach on `onAudioSessionIdChanged` — ExoPlayer can rebuild its audio sink
(e.g. on a format change), which invalidates effects bound to the old session.
### Make the silent-failure mode impossible
The trait's default is the root cause of this whole class of bug:
```rust
// backend.rs:85 — reports success while doing nothing
fn set_audio_settings(&mut self, _settings: &AudioSettings) -> Result<(), PlayerError> {
Ok(())
}
```
Android inherits this, so every EQ/normalization change on Android returns `Ok`
and silently does nothing — the UI ships and has no effect, with no error anywhere.
Once `ExoPlayerBackend` implements the methods, **change the trait default to
`Err(PlayerError::not_implemented())`**, matching how `set_audio_track` /
`set_subtitle_track` already behave. Any future backend that forgets to implement
audio settings then fails loudly instead of lying.
Check the call sites before flipping it: `NullBackend` overrides both methods, so
the graceful-degradation path is unaffected, but confirm nothing treats a
`set_audio_settings` error as fatal to playback.
### Re-application on track load
`PlayerController` already re-pushes `AudioSettings` per track on the platforms
that implement it; the Android path inherits that for free once the trait methods
exist. No controller change.
### 🔴 Threading note
`setAudioSettings` is invoked from Rust on whatever thread the command lands on.
`AudioEffect` construction must not happen on the ExoPlayer application thread
from inside a player callback — that is the re-entrancy hazard CLAUDE.md warns
about, and the same shape as the `AutoplayDecision` deadlock. Post the work to
the player's handler rather than doing it inline in a listener.
## Out of scope
- **Crossfade (UR-031 / DR-034).** Not implemented on *any* platform today, and
architecturally blocked on MPV (single-stream audio chain; `acrossfade` needs
two inputs). Implementing it on Android alone would invert the parity gap. It
needs its own spec and probably two player instances.
- True EBU R128 normalization. `LoudnessEnhancer` is a gain stage; matching
`dynaudnorm` exactly is out of reach without a custom `AudioProcessor`.
- Windows audio settings — see [windows-native-audio-backend.md](windows-native-audio-backend.md).
## Acceptance criteria
- [ ] `ExoPlayerBackend` overrides `set_audio_settings` and `audio_settings`.
- [ ] EQ preset change on Android audibly changes playback; setting persists across track changes and app restart.
- [ ] Normalization toggle audibly changes level; the three presets are ordered Loud > Normal > Quiet.
- [ ] Disabling gapless produces a gap between consecutive tracks; enabling it does not.
- [ ] Effects are released on `release()` and survive an audio-session rebuild.
- [ ] `requirements.md` parity matrix updated: EQ and normalization ✅ Android.
- [ ] `bun run check` and `bun run test` pass.
- [ ] `cargo fmt` clean, `cargo clippy` clean, `bun run test:rust` passes.
- [ ] `bun run check:boundary` passes.
- [ ] New requirement-implementing code carries `// TRACES:` comments.
- [ ] `bindings.ts` regenerated if Rust types changed.
## Testing
**Rust** (`cargo test`): `set_audio_settings` stores the sanitized settings and
`audio_settings()` returns them — assert clamping/normalisation is applied
(crossfade clamped to 12s, band vector normalised to `EQ_BANDS.len()`). The JNI
call itself is not unit-testable; extract the JSON serialization into a pure
function and test that its shape matches what the Kotlin parser expects. That
serialization contract is the part most likely to silently break.
**Kotlin**: the band-resampling function (10 canonical bands → N device bands) is
pure arithmetic — extract it and unit-test it, including the degenerate cases of
a 5-band device and a device reporting 10 bands.
**Manual, on device** (these are the ones that actually prove it):
1. Set Bass Boost, play a track, confirm audible change.
2. Toggle normalization mid-track; confirm level change without a playback stall.
3. Queue two gapless-encoded tracks, toggle the setting, confirm the gap appears/disappears.
4. Force a format change (44.1kHz → 48kHz track) and confirm the EQ still applies afterwards — this exercises the session-rebuild re-attach.
## TRACES
- `ExoPlayerBackend::set_audio_settings``// TRACES: UR-027, UR-032, UR-033 | DR-030, DR-035, DR-036`
- Kotlin `setAudioSettings` / `applyEqualizer` / `applyNormalization` → same IDs
- Band-resampling helper + its tests → `DR-030 | UT-xxx`
## Notes for the implementer
- Read [audio-equalizer.md](audio-equalizer.md) first — it defines the canonical
band layout and the preset→curve rule this spec consumes. Do not redefine bands
in Kotlin.
- Android source edits go in `src-tauri/android/src` (canonical tree), then run
`scripts/sync-android-sources.sh`. Never edit the `gen/` tree.
- There is a **stale duplicate** `JellyTauPlayer.kt` (285 lines) at
`src-tauri/android/app/src/main/java/com/dtourolle/jellytau/player/` alongside
the real 1103-line file at `src-tauri/android/src/main/java/...`. Edit the
latter. Consider deleting the former as a separate change.
- A parallel Claude session may be active — `git diff` before "repairing"
unexpected changes.
+306
View File
@@ -0,0 +1,306 @@
# Spec: Android native video — transparent-webview spike
**Status:** Spike succeeded (2026-08-11); shipped behind `experimentalNativeVideo`,
default off. Flipping that default shipped **audio with no picture** and was
reverted (DR-172). Three defects behind that have since been fixed — DR-182
(nothing on the native path could lift the poster overlay), DR-183 (the JS
bridges raced the page load), DR-184 (the SurfaceView was never detached).
Branch `fix/android-native-video-visible`. **The default stays off until the
device criteria below are green.**
**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)
## Summary
Test whether ExoPlayer's existing `SurfaceView` video path can be composited
behind a transparent Tauri WebView on Android. If it works, Android regains
hardware video decoding (MediaCodec) and libass-quality ASS/SSA subtitles, both
of which the current webview path lacks. If it does not, we document why and
delete the dead code.
This is a **spike**, not a feature commitment. The deliverable is a yes/no answer
with evidence, plus either a working path behind a flag or a removal.
## Motivation
`createAdapter()` hardcodes `const effectiveKind = "html5"` and does
`void backendKind`, discarding the `use_html5_element` value Rust computes in
`get_player_status`. As a result:
- `NativePlayerAdapter` is dead code.
- `JellyTauPlayer.kt`'s `getOrCreateSurfaceView()` — which already calls
`setZOrderMediaOverlay(false)` and wires `setVideoSurfaceHolder` — is
unreachable.
- Android video decodes in the WebView instead of via MediaCodec, despite
`CodecDetector.kt` going to the trouble of reporting hardware codec
capabilities back to Rust for DeviceProfile generation.
The code comment in `nativeAdapter.ts:11-14` justifies this by citing
tauri#10152 as an upstream blocker. **That justification is stale.**
### Why the blocker no longer holds
- tauri#10152 is open but **dead since 2024-07-01**, and it is a *feature
request* ("Support transparent webviews on mobile"), not a bug report about
compositing.
- The capability shipped in tauri commit `27d01834` (2024-09-02) — a clippy
cleanup that moved `transparent()` out of the desktop-gated impl block, fencing
only the tao call behind `#[cfg(desktop)]`. Because it landed as unrelated
cleanup, nobody closed the issue.
- The black/white-screen reports (tauri#8381, tauri#9408) were a real but
*different* bug: a broken JNI signature for `setBackgroundColor`, fixed in
**wry 0.39.4** (PR #1237). We ship wry 0.55.x.
- Current wry calls `setBackgroundColor(0)` unconditionally on Android when
transparency is requested.
### The honest caveat
**Nobody has demonstrated SurfaceView-behind-WebView on Tauri Android.** A search
of both `tauri-apps/tauri` and `tauri-apps/wry` issues for `surfaceview` returns
zero results, and the one native-video Tauri plugin
(`YeonV/tauri-plugin-videoplayer`) sidesteps compositing by launching a separate
fullscreen Activity. Nothing upstream blocks this; nothing upstream proves it.
Hence: spike, not feature.
Note this is the *Android* question only. The equivalent Linux compositing
problem is maintainer-declared unfixable and is **not** in scope — see the
unification spec.
## Layer assignment
| Logic / responsibility | Layer | Why it belongs there |
|------------------------|-------|----------------------|
| Which video backend this platform uses | Rust (existing) | `get_player_status` already computes `use_html5_element`. The frontend must *consume* it, not decide it. Restoring that is the point of the spike. |
| Surface creation, z-ordering, `setVideoSurfaceHolder` lifecycle | Kotlin | Android platform mechanics; already written in `JellyTauPlayer.kt`. |
| Seek/audio-track *strategy* | Rust (existing) | Already returned by `player_seek_video` / `player_switch_audio_track`; `NativePlayerAdapter` executes the chosen primitive. Unchanged — this is exactly what the `PlayerAdapter` contract was built for. |
| Positioning the surface under the video viewport | Frontend | Pure presentation/layout. **This is the risk area** — see Design. |
## Design
### Phase 1 — prove compositing (no app changes)
Before touching the adapter factory, verify the primitive works at all:
1. Set `"transparent": true` in `tauri.conf.json` for the Android build, plus
`html, body { background: transparent; }`.
2. Confirm the WebView is genuinely transparent (a native view behind it is
visible) and that the app does not regress to a black/white screen.
If this fails, stop — everything downstream is moot, and the finding is that
Tauri Android transparency is still broken in practice despite the shipped fix.
### Phase 2 — un-hardcode the factory
```ts
// src/lib/player/adapters/index.ts
export function createAdapter({ backendKind, host, bridge }: CreateAdapterArgs): PlayerAdapter {
return backendKind === "native"
? new NativePlayerAdapter(host)
: new Html5PlayerAdapter(host, bridge);
}
```
`backendKind` comes from `get_player_status` (`VideoBackend::Native` on Android).
Gate behind a setting — `experimentalNativeVideo`, default **off** — so a broken
spike cannot ship as a regression. Rust already owns this decision; the flag only
suppresses it.
**Also in scope: remove the user-agent sniffing in
`src/lib/services/webviewAudio.ts:30-41`.** It re-derives which audio backend the
platform has from `navigator.userAgent` ("matching the Rust cfg gate", per its own
comment) — the frontend deciding a backend fact it should be told. Same root cause
as the hardcode above, same fix: consume the value Rust already computes. Fold it
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
occupies a laid-out box; the `SurfaceView` must be positioned to match it, and
kept matched through scroll, rotation, and mini-player transitions.
Approach: the video view reports its `getBoundingClientRect()` to Rust, which
forwards the rect to Kotlin to position the `SurfaceView`. This is the same
"faking it" technique the ecosystem uses on desktop — acceptable here *only if*
the video is effectively fullscreen on Android, which it is in the player route.
**Explicit failure criterion**: if the surface cannot be kept aligned during
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
capabilities; the DeviceProfile would finally match what actually plays.
- **ASS/SSA subtitles** are *not* automatic. ExoPlayer cannot render them; that
would require libmpv, which is a separate and much larger decision (see the
unification spec's engine comparison). Scope this spike to hardware decode
only, and do not claim subtitle improvements from it.
## Out of scope
- Linux native video. Maintainer-declared unfixable on WebKitGTK/Wayland.
- Replacing ExoPlayer with libmpv on Android.
- Windows native video.
- Removing the HTML5 path. It stays as the default and the fallback.
## Acceptance criteria
The spike is **complete** when one of these is true:
**Success path**
- [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.
- [x] The poster/title card comes down on the native path. It never could: every `markMediaReady()` call site is a `<video>` element event and the native branch renders no element, so an opaque `bg-black` overlay covered the ExoPlayer surface for the whole session. See DR-182; guarded by `mediaReady.test.ts` (UT-184) and `VideoPlayer.nativeReveal.test.ts` (UT-185), the latter written failing first.
- [x] The `AndroidVideoSurface` bridge is installed before the page that calls it loads, via `WryActivity.onWebViewCreate` instead of a 500 ms tree walk, and a missing bridge now logs an error instead of no-oping. See DR-183.
- [x] The SurfaceView is detached when video stops, instead of accumulating one leaked view per native video. See DR-184.
- [ ] 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**
- [ ] The blocking behaviour is documented in this spec with evidence.
- [ ] `NativePlayerAdapter` and the unreachable `SurfaceView` code are deleted, or explicitly retained with a *correct* comment.
- [ ] `nativeAdapter.ts:11-14` no longer cites tauri#10152.
Either way:
- [x] `bun run check` (0 errors), `bun run test` (997 passed), `bun run check:boundary` pass.
- [x] `cargo fmt` / `cargo clippy` clean (no new warnings); `cargo test` passes (603 lib + 7 doc).
### Why the 2026-08-11 verification and DR-172 do not contradict each other
The spike was reported working on device; the same path then shipped as audio
with no picture. Both are consistent with DR-182: the poster overlay is drawn
only while `isMediaReady` is false, and the native path has no way to set it, so
what the surface shows depends entirely on **whether that overlay is on screen**
— not on whether compositing works. Any run that reached the player through a
path leaving `isMediaReady` already true (a handoff return, a re-render, a
session that had previously played on the HTML5 path) shows video; a cold start
into the native path never does. That is also why DR-172 read the symptom as a
compositing failure: on screen the two are identical, and the one piece of
evidence separating them — `WebView transparent = true` never being logged —
points at DR-183 rather than at the compositing itself.
**This reasoning is not yet device-confirmed.** It explains the reports and is
backed by the code, but the criteria above are what settle it.
> 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
Adapter-selection logic is pure and testable without a device: assert
`createAdapter` returns `NativePlayerAdapter` for `backendKind: "native"` with
the flag on, and `Html5PlayerAdapter` in every other combination — including that
the flag off forces HTML5 even when Rust says native. That last case is the
regression guard.
Everything else is manual on-device; there is no meaningful way to unit-test
surface compositing. Test on at least two devices — compositing behaviour varies
by OEM and Android version.
Per CLAUDE.md, if the spike turns into a bug fix (e.g. seek breaks under the
native adapter), write the failing test first.
## TRACES
- `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.
## Notes for the implementer
- **Do not skip Phase 1.** If transparency does not work, phases 2 and 3 are
wasted effort.
- `VideoPlayer.svelte` has a documented hazard: no lifecycle calls after an
`await` in `onMount` — it flips to HTML5 mode and breaks Android seek. The
adapter swap touches exactly this code path.
- tauri-specta tagged responses keep Rust field names (`new_url`, not `newUrl`).
- Android source edits go in `src-tauri/android/src`, then run
`scripts/sync-android-sources.sh`.
- A parallel Claude session may be active — `git diff` first.
+176
View File
@@ -0,0 +1,176 @@
# Spec: Audio equalizer
**Status:** Accepted
**Requirements:** UR-027 → DR-030 (EQ UI), IR-020 (MPV EQ integration).
**UX spec:** n/a (extends the Settings Audio section, ux-flows §8.1 instant-apply).
**Supersedes / revises:** —
**Revised by:** [android-audio-settings-parity.md](android-audio-settings-parity.md) — lifts the "Android is a no-op" limitation below.
## Summary
Add a graphic audio equalizer to playback. Users pick a preset (Flat, Rock,
Pop, Jazz, Classical, Bass Boost, Treble Boost, Vocal) or set custom per-band
gains, from a new block in Settings Audio. On Linux the gains apply live via
MPV's audio-filter chain; the settings persist and re-apply on the next track
and at startup, exactly like crossfade/gapless/normalize do today. Android is a
no-op for now (documented parity gap, same as those three features).
## Motivation
UR-027 is one of the few still-unbuilt audio features. The audio-settings
pipeline it needs already exists — `AudioSettings` + `set_audio_settings` on the
`PlayerBackend` trait, the `player_set_audio_settings` command, and the Settings
Audio UI with instant-apply. Crossfade, gapless, and volume normalization all
ride that pipeline. The equalizer is the same shape: N more fields on
`AudioSettings`, an `af` filter on the MPV backend, one more block in the
settings panel. No new command, no new state machine.
## Layer assignment
| Logic / responsibility | Layer | Why it belongs there |
|------------------------|-------|----------------------|
| EQ band count, centre frequencies, gain range/clamping | Rust | Domain of the audio engine; the bands must match what the MPV filter expects. Changing the DSP must not require a frontend change. |
| Preset name → per-band gain curve | Rust | A preset *is* a domain gain curve, not a label. It changes with the audio engine's band layout, never with the UI. Placing it in the frontend would be the scoped-search taxonomy mistake again (values that look like config but are domain data). |
| Translating gains → MPV `af` filter string | Rust | Platform playback detail; lives with the other `set_audio_settings` filter code in `mpv_backend.rs`. |
| Persisting the chosen settings, re-pushing on load | Rust/existing | Same path crossfade/etc. already use; the controller re-applies `AudioSettings` per track. |
| Rendering band sliders, the preset chips, live readouts | Frontend | Pure presentation; changes only if the settings UI is redesigned. |
| Which preset chip is highlighted; instant-apply on change | Frontend | Presentation/input handling (UR-057), the same as the normalize preset picker. |
Tie-breaker note: the preset→curve map is the one tempting boundary leak. It goes
in Rust because a preset is a set of band gains defined *by the band layout*,
which is an engine property. The frontend only ever names a preset and renders
the resulting gains; it never defines them.
## Design
### `AudioSettings` (Rust, `settings.rs`)
Add two fields (both `#[serde(rename_all = "camelCase")]` via the existing
struct attribute):
```rust
/// Equalizer enabled. When false, no `af` EQ filter is applied.
pub equalizer_enabled: bool,
/// Per-band gains in dB, one per FIXED band (see EQ_BANDS). Length is
/// validated/normalised to EQ_BANDS.len(); clamped to [-12, +12] dB.
pub equalizer_bands: Vec<f32>,
```
Fixed 10-band ISO layout (domain constant in `settings.rs`):
```rust
pub const EQ_BANDS: [f32; 10] =
[31.0, 62.0, 125.0, 250.0, 500.0, 1000.0, 2000.0, 4000.0, 8000.0, 16000.0];
pub const EQ_GAIN_MIN: f32 = -12.0;
pub const EQ_GAIN_MAX: f32 = 12.0;
```
- `Default`: `equalizer_enabled: false`, `equalizer_bands: vec![0.0; 10]` (flat).
- New `with_equalizer_normalised(self)` clamps each gain to `[EQ_GAIN_MIN,
EQ_GAIN_MAX]` and pads/truncates the vec to 10 bands. Applied in the command
alongside `with_crossfade_clamped` (add that call too — it's currently missing).
- Backward compat: both fields `#[serde(default)]` so old persisted JSON loads.
### Presets (Rust, `settings.rs`)
```rust
#[derive(specta::Type, Serialize, Deserialize, Clone, Copy, PartialEq)]
#[serde(rename_all = "camelCase")]
pub enum EqPreset { Flat, Rock, Pop, Jazz, Classical, BassBoost, TrebleBoost, Vocal }
impl EqPreset {
/// The 10-band gain curve (dB) for this preset.
pub fn gains(&self) -> [f32; 10] { /* table */ }
}
```
Preset selection is a *frontend* convenience: tapping a chip sets
`equalizer_bands = preset.gains()` and pushes settings. The curve tables live in
Rust; the frontend reads them via a tiny `player_get_eq_presets` command
returning `Vec<(EqPreset, Vec<f32>)>` (or a map), so the frontend never encodes
the numbers. (If exposing the whole table is awkward through specta, expose
`player_eq_preset_gains(preset) -> Vec<f32>` instead — pick at implement time.)
### MPV application (Rust, `mpv_backend.rs::set_audio_settings`)
Build an `equalizer` / `anequalizer` filter from the bands and set the `af`
property. When `equalizer_enabled` is false or all gains are 0, clear the EQ
filter (leave any other `af` entries intact). Use `af add`/`af remove` or a
rebuilt `af` string; keep it isolated so it doesn't stomp a future crossfade
filter. Errors map to `PlayerError` like the gapless code.
### No new persistence table
`AudioSettings` is already round-tripped by the frontend settings store and
re-pushed via `player_set_audio_settings` on change and on load. The two new
fields ride along. `NullBackend`/Android inherit the trait default (no-op).
### Wire summary
- Command names unchanged: `player_set_audio_settings`,
`player_get_audio_settings` (now carry the EQ fields).
- New (optional) read-only command for preset curves — kebab n/a (it's a
command): `player_get_eq_presets` (or `player_eq_preset_gains`).
- Regenerate `bindings.ts` from the Rust types; never hand-edit.
## Out of scope
- Android/ExoPlayer EQ (parity gap tracked with crossfade/gapless/normalize).
**Now specified in [android-audio-settings-parity.md](android-audio-settings-parity.md)**,
which implements `set_audio_settings` on `ExoPlayerBackend`. The canonical band
layout and preset→curve map defined here remain authoritative; the Android side
resamples those bands onto the device equalizer rather than defining its own.
- Per-track or per-library EQ profiles — one global profile only.
- Automatic loudness/room correction; only manual bands + presets.
- Changing the crossfade/normalize TODOs in `set_audio_settings` beyond wiring
the missing `with_crossfade_clamped` call.
## Acceptance criteria
- [ ] Settings Audio has an Equalizer block: enable toggle, preset chips, 10
band sliders with live dB readouts, instant-apply (no Save button).
- [ ] Choosing a preset sets the bands from the Rust-defined curve; editing a
band switches the highlighted preset to "Custom" (frontend-only label).
- [ ] Gains clamp to [-12, +12] dB; the band vector always normalises to 10.
- [ ] On Linux, enabling EQ audibly changes output and persists across tracks
and app restart; disabling clears the filter without affecting other audio.
- [ ] Old persisted settings (no EQ fields) load without error, defaulting flat.
- [ ] `bun run check` and `bun run test` pass.
- [ ] `cargo fmt` clean, `cargo clippy` clean, `bun run test:rust` passes.
- [ ] `bun run check:boundary` passes (no preset curve numbers in the frontend).
- [ ] New requirement-implementing code carries `// TRACES:` comments.
- [ ] `bindings.ts` regenerated.
## Testing
- Rust (`settings.rs`): default is flat + disabled; `with_equalizer_normalised`
clamps out-of-range gains and pads/truncates band length; serialization
round-trips the camelCase fields; backward-compat load of pre-EQ JSON; each
preset returns a 10-length curve; Flat is all zeros.
- Rust IPC param naming for any new command (camelCase rule per CLAUDE.md).
- Frontend (`settings` page or an extracted helper): selecting a preset sets the
expected band array; editing a band flips the label to Custom; enable toggle
gates the sliders. Keep DSP untested on the frontend (it's Rust's).
## TRACES
- `AudioSettings` EQ fields + normalise + presets: `UR-027 | DR-030` (+ unit tests)
- MPV EQ filter application: `UR-027 | IR-020`
- Settings EQ UI block: `UR-027 | DR-030`
- Preset-curve command: `UR-027 | DR-030`
## Notes for the implementer
- A parallel Claude session is active in this repo (it has touched
`tauri.conf.json`, `Dockerfile`, `package.json`, home components, and added
build scripts, and the Rust build is currently broken by its
`tauri.conf.json` bundle-target change). `git diff` before "repairing"
anything you didn't write; keep EQ changes isolated to `settings.rs`,
`mpv_backend.rs`, `backend.rs` (trait default already covers it),
`commands/player/settings.rs`, and the settings page.
- Mirror the volume-normalization block in the settings page for the toggle +
preset-picker pattern; mirror the gapless code in `set_audio_settings` for the
MPV property handling.
- Confirm the exact MPV filter name available in the linked libmpv
(`equalizer` vs `anequalizer`/`superequalizer`) before committing the filter
string; gate cleanly if unavailable.
+215
View File
@@ -0,0 +1,215 @@
# Spec: Harden the frontend boundary tripwire
**Status:** Implemented
**Requirements:** DR-094
**UX spec:** n/a — developer tooling.
**Supersedes / revises:** revises the detection rule in
[scripts/check-frontend-boundary.sh](../../scripts/check-frontend-boundary.sh);
the boundary *policy* in [scoped-search-boundary.md](scoped-search-boundary.md)
is unchanged.
## Summary
`bun run check:boundary` passes on a tree that contains the exact leak it was
built to catch. It matches a multi-type array only when written **inline at the
query site**, so assigning the same array to a named const evades it entirely —
which is how [searchScope.ts](../../src/lib/utils/searchScope.ts) has kept a
category→item-type mapping through every green CI run. This spec broadens the
match to item-type array literals anywhere in `src/`, and resolves the handful
of legitimate hits that broadening surfaces.
## Motivation
The current pattern is anchored to the `includeItemTypes:` key:
```sh
PATTERN='includeItemTypes:[[:space:]]*\[[^]]*,[^]]*\]'
```
The live leak is not written that way:
```ts
// src/lib/utils/searchScope.ts:29 — invisible to the tripwire
const SCOPE_ITEM_TYPES = { music: ["MusicAlbum", "MusicArtist", "Audio", "Playlist"], … };
```
The taxonomy and the query are one indirection apart, and the grep only sees the
query. The script's own header is admirably honest that it is "a TRIPWIRE, NOT A
PROOF" — but the gap here is not a subtle judgment call it was designed to
defer to human review. It is the *crudest form* of the violation, one `const`
away from the shape it does match, in the very file the founding incident was
written about.
Broadening the pattern to any item-type array literal finds it, with a
manageable number of other hits (measured, not estimated):
| Site | Verdict |
|------|---------|
| `searchScope.ts:30,32` | 🔴 The leak. Removed by [scoped-search-boundary-implementation.md](scoped-search-boundary-implementation.md). |
| `PersonDetailView.svelte:30` | Already allowlisted, with a recorded reason. |
| `DownloadedBrowse.svelte:95` | Borderline — `["MusicAlbum","Series","Season","BoxSet"].includes(item.type)` as an "is this a container?" predicate. |
| `GenericMediaListPage.svelte:298` | Borderline — `["MusicAlbum","MusicArtist","Audio","Playlist"].includes(config.itemType)` as a music-styling predicate. |
| 6 hits in `*.test.ts` | Excluded; tests legitimately name types. |
Four non-test sites total. This is a tractable change, not a boil-the-ocean one.
## Layer assignment
Tooling only — no application logic, nothing crosses IPC. The two borderline
*application* sites do get a layer decision, below.
| Logic / responsibility | Layer | Why it belongs there |
|------------------------|-------|----------------------|
| Detecting item-type array literals in `src/` | Build tooling (`scripts/`) | Static analysis of repo source; belongs beside the existing check. |
| "Is this item a container?" (`DownloadedBrowse`) | **Rust** (recommended) | Containers-vs-leaves is Jellyfin structure, and the set grows when Jellyfin adds a container type — the litmus test's "yes". Prefer a `MediaItem.isContainer` boolean from the backend over a type-set predicate in a component. |
| "Is this music content?" (`GenericMediaListPage`) | **Frontend, allowlisted** | Selects a grid *style*. It reads `config.itemType`, a value the page already declares about itself, and changes only if the UI is redesigned — the litmus test's "no". Single-type presentation is explicitly not the target of the rule. |
`DownloadedBrowse` defaults to Rust per the checklist's borderline rule; see
Out of scope for why the migration itself is deferred rather than bundled.
## Design
### 1. Broaden the pattern
Replace the key-anchored pattern with one matching an array literal of two or
more known Jellyfin item types, wherever it appears:
```sh
# Two or more adjacent item-type string literals inside a bracket.
TYPES='Movie|Series|Episode|Audio|MusicAlbum|MusicArtist|MusicVideo|Season|BoxSet|Playlist|Book|AudioBook|Video|Person|Folder|CollectionFolder|TvChannel|LiveTvChannel'
PATTERN="\[[[:space:]]*\"($TYPES)\"[[:space:]]*,[[:space:]]*\"($TYPES)\""
```
Properties worth stating, because each is a deliberate trade:
- **Not anchored to any key**, so a named const, a function return, a `Record`
value, or an inline query all match equally.
- **Requires two adjacent type literals**, preserving the existing and correct
carve-out that single-type presentation (`itemType: "Movie"`) is legitimate.
- **Requires string literals**, so `item.type === "Audio"` (display inspection)
still does not match.
- **Explicit type list**, not `[A-Z][a-z]+`, so arbitrary string arrays
(`["High","Low"]`, `["Songs","Albums"]`) do not produce noise.
Keep `grep -rInE`, the `*.test.*` exclusion, and the allowlist mechanism as they
are — all three work.
### 2. Resolve the surfaced sites
- `PersonDetailView.svelte` — already allowlisted; entry unchanged.
- `GenericMediaListPage.svelte`**add to the allowlist** with the reason from
the layer table (grid styling over a self-declared `itemType`).
- `DownloadedBrowse.svelte` — **add to the allowlist with a `TODO` naming the
preferred fix** (backend `isContainer`). An allowlist entry that records a
known-borderline decision is honest; silently broadening the pattern to miss
it would not be.
- `searchScope.ts`**not allowlisted.** It is the leak, and
[scoped-search-boundary-implementation.md](scoped-search-boundary-implementation.md)
deletes it.
### 3. 🔴 Sequencing
**This spec must land after Stage 1 of
[scoped-search-boundary-implementation.md](scoped-search-boundary-implementation.md).**
Hardening the tripwire first turns `master` red on a violation with no fix
available, and the only ways out are reverting the hardening or allowlisting the
leak — the second of which is exactly how a boundary rule dies.
### 4. Keep the allowlist honest
The script already warns that a growing allowlist means the boundary is eroding.
This change takes it from 1 entry to 3, which is close to that line. Add a hard
cap so drift is caught mechanically rather than by whoever notices:
```sh
MAX_ALLOWLIST=4
if [ "${#ALLOWLIST[@]}" -gt "$MAX_ALLOWLIST" ]; then
echo "❌ Allowlist has ${#ALLOWLIST[@]} entries (max $MAX_ALLOWLIST)."
echo " Push taxonomy into Rust instead of appending here."
exit 1
fi
```
The cap is deliberately just above the current count: the next exception forces
a conversation instead of a one-line append.
### 5. Restate the limits
The header's "tripwire, not a proof" caveat stays and gets sharper. The broadened
pattern still cannot see:
- a type set built at run time (`[...musicTypes, "Playlist"]`),
- types split across variables (`const A = "Audio"; [A, B]`),
- taxonomy expressed as a `switch` or chained `||` rather than an array.
The spec-review checklist remains the real gate. This raises the floor; it does
not close the class.
## Out of scope
- **Migrating `DownloadedBrowse` to a backend `isContainer` flag.** It touches
`MediaItem`, `bindings.ts`, and the offline path — its own spec. Allowlisted
with a TODO here so it is recorded, not forgotten.
- The scoped-search fix itself — [scoped-search-boundary-implementation.md](scoped-search-boundary-implementation.md).
- Detecting the run-time-construction cases listed above.
- Extending the check to Rust or Kotlin (the rule is about `src/`).
- Changing the boundary *policy* in CLAUDE.md.
## Acceptance criteria
- [ ] With `searchScope.ts` reverted to its leaking form, `bun run check:boundary`
**fails** and names `src/lib/utils/searchScope.ts`. This is the criterion
that proves the fix — verify it explicitly before landing.
- [ ] On the post-fix tree, `bun run check:boundary` passes.
- [ ] A newly introduced `const X = ["Movie", "Series"]` in any non-test `src/`
file fails the check (regression test for the const-indirection evasion).
- [ ] `itemType: "Movie"` and `item.type === "Audio"` do **not** trip the check.
- [ ] `["High", "Low"]` and other non-item-type arrays do **not** trip it.
- [ ] Test files are still excluded (the 6 known test hits stay silent).
- [ ] The allowlist has exactly 3 entries, each with a written reason; a 5th
entry fails the check via `MAX_ALLOWLIST`.
- [ ] The script header still states it is a tripwire, not a proof, and names the
evasions it cannot see.
- [ ] `bun run check` and `bun run test` pass.
- [ ] `bun run test:all` passes.
## Testing
The script is bash and has no test harness. Verify by construction — each is a
temporary edit, run, revert:
1. Reintroduce the `SCOPE_ITEM_TYPES` const → **must fail**.
2. Add `const T = ["Movie","Series"]` to a scratch `.svelte` file → **must fail**.
3. Add the same to a `.test.ts` file → **must pass** (exclusion holds).
4. Add `itemType: "Movie"`**must pass**.
5. Add a 5th allowlist entry → **must fail** on the cap.
Record the five results in the PR description. A grep-based gate that has never
been observed failing is indistinguishable from one that cannot fail — which is
the precise condition this whole spec exists to correct.
## TRACES
Allocate in `requirements.md`:
- **DR-094** — "Frontend boundary tripwire detects Jellyfin item-type array
literals anywhere in `src/` (not only inline at an `includeItemTypes:` query
site), so a category→type mapping cannot evade the check via a named const;
allowlist is capped to force taxonomy into Rust rather than accumulating
exceptions." Category: Tooling. Status: Done on merge.
Shell scripts carry no `TRACES:` comment convention in this repo; reference
DR-094 in the script header comment instead.
## Notes for the implementer
- A parallel Claude session may be active in this repo — `git diff` before
"repairing" unexpected changes (CLAUDE.md §Gotchas).
- **Land after Stage 1 of the scoped-search fix** — see §3. This is the one
ordering constraint that will break `master` if ignored.
- Test the regex against the current tree *before* committing:
`grep -rInE "$PATTERN" src/ | grep -v '\.test\.'` should return exactly the
four sites in the Motivation table.
- The `TYPES` list will need occasional extension as Jellyfin adds types.
That is acceptable for a tripwire — an unlisted type produces a false
negative, never a false positive, so the check degrades safely.
+250
View File
@@ -0,0 +1,250 @@
# Spec: Build provenance (git describe + build profile)
**Status:** Proposed
**Requirements:** new DR-093 (build provenance surfaced in-app and in logs); no UR — this is a diagnostic capability, not a user feature
**UX spec:** n/a — adds an About block to Settings; no new flow
**Supersedes / revises:** —
## Summary
Make every build say exactly what it is. Today a running JellyTau reports no
version at all — not in the UI, not in the logs — and the only version string in
the tree is the hand-maintained `0.2.0` duplicated across three files.
This adds a `build.rs`-generated provenance string (`git describe` + short SHA +
dirty flag + debug/release profile), exposes it over IPC, and renders it in a new
Settings About block. It also removes one of the three hand-bumped version
files.
## Motivation
The concrete problem: when a user reports "the equalizer does nothing on my
device" — which is a live risk for v0.2.0, whose Android audio settings are not
yet device-verified — there is currently no way to tell which build they are
running. Tag? Master? A local debug build from three weeks ago? The bug report
cannot distinguish them.
Two smaller irritations this also fixes:
- **Debug builds masquerade as releases.** `0.2.0` is `0.2.0` whether it came
from a tagged release or `bun run tauri dev`.
- **Three files carry the version.** `package.json`, `src-tauri/Cargo.toml` and
`src-tauri/tauri.conf.json` must be bumped in lockstep; the release checklist
exists partly to stop them drifting.
### What this deliberately does *not* do
**The canonical version stays hand-bumped in `Cargo.toml`.** Cargo requires a
literal semver string at manifest-parse time and cannot derive it from git. The
same is true of `tauri.conf.json`. Attempting to source the *release* version
from a tag trades a scripted, reviewable bump for a fragile build-time
dependency that breaks in exactly the environment we care most about (CI, in
Docker, from a shallow clone).
So: **the release version is authored; the build provenance is derived.** They
answer different questions — "what release is this?" versus "what commit is this
binary actually built from?" — and only the second benefits from git.
## Layer assignment
| Logic / responsibility | Layer | Why it belongs there |
|------------------------|-------|----------------------|
| Capturing git describe / SHA / dirty state at compile time | Rust (`build.rs`) | Only the Rust build has a compile step that can shell out to git and bake the result into the binary. A frontend equivalent would report the *dev server's* state, not the shipped binary's. |
| Degrading to a sentinel when git is unavailable | Rust (`build.rs`) | Build-environment concern. Must never fail the build — CI runs in Docker from a shallow clone. |
| Release version (`0.2.0`) | Rust (`Cargo.toml`, authored) | Domain fact about the product, not derivable from the environment. |
| Deciding *what a build is* (release / dev / dirty) | Rust | Domain classification. The frontend must not infer "this is a dev build" from a string shape — it renders what it is told. |
| Rendering the About block, copy-to-clipboard | Frontend | Pure presentation. |
Borderline row: the release/dev/dirty classification could be done in the
frontend by pattern-matching the describe string. It goes to Rust because that is
a *rule about what constitutes a release build*, and it would have to change if
the tagging scheme changed — the litmus test in the template puts that in Rust.
Send a typed enum, not a string for the frontend to parse.
## Design
### `build.rs`
```rust
fn main() {
emit_build_provenance();
tauri_build::build()
}
fn emit_build_provenance() {
let describe = std::process::Command::new("git")
.args(["describe", "--tags", "--always", "--dirty"])
.output()
.ok()
.filter(|o| o.status.success())
.and_then(|o| String::from_utf8(o.stdout).ok())
.map(|s| s.trim().to_string())
.unwrap_or_else(|| "unknown".to_string());
println!("cargo:rustc-env=JELLYTAU_GIT_DESCRIBE={describe}");
// Rebuild when HEAD moves or a ref is written, so the string does not go
// stale across commits. Guarded: these paths do not exist in a git-less
// source tarball, and emitting rerun-if-changed for a missing path would
// force a rebuild every time.
for p in [".git/HEAD", ".git/refs"] {
if std::path::Path::new("../").join(p).exists() {
println!("cargo:rerun-if-changed=../{p}");
}
}
}
```
🔴 **`build.rs` must never fail the build.** Every git call is
`.ok()`-swallowed; a missing git binary, a shallow clone, or a source tarball all
yield `"unknown"`. A build that breaks because git is absent would be a worse bug
than the one this fixes.
Note the `../` prefixes: `build.rs` runs with CWD at `src-tauri/`, so the repo's
`.git` is one level up.
### The provenance type
```rust
/// TRACES: DR-093
#[derive(specta::Type, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct BuildInfo {
/// Authored release version (Cargo.toml).
pub version: String,
/// `git describe --tags --always --dirty`, or "unknown".
pub git_describe: String,
/// What kind of build this is — classified in Rust, not inferred by the UI.
pub kind: BuildKind,
}
/// TRACES: DR-093
#[derive(specta::Type, Serialize)]
#[serde(rename_all = "camelCase")]
pub enum BuildKind {
/// Built from a clean, exactly-tagged commit in release mode.
Release,
/// Release-mode build that is not on a clean tag (e.g. master, or dirty).
Untagged,
/// debug_assertions build.
Development,
/// Git state unavailable at build time.
Unknown,
}
```
Classification:
```rust
let kind = if cfg!(debug_assertions) {
BuildKind::Development
} else if describe == "unknown" {
BuildKind::Unknown
} else if describe.contains('-') { // "v0.2.0-3-gcb79a37" or "...-dirty"
BuildKind::Untagged
} else {
BuildKind::Release
};
```
### Command
```rust
/// TRACES: DR-093
#[tauri::command]
#[specta::specta]
pub fn get_build_info() -> BuildInfo { … }
```
No parameters, so the camelCase param rule does not apply; the struct fields do
need `#[serde(rename_all = "camelCase")]` (above). Regenerate `bindings.ts`.
Also log the provenance once at startup, next to the existing init logging —
that is what makes a user-submitted log file self-identifying, which is most of
the value.
### Settings About
A new block at the bottom of `src/routes/settings/+page.svelte`, rendering
version, describe string, and a badge for non-release builds. One
copy-to-clipboard button that yields a paste-ready block for bug reports:
```
JellyTau 0.2.0 (v0.2.0-3-gcb79a37-dirty, development)
linux x86_64
```
Platform/arch come from the existing Tauri APIs; do not shell out.
### Removing one version file
`tauri.conf.json`'s `"version"` field can be omitted, in which case Tauri falls
back to the Cargo version. That takes the bump from three files to two.
**Verify before adopting**: confirm the Android `versionName`/`versionCode` and
the NSIS installer version still resolve correctly with the field absent —
Android packaging in particular reads the Tauri config. If either regresses,
keep the field and drop this part; it is a convenience, not the point of the
spec.
## Out of scope
- Deriving the *release* version from git tags (see Motivation).
- A build-time timestamp. It defeats reproducible builds and adds little over
the commit SHA.
- CI provenance/attestation, SBOM, signing.
- Displaying the Jellyfin server version (separate concern, already available
from `/System/Info`).
## Acceptance criteria
- [ ] `cargo build` succeeds with git absent, from a shallow clone, and from a source tarball with no `.git` — yielding `"unknown"` in each case, never a build failure.
- [ ] A tagged clean release build reports `BuildKind::Release`; `bun run tauri dev` reports `Development`; a dirty tree reports `Untagged` (release mode) with `-dirty` in the describe string.
- [ ] The describe string changes after a new commit without a manual `cargo clean` (rerun-if-changed works).
- [ ] Provenance is logged once at startup.
- [ ] Settings About renders version + describe + build-kind badge, with working copy-to-clipboard.
- [ ] 🔴 CI checkouts that build a shippable artifact set `fetch-depth: 0`, or their artifacts are knowingly stamped `unknown`. Currently only `publish-docs.yml` sets it; `build-release.yml` has five checkouts and `build-and-test.yml` two, all of which would report `unknown` as-is.
- [ ] **No toolchain installed in CI** — git is already present in the builder image; nothing new is added.
- [ ] `bun run check`, `bun run test`, `bun run check:boundary` pass.
- [ ] `cargo fmt` clean, `cargo clippy` clean, `bun run test:rust` passes.
- [ ] `bindings.ts` regenerated.
- [ ] DR-093 allocated in `requirements.md`; new code carries `// TRACES:`.
## Testing
**Rust**: the classification is pure and must be extracted from the command as
`classify_build(describe: &str, debug: bool) -> BuildKind` so it can be tested
directly. Cover: `"v0.2.0"``Release`; `"v0.2.0-3-gcb79a37"``Untagged`;
`"v0.2.0-dirty"``Untagged`; `"unknown"``Unknown`; `debug = true` → always
`Development` regardless of describe.
`build.rs` itself is not unit-testable. Verify its failure path manually by
building with `PATH` stripped of git, and from a `git archive` tarball — both
must succeed with `"unknown"`.
**Frontend**: assert the About block renders each `BuildKind` correctly, and that
it renders the backend-supplied kind rather than re-deriving it from the string
(a test that passes a `Release` kind with a `-dirty` describe and asserts the
badge follows the *kind* would catch that regression).
## TRACES
- `build.rs` provenance emission → `// TRACES: | DR-093`
- `BuildInfo` / `BuildKind` / `classify_build``// TRACES: | DR-093`
- `get_build_info` command → `// TRACES: | DR-093`
- Settings About block → `// TRACES: | DR-093`
- `classify_build` tests → `UT-BUILD-1`
- Allocate **DR-093** in `requirements.md` ("Build provenance: git describe and
build profile surfaced in-app and in logs"). Next free DR at time of writing
is DR-093.
## Notes for the implementer
- Do the `build.rs` + command + logging first; the About UI is the smaller half
and the logging alone delivers most of the diagnostic value.
- The `fetch-depth: 0` change is the easiest part to forget and the one that
makes CI artifacts useless if missed — it is why that acceptance box is
flagged. Weigh it per workflow: test-only jobs do not need it.
- Do not add a build timestamp "while you are in there" — see Out of scope.
- A parallel Claude session may be active — `git diff` before "repairing"
unexpected changes.
+334
View File
@@ -0,0 +1,334 @@
# Spec: Locally-indexed search
**Status:** Implemented
**Requirements:** UR-065 → DR-108, DR-109, DR-110, DR-111; IR-030
**UX spec:** [ux-flows.md §6.1](../ux-flows.md) (search surface is unchanged)
**Revises:** [scoped-search.md](scoped-search.md) and
[scoped-search-boundary.md](scoped-search-boundary.md) — scope semantics are
untouched; this changes only *which corpus* the cache leg searches.
## Summary
Search stops depending on a per-keystroke round trip to Jellyfin. The local
SQLite catalog — which is already synced and already FTS5-indexed — becomes the
corpus the instant leg of search reads, so results appear as fast as SQLite can
answer, online or offline. A background indexer keeps that catalog fresh on a
schedule instead of only at app start, prunes content deleted on the server, and
covers the item types search groups results by. The server query stays, demoted
to a background reconciliation that merges in late results for anything indexed
since the last pass.
## Motivation
The pieces are already built and simply not wired together:
- [`sync_full_catalog`](../../src-tauri/src/commands/catalog.rs) already walks
every library `Recursive=true` and persists items with `synced_at`.
- `items_fts` (schema.rs migration 001) already indexes `name`, `overview`,
`album_name`, `album_artist`, `artists`, `series_name` with keep-in-sync
triggers.
- `repository_search` is already two-phase — synchronous cache result, then a
spawned server query merged in via the `search-event`.
What breaks the chain is that the cache leg is hard-restricted to *downloaded*
items. `OfflineRepository::search` wraps its FTS query in a `downloaded_items`
CTE requiring `d.status = 'completed'`:
```sql
FROM items i
JOIN items_fts fts ON fts.rowid = i.rowid
INNER JOIN downloaded_items di ON i.id = di.id
WHERE i.server_id = ? AND items_fts MATCH ?
```
So for a user with no downloads, phase 1 returns nothing on every query, and
every debounced keystroke falls through to a full `Recursive=true` server
request with `Limit=10000`. The populated local index is never read.
`get_items` does not have this problem — it gates a third `synced_at IS NOT NULL`
branch on `include_catalog_browse()` (offline.rs, the "Show all server media"
toggle). The asymmetry is the bug: **offline you can already browse the whole
catalog but cannot search it.**
Three further defects found while confirming the above:
1. **The FTS index grows without bound.** `save_to_cache` uses
`INSERT OR REPLACE INTO items`, but `recursive_triggers` is never enabled
(`storage/mod.rs` sets only `foreign_keys` and `journal_mode`). SQLite fires
`AFTER DELETE` triggers on a REPLACE *only* with recursive triggers on — so
`items_ad` never runs, the old FTS row is orphaned, and because `items.id` is
a `TEXT PRIMARY KEY` the replacement row takes a **new rowid** and inserts a
second FTS entry. Every sync appends a duplicate index. Results stay correct
(the `INNER JOIN … ON fts.rowid = i.rowid` hides orphans, and no rowid is ever
reused because nothing is deleted) but `MATCH` degrades permanently.
2. **Server-side deletions never propagate.** There is no `DELETE FROM items`
anywhere in the codebase. The local catalog is append-only, so media removed
from the server would stay searchable forever — tolerable when the cache was
only a browse accelerator, not acceptable when it is the search corpus.
3. **The index omits types search groups by.** `CATALOG_ITEM_TYPES` is
`MusicAlbum, Movie, Series, Season, Episode, Audio, BoxSet` — no
`MusicArtist`, no `Playlist`, and People live in a separate `people` table
with no FTS at all. UR-060 mandates Artists and People result groups, so today
those can *only* come from the server.
## Layer assignment
| Logic / responsibility | Layer | Why it belongs there |
|------------------------|-------|----------------------|
| Which corpus search reads (downloads-only vs full synced catalog) | **Rust** | Sync/availability policy over domain data. Changes if Jellyfin's API or the offline rules change, not if the UI is redesigned. Reuses the existing `include_catalog_browse()` flag so search and browse cannot diverge again. |
| Index freshness policy — TTL, when a re-index is due, skip-while-offline | **Rust** | Explicitly named as domain policy in [SPEC-REVIEW-CHECKLIST.md](SPEC-REVIEW-CHECKLIST.md) ("reachability/sync policy"). It is currently frontend-driven in `offlineCatalog.ts`; this spec moves it. |
| Which Jellyfin item types get indexed (`CATALOG_ITEM_TYPES`) | **Rust** | Textbook domain taxonomy — a category→item-type set. Must never appear in `src/`. |
| Reconciling a crawl against local rows (what to prune) | **Rust** | Operates on domain data and depends on crawl completeness semantics. |
| FTS query construction, ranking, scope→type expansion | **Rust** | Already there (`search_rank.rs`, `SearchScope::item_types()`); unchanged by this spec. |
| Rendering a "catalog last indexed N ago" hint and any re-index button | **Frontend** | Pure presentation of a backend-supplied timestamp. |
| Debounce interval, result group order, scope chips | **Frontend** | Input handling and view preference; changes only if the UI is redesigned. |
Borderline call, recorded: the **TTL value itself** (how many hours before a
re-index is due) could be argued as a user preference and therefore frontend. It
is placed in Rust because the frontend must not be able to decide *whether the
cache is authoritative* — that is the same class of decision as
`include_catalog_browse`, which already lives in Rust. If the TTL later becomes
user-configurable it stays a Rust-owned setting the frontend edits through a
command, not a frontend constant. Borderline defaults to Rust.
## Design
### 1. Search the full synced catalog (DR-108)
`OfflineRepository::search` mirrors `get_items` exactly: rename the CTE to
`available_items` and add the same third branch, gated on the same flag.
```rust
let catalog_branch = if include_catalog_browse() {
"UNION
-- Synced catalog: fast online search, or the offline 'Show all
-- server media' view. Mirrors get_items; see set_include_catalog_browse.
SELECT DISTINCT i.id
FROM items i
WHERE i.synced_at IS NOT NULL"
} else {
""
};
```
No new IPC surface and no frontend change: `set_include_catalog_browse` is
already called with `true` when online or when the offline toggle is on, and
`false` only when offline with the toggle off. Search inherits the correct
behaviour in all three states, and the "search is restricted to downloads" case
survives for users who deliberately asked for downloads-only.
Also fix, in the same function, the `type_filter` built by **string
interpolation** of `include_item_types` rather than bound parameters. It is
currently safe only because callers pass `SearchScope`-derived values, but
`SearchOptions.include_item_types` is settable directly from the frontend (as
`GenericMediaListPage` does). Bind the values.
Phase 2 (the server query) is unchanged and still merges via `search-event`, so
content added to the server since the last index still surfaces — just late
rather than first.
### 2. Scheduled background indexer (DR-109, IR-030)
A Rust-owned task replaces the frontend's startup-only trigger.
```rust
/// How long a full-catalog index stays fresh before a re-index is due.
const CATALOG_INDEX_TTL: Duration = Duration::from_secs(6 * 60 * 60);
```
Behaviour:
- On app setup, spawn a tokio task that ticks every 30 min.
- Each tick: if a repository is active **and** the server is reachable **and**
`now - last_catalog_sync > CATALOG_INDEX_TTL`, run a full index pass.
- On the existing `ConnectivityMonitor` reconnect signal, evaluate the same
staleness condition immediately rather than waiting for the next tick.
- Never run two passes concurrently (the existing `syncInProgress` guard moves
into Rust as an `AtomicBool`).
`last_catalog_sync` is already written to `app_settings` by `sync_full_catalog`
and is currently read only for a UI hint; this makes it load-bearing.
`RepositoryManager` (`commands/repository.rs`) is a `HashMap<String, …>` with no
notion of an active handle, so the task has nothing to run against. Add:
```rust
pub struct RepositoryManager {
repositories: Arc<Mutex<HashMap<String, Arc<HybridRepository>>>>,
active: Arc<Mutex<Option<String>>>, // set in create(), cleared in destroy()
}
```
Progress is reported with a **kebab-case** event (per the project convention):
```rust
// event name: "catalog-index-event"
#[derive(specta::Type, Serialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct CatalogIndexEvent {
pub state: CatalogIndexState, // #[serde(tag = "type")] Idle | Running | Complete | Failed
pub libraries_done: usize,
pub libraries_total: usize,
pub items_indexed: usize,
}
```
`sync_full_catalog` stays a command so the UI can still force a pass; it and the
scheduler share one internal `run_index_pass()`.
### 3. Index hygiene — no orphans, and deletions propagate (DR-110)
**Orphan growth.** Replace `INSERT OR REPLACE INTO items (…)` in `save_to_cache`
with a true upsert:
```sql
INSERT INTO items (id, server_id, …) VALUES (…)
ON CONFLICT(id) DO UPDATE SET
name = excluded.name, overview = excluded.overview, …,
synced_at = excluded.synced_at
```
This preserves the rowid (which `items_fts` keys on via `content_rowid`) and
fires `items_au` instead of silently orphaning a row. Preferred over
`PRAGMA recursive_triggers = ON` because it also stops the rowid churn, and the
three FTS triggers are the only triggers in the schema so nothing else depends
on REPLACE semantics.
A new migration `021_rebuild_items_fts` clears the orphans already accumulated on
existing installs:
```sql
INSERT INTO items_fts(items_fts) VALUES('rebuild');
```
**Deletions.** After a library crawls *successfully and completely*, reconcile:
delete local rows for that library whose `id` was not seen in the crawl. Two
constraints the implementation must respect:
- Skip any item with a completed download — the user has the file; removing the
row would orphan it. Prune only synced-but-not-downloaded rows.
- Only sweep libraries whose crawl succeeded. `sync_full_catalog` is
deliberately best-effort per library, and `items.parent_id` is
`ON DELETE CASCADE` — sweeping on a partial crawl would cascade a whole series
away because one request timed out.
### 4. Index the types search groups by (DR-111)
Add `MusicArtist` and `Playlist` to `CATALOG_ITEM_TYPES`.
People need a different mechanism: they live in `people` (`id`, `server_id`,
`name`, `overview`, `primary_image_tag`, `synced_at`), populated incidentally by
item-detail fetches, with no FTS table. Migration `022_people_fts` adds one
mirroring the `items_fts` pattern:
```sql
CREATE VIRTUAL TABLE IF NOT EXISTS people_fts USING fts5(
name, overview, content='people', content_rowid='rowid'
);
-- plus people_ai / people_ad / people_au triggers
```
`OfflineRepository::search` UNIONs `people_fts` matches into its result set as
`Person`-typed items when the resolved scope permits them (i.e. when
`include_item_types` is `None``SearchScope::All`). `search_rank.rs` already
handles `MediaKind::Person`, so ranking needs no change.
## Out of scope
- **Incremental indexing** (e.g. Jellyfin's `MinDateLastSaved`). A full crawl is
what makes the deletion sweep in §3 sound — it yields the authoritative id set
per library. An incremental pass cannot detect deletions, so it would need a
separate reconciliation strategy. Worth revisiting if full crawls prove too
slow on large libraries; measure first.
- **Changing search UX** — scope chips, group order, the debounce, and the
`/search` route are untouched.
- **Removing the server leg.** Phase 2 stays.
- The two dead search implementations (`storage_search_items` in
`commands/storage/mod.rs`, `offline_search` in `commands/offline.rs`) — both
registered in `lib.rs` and exported to `bindings.ts`, neither called from the
frontend. Deleting them is correct but is cleanup, not this feature; file
separately so this spec's diff stays reviewable.
- `GenericMediaListPage` passing raw `includeItemTypes` and re-implementing the
store's request-id/event protocol. A real boundary smell, tracked separately.
## Acceptance criteria
- [ ] With a synced catalog and **zero downloads**, typing a query returns
results from the local index before any server request completes.
- [ ] Offline with "Show all server media" **on**, search returns the full
catalog (non-downloaded entries greyed out, matching browse).
- [ ] Offline with the toggle **off**, search returns downloaded media only —
the behaviour that exists today.
- [ ] Re-running a full index pass N times does not grow `items_fts` row count
beyond the `items` row count.
- [ ] An item deleted server-side disappears from local search after one index
pass; a **downloaded** item deleted server-side does not.
- [ ] A library that fails mid-crawl prunes nothing.
- [ ] Searching an artist or actor name returns results with the server
unreachable.
- [ ] `bun run check` and `bun run test` pass.
- [ ] `cargo fmt` clean, `cargo clippy` clean, `bun run test:rust` passes.
- [ ] `bun run check:boundary` passes.
- [ ] New requirement-implementing code carries `// TRACES:` comments.
- [ ] `bindings.ts` regenerated (new `CatalogIndexEvent` type).
## Testing
Per CLAUDE.md, each defect gets a **failing test first**.
Rust (`cargo test`), against an in-memory DB seeded with synced-but-not-
downloaded items:
- `search` returns synced items when `include_catalog_browse()` is true, and
only downloaded items when false. *Fails today* — the current CTE returns
empty in the first case.
- Upserting the same item twice leaves exactly one `items_fts` row. *Fails
today.*
- The sweep removes a vanished synced item, retains a vanished downloaded item,
and no-ops for a library whose crawl errored.
- `type_filter` binds parameters — a type string containing a quote does not
alter the query.
- Staleness: a `last_catalog_sync` inside the TTL does not trigger a pass; one
outside it does; offline never does.
- `people_fts` matches surface as `Person` items under `SearchScope::All` and
are excluded under `Music`/`Movies`/`Tv`.
Frontend (`vitest`): the catalog-index event maps to the staleness hint; no
change to the search store's request-id/stale-response handling, which stays
covered by its existing tests.
## TRACES
| Piece | Tag |
|---|---|
| `OfflineRepository::search` availability CTE | `// TRACES: UR-065 \| DR-108` |
| Background indexer task + scheduling | `// TRACES: UR-065 \| DR-109, IR-030` |
| `save_to_cache` upsert + FTS rebuild migration | `// TRACES: UR-065 \| DR-110` |
| Deletion reconciliation | `// TRACES: UR-065 \| DR-110` |
| `CATALOG_ITEM_TYPES` widening + `people_fts` | `// TRACES: UR-065, UR-060 \| DR-111` |
## Notes for the implementer
- **A parallel Claude session may be active in this repo.** Run `git diff`
before "repairing" changes you did not make (CLAUDE.md gotchas).
- The frontend's `offlineCatalog.ts` startup trigger should be **removed**, not
left alongside the Rust scheduler — two independent triggers with one
`syncInProgress` guard each is how double-crawls happen.
- `downloads` has a relaxed FK to `items` (migration 005). Verify the deletion
sweep's interaction with it before enabling the sweep, and check whether
`parent_id`'s `ON DELETE CASCADE` reaches further than intended.
- The existing 100 ms `cache_with_timeout` in `hybrid.rs` returns *empty* on
timeout rather than erroring. Once the cache leg is the primary path, that
budget may need raising — an FTS query over a large catalog on cold page cache
can exceed it, and the failure mode is a silently empty result.
- Keep `SearchScope` semantics as-is: `All => None` (no filter), deliberately
not a union, so People and folders are not filtered out (DR-063).
- Noted but deliberately not fixed here: `pushCatalogVisibility` in
`offlineCatalog.ts` derives the flag as `connected || showCatalog` — the
frontend computing an availability *policy*, even though the flag itself is
Rust-stored. DR-108 depends on that derivation being correct and it is, so
this spec leaves it alone. Once DR-109 has moved sync policy into Rust, the
derivation belongs there too, with the frontend pushing only the raw user
toggle. Folding it into this change would enlarge the diff for no behavioural
gain — but do not add *new* policy on the frontend side of that line.
+166
View File
@@ -0,0 +1,166 @@
# Spec: Downloads as a browsable offline library
**Status:** Draft — ready to implement
**Scope:** Frontend-heavy; one new repository-client browse path. Minimal Rust.
**Requirements:** UR-055 → DR-081, DR-082, DR-083, DR-084; UR-056 → DR-085
(see [requirements.md](../requirements.md)).
**UX spec:** [ux-flows.md §7.27.7](../ux-flows.md).
## Summary
Replace the flat Active/Completed download list with two views under
`/downloads`:
1. **Downloaded** (default) — the library, filtered to what's on the device,
using the *same* browse screens as online (grids, cards, detail pages).
2. **Transfers** — the existing progress-row list, demoted to a secondary tab,
showing only in-flight transfers.
Plus per-item disk usage (UR-056) shown in familiar units on cards, detail
pages, a device total, and the remove confirmation.
## Motivation
A user who downloaded three seasons and two albums sees ~70 individual transfer
rows today, with no grouping and no reuse of the library UI. "What do I have
offline" and "what is downloading" are different questions crammed into one flat
list. Browsing offline should feel exactly like browsing online.
## Background: verified current state
1. **The offline repository is already a browsable tree.**
[offline.rs](../../src-tauri/src/repository/offline.rs) — `get_items` returns
downloaded items **plus** containers (MusicAlbum, Series, Season) that have at
least one downloaded child. `get_libraries`, `get_item`, and `search` all
filter to downloaded content via CTEs. This is the data source for
Downloaded; **do not build a new query layer.**
2. **The client cannot reach it independently.**
[repository-client.ts](../../src/lib/api/repository-client.ts) `getItems`
`repositoryGetItems` always goes through the **hybrid** repository
([hybrid.rs](../../src-tauri/src/repository/hybrid.rs)), which merges cache and
server. There is no "offline only" browse path exposed. This is the one real
backend gap (DR-082).
3. **Downloads page is a flat two-tab list.**
[downloads/+page.svelte](../../src/routes/downloads/+page.svelte) — Active /
Completed tabs, one `DownloadItem` row per transfer, no browsing.
4. **Library browse components are reusable as-is.** `LibraryGrid`, `MediaCard`,
the `/library/[id]` detail page (§5A/§5B) render whatever items they are
given. Downloaded browse is those components with an offline-scoped source.
5. **A related fallthrough bug is already tracked** (DR-080, another session):
`HybridRepository::get_items` treats an empty offline result as a cache miss
and falls through to the server. The offline-only browse path (DR-082) must
**not** share that behaviour — an empty result there is authoritative "nothing
downloaded here."
6. **Concurrency, the 3-download cap, and the auto-pump are backend concerns.**
Do not surface them as manual controls; do not loop `startDownload` from the
frontend (see [CLAUDE.md](../../CLAUDE.md) gotchas).
## Design
### View split (DR-081)
`/downloads` renders a **Downloaded** / **Transfers** switch. Downloaded is the
default. Transfers shows a count/badge only while transfers are active.
Initiating downloads stays on item/album/series detail pages (§7.1) — this page
does not start downloads.
### Offline-scoped browse source (DR-082, DR-083)
Add an explicit offline-only browse path so Downloaded never merges server
results and never depends on reachability. Two viable shapes — pick per the
codebase, do not do both:
- **(a)** A dedicated command (e.g. `repository_get_downloaded_items` /
`_libraries`) that calls the offline repository directly, with a matching
client method; or
- **(b)** An explicit `offlineOnly`/scope flag on the existing get-items path
that bypasses the hybrid merge and the empty→fallthrough behaviour.
Either way: an empty result is authoritative (do **not** reuse the DR-080
fallthrough), and the path is available while the server is reachable (a user
online still wants to browse their downloads).
Downloaded then reuses `LibraryGrid` / `MediaCard` / the detail page against this
source. Omit libraries and containers with no downloaded content. Badge
partially- vs fully-downloaded containers. Play uses the local file; remove is
available at item / album / season / series level and removes a container from
the browse when its last downloaded child goes.
### Transfers view (DR-084)
The existing list, filtered to in-flight rows only: downloading (with progress),
queued, paused, failed, waiting-for-WiFi (the DR-074 state from the other
session). Controls: Pause / Resume / Cancel / Retry. Completed transfers leave
this view — they appear in Downloaded. Empty state points at the library.
### Disk usage (DR-085, UR-056)
- **Source the bytes from the download manager** — it writes the files and can
stat them. Aggregate to album/season/series subtotals and a device total.
This is display + aggregation, **not** new tracking.
- **Format once, consistently.** One shared formatter, human units, 23
significant figures (`1.2 GB`, `340 MB`). Binary vs decimal — pick one and use
it everywhere.
- **Surface it in familiar places:** a secondary size label on the card and
detail page; a device total at the top of Downloaded (`3.4 GB · 12 items`)
that reconciles with the listed sum; a reclaim figure in the remove
confirmation ("frees 1.2 GB"). No separate "storage report" screen.
- Sort/filter by size is a nice-to-have, not required for v1.
## Out of scope
- Changing download initiation, the 3-concurrent cap, or the auto-pump.
- The catalog-browse / show-server-catalog toggle (UR-052, another session) —
that governs the *online offline-fallback* library; this is the dedicated
Downloads surface. They should be consistent but are separate work.
- Fixing the DR-080 hybrid fallthrough bug (owned elsewhere) — just don't depend
on that behaviour here.
## Acceptance criteria
- [ ] `/downloads` opens on Downloaded and can switch to Transfers.
- [ ] Downloaded lists only libraries/containers with downloaded content, using
the same grids/cards/detail pages as online browsing.
- [ ] Browsing Downloaded never shows non-downloaded server items, online or off.
- [ ] An empty Downloaded result reads as "nothing downloaded," never falls
through to the server.
- [ ] Play from Downloaded plays the local file.
- [ ] Remove works at item/album/season/series level and updates the browse.
- [ ] Transfers shows only in-flight rows with working controls; finished
transfers move to Downloaded.
- [ ] Each downloaded item/container shows its on-disk size; a device total is
shown and reconciles with the sum; remove states the reclaim amount.
- [ ] `bun run check`, `bun run test`, and (if Rust touched) `cargo test` +
`cargo clippy` pass.
## Testing
- Repository client: the offline-only browse path returns downloaded content and
its containers, and an empty result does **not** trigger server fallthrough.
- Downloaded view: libraries/containers with no downloads are omitted;
partial/full container badging.
- Transfers: only in-flight statuses render; a completed transfer disappears.
- Size formatter: rounding and unit thresholds; subtotal aggregation; device
total reconciles with listed items.
- If a Rust command is added, add the tauri IPC param-naming coverage per
[CLAUDE.md](../../CLAUDE.md) (camelCase rule).
New requirement-implementing code needs `TRACES:` comments. Suggested tags:
view split `UR-055 | DR-081`; offline browse path `UR-055 | DR-082, DR-083`;
Transfers `UR-055 | DR-084`; size display `UR-056 | DR-085`.
## Notes for the implementer
- Read [ux-flows.md §7.27.7](../ux-flows.md) first — behavioural spec; this is
the implementation plan.
- The offline repository already does the hard part. The main work is a clean
offline-only client path and reusing the library components — resist
rebuilding browse UI.
- Another session is active in downloads/offline/connectivity code (DR-074,
DR-078080). Coordinate on [downloads/+page.svelte](../../src/routes/downloads/+page.svelte)
and the repository layer; check `git diff` before repairing unexpected changes.
+403
View File
@@ -0,0 +1,403 @@
# Spec: Favourites — marking, browsing, and sync
**Status:** Implemented
**Requirements:** UR-067, UR-068, UR-069 → DR-113 … DR-120; JA-033, JA-034
(allocated in [requirements.md](../requirements.md); tests UT-099 … UT-107).
Note: UR-066/DR-112/IR-031 were claimed by the concurrent safe-area work while
this spec was being written, so the ids here start one higher than first drafted.
Existing: UR-017 → DR-021, JA-017, JA-018 (the toggle itself, already built).
**UX spec:** [ux-flows.md](../ux-flows.md) §3.2 (full-player favourite), §5.2
(album detail favourite), §5B.3 (movie detail hero: *Play / Download / Favorite*)
— all three already specify favourite affordances that **do not exist in the
build**. This spec closes those, and adds a new §5C for the Favourites browse
surface.
**Supersedes / revises:** nothing.
## Summary
JellyTau can favourite an item but can never show you what you favourited. The
heart is mounted in exactly one place (the mini player), no query anywhere asks
Jellyfin or the local database for favourites, and favourites marked on any other
client are invisible here. This spec adds the read side (a Favourites page, home
carousels, an in-library filter), puts the heart on detail pages and media cards,
teaches the backend to ingest server-side favourite state, and drains favourite
toggles made while offline.
## Background: what exists today
Verified in code, 2026-08-04. The **write** path is real and mostly correct; the
**read** path does not exist at all.
1. **Toggling works, from one place only.**
[FavoriteButton.svelte](../../src/lib/components/FavoriteButton.svelte) is
mounted solely in
[MiniPlayer.svelte:381](../../src/lib/components/player/MiniPlayer.svelte#L381).
Nothing else in `src/` renders it — so the only favouritable item in the app
is the one currently playing.
2. **The toggle's plumbing is sound.**
[favorites.ts](../../src/lib/services/favorites.ts) writes local first
(`storage_toggle_favorite`,
[storage/mod.rs:908](../../src-tauri/src/commands/storage/mod.rs#L908) — sets
`user_data.is_favorite` + `pending_sync = 1`), then POST/DELETEs
`/Users/{uid}/FavoriteItems/{id}`
([online.rs:1652](../../src-tauri/src/repository/online.rs#L1652)) only when
connected. Leave this design intact.
3. **`MediaItem.user_data` is always `None` from the server.**
`JellyfinItem` has no `UserData` field, and `to_media_item` hardcodes
[`user_data: None`](../../src-tauri/src/repository/online.rs#L656) with the
comment *"User data not included in basic item responses"*. The only
populated `user_data` in the app comes from
[series_progress.rs](../../src-tauri/src/repository/series_progress.rs#L244)
and the local read in
[offline.rs:115](../../src-tauri/src/repository/offline.rs#L115). **Nothing
ingests server favourite state**, which is why the mini player has to fetch
`storageGetPlaybackProgress` per track to colour one heart
([MiniPlayer.svelte:75-93](../../src/lib/components/player/MiniPlayer.svelte#L75-L93)).
4. **No favourites query exists.**
[`GetItemsOptions`](../../src-tauri/src/repository/types.rs#L278) has no
favourites field; `Filters=IsFavorite` appears nowhere; no SQL selects
`is_favorite = 1`; there is no `/library/favorites` route and no favourites
carousel in [home.ts](../../src/lib/stores/home.ts).
5. **Offline favourites are silently lossy.** Offline `mark_favorite` /
`unmark_favorite` are no-ops
([offline.rs:1620](../../src-tauri/src/repository/offline.rs#L1620)), so an
offline toggle survives only as a local row with `pending_sync = 1` — and
**nothing ever drains that flag**. `syncService.queueFavorite`
([syncService.ts:91](../../src/lib/services/syncService.ts#L91)) exists with
no callers.
## Motivation
Favouriting is a promise: the app takes the input and shows a "Added to
favorites" toast, then discards it as far as the user can tell. Three of the UX
flows already specify favourite buttons that were never built, and the one that
was built (mini player) writes to a store nothing reads. Either the feature gets
its read side or the heart should be removed — this spec takes the first option.
## Layer assignment
| Logic / responsibility | Layer | Why it belongs there |
|------------------------|-------|----------------------|
| Favourites **scope** → set of Jellyfin item types | Rust | Domain taxonomy. Changes when Jellyfin adds/renames a type, never when the UI is redesigned. Reuses the canonical `SearchScope::item_types()` ([types.rs:324](../../src-tauri/src/repository/types.rs#L324)) — the exact leak class of [scoped-search-boundary.md](scoped-search-boundary.md). |
| Cross-library favourites query (`Filters=IsFavorite`, `Recursive`, paging, sort field) | Rust | Query shaping against the Jellyfin API is domain logic; the endpoint's contract changes with the server, not the UI. |
| Offline favourites SQL (join `user_data`, downloaded/catalog gating) | Rust | Storage + domain. Must obey the existing catalog-browse gate (DR-080) which the frontend cannot see. |
| Deserialising Jellyfin `UserData` into `MediaItem.user_data` | Rust | Provider payload mapping. |
| Mirroring server favourite state into the local `user_data` table | Rust | Cache/sync policy. |
| Conflict rule: a local row with `pending_sync = 1` beats the server value | Rust | Business rule about which write wins; nothing to do with rendering. |
| Draining pending favourite toggles on reconnect | Rust | Sync policy, and it must run whether or not any view is mounted — a frontend-driven drain dies with the component. Consistent with *reachability from real traffic* (DR-055). |
| Which surfaces show favourites, tab order, row placement on home | Frontend | Pure presentation; changes only if the UI is redesigned. |
| Heart placement, animation, toast, haptics, empty-state copy | Frontend | Presentation. |
| In-session optimistic heart state shared across views | Frontend | View state, not persisted truth; the durable write already goes to Rust. |
**Borderline, and the tie-breaker used:** *which* scopes appear as tabs (All /
Movies / Shows / Music) is a presentation choice — the frontend picks which
`SearchScope` values to offer. What each scope *means* is Rust's. The frontend
sends the enum value and never names an item type in connection with favourites.
Single-type pages (`itemType: "Movie"` on the Movies list page) stay as they are;
this rule targets category taxonomy, not every mention of a type.
## Design
### 1. Server user data reaches `MediaItem` (Rust, DR-113, JA-034)
Jellyfin returns `UserData` on `/Users/{uid}/Items*` responses. Add the field to
`JellyfinItem` and map it in `to_media_item`, replacing the hardcoded `None`:
```rust
// in JellyfinItem
#[serde(alias = "UserData")]
pub user_data: Option<JellyfinUserData>,
```
`JellyfinUserData` deserialises `IsFavorite`, `Played`, `PlaybackPositionTicks`,
`PlayCount`, `LastPlayedDate` into the existing
[`UserData`](../../src-tauri/src/repository/types.rs#L44) type (which already
carries `is_favorite` and already serialises camelCase, so `bindings.ts` needs no
new type — only regeneration). Add `UserData` to the `Fields=` list in `get_items`
/ `get_item` so the shape is explicit rather than relying on the default.
Wire shape, unchanged from today's `UserData`:
```ts
item.userData?.isFavorite // boolean | null | undefined
```
Delete the now-false `// User data not included in basic item responses` comment.
### 2. Local mirror of server favourites (Rust, DR-114)
Choke point: `save_to_cache(parent_id, &items)` in
[offline.rs](../../src-tauri/src/repository/offline.rs) — every server result
that gets cached (including via
[`cache_items_from_server`](../../src-tauri/src/repository/hybrid.rs#L124) and
the background cache refresh) passes through it.
For each item carrying `user_data.is_favorite`, upsert:
```sql
INSERT INTO user_data (user_id, item_id, is_favorite, synced_at, pending_sync)
VALUES (?, ?, ?, ?, 0)
ON CONFLICT(user_id, item_id) DO UPDATE SET
is_favorite = excluded.is_favorite,
synced_at = excluded.synced_at
WHERE user_data.pending_sync = 0; -- local unsynced change wins
```
The `WHERE` on the conflict clause is the whole conflict rule: a toggle made
offline is never overwritten by a stale server value before it has been pushed.
### 3. Favourites queries (Rust, DR-115, DR-116, JA-033)
**(a) In-library filter** — one new field on `GetItemsOptions`:
```rust
#[serde(skip_serializing_if = "Option::is_none")]
pub favorites_only: Option<bool>,
```
- online `get_items`: append `&Filters=IsFavorite` when true.
- offline `get_items`: add `INNER JOIN user_data ud ON ud.item_id = i.id AND ud.user_id = ? AND ud.is_favorite = 1`, composed with the existing `available_items` CTE so the downloads-only gate still applies.
Frontend sends `{ favoritesOnly: true }` (camelCase — nested struct field, needs
the existing `#[serde(rename_all = "camelCase")]` on `GetItemsOptions`, already
present).
**(b) Cross-library favourites** — a new trait method, because favourites span
libraries and `get_items` is `ParentId`-shaped:
```rust
/// TRACES: UR-067 | DR-115 | JA-033
async fn get_favorites(
&self,
scope: SearchScope,
options: Option<GetItemsOptions>,
) -> Result<SearchResult, RepoError>;
```
```rust
#[tauri::command]
#[specta::specta]
pub async fn repository_get_favorites(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
scope: SearchScope,
options: Option<GetItemsOptions>,
) -> Result<SearchResult, String>
```
Frontend call (command name matches the Rust fn exactly; top-level params
auto-camelCase; `SearchScope` is `#[serde(rename_all = "camelCase")]` so the wire
values are `"all" | "music" | "movies" | "tv"`):
```ts
await commands.repositoryGetFavorites(handle, "movies", { limit: 100 });
```
- **online**: `/Users/{uid}/Items?Filters=IsFavorite&Recursive=true&SortBy=SortName&SortOrder=Ascending` + `&IncludeItemTypes=…` from `scope.item_types()` (omit entirely on `None`, per that function's contract) + the standard `Fields=`.
- **offline**: `items ⨝ user_data (is_favorite = 1)`, type filter from the same `scope.item_types()`, honouring `include_catalog_browse()`.
- **hybrid**: same cache-first race as `get_items`, **including the DR-080 rule** — with the catalog-browse gate off, an empty offline result is authoritative and must not fall through to the server. Getting this wrong reproduces Defect B from [offline-downloaded-only-filter.md](offline-downloaded-only-filter.md).
**The cache-first result arrives stale, and there is no second payload.** On a
cache hit, `hybrid::get_items` returns the local rows and refreshes the cache in
a background task whose result the frontend never sees — fine for a library
listing that changes daily, wrong for favourites, where the *point* is that
another client just changed something. Favourites is the second read path (after
search) that needs the deferred update, so the background refresh in
`get_favorites` must emit the same `favorites-changed` event as §4 when the
server's favourite set differs from what was returned:
```
favorites-changed → { itemIds: string[] } // union of ids whose is_favorite flipped
```
Both producers (background refresh, reconnect drain) emit the identical payload,
and the frontend has one handler that refreshes the `favorites` store. Without
this, a favourite marked on another client appears in JellyTau only on the
*second* visit to the page.
### 4. Draining offline toggles (Rust, DR-120)
On the offline→online transition already detected by `ConnectivityMonitor`,
select `user_data WHERE pending_sync = 1 AND is_favorite IS NOT NULL`, POST or
DELETE `/Users/{uid}/FavoriteItems/{id}` per row, then set `pending_sync = 0` and
`synced_at`. Failures leave the row pending for the next transition.
Emit a kebab-case event when anything changed, so open views refresh without
polling:
```
favorites-changed → { itemIds: string[] }
```
`syncService.queueFavorite` is dead code once this lands — delete it or point it
at the backend drain; do not leave two competing queues.
### 5. Frontend surfaces (DR-117, DR-118, DR-119)
**Favourites page** — new route `/library/favorites`:
- Scope tabs *All / Movies / Shows / Music* via the existing `LibraryViewTabs`; each tab sends a `SearchScope` value, nothing more.
- Renders through `LibraryGrid` + `MediaCard` (tracklist for Music→tracks if the tab is later split; not in this pass).
- Entry points: a card on the library overview ([library/+page.svelte](../../src/routes/library/+page.svelte)) and "See all" on the home rows.
- Empty state per tab: "Nothing favourited yet — tap the heart on anything you like."
**Home carousels** — `favoriteMovies`, `favoriteShows`, `favoriteMusic` added to
[home.ts](../../src/lib/stores/home.ts), each `repositoryGetFavorites(scope, { limit: 20 })`,
rendered after *Recently Added* and **only when non-empty** (no empty rows on a
fresh install).
**In-library filter** — a favourites toggle in the header of
[GenericMediaListPage](../../src/lib/components/library/GenericMediaListPage.svelte)
and the Movies/TV landing pages, passing `favoritesOnly: true` into the existing
`repo.getItems(...)` options. Session-scoped state; not persisted (a persisted
filter that hides most of a library is a support call waiting to happen).
**Hearts** — mount `FavoriteButton`:
- Movie / series detail hero button row, beside the download buttons ([library/[id]/+page.svelte:528-553](../../src/routes/library/%5Bid%5D/+page.svelte#L528-L553)) — closes ux-flows §5B.3.
- `EpisodeFocusView`, `ArtistDetailView`, `PlaylistDetailView`, album detail — closes ux-flows §5.2.
- `MediaCard` artwork overlay (top-right). Suppressed on `isServerOnly` cards, and must not fight the existing long-press/scroll-guard handlers ([MediaCard.svelte:60-70](../../src/lib/components/library/MediaCard.svelte#L60-L70)) — the heart is its own button and stops propagation.
**Shared optimistic state** — a small `favorites` store (`Map<string, boolean>`
overlay + `favorites.set(id, value)`), so un-hearting an item on the Favourites
page removes it from the grid and from any home row without a refetch, and a
heart tapped on a card is reflected on the detail page. Resolution order:
```
favorites store override ?? item.userData?.isFavorite ?? false
```
`toggleFavorite()` updates the store alongside its existing local + server
writes; the `favorites-changed` event refreshes it. This removes the mini
player's per-track `storageGetPlaybackProgress` fetch once items carry
`userData`.
### 6. Offline behaviour
Toggling offline keeps working exactly as now (local write + `pending_sync`), and
now actually reaches the server on reconnect (§4). The Favourites page offline
shows favourites among downloaded/cached items, subject to the existing
catalog-browse gate. The offline repo's no-op `mark_favorite`/`unmark_favorite`
stay no-ops — the local write plus the drain is the offline path.
## Out of scope
- Favouriting people, genres, or collections; favourite **playlists** are included only insofar as they fall under the Music scope.
- Sorting by "date favourited" — Jellyfin does not expose it. Favourites sort by name.
- A dedicated bottom-nav tab for favourites (reachable from library overview + home).
- Building a playlist or download batch from favourites.
- Reconciling favourites for items that no longer exist on the server.
- Splitting the Music tab into albums/artists/tracks sub-tabs.
## Acceptance criteria
- [ ] Favouriting is possible from movie, series, episode, album, artist and playlist detail pages, and from media cards in any grid.
- [ ] A favourite marked in another Jellyfin client shows a filled heart in JellyTau without toggling it here.
- [ ] `/library/favorites` lists favourites across libraries, filtered by the All/Movies/Shows/Music tabs.
- [ ] Home shows favourite rows for movies, shows and music, and shows no row when a category has none.
- [ ] Movies/TV/Music list pages can be filtered to favourites only.
- [ ] Un-hearting an item on one surface updates the others without a manual refresh.
- [ ] A favourite toggled while offline reaches the server after reconnect (verified against a real server or a fake repository).
- [ ] Offline, the Favourites page respects the "Show all server media" gate — with it off, an empty result stays empty and does not fall through to the server.
- [ ] No item-type set appears in `src/` in connection with favourites; the frontend sends `SearchScope` only.
- [ ] `bun run check` and `bun run test` pass.
- [ ] `cargo fmt` clean, `cargo clippy` clean, `bun run test:rust` passes.
- [ ] `bun run check:boundary` passes (necessary, not sufficient — see CLAUDE.md).
- [ ] New requirement-implementing code carries `// TRACES:` comments.
- [ ] `bindings.ts` regenerated from Rust, not hand-edited.
## Testing
**🔴 §4 (the pending-sync drain) is a bug fix — failing test first.** Write a
test that toggles a favourite with the repository offline, transitions to online,
and asserts the server call happened; watch it fail before writing the drain.
Rust (`cd src-tauri && cargo test`):
| Test | Covers |
|------|--------|
| UT-099 | A Jellyfin item JSON fixture with `UserData.IsFavorite: true` maps to `MediaItem.user_data.is_favorite == Some(true)` |
| UT-100 | `online::get_favorites` builds an endpoint with `Filters=IsFavorite`, `Recursive=true`, and the scope's `IncludeItemTypes`; `SearchScope::All` omits the type filter entirely |
| UT-101 | `offline::get_favorites` returns only `is_favorite = 1` rows, respects the scope type filter, and returns nothing extra when the catalog-browse gate is off |
| UT-102 | `save_to_cache` mirror does **not** overwrite a row with `pending_sync = 1` |
| UT-103 | Drain pushes pending rows, clears `pending_sync`, sets `synced_at`, and leaves failed rows pending |
| UT-104 | `get_items` with `favorites_only: true` filters both online (endpoint) and offline (SQL) |
| UT-107 | The background refresh in `hybrid::get_favorites` emits `favorites-changed` with the flipped ids, and emits nothing when the server set matches the cache |
Frontend (`bun run test`):
| Test | Covers |
|------|--------|
| UT-105 | `favorites` store override precedence: store value beats `userData.isFavorite` beats `false` |
| UT-106 | Un-hearting removes the item from a favourites list view (pure logic extracted to a `.ts` module, per the TrackList/episodeStrip pattern) |
| IT-0xx | `repositoryGetFavorites` param naming — add to [tauriIntegration.test.ts](../../src/lib/utils/tauriIntegration.test.ts): camelCase top-level params, scope serialised as `"movies"` etc. |
Any component logic worth testing gets extracted into a plain `.ts` module first
(`favoritesView.ts`), rather than tested through the component.
## TRACES
| Piece | Tag |
|-------|-----|
| `JellyfinUserData` + `to_media_item` mapping | `// TRACES: UR-069 \| DR-113, JA-034 \| UT-099` |
| `save_to_cache` user_data mirror | `// TRACES: UR-069 \| DR-114 \| UT-102` |
| `get_favorites` (trait, online, offline, hybrid) + command | `// TRACES: UR-067 \| DR-115, JA-033 \| UT-100, UT-101` |
| `GetItemsOptions.favorites_only` handling | `// TRACES: UR-067 \| DR-116 \| UT-104` |
| `/library/favorites` route + tabs | `// TRACES: UR-067 \| DR-117` |
| Home favourite carousels | `// TRACES: UR-067 \| DR-118` |
| `FavoriteButton` mounts + `favorites` store | `// TRACES: UR-068 \| DR-119 \| UT-105, UT-106` |
| Pending-favourite drain + `favorites-changed` | `// TRACES: UR-069 \| DR-120 \| UT-103` |
New requirement rows to add to [requirements.md](../requirements.md):
- **UR-067** — Browse favourited media across libraries (page, home rows, in-library filter).
- **UR-068** — Mark/unmark favourites from browse and detail surfaces, not only the player.
- **UR-069** — Favourite state stays consistent with the server in both directions.
- **DR-113 … DR-120** — as tabled above.
- **JA-033** — Query favourite items (`Filters=IsFavorite`).
- **JA-034** — Read `UserData` from item responses.
## Implementation notes (as built)
Two things landed differently from the design above, both forced by where the
`AppHandle` lives:
1. **The `favorites-changed` event is emitted from the command layer, not the
repository.** `HybridRepository` has no `AppHandle` — the same reason
`search-event` is emitted from `repository_search`. `repository_get_favorites`
therefore does the two-phase read itself (cache leg returned, server leg
spawned) and diffs the two id sets via `changed_favorite_ids`, which is
extracted and unit-tested (UT-107) rather than buried in the spawn.
2. **The drain hooks the existing `connectivity:reconnected` event** via
`app.listen` in `commands/favorites.rs`, rather than reaching into
`ConnectivityMonitor` (which knows nothing about repositories). It drains
through a narrow `FavoriteSink` trait so it can be tested against a recording
double instead of a forty-method `MediaRepository` mock.
3. **The command falls back to `HybridRepository::get_favorites` when nothing is
cached.** The two-phase read alone paints "Nothing favourited yet" on a fresh
install and corrects it a server round trip later, which is a wrong answer
shown to the user. An empty cache leg therefore defers to the repository's
own cache-first-then-server read. That read was also fixed to *save through*
on a server hit — without it the page re-queried the server on every visit
and the DR-114 mirror was never filled by this path.
Also as built: `DatabaseService` is not object-safe (generic methods), so the
drain takes `Arc<RusqliteService>` like the rest of the storage code, and
`get_items`' endpoint construction was extracted to `build_get_items_endpoint`
so the `favorites_only` filter could be asserted without an HTTP server.
**Not built:** the full-player heart. ux-flows §3.2 lists one among the full
player's secondary controls and it remains unbuilt — recorded as a known
deviation in ux-flows §5C.5 rather than silently dropped.
## Notes for the implementer
- A parallel Claude session may be active in this repo — `git diff` before "repairing" unexpected changes.
- Do **not** try to reuse `get_items` with an empty `ParentId` for cross-library favourites; that endpoint is built as `?ParentId={}` ([online.rs:731](../../src-tauri/src/repository/online.rs#L731)) and an empty value is not a reliable "all libraries" request. Use `get_favorites`.
- `SearchScope` is reused rather than a new `FavoritesScope` so there is one taxonomy expansion in the codebase, not two that can drift. If the name grates once favourites ship, rename the type across search + favourites in one commit — don't fork it.
- `SearchScope::All` returns `None` from `item_types()` **on purpose**; callers must omit `IncludeItemTypes` entirely rather than sending a union (see the doc comment at [types.rs:316](../../src-tauri/src/repository/types.rs#L316)).
- Ship order that keeps each step demonstrable: §1+§2 (state becomes visible) → §5 hearts (marking becomes possible) → §3+§5 browse surfaces (finding becomes possible) → §4 drain.
- Regenerate `bindings.ts` after the Rust types change; never hand-edit it.
+309
View File
@@ -0,0 +1,309 @@
# Spec: Remove Jellyfin-specific models from the frontend
> **Implementation status (branch `frontend-domain-model`, worktree
> `../JellyTau-domain-model`):** Catalog surface **done**. The frontend's *item
> classification* and *time units* no longer speak Jellyfin:
> - `domain/` module is the single source of truth; `MediaKind` enum + isolated
> `from_jellyfin` mapping. The model gained real distinctions the flat
> `item_type` had hidden: `LiveChannel` / `ChannelItem` / `Channel`.
> - Every catalog `item.type === "..."``item.kind` (0 remaining in `src/`).
> - Catalog ticks → milliseconds (`durationMs`, `playbackPositionMs`);
> `formatDuration` takes ms; progress bars are unit-consistent.
> - User-facing type badge → `kindLabel()`.
> - Old Jellyfin-named fields remain **dual-carried** on the wire so nothing broke.
>
> **Deferred (tracked, not done):**
> - `primaryImageTag``imageId` rename (naming-only; ~40 sites across catalog +
> `PlayerMediaItem`/`MergedMediaItem`, the latter needing a Rust `image_id`
> round-trip). Catalog `MediaItem` already has `imageId`.
> - Player/session/reporting tick math (`Queue`, `SessionCard`, `RemoteControls`,
> `playbackReporting`, `playerEvents`) — crosses storage/Jellyfin *command
> signatures* in ticks; needs those commands to accept ms (phase 4).
> - `stream.type` (`mediaStreams[].type`) — Jellyfin stream vocabulary (phase 4).
> - Delete `playbackUnits.ts` / `jellyfinFieldMapping.ts` once their last
> consumers migrate; drop the dual-carried fields once nothing reads them.
**Status:** Partially implemented (catalog surface); see banner.
**Requirements:** Architectural (boundary integrity — CLAUDE.md core principles).
Allocate new DRs on acceptance; suggested: DR for the domain `MediaItem`/`MediaKind`
type, DR for tick/image-tag hoisting, DR for the phased frontend migration
(see [requirements.md](../requirements.md)). Relates to UR-007, UR-008, UR-034.
**UX spec:** n/a — zero user-visible behaviour change. This is a pure
architecture/boundary migration.
**Supersedes / revises:** none. Extends the boundary work started in
[scoped-search-boundary.md](scoped-search-boundary.md) from *taxonomy* to the
*whole media model*.
## Summary
The frontend currently consumes Jellyfin's data model directly: `MediaItem` is a
Jellyfin DTO (`runTimeTicks`, `primaryImageTag`, `parentIndexNumber`, a
stringly-typed `type: string` carrying Jellyfin's item vocabulary), mirrored via
specta into **36+ frontend files**, with **127 `item.type === "…"` string
comparisons across 23 files** and two frontend utility modules
(`playbackUnits.ts`, `jellyfinFieldMapping.ts`) doing Jellyfin-specific unit and
field conversion in the presentation layer.
This spec defines a **provider-neutral domain model**, owned by Rust, that the
Jellyfin repository maps *into*. The frontend consumes only that model. When done,
no Jellyfin vocabulary — item-type strings, ticks, image tags, Jellyfin field
names — remains in `src/`.
## Motivation
Two concrete problems, one strategic:
1. **Boundary violation at scale.** Per CLAUDE.md, the frontend is
presentation-only and Rust owns the domain. Today the *domain model itself* is
Jellyfin's wire shape, propagated unchanged across IPC. The frontend knows what
a "tick" is, what `primaryImageTag` means, and that `"Audio"` is a track. That
is domain knowledge in the wrong layer, 36 files deep.
2. **Fragility.** `type: string` is unchecked: a typo (`"Epis0de"`) or a Jellyfin
rename fails silently at runtime with no compiler help, across 127 sites. Tick
math (`* 10_000_000`) duplicated frontend-side is a class of bug the backend
should have already resolved.
3. **Strategic (the reason we chose the ambitious target):** a neutral domain
model is the precondition for **ever supporting a non-Jellyfin backend** (Plex,
local files, Subsonic). As long as the UI speaks Jellyfin, that door is welded
shut.
## Layer assignment
| Logic / responsibility | Layer | Why it belongs there |
|------------------------|-------|----------------------|
| Definition of the media domain model (`MediaItem`, `MediaKind`) | **Rust** | The canonical shape the whole app reasons about; must not be a provider's wire format. |
| Jellyfin DTO → domain mapping (ticks→ms, image tag→url/id, `"Audio"``Track`, `PremiereDate``releaseDate`) | **Rust**, in the Jellyfin repository | Provider-specific translation; changes if Jellyfin changes; is the definition of "how Jellyfin maps to our domain." |
| Tick arithmetic (`playbackUnits.ts`) | **Rust** | A Jellyfin unit. The frontend should never see ticks; it receives `durationMs`/`positionMs`. |
| Sort-field mapping (`jellyfinFieldMapping.ts`, `title→SortName`) | **Rust** | Maps neutral sort keys to Jellyfin query fields — provider vocabulary. Frontend sends a neutral `SortKey`. |
| `MediaKind` classification (is this a track / album / episode?) | **Rust** | Derived from Jellyfin's `item_type`; the frontend receives the already-classified kind. |
| Choosing which kind renders as a card vs a list row; grid/list toggle; group order | **Frontend** | Pure presentation over the neutral `kind`. Changes only if the UI is redesigned. |
| Navigation decisions (`kind === Track && albumId` → go to album) | **Frontend** | Presentation/routing over neutral fields. |
**Borderline calls, resolved:**
- *`MergedMediaItem`* (the lightweight now-playing projection) is already
half-neutral (`title`, `artist`, `duration`) — it becomes a straightforward
subset of the new domain model, not a special case.
- *Context discriminators* `"album"`, `"playlist"`, `"remote"` (in `TrackList`,
playback context, sessions) are **already domain-neutral** — they are *our*
vocabulary, not Jellyfin's. They stay as-is; do not confuse them with
`item_type`. Only the Jellyfin item-type strings move.
- *`mediaStreams[].type === "Audio"/"Subtitle"/"Video"`* (track selection in
VideoPlayer) is Jellyfin stream vocabulary too, but is lower-risk and
self-contained — deferred to a late phase, not phase 1.
## Design
### Single canonical model, one location, isolated mappings
The domain model is defined **once**, in a dedicated top-level Rust module
`src-tauri/src/domain/`, and is the single source of truth shared across the
whole app:
```
src-tauri/src/domain/
media.rs canonical MediaItem, MediaKind, and the other media types
from_jellyfin.rs Jellyfin DTO -> domain mapping, ISOLATED here
mod.rs re-exports
| tauri-specta (export_typescript_bindings test)
v
src/lib/api/bindings.ts generated MediaItem/MediaKind — the frontend copy
```
- **One definition.** `domain::MediaItem` is *the* model. Rust (repositories,
player, downloads) uses it directly. The frontend uses the generated `bindings.ts`
projection of it. There is no second hand-written copy in either language, so it
cannot drift — "shared between frontend and backend" is realized by generation,
not duplication.
- **Mappings live beside the model, never in consumers.** All provider translation
(`JellyfinItem``domain::MediaItem`, ticks→ms, image-tag→id, item-type→`MediaKind`)
lives in `domain/from_jellyfin.rs`. It is the *only* place Jellyfin vocabulary
touches the domain type. Adding a second provider later means a new
`from_<provider>.rs` beside it — the model and every consumer stay untouched.
- **`domain` is a top-level module** (not under `repository/`) because `MediaItem`
is used by `player/`, `download/`, and `playback_mode/` too — it is not
repository-specific.
- The existing `JellyfinItem` DTO + `to_media_item()` in
[online.rs](../../src-tauri/src/repository/online.rs) is the seam that already
exists; it **moves** into `domain/from_jellyfin.rs` and is enriched to do real
translation instead of copying `item_type` through.
### The domain model (Rust)
```rust
// src-tauri/src/domain/media.rs — provider-neutral. NO Jellyfin vocabulary.
#[derive(specta::Type, Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub enum MediaKind {
Track, Album, Artist, Playlist, // music
Movie, Series, Season, Episode, // video
Person, // cast/crew
Channel, Folder, // containers/live
}
#[derive(specta::Type, Serialize, Deserialize, Clone, Debug)]
#[serde(rename_all = "camelCase")]
pub struct MediaItem {
pub id: String,
pub name: String,
pub kind: MediaKind, // was: type: String
pub is_folder: bool,
pub server_id: String,
// Times in milliseconds — NEVER ticks.
pub duration_ms: Option<i64>, // was: run_time_ticks
// Image as a resolved identifier the frontend turns into a URL via the
// existing image command — no raw Jellyfin tag semantics leak.
pub image_id: Option<String>, // was: primary_image_tag
pub backdrop_image_ids: Option<Vec<String>>,
pub overview: Option<String>,
pub genres: Option<Vec<String>>,
pub production_year: Option<i32>,
pub release_date: Option<String>, // was: premiere_date (ISO-8601)
pub community_rating: Option<f64>,
pub official_rating: Option<String>,
// Relationships — already neutral, kept.
pub album_id: Option<String>, pub album_name: Option<String>,
pub album_artist: Option<String>, pub artists: Option<Vec<String>>,
pub artist_items: Option<Vec<ArtistItem>>,
pub series_id: Option<String>, pub series_name: Option<String>,
pub season_id: Option<String>, pub season_name: Option<String>,
// Ordinal position — rename off Jellyfin's index vocabulary.
pub track_number: Option<i32>, // was: index_number
pub disc_number: Option<i32>, // was: parent_index_number
pub user_data: Option<UserData>,
pub media_streams: Option<Vec<MediaStream>>,
pub media_sources: Option<Vec<MediaSource>>,
pub people: Option<Vec<Person>>,
}
```
The existing `JellyfinItem` DTO (already defined in `online.rs`, deserialized
from the Jellyfin JSON) **moves into `domain/from_jellyfin.rs`** and stays
private to that module. Its `to_media_item()` — today a near-passthrough that
copies `item_type` straight across — is enriched into the single, tested place
that:
- classifies `item_type: String``MediaKind` (including the edge cases found in
the audit: `"ChannelFolderItem"``Channel`/`Folder` by `is_folder`,
`"TvChannel"``Channel`, `"Composer"/"Director"/"Writer"``Person`,
`"Video"``Movie` or a video leaf). Unknown strings map to `Folder` or a new
`Other` variant — **decide at implementation; must not panic.**
- converts `run_time_ticks``duration_ms` (`ticks / 10_000`).
- maps `PremiereDate``release_date`, image tags → image ids.
`SortKey` enum + its Jellyfin field mapping (`jellyfinFieldMapping.ts` contents)
moves into the Jellyfin repository; the command takes a neutral `SortKey`.
### 🔴 The `search-event` / dual-payload rule applies again
Every path that returns `MediaItem` — command returns **and** the `search-event`
and any other event payloads — emits the new domain shape. Both sides of a
twice-delivered result must match (same rule as
[scoped-search-boundary.md](scoped-search-boundary.md)). Grep for `MediaItem` in
event definitions before declaring a phase done.
### Frontend after
- `MediaItem`/`MediaKind` come from generated `bindings.ts`.
- `item.type === "Audio"``item.kind === "track"` (127 sites, mechanical).
- `runTimeTicks` usages → `durationMs`; **delete `playbackUnits.ts`** (ticks no
longer cross the boundary; keep only any purely-display seconds↔clock helpers if
they exist, which are not Jellyfin-specific).
- `primaryImageTag``imageId` through the existing image-URL command.
- **Delete `jellyfinFieldMapping.ts`**; sort options send a neutral `SortKey`.
- Assert with the boundary tripwire + a new grep (see acceptance).
## Phased migration
This is too large and too collision-prone for one change. Phases are independently
shippable, each keeps all tests green, and each is a reviewable PR:
1. **Establish the `domain/` module + enriched mapping, tests — no frontend
change yet.** Create `src-tauri/src/domain/{media,from_jellyfin,mod}.rs`. Move
`JellyfinItem`/`to_media_item` in. Add `MediaKind` and the neutral fields to
`domain::MediaItem` as *additive, defaulted* fields, and populate them in the
mapping, while **keeping the old Jellyfin-named fields too** (dual-carry). The
wire shape is a superset of today's, so the frontend still compiles and
behaves identically. Lands the authority + full mapping unit coverage first,
with zero blast radius on the 52 construction sites (they set the old fields;
new ones default).
2. **Flip the wire shape.** Commands + events emit the new `MediaItem`.
Regenerate `bindings.ts`. Frontend breaks to compile errors — fix them
mechanically (`type``kind`, values `"Audio"``"track"`, `runTimeTicks`
`durationMs`, `primaryImageTag``imageId`). This is the big mechanical PR;
`bun run check` is the driver.
3. **Delete the frontend conversion helpers** (`playbackUnits.ts` ticks,
`jellyfinFieldMapping.ts`) and route sorting through the neutral `SortKey`.
4. **Stream vocabulary** (`mediaStreams[].type`) and any remaining stragglers;
tighten the boundary check to forbid Jellyfin item-type strings in `src/`
outside tests.
Ship 1 → 2 → 3 → 4 as separate PRs. Do **not** attempt all four at once.
## Out of scope
- Actually adding a second backend (Plex/Subsonic). This spec only *unblocks* it.
- Changing any user-visible behaviour, layout, or copy.
- The player-internal `PlayerMediaItem` / `MediaSessionType` shapes, except where
they carry the fields being renamed — align them in phase 2 only if the compiler
demands it.
- Context discriminators (`"album"`, `"playlist"`, `"remote"`) — already neutral.
## Acceptance criteria
- [ ] No Jellyfin item-type string (`"Audio"`, `"MusicAlbum"`, `"Series"`, …) is
compared against `.type`/`.kind` anywhere in `src/` (outside tests). Verify:
`grep -rIn '\.kind === "\(Audio\|MusicAlbum\|MusicArtist\|Series\|Episode\|Movie\|Playlist\)"' src/` returns nothing.
- [ ] No `Ticks`, `runTimeTicks`, `primaryImageTag`, `PremiereDate`, or Jellyfin
sort-field name (`SortName`, `RunTimeTicks`, …) appears in `src/` outside
tests. `playbackUnits.ts` (ticks) and `jellyfinFieldMapping.ts` are deleted.
- [ ] `MediaItem`/`MediaKind`/`SortKey` in the frontend come from `bindings.ts`.
- [ ] The `From<JellyfinMediaDto>` mapping is total and never panics on an unknown
item type (Rust test with a garbage type string).
- [ ] Behaviour is identical: same library/search/home rendering, same sorting,
same navigation, offline included.
- [ ] Both command returns and event payloads carry the new shape (no flicker).
- [ ] `bun run check`, `bun run test`, `bun run check:boundary` pass;
`cargo fmt`/`cargo clippy`/`bun run test:rust` pass; `bindings.ts` regenerated.
## Testing
**Rust** (`cargo test`): the `From<JellyfinMediaDto> for MediaItem` mapping is the
critical surface —
- every known `item_type` → correct `MediaKind` (table test over all 20 values
found in the audit, incl. `ChannelFolderItem`, `TvChannel`, `Composer`);
- unknown type string → safe fallback, no panic;
- `run_time_ticks``duration_ms` (10_000 divisor), boundary/None cases;
- `SortKey` → Jellyfin field mapping (port `jellyfinFieldMapping.ts`'s cases).
**Frontend** (vitest): update the many tests asserting `.type`/`runTimeTicks`;
they become `.kind`/`durationMs`. `jellyfinFieldMapping`/`playbackUnits` tests are
deleted with their modules. Add a compose/render test proving `kind`-based
branching matches the old `type`-based branching for a representative mix.
## TRACES
Per [CLAUDE.md](../../CLAUDE.md): the domain type + mapping
`UR-007, UR-008 | <new DR>`; the tick/field hoist `<new DR>`; frontend migration
phases share the DRs of the capability each touches (don't invent per-file DRs).
## Notes for the implementer
- **This is the highest-collision change in the repo's history** — it touches 36+
frontend files and the core Rust types. A parallel Claude session in any media
file will conflict. Strongly prefer a dedicated worktree per phase, and
`git diff` before repairing anything (CLAUDE.md gotchas / project memory).
- Phase 1 deliberately maps *back* to the old shape so it can land safely ahead of
the disruptive flip. Resist the urge to skip it.
- IPC camelCase rules apply to the new enums/structs
([04-type-sync-and-threading.md](../architecture/04-type-sync-and-threading.md)):
`#[serde(rename_all = "camelCase")]`; tagged-enum tag convention; regenerate
`bindings.ts`, never hand-edit.
- Reviewed against [SPEC-REVIEW-CHECKLIST.md](SPEC-REVIEW-CHECKLIST.md) — the
Layer assignment table above is the load-bearing section.
+169
View File
@@ -0,0 +1,169 @@
# Spec: Migrate to libmpv2 and declare the project licence
**Status:** Proposed
**Requirements:** UR-003 → IR-003 (revises the MPV integration); no new user-facing behaviour
**UX spec:** n/a
**Supersedes / revises:** dependency and licensing housekeeping identified in [playback-backend-unification.md](playback-backend-unification.md)
## Summary
Two related pieces of housekeeping that block or complicate later work:
1. Replace the abandoned `libmpv` crate (pinned to a git branch) with the
maintained `libmpv2`.
2. Add a `LICENSE` file. The project has none, which leaves its legal status
undefined while it links GPL-licensed libmpv.
Neither changes user-visible behaviour. Both are prerequisites for
[windows-native-audio-backend.md](windows-native-audio-backend.md).
## Motivation
### The dependency is dead
```toml
# src-tauri/Cargo.toml
libmpv = { git = "https://github.com/ParadoxSpiral/libmpv-rs.git", branch = "master" }
```
- crates.io `libmpv` 2.0.1 was published **2020-09-29**.
- The upstream repo's last commit was **2023-01-08**; nothing since was released.
- We pin a git *branch*, so builds are not reproducible — the same lockfile-less
checkout can resolve differently over time, and CI has no protection if the
branch moves or the repo disappears.
`libmpv2` (kohsine/libmpv2-rs) is a maintained fork of exactly this crate:
6.0.0 released **2026-05-12**, ~23.5k recent downloads against the original's
~1.1k, releases roughly quarterly since 2024.
### The project has no licence
There is no `LICENSE`/`COPYING` file and `src-tauri/Cargo.toml` has no `license`
field. The project is open source and will never be commercial, so this is purely
an omission — but it matters because we link libmpv, and "no licence" defaults to
*all rights reserved*, which is incompatible with distributing a GPL-derived
work.
## Design
### Part 1 — licence
**Use GPLv3.** This is forced, not chosen:
- mpv's default build is **GPLv2-or-later**, so the combined work must be
GPL-compatible.
- Apache-2.0 is **GPLv2-incompatible** (patent-termination and indemnification
clauses) but GPLv3-compatible.
- A scan of the dependency tree found Apache-2.0-**only** crates with no
alternative arm — most importantly **`tao`** (Tauri's own windowing crate),
plus `sync_wrapper`, `gethostname`, and `ring` (Apache-2.0 AND ISC).
`tao` is unavoidable in a Tauri app, so GPLv2 is unavailable. Exercising mpv's
"or later" option puts the combination at **GPLv3**.
Actions:
- Add `LICENSE` containing the GPLv3 text.
- Add `license = "GPL-3.0-or-later"` to `src-tauri/Cargo.toml` and `license` to
`package.json`.
- Note in the README that the binary links libmpv (GPLv2+) and FFmpeg.
Because the project is open source, we use mpv's **default GPL build** — no
`-Dgpl=false`, no LGPL FFmpeg build, and none of the LGPL §6 relinking analysis
that a proprietary app would need. We keep VAAPI/VDPAU/X11 and every GPL FFmpeg
filter.
🔴 Never build FFmpeg with `--enable-nonfree` — that produces a binary that is
**unredistributable under any licence**, open source or not.
### Part 2 — libmpv → libmpv2
```toml
# Linux (and later Windows, per the Windows audio spec)
libmpv2 = "=6.0.0"
```
Pin exactly: `libmpv2` has broken its API in **every** major release.
Breaking changes to expect, from the changelog:
| Version | Change | Impact here |
|---|---|---|
| 4.0.0 | Removed command helper methods — call `mpv.command(...)` directly | Low; we already use `command`/`set_property` |
| 5.0.0 | Removed `mpv_node` support entirely (properties return strings; parse JSON yourself); `EventContext` folded into `Mpv`; `ProtocolContext``Protocol` | **Medium**`start_event_loop` uses `create_event_context()`; check whether that call still exists |
| 6.0.0 | `RenderContext::new()``Mpv::create_render_context()`; `'static` bound on `OpenGLInitParams`; render context now borrows `Mpv` (fixes a use-after-free) | **None** — we do not use the render API |
The last row matters: we run mpv audio-only (`video = no`), so the entire render
surface is irrelevant to us. Consider disabling the default `render` feature to
reduce build surface.
The main porting work is the event loop in `mpv_backend.rs``wait_event`,
`disable_deprecated_events`, and the `FileLoaded` / `PlaybackRestart` /
`PropertyChange` / `EndFile` handling, given 5.0.0 folded `EventContext` into
`Mpv`.
Everything else — `set_property` calls, the `af` filter graph, the 250ms position
thread, the seek-suppression window — should port unchanged.
## Layer assignment
No logic moves. This is a dependency swap plus a licence file; the
`PlayerBackend` trait boundary is untouched.
| Logic / responsibility | Layer | Why it belongs there |
|------------------------|-------|----------------------|
| mpv event → `PlayerStatusEvent` mapping | Rust (unchanged) | Already correct; only the binding API beneath it changes. |
## Out of scope
- Any behaviour change. If playback behaves differently after this, that is a bug.
- Windows support — separate spec, but this must land first.
- Adopting the render API. We are audio-only on mpv.
- Re-licensing decisions beyond adding the file the project already implies.
## Acceptance criteria
- [ ] `LICENSE` (GPLv3) present; `license` field set in `Cargo.toml` and `package.json`.
- [ ] A full dependency-licence audit has been run (`cargo install cargo-license && cargo license`) and confirms no GPLv3-incompatible dependency. *(The scan behind this spec resolved 441 of 575 crates from the local registry cache; the remaining 134 are unverified.)*
- [ ] `libmpv` git dependency removed; `libmpv2` pinned to an exact version.
- [ ] Linux audio playback works identically: play/pause/seek/volume, queue advance, gapless, EQ, normalization, sleep timer.
- [ ] Position updates still arrive at 250ms; the 150ms post-seek suppression still prevents the jump-to-zero glitch.
- [ ] `EndFile` still emits `PlaybackEnded` only for EOF (not STOP/QUIT/ERROR) — autoplay depends on this.
- [ ] Builder image updated if the libmpv dev package requirement changed; **no toolchain install added to any CI step**.
- [ ] `bun run check`, `bun run test`, `bun run check:boundary` pass.
- [ ] `cargo fmt` clean, `cargo clippy` clean, `bun run test:rust` passes.
## Testing
The existing `mpv_backend_test.rs` plus the `build_af_filter`,
`eq_filter_entries`, and `normalize_filter_entry` tests are the regression net —
they must pass unchanged, since none of them touch the binding API.
The event loop has no unit tests and is where the risk concentrates. Verify
manually on Linux:
1. Play → pause → play; confirm position does not flash to 0:00 (the known
playing-event regression).
2. Seek mid-track; confirm no jump-to-zero within 150ms.
3. Let a track end naturally; confirm autoplay advances (exercises `EndFile` EOF).
4. Press stop; confirm autoplay does **not** advance.
5. Sleep-timer expiry; confirm it stops without triggering autoplay.
Cases 35 are the ones most likely to break silently, and each corresponds to a
bug already fixed once in this codebase.
## TRACES
- `MpvBackend` construction / event loop → existing `// TRACES: UR-003 | IR-003`, unchanged
- No new requirement IDs; this is a dependency migration.
## Notes for the implementer
- Do this **before** the Windows audio backend.
- Read the 4.0/5.0/6.0 changelogs before writing code — the crate has broken API
in every major release, most recently two months before this spec.
- The crates.io `repository` field for `libmpv2` points at `kohsine/libmpv-rs`,
but the repo was renamed to **`libmpv2-rs`**; the old raw URLs 404.
- `libmpv2-sys` ships pregenerated bindings and vendored headers, so no libclang
is needed at build time — relevant to keeping the builder image thin.
- A parallel Claude session may be active — `git diff` before "repairing"
unexpected changes.
+125
View File
@@ -0,0 +1,125 @@
# Spec: Library mosaic (library overview + home shortcuts)
**Status:** Implemented
**Requirements:** UR-075 → DR-174, DR-175 (with UR-067 → DR-117 extended)
**UX spec:** [ux-flows.md](../ux-flows.md) §5C.2 (Favourites)
## Summary
The library overview and the home "Your Libraries" strip stop being fixed-shape
grids and become a **mosaic**: rows share one height, and each tile is as wide as
its own artwork is. A square music cover, a 16:9 library backdrop and a 2:3
poster sit in the same row at their own proportions instead of all three being
cropped into whichever box the grid picked. Favourites gain a tile per category,
placed beside the library that category belongs to, alongside the existing
cross-library entry.
## Motivation
Every surface here shows artwork of more than one shape. The grid resolved that
by choosing one shape and cropping to it — and the home strip said so out loud:
> Uniform 16:9 artwork so music (square) and video libraries line up at the same
> height in this mixed row.
Lining them up is right; cropping the covers to do it is not. Holding the
**height** fixed and letting the **width** vary achieves the same alignment with
no crop at all, which is the whole idea of a justified layout.
Favourites had one entry for everything. With per-category tiles, "my favourite
albums" is one tap from the library page rather than a tap plus a tab.
## Layer assignment
| Logic / responsibility | Layer | Why it belongs there |
|------------------------|-------|----------------------|
| Collection type → favourites category (`movies` → Movies, `livetv` → none) | **Rust** | Jellyfin vocabulary. It changes when Jellyfin renames a collection type, never when this page is redesigned — the same test that put `SearchScope::item_types` in Rust. Shipping it in Svelte would have re-created the leak [scoped-search-boundary.md](scoped-search-boundary.md) exists to document. |
| Which scopes exist at all (`SearchScope`) | **Rust** | Already there; unchanged. |
| Row packing: heights, widths, justification, clamping | Frontend | Geometry of a rendered page. It changes when the layout is redesigned and never when the API does. |
| Assumed artwork shape before the image loads (music = square, else wide) | Frontend | The shape of a *picture*, not a taxonomy — and it is only a starting guess, overruled by the decoded image. |
| Tile labels, order, and showing a category's tile once | Frontend | Pure presentation: wording and placement. |
Borderline row: the "assumed artwork shape" is a per-collection-type default, and
any per-collection-type table deserves suspicion. The tie-breaker: it does not
decide *what a category means* or what is fetched — it seeds a pixel dimension
that the loaded bitmap immediately corrects. Getting it wrong costs one re-pack,
not a wrong result. The scope mapping, which does decide what is fetched, went to
Rust.
## Design
### Wire
`Library` gains one optional field, derived at construction:
```rust
pub struct Library {
pub id: String,
pub name: String,
pub collection_type: String,
pub image_tag: Option<String>,
pub favorites_scope: Option<SearchScope>, // ← new
}
impl SearchScope {
pub fn for_collection_type(collection_type: &str) -> Option<SearchScope>;
}
```
```ts
type Library = { …; favoritesScope?: SearchScope | null }
```
`Library::new` derives it, so the four construction sites (online views, two
offline cache reads, tests) cannot forget it. `None` is *omitted* from the JSON,
not sent as null. No new command, no new event.
### Layout
`src/lib/components/library/mosaic.ts` — pure, no DOM:
- `layoutMosaic(items, { containerWidth, targetHeight, gap })` → rows of tiles
with pixel boxes. Tiles join a row until the height needed to fill the width
drops to the target; the row closes there and is justified to the container
width, the rounding remainder absorbed by its widest tile. The **last row is
not justified** (one leftover tile would inflate into a banner) — it sits at
the target height, left-aligned.
- `layoutMosaicStrip(items, height)` → the same rule as one fixed-height row, for
a horizontally scrolling shelf.
- `mosaicTargetHeight(containerWidth)` → the row height chosen when the caller
doesn't pick one. Bounded so a phone still fits two tiles across and a desktop
doesn't turn each library into a billboard.
- Ratios are clamped to a band (0.52.5) so one panorama can't own a row.
`MosaicGrid.svelte` supplies the two things only the DOM knows — the measured
container width (`bind:clientWidth`) and the artwork's decoded ratio — and
renders the caller's `tile` snippet. `CachedImage` gained an `onNaturalSize`
callback for the second. Measured ratios are committed in one debounced batch
(120 ms): artwork arrives over several hundred milliseconds and re-packing per
image would shuffle the grid under the pointer.
`MosaicTile.svelte` draws one tile at an exact pixel box, with its label written
**over** the bottom of the artwork. A caption below the box would add height the
layout didn't compute, and a caption that wrapped to two lines would break the
row alignment the mosaic exists to provide.
### Composition
`libraryMosaic.ts` (pure, tested) builds the tile list: the cross-library
favourites entry first, then each library followed by its own category tile. A
category appears **once** — two movie libraries share one favourites list, so a
tile each would be two tiles to the same place. A library whose `favoritesScope`
is absent (Live TV, channels, books) gets no tile rather than one opening an
unfiltered list.
Home uses the same tiles in `layout="strip"` but **without** the favourites tiles:
home already carries Favourite Movies / Shows / Music rows of its own, and a
second entry point in the strip above them would be redundant.
## Out of scope
- The item grids inside a library (`/library/movies`, `/library/music/albums`, …).
Those show one item type each, so a uniform grid crops nothing; the mosaic buys
them nothing but reflow.
- Backdrop/collage artwork for libraries with no image of their own.
- Reordering or pinning libraries.
@@ -0,0 +1,235 @@
# Spec: Offline "downloaded only" filtering (issue #10)
**Status:** Implemented
**Scope:** Frontend (connectivity store) + Rust (hybrid repository). No new
commands, no schema changes, no UI additions.
**Requirements:** UR-052 → DR-078, DR-079, DR-080
(see [requirements.md](../requirements.md)).
**Tracking:** issue #10 — *"when offline the filter to show only downloaded
media does not work."*
## Summary
Offline, a library page is supposed to show **only media on the device**, with a
"Show all server media" toggle that additionally reveals the cached server
catalog greyed out (queueable for download on reconnect). In practice the toggle
does not gate the listing — every server item still appears. This spec fixes
that with two independent changes; either one alone leaves the bug visible.
## Background: what already exists
Verified in code. **The feature is built and mostly correct — this is a
two-point repair, not new infrastructure.** Do not rebuild the toggle, the
command, or the SQL gate.
1. **The SQL gate works and is unit-tested.**
[offline.rs](../../src-tauri/src/repository/offline.rs) — `get_items` appends
the synced-catalog `UNION` branch only when `include_catalog_browse()` is
true; with it false, only downloaded/local rows return. Guarded by
`test_get_items_toggle_gates_synced_catalog` (UT-067). **Do not touch the
query.**
2. **The toggle → backend path is wired.** The `showServerCatalog` store and the
`set_show_server_catalog` command
([catalog.rs](../../src-tauri/src/commands/catalog.rs)) drive the process-wide
`INCLUDE_CATALOG_BROWSE` flag. `pushCatalogVisibility` in
[offlineCatalog.ts](../../src/lib/services/offlineCatalog.ts) computes
`include = connected || showCatalog` and pushes it on every change.
3. **Home-screen queries are already downloads-only.** `get_latest_items`,
`get_resume_items`, `get_recently_played_audio`, `get_resume_movies` all
`INNER JOIN downloads ... status = 'completed'`. They are unaffected — leave
them.
4. **`MediaCard` already greys and queues.**
[MediaCard.svelte](../../src/lib/components/library/MediaCard.svelte) —
`isServerOnly` renders the greyed, inert card with a queue button; the queued
row heals its `stream_url` on reconnect via the offlineCatalog service. Leave
it.
## The two defects
### Defect A — offline is never actually entered (DR-079)
`pushCatalogVisibility` keys off `isConnected`, but
[connectivity.ts](../../src/lib/stores/connectivity.ts) derives:
```ts
isConnected = isOnline && isServerReachable // isOnline = navigator.onLine
```
`navigator.onLine` is documented in that same file as **advisory only** — the
Rust `ConnectivityMonitor` is the source of truth (principle: *reachability from
real traffic*, DR-055). When the server is unreachable but the device link is
up (server down, wrong LAN, VPN dropped), `isOnline` stays true, so `isConnected`
stays true, so `include` stays true, so the backend keeps returning the full
catalog. The user is "offline" in every meaningful sense but the toggle never
gets a chance to gate anything.
This is the primary cause: it explains why the filter looks dead rather than
merely inverted — the gate never closes.
### Defect B — an intentionally empty result falls through to the server (DR-080)
With the gate off and nothing downloaded in a library, offline `get_items`
correctly returns few or zero rows. But
[hybrid.rs](../../src-tauri/src/repository/hybrid.rs) treats a cache result as a
hit only `if data.has_content()`. An empty offline result is indistinguishable
from a cache miss, so `HybridRepository::get_items` (and `parallel_race`, used by
~10 other reads) falls through to the server and returns the full server list —
re-defeating the filter even after Defect A is fixed.
## Design
### Fix A: `isConnected` follows backend reachability alone (DR-079)
In [connectivity.ts](../../src/lib/stores/connectivity.ts), redefine the derived
store:
```ts
export const isConnected = derived(
connectivity,
($c) => $c.isServerReachable
);
```
`navigator.onLine` stays wired to what it is good for — a *trigger* for an
immediate recheck (`online`/`offline` listeners already call
`checkServerReachable()`); it must no longer be a *term* in the offline decision.
Leave `isOnline` on the state object and the listeners intact.
Consider whether the optimistic `isServerReachable: true` startup default
([connectivity.ts](../../src/lib/stores/connectivity.ts)) should hold until the
first real check resolves. Keep it — flipping the app to "offline" on launch is a
worse regression than a brief full-catalog flash before the first probe. Note the
choice in a comment.
**Blast radius — this is the reason this is a spec, not a patch.** `isConnected`
is consumed beyond this feature (banners, `MediaCard`, mini-player gating,
anything importing it). Enumerate consumers first:
```
grep -rn "isConnected" src/ | grep -v node_modules
```
For each, confirm "server unreachable" (not "device link down") is the correct
trigger. It almost always is — that is the whole point of the reachability model
— but verify rather than assume, and call out anything that genuinely wanted the
device link in the PR description.
### Fix B: an empty offline result is authoritative when the gate is off (DR-080)
The backend must distinguish "cache is cold, go ask the server" from "user asked
for downloads only and there are none here." The gate flag already encodes intent
— reuse it.
Add a getter beside the existing setter in
[offline.rs](../../src-tauri/src/repository/offline.rs):
```rust
pub fn include_catalog_browse() -> bool { /* pub, already exists privately */ }
```
In [hybrid.rs](../../src-tauri/src/repository/hybrid.rs) `get_items`: when
`!include_catalog_browse()`, treat the offline result as authoritative and return
it **as-is even when empty** — do not spawn/await the server fallback for this
call. When the flag is on (online fast-path, or offline with the toggle on),
behaviour is unchanged: empty cache still falls through to the server.
Keep it surgical:
- Scope the change to `get_items`. The gate is a `get_items` concept; do not
thread it into `parallel_race` or the other readers, which have no catalog
gate and legitimately want the server on an empty cache.
- Preserve the online path exactly: with the flag on (its default, and always so
while reachable) the method behaves as it does today, including the background
cache refresh on a hit.
- The flag is process-global `Relaxed`; it is set from the frontend before the
query. That ordering already holds for the SQL gate — no new synchronization.
### Why both
Fix A closes the gate; Fix B stops the hybrid from re-opening it. A alone: with
downloads present the list still gets padded by the server fallback whenever a
library's cache is thin. B alone: the gate never closes because `isConnected`
never goes false on a live link. Ship them together.
## Out of scope
- The SQL gate, the toggle, the command, `INCLUDE_CATALOG_BROWSE` — all correct.
- `MediaCard` greying / queue-on-reconnect — correct.
- Home-screen and resume queries — already downloads-only.
- The Rust `ConnectivityMonitor` reachability logic itself — unchanged; this
spec only stops the *frontend* from diluting its verdict with `navigator.onLine`.
- Any new IPC command, DB column, or settings entry.
- Making the "Show all server media" toggle reachable from Settings (that is a
UX-placement question, tracked separately under UR-051's toggle note).
## Acceptance criteria
- [~] With the server unreachable on a live device link, a library page lists
only downloaded media when the toggle is off (IT-016 — pending e2e; unit
coverage via UT-069 + gate tests).
- [x] Turning the toggle on reveals the greyed-out cached catalog; turning it off
hides it again — without leaving/re-entering the page (SQL gate + toggle
wiring unchanged; UT-068 confirms the flag is pushed on toggle change).
- [x] A library with downloads and a thin cache does not get padded with
non-downloaded server items when offline with the toggle off (Defect B —
UT-070: gate off + empty offline result returned as-is, server not queried).
- [x] `isConnected` is false whenever the server is unreachable, regardless of
`navigator.onLine`; true for a reachable server even if the browser reports
offline (UT-069).
- [x] Every existing `isConnected` consumer still behaves correctly (banner in
`+layout.svelte`, `MediaCard`, `favorites.ts` server-write skip — all want
"server unreachable", which is the new semantics; `CastButton`'s local
`isConnected` is unrelated). Full frontend suite (616 tests) green.
- [x] Online behaviour is unchanged: with the flag on (its default, always so
while reachable) `get_items` keeps the offline fast-path and background
refresh (UT-067 + gate-on fall-through test).
- [~] A download queued from a greyed offline card resolves and starts on
reconnect (IT-017 — regression check, no code change; offlineCatalog
resume path untouched).
- [x] `bun run check`, `bun run test`, and `bun run test:rust` pass;
`cd src-tauri && cargo fmt && cargo clippy` clean (no new warnings in the
touched files).
## Testing
Rust ([offline.rs](../../src-tauri/src/repository/offline.rs) /
[hybrid.rs](../../src-tauri/src/repository/hybrid.rs) test modules):
- **UT-070** — hybrid `get_items` with the gate off returns an empty offline
result as-is and does **not** query the server. Assert via a mock online repo
whose `get_items` bumps a call counter that must stay at zero.
- Gate on + empty cache still falls through to the server (guard the online path).
- UT-067 (`test_get_items_toggle_gates_synced_catalog`) must still pass untouched.
Frontend (vitest, `src/lib/**/*.test.ts`):
- **UT-069**`isConnected` follows `isServerReachable` alone: false when
unreachable with `navigator.onLine === true`; true when reachable with
`navigator.onLine === false`.
- **UT-068**`pushCatalogVisibility` resolves `serverReachable || showCatalog`
and pushes to the backend on a change of either input (extend the existing
offlineCatalog tests).
Integration (IT-016, IT-017) are documented as pending in
[requirements.md](../requirements.md); wire them if the e2e harness can simulate
an unreachable-server-on-live-link state, otherwise leave them pending with a note.
New/changed requirement code keeps its `TRACES:` comments — see
[CLAUDE.md](../../CLAUDE.md). The affected files already carry tags:
`connectivity.ts` (`… | DR-079`), `hybrid.rs` (`… | DR-080`), `offline.rs`
(`… | DR-078`). Update the getter's tag when you expose it.
## Notes for the implementer
- Read [docs/architecture/07-connectivity.md](../architecture/07-connectivity.md)
before Fix A — it is the canonical statement of the reachability model this fix
restores fidelity to.
- Fix B relies on the frontend having pushed the flag before the query runs; that
ordering already holds for the SQL gate today. No new locking.
- Another session is active in this repo (WiFi-only downloads, account menu
landed alongside this work). Check `git diff` before "repairing" unexpected
changes, and expect requirement IDs around UR-052 / DR-078 to be adjacent to
other new rows.
+226
View File
@@ -0,0 +1,226 @@
# Spec: Playback backend unification — findings and strategy
**Status:** Accepted (analysis; no code changes)
**Requirements:** IR-004, UR-031, UR-032, UR-033 — revises the "Platform Playback Backend Parity" issue in requirements.md
**UX spec:** n/a
**Supersedes / revises:** informs [android-native-video-spike.md](android-native-video-spike.md), [android-audio-settings-parity.md](android-audio-settings-parity.md), [windows-native-audio-backend.md](windows-native-audio-backend.md)
## Summary
This spec records the outcome of an investigation into unifying JellyTau's
playback backends (Linux/MPV, Android/ExoPlayer, Windows/webview) onto a single
engine with hardware acceleration everywhere. **The conclusion is that video
cannot be unified onto a native engine, and should not be attempted.** Audio
*can* be, and that is where the remaining specs direct effort.
No code changes follow from this spec directly. It exists so the decision is
written down with its evidence, and so a future session does not re-run the same
investigation.
## Motivation
The requirements doc carries a "Platform Playback Backend Parity" issue noting
that audio settings work on Linux but not Android, and proposing eventual
convergence. The natural next question — "should we just run one engine
everywhere?" — needed answering before spending effort on per-backend patches.
The investigation also surfaced that several statements in requirements.md and in
code comments are factually wrong. Those corrections are part of the deliverable.
## Findings
### 1. The current architecture is not what the docs describe
| Platform | Audio | Video |
|----------|-------|-------|
| Linux | MPV (native, **audio-only**) | webview `<video>` + hls.js |
| Android | ExoPlayer (native) | **webview `<video>` + hls.js** |
| Windows | webview `<audio>` | webview `<video>` + hls.js |
Two surprises:
- **MPV never decodes video.** `mpv_backend.rs` sets `video = no` and
`audio-display = no` at construction. Linux video has always been the webview.
Correspondingly, `player_play_item` deliberately does *not* load into MPV on
Linux (it calls `set_current_item`, which only updates the queue).
- **Android video is also the webview.** `createAdapter()` in
`src/lib/player/adapters/index.ts` hardcodes `const effectiveKind = "html5"`
and does `void backendKind`, discarding the `use_html5_element` signal that
`get_player_status` computes in Rust. `NativePlayerAdapter` is dead code, and
ExoPlayer's `SurfaceView` path in `JellyTauPlayer.kt` is unreachable.
So video is *already* unified — on HTML5, everywhere, by accident of that
hardcode — and on the path without hardware decoding on Android.
### 2. Native video cannot be composited with a Tauri webview
This is the load-bearing finding. It is **not** an mpv limitation; it defeats
every candidate engine identically:
- **mpv**: `tauri-plugin-libmpv`'s own platform table reads Linux ⚠️
*"Experimental. Window embedding is not working."*
- **GStreamer** (wry discussion #284, 2024): *"Gstreamer was rendering above the
surface and covering all html elements."*
- **libVLC** (tauri discussion #6343, 2024): *"I had to render the webview in a
child window though because vlc kept rendering on top of it."*
Root cause, from Tauri maintainer amrbashir (tauri#9220, 2024-03-30):
> "we are limited to using Webkit2GTK on Linux and that requires a GTK window.
> While possible to add a GTK widget as a child X11 window inside raw X11 window,
> this is however a bit hacky and **it is not possible on Wayland at all**."
WebKitGTK, WebView2, and Android WebView each draw into their own compositor
surface. A native video surface is either entirely above or entirely below the
webview; it cannot interleave with HTML. Every working example in the ecosystem
is the same hack — a separate child window position-synced to a
`getBoundingClientRect()` div — which breaks on resize, scroll, and any UI drawn
over the video. For JellyTau that means the controls, subtitle overlay, and
mini-player.
The most recent comment on tauri#6343 (2026-05-23) confirms it is still unsolved:
> "I'm faking it and the window is not truly embedded, basically when the parent
> moves or resizes I reset the position and size of the libmpv window to align it
> with an HTML div."
**The principle to carry forward: audio can unify on a native engine; video
cannot, because video needs a surface and the webview owns the surface.**
### 3. mpv would regress streaming quality
mpv has **no adaptive bitrate**. It delegates HLS to FFmpeg's demuxer, which
selects one variant at open time and never adapts; mpv#3548 (2016) requested ABR
and it never landed. `--hls-bitrate` is a static picker defaulting to `max`.
The webview path already has real ABR via hls.js. Moving video to mpv would be a
**downgrade** on every platform — no graceful degradation on weak networks, and
quality changes requiring teardown and reload.
### 4. Crossfade is architecturally blocked on mpv
mpv's audio chain is single-stream. FFmpeg's `acrossfade` is an `N→A` filter
requiring two input streams, so there is no second input to feed it. Real
crossfade needs **two libmpv instances** with manually ramped volumes. Upstream
maintainer response (mpv#4512, closed three minutes after opening):
> "No. I also find crossfading stupid and complex, so the likeliness of that
> happening is low."
GStreamer *could* do it via `audiomixer`. mpv cannot, at any reasonable cost.
### 5. Engine comparison summary
| Criterion | mpv | GStreamer | libVLC |
|-----------|-----|-----------|--------|
| Webview compositing | ❌ Linux broken | ❌ same wall | ❌ same wall |
| Adaptive bitrate HLS | ❌ none | ✅ adaptivedemux2 | ✅ adaptive module |
| Rust bindings | ⚠️ `libmpv2` active; our pin is dead | ✅ `gstreamer-rs` excellent | ❌ `vlc-rs` abandoned (2018) |
| Windows cross-MSVC | ⚠️ prebuilt DLL | ❌ pkg-config vs cargo-xwin | ❌ no better |
| Android packaging | ✅ Maven AAR (used by Findroid) | ⚠️ Cerbero/NDK, painful | ✅ mature AAR |
| ASS/SSA subtitles | ✅ libass built in | ✅ libass | ✅ libass |
| Crossfade | ❌ impossible | ✅ `audiomixer` | ⚠️ unclear |
Every candidate fails the first row, which is the disqualifying one.
### 6. Two further options ruled out
**Webview `<audio>`/`<video>` everywhere** (i.e. delete the native audio backends
too) is dead on Android: `navigator.mediaSession` is *deliberately compiled out*
of Android WebView (Chromium CL 2613133003), so lockscreen/media-notification
control would be impossible. Chromium has also never shipped `audioTracks`. It
remains fine for Windows *video*, which is what we already do.
**FFmpeg-direct / Rust-native** (`ffmpeg-next`, `rsmpeg`, Symphonia) is not
close: the safe bindings do not expose hardware decode at all, `ffmpeg-next` is
self-declared maintenance-only, and Symphonia lacks HE-AAC and gapless AAC. This
is a multi-person-year path to reach parity with what we already have.
### 7. If libmpv is ever revisited on Android
Recorded so the next investigation starts from evidence rather than repeating the
search. The `dev.jdtech.mpv:libmpv` AAR — maintained by Findroid's author, i.e.
another Jellyfin Android client — was inspected directly:
- `libmpv.so` exports the full 54-function `mpv_*` C API with **zero `Java_`
symbols**; JNI is a separate optional ~19 KB `libplayer.so`. So it is drivable
from Rust without a Java shim. (This is precisely what disqualifies libVLC,
whose Android video path hard-requires a Java `AWindow` jobject.)
- ~23 MB/ABI, versus libVLC's ~46 MB/ABI.
- 🔴 **The published AAR is built `--enable-gpl --enable-version3` — it is
GPLv3**, not LGPL. Fine for us (see [libmpv2-migration.md](libmpv2-migration.md)),
but it would be a hard constraint for anyone shipping closed source, and an
LGPL rebuild would be your own build to own.
- Top unverified risk if anyone tries this: whether `libmpv2-sys` can
cross-compile for `aarch64-linux-android` against that prebuilt `.so`. No
working example of `libmpv2` on Android was found.
None of this changes the verdict — the cost is the MediaSession/foreground-service
rewrite, not the bindings.
## Decision
1. **Do not unify video onto a native engine.** Video stays in the webview with
hls.js on all platforms. This is not a compromise — it is the configuration
that falls out of the compositing constraint, and it is the only one that
gives us ABR for free.
2. **Android native video is worth a bounded spike anyway** — not for
unification, but because ExoPlayer's `SurfaceView` path already exists and
would restore hardware decode plus ASS/SSA subtitles. See
[android-native-video-spike.md](android-native-video-spike.md).
3. **Audio parity is the real gap** and is achievable without touching any of the
above. See [android-audio-settings-parity.md](android-audio-settings-parity.md)
and [windows-native-audio-backend.md](windows-native-audio-backend.md).
4. **Migrate the dead libmpv pin** regardless of any of this. See
[libmpv2-migration.md](libmpv2-migration.md).
## Corrections to existing docs
These are factual errors found during the investigation. Fixing them is in scope
for this spec.
| Location | Says | Actually |
|----------|------|----------|
| `requirements.md` UR-031 (line ~44) | "Done (Linux only)" | Not implemented on any platform. |
| `requirements.md` DR-034 (line ~196) | "Done (Linux only)" | Not implemented anywhere — `mpv_backend.rs` has a bare `// TODO: Implement crossfade via MPV audio filters if needed`. Architecturally blocked on mpv (finding 4). |
| `requirements.md` parity matrix | Crossfade ✅ Linux / ❌ Android | ❌ / ❌ |
| `requirements.md` parity matrix | (no EQ row) | EQ is also Linux-only — `build_af_filter`/`eq_filter_entries` exist only in `mpv_backend.rs`. Same root cause, same fix. |
| `nativeAdapter.ts:11-14` | Native Android video "blocked upstream by tauri#10152" | tauri#10152 is a stale *feature request*, dead since 2024-07-01. The capability shipped in tauri commit `27d01834` (2024-09-02). Not a blocker. |
## Layer assignment
No new logic. The one boundary observation worth recording:
| Logic / responsibility | Layer | Why it belongs there |
|------------------------|-------|----------------------|
| Which video backend a platform uses (`use_html5_element`) | Rust | Already correctly computed in `get_player_status`. The frontend currently *discards* it — that is the bug, not the design. Restoring it means the frontend consumes a backend decision rather than making its own. |
## Out of scope
- Any code change. This spec is analysis; the sibling specs carry the work.
- iOS/macOS. Not current targets.
- Replacing hls.js.
## Acceptance criteria
- [ ] `requirements.md` DR-034 status corrected; parity matrix updated (crossfade ❌/❌, EQ row added).
- [ ] Stale tauri#10152 comment in `nativeAdapter.ts` corrected.
- [ ] The four sibling specs exist and are linked from here.
## Testing
n/a — documentation only.
## TRACES
No new code. Requirement text changes only; DR-034's status line is the one
substantive edit.
## Notes for the implementer
- The evidence above was gathered in July 2026. The compositing constraint has
been stable since 2021 (wry#284) and is maintainer-declared unfixable, so it is
unlikely to change soon — but if someone revisits this, tauri#6343 and wry#284
are the threads to re-read first.
- A parallel Claude session may be active in this repo — `git diff` before
"repairing" unexpected changes.
+198
View File
@@ -0,0 +1,198 @@
# Spec: Playback documentation corrections
**Status:** Proposed
**Requirements:** revises the status of DR-034; corrects the parity matrix in [requirements.md](../requirements.md)
**UX spec:** n/a
**Supersedes / revises:** implements the "Corrections to existing docs" section of [playback-backend-unification.md](playback-backend-unification.md)
## Summary
Fix four factual errors in the requirements doc and the player source comments,
all found while investigating backend unification. Each claims something the code
does not do. Small change, but they are actively misleading: two of them assert a
feature is implemented when it is implemented nowhere, and one cites an upstream
blocker that no longer exists.
Documentation and comments only — no behaviour change.
## Motivation
These errors compound. DR-034 reads "Done (Linux only)", so a future session
planning Android parity would reasonably assume crossfade exists on Linux and
only needs porting — when in fact it is unimplemented everywhere *and*
architecturally blocked on the engine it supposedly runs on. Likewise the
tauri#10152 comment has been discouraging work on Android native video since the
upstream capability shipped in September 2024.
## The corrections
### 1. DR-034 status is wrong
`requirements.md` line ~196:
```
| DR-034 | Crossfade engine with configurable duration (0-12s) | Player | UR-031 | Done (Linux only) |
```
The code:
```rust
// src-tauri/src/player/mpv_backend.rs, in set_audio_settings
// TODO: Implement crossfade via MPV audio filters if needed
```
That is the entire crossfade implementation. `crossfade_duration` is plumbed
through `AudioSettings` and clamped to 012s, but no backend ever acts on it.
**Change to:** `Not implemented (blocked on MPV — see playback-backend-unification.md)`
Worth stating *why* in the requirements entry, because it is not a scheduling
gap: mpv's audio chain is single-stream, and FFmpeg's `acrossfade` is an `N→A`
filter needing two inputs. Real crossfade requires two libmpv instances with
manually ramped volumes. Upstream declined the feature (mpv#4512).
### 1b. UR-031 status is wrong for the same reason
`requirements.md` line ~44:
```
| UR-031 | Crossfade between audio tracks | Low | Done (Linux only) |
```
Same error one level up: the *user* requirement is also marked done. Since no
backend implements crossfade, UR-031 is not satisfied on any platform.
**Change to:** `Not implemented (blocked — see DR-034)`
Note line ~517 of the same file (`UR-031 (Crossfade), UR-032 (Gapless),
UR-033 (Normalization) only work on Linux`) inherits the error — crossfade works
nowhere, so it should read UR-032/UR-033 only.
### 2. Parity matrix crossfade row is wrong
```
| Crossfade | ✅ | ❌ | Gap |
```
**Change to** `| Crossfade | ❌ | ❌ | Not implemented |` — it is not a
platform-parity gap, it is an unbuilt feature.
### 3. Parity matrix is missing the equalizer
The matrix lists crossfade, gapless, and normalization but omits the EQ, which
has the same Linux-only shape and the same root cause (`ExoPlayerBackend` not
overriding `set_audio_settings`). `build_af_filter` and `eq_filter_entries` exist
only in `mpv_backend.rs`; there is no equalizer code in the Android tree.
**Add:** `| Equalizer (10-band) | ✅ | ❌ | Gap |`
### 4. `nativeAdapter.ts` cites a stale blocker
`src/lib/player/adapters/nativeAdapter.ts:11-14` states native Android video is
blocked upstream by tauri#10152 (transparent webview / SurfaceView compositing).
tauri#10152 is open but **dead since 2024-07-01**, and it is a *feature request*
that `WebviewWindowBuilder::transparent` was desktop-only — not a report that
compositing is broken. The capability shipped in tauri commit `27d01834`
(2024-09-02), which moved `transparent()` into the cross-platform impl block with
only the tao call `#[cfg(desktop)]`-fenced. It landed as a clippy cleanup, so the
issue was never closed. Separately, the black/white-screen bug (tauri#8381,
tauri#9408) was a broken JNI signature for `setBackgroundColor`, fixed in wry
0.39.4; we ship wry 0.55.x.
**Change to:** a comment stating the adapter is currently unreachable because
`createAdapter` hardcodes the HTML5 kind, that transparency is no longer an
upstream blocker, and that
[android-native-video-spike.md](android-native-video-spike.md) tracks whether
SurfaceView compositing actually works. Be explicit that *nobody has
demonstrated* SurfaceView-behind-WebView on Tauri Android — nothing upstream
blocks it, and nothing upstream proves it.
### 5. Platform capability is signalled three incompatible ways
Not a doc error — a real inconsistency found during the same investigation, worth
recording here even though fixing it needs its own change.
Which backend a platform uses is currently expressed three ways:
1. Rust `#[cfg]` gates in `player/mod.rs` and `create_player_backend` — the truth.
2. The `useHtml5Element` / `VideoBackend` value from `get_player_status` — which
the frontend discards (see the spike spec).
3. **Frontend user-agent sniffing** in `src/lib/services/webviewAudio.ts:30-41`:
```ts
const ua = navigator.userAgent.toLowerCase();
const isAndroid = ua.includes("android");
const isLinux = ua.includes("linux") && !isAndroid;
return !isAndroid && !isLinux;
```
The comment says it is "matching the Rust cfg gate" — i.e. the frontend
re-derives a backend decision from the user-agent string and hopes it stays in
sync. That is the frontend deciding *which backend exists*, which is domain
knowledge, not presentation. It also breaks silently the moment a new target is
added or a webview's UA changes.
**This is a boundary leak of the same family the spec-review checklist exists to
catch**, even though `check:boundary`'s tripwire (item-type arrays) does not
match it. Rust already computes the answer; the frontend should consume it.
Not fixed by this spec — it is behavioural, not documentation. It should be
folded into the spike spec's factory rework, where the same
"consume Rust's decision instead of re-deriving it" change is already in scope.
### Also worth fixing while here
`requirements.md` IR-004 reads "In Progress (basic playback works, audio settings
missing)". That stays accurate until
[android-audio-settings-parity.md](android-audio-settings-parity.md) lands, but
the "Future Fix" list in the parity issue proposes
`ConcatenatingMediaSource` for crossfade — **deprecated in current Media3**. Drop
that suggestion; the modern approach is a custom `AudioProcessor`.
## Layer assignment
No logic. Documentation and comments only.
| Logic / responsibility | Layer | Why it belongs there |
|------------------------|-------|----------------------|
| — | — | No logic introduced or moved by this spec. |
## Out of scope
- Implementing crossfade. This spec only stops claiming it exists.
- Implementing Android audio settings — see the parity spec.
- Running the Android video spike — see that spec.
- Rewriting the architecture docs. `docs/architecture/05-platform-backends.md`
should be re-read for the same class of error, but that is a larger pass.
## Acceptance criteria
- [ ] DR-034 status corrected, with the blocking reason stated.
- [ ] UR-031 status corrected (line ~44), and the "only work on Linux" line (~517) no longer lists crossfade.
- [ ] Parity matrix: crossfade ❌/❌; equalizer row added.
- [ ] `ConcatenatingMediaSource` suggestion removed from the "Future Fix" list.
- [ ] `nativeAdapter.ts` comment corrected and pointing at the spike spec.
- [ ] `bun run check` and `bun run test` pass (a comment change still touches TS).
- [ ] `bun run traces:markdown` re-run if requirement text changed.
No Rust changes, so the `cargo` gates do not apply.
## Testing
None beyond the standard gates — no behaviour changes. Confirm
`bun run traces:markdown` regenerates cleanly, since DR-034's row is referenced
by the traceability matrix.
## TRACES
No code implementing requirements changes; no TRACES comments to add or update.
The DR-034 row in `docs/traceability.md` will regenerate with the corrected text.
## Notes for the implementer
- Do **not** silently delete DR-034. The requirement (UR-031 crossfade) is still
wanted; it is the *status* that is wrong. Keeping the row with an honest status
and a reason is the point.
- A parallel Claude session may be active — `git diff` before "repairing"
unexpected changes.
+258
View File
@@ -0,0 +1,258 @@
# Spec: Enforce the unified player boundary
**Status:** Proposed
**Requirements:** DR-095 (new); relates to UR-005 and the unified-player-boundary
principle in CLAUDE.md and [02-svelte-frontend.md](../architecture/02-svelte-frontend.md)
**UX spec:** n/a — refactor, no user-visible change.
**Supersedes / revises:** n/a
## Summary
The stated principle is that UI controls playback **only** through
`playerController` ([src/lib/player/index.ts](../../src/lib/player/index.ts)),
never by calling `commands.player*` directly. There are **52 direct call sites
outside** that facade. This spec routes the genuine playback-control calls
through the facade, narrows the principle's wording so it stops forbidding
things it never meant to forbid, and adds the lint rule that keeps it true —
because this rule is the one design principle in the audit with **no automated
check at all**, and it is also the one that drifted furthest.
## Motivation
Direct `commands.player*` usage outside `src/lib/player/`, by file:
| File | Sites |
|---|---|
| [queue.ts](../../src/lib/stores/queue.ts) | 10 |
| [player/[id]/+page.svelte](../../src/routes/player/[id]/+page.svelte) | 9 |
| [VideoPlayer.svelte](../../src/lib/components/player/VideoPlayer.svelte) | 8 |
| [settings/+page.svelte](../../src/routes/settings/+page.svelte) | 5 |
| [sleepTimer.ts](../../src/lib/stores/sleepTimer.ts) / [auth.ts](../../src/lib/stores/auth.ts) / [autoplay.ts](../../src/lib/api/autoplay.ts) | 4 each |
| [preload.ts](../../src/lib/services/preload.ts) | 3 |
| [library/[id]](../../src/routes/library/[id]/+page.svelte), [playerEvents.ts](../../src/lib/services/playerEvents.ts), [playbackMode.ts](../../src/lib/stores/playbackMode.ts) | 12 each |
These are **not** equivalent violations, and treating them as one number is why
the rule has been easy to ignore. Three distinct groups:
**(a) Genuine violations — playback control with a facade method that already
exists.** `playerStop` ×6, `playerPlayTracks` ×4, `playerSeek` ×2,
`playerPlayAlbumTrack` ×2, `playerNext`, `playerPrevious`, `playerSkipTo`,
`playerToggleShuffle`, `playerCycleRepeat`, `playerRemoveFromQueue`,
`playerMoveInQueue`, `playerAddTrackById`, `playerAddTracksByIds`,
`playerSetSubtitleTrack`, `playerPlayItem`. The facade exposes `stop()`,
`seek()`, `next()`, `previous()`, `skipTo()`, `toggleShuffle()`,
`cycleRepeat()`, `removeFromQueue()`, `moveInQueue()`, `addTrackById()`,
`addTracksByIds()`, `setSubtitleTrack()`, `playTracks()`, `playAlbumTrack()`,
`playItem()` — every one of these has a facade equivalent that is simply not
being called. `queue.ts` is the starkest case: it imports `commands` directly
and re-implements ten methods the facade already provides.
**(b) Playback control with no facade method.** `playerPlayQueue`,
`playerGetQueue`, `playerGetStatus`, `playerEnterBackgroundAudio`,
`playerExitBackgroundAudio`, `playerSetSleepTimer`, `playerCancelSleepTimer`,
`playerPlayNextEpisode`, `playerCancelAutoplayCountdown`. In scope for the
principle, but currently *impossible* to comply with — the facade has no surface
for them. A rule that cannot be followed is not being broken so much as it is
unfinished.
**(c) Not playback control.** `playerConfigureJellyfin` ×3,
`playerDisableJellyfin`, `playerGet/SetAudioSettings`,
`playerGet/SetVideoSettings`, `playerGetEqPresets`,
`playerGet/SetAutoplaySettings`, `playerGet/SetCacheConfig`,
`playerPreloadUpcoming`. These are configuration and lifecycle calls that happen
to live under the `player_` command prefix. The principle is about *who is
authoritative for playback state* — settings CRUD isn't that.
The audit's read: the rule as written is violated 52 times, which makes real
drift indistinguishable from acceptable usage, and that ambiguity is what lets
group (a) persist. Note also that the principle **is** well-honoured where it
matters most — the read side is clean, with UI reading state exclusively from
the facade's re-exported stores. The write side is what drifted.
## Layer assignment
Frontend-internal refactor. No domain logic moves and nothing new crosses IPC —
the same Rust commands are called, through one module instead of many.
| Logic / responsibility | Layer | Why it belongs there |
|------------------------|-------|----------------------|
| Playback command dispatch (adapter routing: native vs HTML5) | Frontend — `src/lib/player/` **only** | Presentation-layer plumbing, but must be centralised: the facade picks between the native backend and the HTML5 `<video>` adapter. A caller bypassing it silently skips that routing. |
| Playback *authority* (position, pause, rate, track changes) | **Rust / the player** | Unchanged. The player is authoritative; UI is a consumer. This spec does not touch that direction. |
| Queue mutation commands | Frontend facade → Rust | Rust owns queue state; the facade is the single call path to it. |
| Player settings CRUD (EQ, video, autoplay, cache) | Frontend, **outside** the facade | Configuration, not playback control — read/written on a settings page with no adapter routing. Explicitly carved out below. |
| Backend→frontend event handling | `playerEvents.ts` | Already correct. It is the facade's own plumbing, not a bypassing consumer. |
No Jellyfin taxonomy is involved, so no boundary-leak risk.
## Design
### 1. Narrow the principle to what it actually means
Amend CLAUDE.md and [02-svelte-frontend.md](../architecture/02-svelte-frontend.md):
> **Unified player boundary.** UI controls **playback** — transport, queue
> mutation, track selection, playback initiation — *only* through
> `playerController`. Player **configuration** commands (`player_*_settings`,
> `player_configure_jellyfin`, `player_*_cache_config`, `player_preload_upcoming`)
> are ordinary IPC and may be called directly from settings surfaces.
This is a clarification, not a relaxation: it makes group (c) explicitly fine so
that a violation count means something. A rule with 52 nominal violations, most
of them acceptable, provides no signal.
### 2. Fill the facade gaps (group b)
Add to `playerController`, each a thin pass-through preserving current
behaviour:
```ts
playQueue, getQueue, getStatus,
enterBackgroundAudio, exitBackgroundAudio,
setSleepTimer, cancelSleepTimer,
playNextEpisode, cancelAutoplayCountdown,
```
Do this **first** — group (a) cannot be fully migrated while callers still need
a direct import for a neighbouring call, and a file that imports `commands` for
one reason will keep using it for others.
### 3. Migrate group (a)
Mechanical: replace `commands.playerX(...)` with `playerController.x(...)`.
Highest-value first: `queue.ts` (10 sites, all direct facade equivalents), then
`player/[id]/+page.svelte`, `VideoPlayer.svelte`, `sleepTimer.ts`,
`playbackMode.ts`, `library/[id]/+page.svelte`.
Two sites need care rather than substitution:
- **`playerEvents.ts`** (`playerOnPlaybackEnded`, `playerStop` in the error
path). This module *is* the facade's event plumbing — the counterpart to
`index.ts`, inside the boundary conceptually though not by directory. Treat
`src/lib/services/playerEvents.ts` as **inside** the boundary and exempt it,
rather than making it call the facade that calls back into it. Record this in
the lint config with the reason.
- **`VideoPlayer.svelte`** — registers its own adapter via `setActiveAdapter`.
Its `playerStop`/`playerPlayItem` calls interact with adapter lifecycle, and
CLAUDE.md's gotcha ("no lifecycle calls after an `await` in `onMount`") applies.
Migrate this file **last and on its own**, so an Android seek regression is
bisectable to one commit.
### 4. Add the lint rule (the part that makes it stick)
The audit's finding was that principles with working checks held up and
principles without them drifted. This principle has no check. Add
`scripts/check-player-boundary.sh`, wired as `bun run check:player-boundary` and
into `test-all.sh`:
```sh
# Playback-control commands that MUST go through the facade.
CONTROL='player(Play|Pause|Toggle|Stop|Seek|Next|Previous|SkipTo|ToggleShuffle|CycleRepeat|RemoveFromQueue|MoveInQueue|SetVolume|ToggleMute|SetSubtitleTrack|SeekVideo|SwitchAudioTrack|PlayTracks|PlayAlbumTrack|PlayItem|PlayQueue|AddTrackById|AddTracksByIds|GetQueue|GetStatus|EnterBackgroundAudio|ExitBackgroundAudio|SetSleepTimer|CancelSleepTimer|PlayNextEpisode|CancelAutoplayCountdown|OnPlaybackEnded)'
# Inside the boundary: the facade and its event plumbing.
EXEMPT='^src/lib/player/|^src/lib/services/playerEvents\.ts$'
```
Flag `commands.$CONTROL` in non-test `src/` files outside `EXEMPT`. Config
commands are deliberately absent from the list, matching §1 — so the check
encodes the narrowed rule rather than the aspirational one.
An ESLint `no-restricted-syntax` rule would give better editor feedback, but the
project has no ESLint config; a shell check matches the existing
`check:boundary` precedent and adds no dependency.
## Out of scope
- Changing playback *behaviour* — pure refactor.
- The one-directional state principle (audited clean; UI reads from facade
stores only).
- Moving settings CRUD behind the facade (§1 explicitly carves it out).
- Introducing ESLint.
- Refactoring `VideoPlayer.svelte`'s 2079 lines generally, beyond its facade
call sites.
- The `commands.player*` calls **inside** `src/lib/player/` — that is the
facade doing its job.
## Acceptance criteria
- [ ] `playerController` exposes the group-(b) methods listed in §2.
- [ ] `grep -rn "commands\.player" src/ --include='*.ts' --include='*.svelte' | grep -v '^src/lib/player/' | grep -v 'playerEvents\.ts' | grep -v '\.test\.' | grep -v bindings.ts`
returns **only** configuration commands per §1 — no transport, queue, or
playback-initiation call.
- [ ] `queue.ts` no longer imports `commands` from bindings.
- [ ] `bun run check:player-boundary` exists, is wired into `test-all.sh`, and
passes.
- [ ] The check **fails** when a `commands.playerStop()` is added to a non-exempt
file — verify explicitly, as with the other gates in this batch.
- [ ] The check does **not** fail on `commands.playerSetAudioSettings()` in
`settings/+page.svelte` (the §1 carve-out works).
- [ ] CLAUDE.md and `02-svelte-frontend.md` carry the narrowed wording, including
the config carve-out and the `playerEvents.ts` exemption with its reason.
- [ ] **No behavioural change**: audio and video playback, queue reorder,
shuffle/repeat, sleep timer, background audio, and autoplay all behave as
before on **both Linux and Android**.
- [ ] Android seek and `onMount` lifecycle still correct after the
`VideoPlayer.svelte` migration (the known-fragile path).
- [ ] `bun run check` and `bun run test` pass.
- [ ] `bun run check:boundary` passes.
- [ ] Changed code carries `// TRACES:` comments.
- [ ] No Rust change, so no `bindings.ts` regeneration.
## Testing
**Frontend** (`bun run test`):
- Extend the existing facade tests to cover each new group-(b) method: it
forwards to the right command with the right arguments, and routes to the
active adapter where applicable.
- `queue.ts` tests: assert calls land on `playerController`, not `commands`. Mock
the facade — a test that mocks `commands` would pass either way and guard
nothing.
- Keep `tauriIntegration.test.ts` and the other IPC param-naming tests green;
they cover the camelCase rule this refactor must not disturb.
**Manual** (no automated coverage for these paths):
- Linux: play/pause/seek/next/prev, queue reorder, shuffle, repeat, sleep timer,
transcoded video (HLS), background audio enter/exit.
- Android: the same, plus lockscreen/MediaSession controls, and **seek after
entering the player** — the specific regression CLAUDE.md warns about.
Because this is a pure refactor, the strongest signal is that no test *changes
expectation*. A test needing its assertions rewritten means behaviour moved —
investigate rather than update it.
## TRACES
Allocate in `requirements.md`:
- **DR-095** — "UI playback control is routed exclusively through the
`playerController` facade (`src/lib/player/`), with `playerEvents.ts` inside
the boundary as its event plumbing and player *configuration* commands
explicitly outside it; enforced by `scripts/check-player-boundary.sh`."
Category: Player. Traces to UR-005. Status: Done on merge.
```typescript
// src/lib/player/index.ts
// TRACES: UR-005 | DR-095
```
New facade tests take `@req-test: UT-089` onward (next free UT is **UT-089**;
coordinate if landing alongside the sibling specs, which draw from the same
pool).
## Notes for the implementer
- A parallel Claude session may be active in this repo — `git diff` before
"repairing" unexpected changes (CLAUDE.md §Gotchas).
- **Order matters**: §2 (fill gaps) → §3 (migrate, `VideoPlayer.svelte` last and
alone) → §4 (add the check). Adding the check first turns `master` red.
- 🔴 **`VideoPlayer.svelte`**: no lifecycle calls after an `await` in `onMount`
it flips to HTML5 mode and breaks Android seek. Do not let a mechanical
substitution introduce an `await` before a lifecycle call.
- The facade's `requireHandle()` may throw where a raw `commands` call did not.
Check each migrated call site's error handling rather than assuming the
try/catch still covers the same cases.
- `playbackMode.ts` interacts with remote-mode routing (`play_on_session` vs
local MPV). Verify remote casting still works after migrating its
`playerPlayTracks` call.
- This spec is deliberately the *lowest* priority of the audit batch: it is the
largest diff and the only one carrying real regression risk, while the
traceability gate is a few lines and restores a dead safety net.
+219
View File
@@ -0,0 +1,219 @@
# Spec: Two-path media — selectable playback bitrate, independent whole-file download
**Status:** Proposed
**Requirements:** UR-070, UR-071 → DR-121, DR-122, DR-123, DR-124, DR-125; IR-032
**UX spec:** player quality selector — needs a `ux-flows.md` section before build
**Related:** [catalog-index-search.md](catalog-index-search.md),
[downloads-as-offline-library.md](downloads-as-offline-library.md)
## Summary
Two things that are today tangled become explicitly separate:
- **The playback path** streams at a bitrate the viewer can change from the
player. It is ephemeral and its rendition is volatile.
- **The download path** fetches the whole file at one canonical quality, in the
background, independently of whatever playback is doing.
Bytes fetched for playback are kept **only** when the playback rendition happens
to be the same artifact the download path would produce — i.e. direct play.
Otherwise playback bytes are discarded and the download path does its own fetch.
## Motivation
The appealing version of this — "stream and download at once, switch when enough
has arrived" — breaks the moment the viewer can change bitrate. A capture taken
while the rendition changes underneath it is a splice of two encodings: not a
playable file, and not something that can be honestly recorded as a download.
Once bitrate is selectable, one stream cannot serve both jobs.
Separating the paths also removes the thing that made the original idea
expensive: there is no mid-playback source swap to engineer, because the download
never has to take over the live session. It lands on disk and is used at the next
natural boundary — next episode, or next time the item is played.
What exists already and is *not* this: `SmartCache` predictively downloads *other*
items, `player_preload_upcoming` warms the next one, and
`refresh_queue_local_sources` swaps queue entries to local at boundaries. All of
it concerns items you are not currently playing.
## Layer assignment
| Logic / responsibility | Layer | Why it belongs there |
|------------------------|-------|----------------------|
| Available bitrate options for an item | **Rust** | Derived from Jellyfin's media sources and playback-info negotiation; changes with the API. |
| Mapping a chosen bitrate to transcode parameters | **Rust** | Domain vocabulary. `get_video_download_url` already owns the quality→params mapping; playback must reuse it, not restate it. |
| Deciding whether playback bytes are keepable (direct play vs transcode) | **Rust** | Depends on the negotiated session. |
| Canonical download quality | **Rust** | Policy over domain data. |
| Cache eviction, storage budget, sparse-range bookkeeping | **Rust** | Storage policy. |
| Promotion to a `downloads` row, and what invalidates a cache entry | **Rust** | Domain state. |
| Rendering the quality selector; remembering the last choice | **Frontend** | Presentation and a view preference. The *list* comes from Rust. |
| WiFi-only / opt-in toggles | **Frontend collects, Rust enforces** | The control is UI; the gate must hold even if the UI never calls. |
Borderline, recorded: the **default** playback bitrate could look like a user
preference (frontend). It goes to Rust because it must be reconcilable with what
the server can actually produce for a given media source — a preference the
backend has to validate is not a preference the frontend can own alone. The
frontend stores the user's *choice*; Rust decides what that choice resolves to.
## Design
### DR-121 — Bitrate selection in the player
The player exposes the qualities Rust reports for the current item. Changing it
re-negotiates the stream URL at the new quality and resumes at the current
position. This is a deliberate, user-initiated interruption — a brief rebuffer is
expected and acceptable, unlike the involuntary swap the earlier design would
have needed.
Constraints that must not be broken:
- On Linux, video playback must keep using the HLS `master.m3u8` URL. CLAUDE.md
records that returning `stream.mp4` means transcoded playback never starts.
A quality change re-negotiates *within* HLS.
- The quality→transcode-parameter mapping already exists in
`get_video_download_url` ([online.rs:1702-1717](../../src-tauri/src/repository/online.rs#L1702-L1717)).
Playback must call into the same mapping. Two copies of that table will drift.
- Track selection (audio/subtitle) already survives a stream re-negotiation
elsewhere in the player; a quality change must preserve it too.
### DR-122 — The playback path is ephemeral
Playback bytes are not persisted unless DR-124 says they are keepable. No partial
capture is ever retained across a quality change: on change, any in-flight capture
for that session is abandoned and its partial file deleted.
### DR-123 — The download path is independent
Downloading the whole file is a separate operation through the existing download
manager, at one canonical quality (default `original`, the direct static copy),
using `/Videos/{id}/stream.mp4` — progressive and Range-capable, which is what
the resumable download worker relies on. It is unaffected by what playback is
doing, and playback is unaffected by it.
Once complete it becomes an ordinary download row, so everything already built on
top of downloads — offline browsing, `refresh_queue_local_sources`, the Downloads
page — picks it up with no further work.
**Prerequisite:** downloaded *video* is currently never played locally.
`repository_get_video_stream_url` goes straight to the online repo and
[player/[id]/+page.svelte:316](../../src/routes/player/[id]/+page.svelte#L316)
calls it with no local check — so a completed video download is still streamed.
This must be fixed or the whole feature is invisible for video.
### DR-124 — Keep playback bytes only when they *are* the download
Capture is enabled only where the played bytes and the canonical download artifact
are the same thing — a **direct-play** session. Then:
| Path | Mechanism |
|---|---|
| Android / ExoPlayer | `SimpleCache` + `CacheDataSource`, keyed by item id **and** media-source id so renditions never collide. LRU evictor sharing the existing smart-cache budget — not a second budget over the same disk. |
| Linux audio / MPV | `stream-record`, set through the existing `set_property` plumbing. |
| Linux video (HLS transcode) | **Not captured.** Segments are not a file; assembling one needs ffmpeg, which is not a dependency and which CI is forbidden from installing at job time. The download path (DR-123) covers this case instead. |
Two abandonment rules, both of which must delete the partial rather than promote
it:
- **Seek during an mpv capture.** `stream-record` is documented as intended for
linear streams; seeking breaks the recording. Straight-through listening
captures, scrubbing does not.
- **Any quality change** (DR-122).
### DR-125 — Promotion, rendition, and invalidation
A capture is promoted to a `downloads` row (`status = 'completed'`) only when it
covers the whole resource. Partial captures stay cache and remain evictable.
A new `downloads.source_rendition` column records the negotiated
quality/container/codec of whatever produced the bytes; `NULL` for rows fetched by
the existing paths, which are always `original`. This is what makes an "upgrade to
original" action possible later, and what stops a 720p capture and a 4K download
being indistinguishable rows.
**Invalidation.** A quality change never touches a file that already exists —
neither a permanent download nor a completed temporary one. Both remain valid
copies of the rendition they hold, and deleting either would throw away bytes
already paid for.
What a quality change *does* invalidate is an **in-flight** capture or background
download of cached media: it is abandoned and restarted at the newly chosen
quality, because a capture spanning a rendition change is a splice of two
encodings rather than a playable file (DR-122).
So the rule is about *ongoing* work, not stored files. Nothing in this spec
deletes user data.
### Gating
Capture and background download obey the existing WiFi-only gate and storage
budget, and are off unless opted in. Enforcement is in Rust.
## Out of scope
- **Mid-playback switch onto a completing download.** Two independent paths make
it unnecessary; the download is used from the next boundary.
- **Backfilling the unplayed remainder of a capture.** Watch 40 minutes and you
have 40 minutes; completing it needs sparse-range bookkeeping and a resumable
tail fetch. The DR-123 download path already produces a complete file, which is
the reason this can wait.
- **Bundling ffmpeg** to make transcoded video capturable. Real option, large
packaging decision, its own proposal.
- **Routing Linux video playback through `stream.mp4`.** Regresses a documented,
hard-won fix.
## Acceptance criteria
- [ ] The player offers the qualities Rust reports, and changing one resumes at
the same position with audio/subtitle selection preserved.
- [ ] A quality change abandons any in-flight capture and leaves no partial file.
- [ ] A quality change never deletes a `downloads` row.
- [ ] A completed background download of a video is *played from disk* on the next
play (the DR-123 prerequisite).
- [ ] A direct-play session played start-to-finish leaves a complete local file
with no second fetch; replaying it fetches no media bytes.
- [ ] Seeking during an mpv capture abandons it; no truncated file is promoted.
- [ ] A transcoded Linux video session is never captured, and never partially
promoted.
- [ ] Promoted rows record their rendition; existing paths still record
`NULL`/`original`.
- [ ] Gates hold with the setting off *and* with the frontend never sending it.
- [ ] Eviction cannot delete bytes backing a promoted download row.
- [ ] `bun run check`, `bun run test`, `cargo fmt`, `cargo clippy`,
`bun run test:rust`, `bun run check:boundary` pass; `bindings.ts`
regenerated if Rust types changed.
## Testing
Rust, table-driven and pure where possible: quality→params resolution shared with
the download path; keepability (direct play vs transcode vs gate off); promotion
(complete → promoted, partial → not, seek-abandoned → not, quality-changed → not);
invalidation (evicts cache, never a download row); rendition round-trip.
Android: instrumented — a played direct-play item yields cache entries, and a
replay issues no media network request.
Frontend: the quality list renders from backend data with no item-type or
codec taxonomy in `src/`; the selector's remembered choice is a view preference.
## TRACES
| Piece | Tag |
|---|---|
| Quality selector + re-negotiation | `// TRACES: UR-070 \| DR-121` |
| Ephemeral playback / capture abandonment | `// TRACES: UR-070 \| DR-122` |
| Independent whole-file download + local video playback fix | `// TRACES: UR-071 \| DR-123, IR-032` |
| ExoPlayer cache / mpv stream-record / keepability | `// TRACES: UR-071 \| DR-124` |
| Promotion, `source_rendition`, invalidation | `// TRACES: UR-071 \| DR-125` |
## Notes for the implementer
- **A parallel Claude session is active in this repo.** `git diff` before
"repairing" anything you did not write.
- Do not duplicate the quality→transcode-parameter table. Call the existing one.
- Reuse the smart-cache storage budget; two budgets over one disk is how devices
fill up.
- The `downloads` FK to `items` is relaxed (migration 005) — exercise promotion
for an item that was never cached.
- Build DR-123's local-playback fix first. Without it nothing in this spec is
observable for video.
+161
View File
@@ -0,0 +1,161 @@
# Spec: Remove the broken `check-req-coverage.sh`
**Status:** Implemented
**Requirements:** supports DR-093 (see [traceability-gate-repair.md](traceability-gate-repair.md))
**UX spec:** n/a — developer tooling.
**Supersedes / revises:** n/a
## Summary
`scripts/check-req-coverage.sh` is broken, orphaned, and actively misleading: it
reports `Total Requirements: 1`, zeros in every category, and then prints
**"✨ All requirements have implementations!"**. Nothing references it — not CI,
not `package.json`, not the docs. This spec deletes it, with a narrowly-scoped
alternative (repair it) documented and rejected below.
## Motivation
Running it today produces:
```
Category Breakdown:
UR: 0 requirements
IR: 0 requirements
DR: 0 requirements
JA: 0 requirements
Summary:
Total Requirements: 1
✅ Fully Implemented: 0 (0%)
✨ All requirements have implementations!
```
Every number is wrong (the real totals are UR 61, IR 29, DR 89, JA 32), and the
concluding message is the *opposite* of a warning — a developer running this to
sanity-check coverage is told everything is fine.
This is worse than having no script. It is a trap, and it sits in `scripts/`
next to tools that do work, with nothing marking it as dead.
Verification that it is genuinely orphaned:
```console
$ grep -rn "check-req-coverage" . --include='*.yml' --include='*.json' \
--include='*.sh' --include='*.md' | grep -v node_modules
(no output)
```
## Layer assignment
Developer tooling only; no application logic and nothing crosses the IPC
boundary.
| Logic / responsibility | Layer | Why it belongs there |
|------------------------|-------|----------------------|
| Requirement-coverage reporting | Build tooling — `extract-traces.ts` | One tool should own coverage analysis. A second, divergent implementation is how the two answers ("1 requirement" vs "211") came to disagree unnoticed. |
## Design
**Delete `scripts/check-req-coverage.sh`.**
Coverage reporting is owned by [scripts/extract-traces.ts](../../scripts/extract-traces.ts),
which is correct, is what CI runs, and gains a first-class local coverage mode
in [traceability-gate-repair.md](traceability-gate-repair.md):
```bash
bun run traces:coverage # the supported way to check coverage locally
```
Then check the sibling scripts for the same rot. `scripts/` also contains
`check-test-coverage.sh` and `find-req-implementations.sh`, neither of which is
referenced from `package.json`. An unreferenced script is never run and so rots
silently — that is the actual failure mode being fixed here, and fixing only the
one instance found by audit leaves the others to be rediscovered later.
### Findings (investigation, 2026-07)
All three scripts turned out to share a **single root cause**, and all three are
deleted:
| Script | Defect |
|---|---|
| `check-req-coverage.sh` | Reads `README.md`, which has held **zero** requirement rows since they moved to `docs/requirements.md``total_reqs=1`, every category 0, "✨ All requirements have implementations!" Also greps `src-tauri/` unscoped. |
| `check-test-coverage.sh` | Greps `src-tauri/` unscoped — including **40 GB** of `target/` build artifacts. Hangs indefinitely; produces no output at all. |
| `find-req-implementations.sh` | Same unscoped `src-tauri/` grep. Same hang. |
So none of them were subtly wrong — two could never terminate, and the third
inverted its own conclusion.
They were nonetheless *salvageable*: scoping the greps to `src-tauri/src` and
repointing at `docs/requirements.md` would be a few lines, and the `@req:` /
`@req-test:` tags they read are still present in the tree (**146** and **76**
occurrences).
**Decision: delete all three anyway.** The tags are an undocumented parallel
convention — `@req:` appears in no doc, and CLAUDE.md describes only `TRACES:`.
Repairing the scripts would re-establish a second traceability system to keep in
sync with the first, which is the same two-sources-of-truth condition that let
"1 requirement" and "211 requirements" coexist unnoticed. `TRACES:` plus the
repaired coverage engine ([traceability-gate-repair.md](traceability-gate-repair.md))
already cover this ground.
The existing `@req:` / `@req-test:` comments are left in place: they are
harmless as prose, several encode genuinely useful test intent, and stripping
222 comments across the tree is a large diff with no functional gain. They are
simply no longer read by any tool.
### Alternative considered: repair rather than delete
Rejected. The script's output format duplicates what `traces:markdown` already
generates, it has no tests, no caller, and no documented purpose distinct from
`extract-traces.ts`. Repairing it recreates the two-sources-of-truth condition
that produced the contradiction. If a shell-based coverage check is ever wanted,
it should shell out to `traces:json` and `jq` rather than re-parse
`requirements.md` independently.
## Out of scope
- The CI workflow denominators — [traceability-gate-repair.md](traceability-gate-repair.md).
- Any change to `extract-traces.ts`'s output (that spec owns it).
- Auditing scripts that *are* referenced from `package.json` — they run
regularly and would fail visibly.
## Acceptance criteria
- [ ] `scripts/check-req-coverage.sh` no longer exists.
- [ ] `grep -rn "check-req-coverage" .` (excluding `node_modules` and this spec)
returns nothing — no dangling reference in CI, docs, or `package.json`.
- [ ] `scripts/check-test-coverage.sh` and `find-req-implementations.sh` have each
been run and either wired into `package.json` or deleted; the decision and
reason are recorded in `scripts/README.md`. **Outcome: all three deleted —
see Findings.**
- [ ] `scripts/README.md` documents `bun run traces:coverage` as the supported
way to check requirement coverage locally.
- [ ] `bun run test:all` passes (confirms nothing invoked the deleted script).
- [ ] `bun run check` and `bun run test` pass.
- [ ] `bun run check:boundary` passes.
## Testing
No unit tests — this is a deletion. Verification is the grep in the acceptance
criteria plus a green `bun run test:all`, which exercises the script paths that
actually run.
## TRACES
No new requirement. The deletion is covered by **DR-093**
([traceability-gate-repair.md](traceability-gate-repair.md)), which establishes
`extract-traces.ts` as the single owner of coverage reporting. Note the removal
in that DR's text when both land.
## Notes for the implementer
- A parallel Claude session may be active in this repo — `git diff` before
"repairing" unexpected changes (CLAUDE.md §Gotchas).
- Land this **after** or alongside [traceability-gate-repair.md](traceability-gate-repair.md),
so `bun run traces:coverage` exists before the broken script is removed and
developers are never left without a coverage command.
- Check `docs/traceability-ci.md` and `docs/traces-quick-ref.md` for prose
references to the deleted script; the grep above covers `.md`, but read the
surrounding sentence rather than deleting the line mechanically.
@@ -0,0 +1,254 @@
# Spec: Land the scoped-search boundary fix (implementation)
**Status:** Stage 1 Implemented — Stage 2 (result-side grouping) outstanding
**Requirements:** UR-049, UR-050 | DR-063, DR-066, DR-067 (existing — no new IDs)
**UX spec:** n/a — zero user-visible change is the point (see Acceptance criteria).
**Supersedes / revises:** implements [scoped-search-boundary.md](scoped-search-boundary.md),
which specified this fix but was never built. That spec remains the **design
authority**; this one is the delivery plan and status correction.
## Summary
[scoped-search-boundary.md](scoped-search-boundary.md) diagnosed a domain-taxonomy
leak, specified the fix in full detail, and became the justification for the
project's boundary rule in CLAUDE.md, the `check:boundary` tripwire, and the
spec-review checklist. **The fix was never implemented.** The leak it describes
is still live in `main`. This spec exists to close that gap and to correct the
record — the codebase currently enforces a rule against a violation it still
contains.
## Motivation
The mapping the rule forbids is present and in use:
```ts
// src/lib/utils/searchScope.ts:29-32
const SCOPE_ITEM_TYPES: Record<Exclude<SearchScope, "all">, string[]> = {
music: ["MusicAlbum", "MusicArtist", "Audio", "Playlist"],
movies: ["Movie"],
tv: ["Series", "Episode"],
};
```
This is not dead code. [library.ts:262](../../src/lib/stores/library.ts#L262)
calls `scopeItemTypes(scope)` and puts the result straight into
`options.includeItemTypes`. Meanwhile there is **no `SearchScope` anywhere in
`src-tauri/`**:
```console
$ grep -rn "SearchScope" src-tauri/src --include='*.rs'
(no output)
```
Three things make this the highest-value item found in the design-principles
audit:
1. **The rule's own founding incident is unremediated.** CLAUDE.md cites this
spec as "the incident this rule came from." A rule whose originating
violation is still shipping is not credible.
2. **The tripwire cannot see it.** `bun run check:boundary` passes — it greps for
a multi-type array literal *at the query site*, and this one is assigned to a
named const and dereferenced elsewhere. Broadening the tripwire is specified
separately in [boundary-tripwire-hardening.md](boundary-tripwire-hardening.md);
note that hardening it **without** landing this fix would turn `master` red.
3. **The spec's own acceptance criterion fails today.** "Adding a hypothetical
new type to a scope requires editing only Rust" — adding a type to the Music
scope right now requires editing `searchScope.ts`.
## Layer assignment
Unchanged from [scoped-search-boundary.md](scoped-search-boundary.md) §Design;
restated so this spec is reviewable on its own.
| Logic / responsibility | Layer | Why it belongs there |
|------------------------|-------|----------------------|
| Scope → Jellyfin item types (`music``MusicAlbum`, `MusicArtist`, `Audio`, `Playlist`) | **Rust** | Domain vocabulary. Changes if Jellyfin adds/renames an item type — the litmus test's "yes" case. This is the leak being fixed. |
| Result item → search group bucketing | **Rust** | Same taxonomy, result side. Classifying a `MediaItem` as a Song vs Album is Jellyfin vocabulary, not layout. |
| `All` sends no filter at all (≠ union of enumerated types) | **Rust** | A query-shaping rule with a correctness consequence (Person/folder results would be silently dropped). Belongs with the expansion it qualifies. |
| Group display order, labels, reordering, persistence | Frontend | Pure presentation — changes only if the UI is redesigned. Explicitly retained frontend-side. |
| `resolveSearchScope(pathname)` — route → initial scope | Frontend | Routing/navigation, no Jellyfin vocabulary. Stays exactly as-is. |
| Chip labels (`SCOPE_LABELS`), scope order (`SEARCH_SCOPES`) | Frontend | Display strings over an opaque enum. |
| `GROUP_SCOPE` (which group belongs to which scope) | **Delete** | Borderline taxonomy, made redundant: once Rust filters by scope, out-of-scope groups arrive empty and drop via the empty-omit rule. Borderline defaults to Rust; here it defaults to *gone*. |
The `SearchScope` and `SearchGroupId` **types** come to the frontend from
generated `bindings.ts`. Naming an opaque enum variant is not taxonomy; knowing
what item types it expands to is.
## Design
**Follow [scoped-search-boundary.md](scoped-search-boundary.md) §Design as
written** — `SearchScope` enum + `item_types()` in `repository/types.rs`,
`SearchOptions.scope`, `SearchGroupId`/`SearchGroup`/`GroupedSearchResult`,
scope-wins precedence, `All``None` → no filter. It is not restated here;
duplicating it would create two drifting copies of the same design.
This spec adds only the delivery sequencing that the original left implicit.
### Staging: land it in two reviewable pieces
The original bundles the query side and the result side into one change. That is
a large diff touching Rust types, `bindings.ts`, the store, and a component, with
the `search-event` dual-payload hazard in the middle. Split it:
**Stage 1 — query side (closes the leak).**
`SearchScope` enum, `SearchOptions.scope`, command resolves scope →
`include_item_types` in Rust, `library.ts` sends `{ scope }`, delete
`SCOPE_ITEM_TYPES` and `scopeItemTypes()`. Result grouping stays as it is.
After Stage 1 the actual boundary violation is gone and
[boundary-tripwire-hardening.md](boundary-tripwire-hardening.md) can land safely.
**Stage 2 — result side.** `SearchGroupId`/`SearchGroup`/`GroupedSearchResult`,
Rust bucketing, both payloads converted, `composeSearchGroups()` shrunk,
`GROUP_ITEM_TYPES`/`groupItemTypes()`/`GROUP_SCOPE` deleted.
Both stages are required for the original spec's acceptance criteria to pass;
Stage 1 alone leaves `GROUP_ITEM_TYPES` in the frontend. **Stage 1 is not a
stopping point** — it is a review boundary. Do not mark the parent spec
Implemented until Stage 2 lands.
### Stage 1 — delivered (July 2026)
- `SearchScope` enum + `item_types()` in [repository/types.rs](../../src-tauri/src/repository/types.rs);
`All``None` → no filter.
- `SearchOptions.scope` with `resolve_scope()`; scope wins over
`include_item_types`, which stays for the non-search `get_items` callers.
- `repository_search` resolves the scope **once, before** the cache/server split,
so both phases filter identically.
- `SCOPE_ITEM_TYPES` and `scopeItemTypes()` deleted; `searchScope.ts` now
re-exports `SearchScope` from the generated bindings instead of a hand-written
union.
- [library.ts](../../src/lib/stores/library.ts) sends `{ scope }`.
- 8 Rust tests (`search_scope_tests`); the frontend suite now asserts the
*opaque scope* is sent rather than an item-type list.
Verified: adding `"AudioBook"` to the Music scope changed **zero** files under
`src/` — the criterion that failed before this work.
**Stage 2 remains open**: `GROUP_ITEM_TYPES` / `groupItemTypes()` (result-side
bucketing, single-type-per-group) are still in `searchScope.ts`, and both search
payloads still carry a flat `MediaItem[]` rather than `GroupedSearchResult`.
### 🔴 The `search-event` dual payload (Stage 2)
The original flags this as "the single largest part of the change and the
easiest to half-do." Restating because it is the one thing that silently breaks:
search resolves **twice** — the command returns instant cache results, then the
merged cache+server union arrives via `search-event`. Both payloads must carry
`GroupedSearchResult`. Convert one and the UI flickers between shapes as server
results land.
Write the failing test for the *event* payload first — the command return is the
obvious half, the event is the half that gets forgotten.
### Note on `SearchOptions.scope` and specta
`SearchOptions` is already `#[serde(rename_all = "camelCase")]` with
`skip_serializing_if = "Option::is_none"`. Add `scope: Option<SearchScope>`
following that pattern so `All`/absent omits the key. Regenerate `bindings.ts`
`SearchOptions` there is currently
`{ limit?, includeItemTypes?, searchTerm? }` and must gain `scope?`. Never
hand-edit it.
## Out of scope
- Redesigning anything in [scoped-search-boundary.md](scoped-search-boundary.md).
If implementation shows the design wrong, revise **that** spec, don't fork it.
- Online/offline `include_item_types` **filtering** — already correct; only the
source of the type list moves.
- Ranking within or across groups (DR-090 territory).
- Chip UX, scope persistence, group-order persistence — unchanged.
- The two lesser type-set sites in `DownloadedBrowse.svelte` and
`GenericMediaListPage.svelte`, handled in
[boundary-tripwire-hardening.md](boundary-tripwire-hardening.md).
- Broadening the tripwire itself — same sibling spec.
## Acceptance criteria
Inherits every criterion from [scoped-search-boundary.md](scoped-search-boundary.md)
§Acceptance criteria. Additionally:
- [ ] `grep -rn "SearchScope" src-tauri/src --include='*.rs'` returns matches —
the enum exists in Rust (it does not today).
- [ ] `grep -n "SCOPE_ITEM_TYPES\|scopeItemTypes\|GROUP_ITEM_TYPES\|groupItemTypes" src/lib/utils/searchScope.ts`
returns nothing.
- [ ] `grep -rn "scopeItemTypes" src/` returns nothing — including the
`library.ts` import and call site.
- [ ] `SearchOptions` in `bindings.ts` includes `scope`; regenerated, not
hand-edited.
- [ ] **Behaviour is byte-identical for the user**: same scoping, same groups,
same order, same empty-group omission, offline included. This spec is a
pure refactor — any visible change is a defect.
- [ ] `All` scope sends no `includeItemTypes` (asserted in a Rust test, not by
inspection).
- [ ] Adding a type to the Music scope requires editing **only** Rust —
demonstrate by making the edit and confirming no `src/` file changes.
- [ ] `scoped-search-boundary.md` status flips to **Implemented**, and
`scoped-search.md`'s "frontend only, no Rust changes" framing gets a
banner pointing at the corrected design.
- [ ] `bun run check` and `bun run test` pass.
- [ ] `cargo fmt` clean, `cargo clippy` clean, `bun run test:rust` passes.
- [ ] `bun run check:boundary` passes.
- [ ] Changed code carries `// TRACES:` comments (IDs below).
## Testing
Follow [scoped-search-boundary.md](scoped-search-boundary.md) §Testing. Emphases:
**Rust** (`cargo test`):
- `SearchScope::item_types()` per scope; `All``None`.
- Scope resolution happens **before** the online/offline split, so both paths
get the same filter — a regression here is invisible until someone searches
offline.
- `scope` set + `include_item_types` set → scope wins (the documented
precedence; assert it rather than trusting the doc).
- Stage 2: mixed `Vec<MediaItem>` buckets correctly; unknown types dropped;
canonical group order; **the `search-event` payload is the grouped shape**.
**Frontend** (`bun run test`):
- `resolveSearchScope()` tests in `searchScope.test.ts` must pass **unchanged**
they cover the part that is not moving, and are the regression net proving the
refactor didn't disturb routing.
- `library.ts` sends `{ scope }` and never `includeItemTypes` for search.
- `composeSearchGroups()` over fixture `SearchGroup[]` with no `.type`
inspection in the implementation.
**Offline parity:** run a scoped search with the server unreachable and confirm
identical grouping. The offline repository path honours `include_item_types`
independently, and this is the case most likely to be missed.
## TRACES
No new requirement IDs — this implements existing ones. Retag as the code moves:
```rust
// src-tauri/src/repository/types.rs
/// TRACES: UR-049 | DR-063
pub enum SearchScope { … }
```
```typescript
// src/lib/utils/searchScope.ts — keep the file header; it retains
// resolveSearchScope + group-order presentation logic.
// TRACES: UR-049, UR-050 | DR-063, DR-066, DR-067
```
Update DR-063's text in `requirements.md` to state that scope expansion is owned
by Rust, so the requirement stops describing the leaked design. New Rust tests
take `@req-test: UT-089` onward (next free UT is **UT-089**).
## Notes for the implementer
- A parallel Claude session may be active in this repo — `git diff` before
"repairing" unexpected changes (CLAUDE.md §Gotchas).
- **Read [scoped-search-boundary.md](scoped-search-boundary.md) first.** This
spec is deliberately thin on design; that one is the authority.
- Sequence with the sibling specs: **Stage 1 here → then
[boundary-tripwire-hardening.md](boundary-tripwire-hardening.md)**. Hardening
the tripwire first turns `master` red on a known-unfixed violation.
- `git log --oneline -- docs/specs/scoped-search-boundary.md` is worth a look
before starting — understanding why the fix stalled may surface a constraint
the spec didn't record.
- The user-visible-change count for this spec is zero. If QA reports a
difference in search results, that is a bug in the refactor, not an
improvement.
+273
View File
@@ -0,0 +1,273 @@
# Spec: Move search scope taxonomy behind the Rust boundary
**Status:** Proposed
**Scope:** Rust + Frontend. **Revises a decision in
[scoped-search.md](scoped-search.md).**
**Requirements:** UR-049, UR-050 (existing) → new DRs for the boundary move
(allocate on implementation; suggested DR-063/DR-065/DR-067 revisions plus one
new DR for the grouped result shape — see [requirements.md](../requirements.md)).
**UX spec:** unchanged — [ux-flows.md §6](../ux-flows.md). This is a pure
architecture/boundary change with **no user-visible behaviour difference**.
## Why this spec exists
[scoped-search.md](scoped-search.md) shipped scoped search as "frontend only, no
Rust changes." That was the smallest wiring change, and it worked — but it left
**Jellyfin's item-type taxonomy encoded in the presentation layer**, which
violates the project's core boundary rule ("Svelte frontend — presentation
only"; all business logic in Rust — see [CLAUDE.md](../../CLAUDE.md) and
[architecture/02-svelte-frontend.md](../architecture/02-svelte-frontend.md)).
The offending knowledge lives in
[searchScope.ts](../../src/lib/utils/searchScope.ts):
```ts
const SCOPE_ITEM_TYPES = {
music: ["MusicAlbum", "MusicArtist", "Audio", "Playlist"],
movies: ["Movie"],
tv: ["Series", "Episode"],
};
const GROUP_ITEM_TYPES = {
songs: ["Audio"], albums: ["MusicAlbum"], artists: ["MusicArtist"],
movies: ["Movie"], tvShows: ["Series", "Episode"],
};
```
This is a **domain definition** — "what the category *Music* means in Jellyfin's
vocabulary" — expressed twice, in the wrong layer. The concrete failure it
creates: the day the backend starts returning a type the frontend never
enumerated (e.g. `MusicVideo`, or Jellyfin renaming a kind), search silently
drops it from both the query filter and the result buckets, and nothing in the
Rust layer — the actual authority on Jellyfin's API — can correct it. Two
sources of truth that will drift.
**This must be fixed while the feature is uncommitted**, before the leak ships
baked into a released wire contract.
### What is *not* a leak (leave it alone)
Single concrete-type list pages are **not** business logic and stay as-is:
- `music.ts``["MusicAlbum"]` / `["Playlist"]`, `movies.ts``["Movie"]`,
`tv.ts``["Series"]`
- `GenericMediaListPage.svelte``[config.itemType]`
- `ArtistDetailView`, `RelatedItemsSection`, `AddToPlaylistModal`,
`PersonDetailView`
"This page shows albums" is a legitimate presentation choice expressed through a
generic `getItems(parentId, { includeItemTypes })` API. Only the **search scope
taxonomy** (a semantic category → many types, defined once and reused) crosses
the line. Do **not** invent a backend enum for every list page — that is
over-abstraction, not cleaner separation.
## The boundary rule after this change
> The frontend never names a Jellyfin item type **in connection with search.**
> It sends an opaque `scope`, and receives results already sorted into labelled
> groups. The frontend owns only **group order** (presentation) and
> **rendering**.
## Design
### Rust owns scope → item-types (query side)
Add an opaque enum that crosses IPC, and move the expansion table into Rust:
```rust
// repository/types.rs
#[derive(specta::Type, Serialize, Deserialize, Clone, Copy, Debug)]
#[serde(rename_all = "camelCase")]
pub enum SearchScope { All, Music, Movies, Tv }
impl SearchScope {
/// The Jellyfin item types this scope requests, or None for `All`
/// (which must send NO includeItemTypes — see below).
pub fn item_types(self) -> Option<Vec<String>> {
match self {
SearchScope::All => None,
SearchScope::Music => Some(vec!["MusicAlbum", "MusicArtist", "Audio", "Playlist"]
.into_iter().map(String::from).collect()),
SearchScope::Movies => Some(vec!["Movie".into()]),
SearchScope::Tv => Some(vec!["Series".into(), "Episode".into()]),
}
}
}
```
`SearchOptions` gains `scope` and the search command resolves it into the
existing `include_item_types` filter **inside Rust**, before dispatching to the
online/offline paths (which already honour `include_item_types` — do not touch
their filtering, per [scoped-search.md](scoped-search.md) §Background 2).
```rust
pub struct SearchOptions {
pub limit: Option<usize>,
pub search_term: Option<String>,
pub scope: Option<SearchScope>, // NEW
// include_item_types stays for the single-type list-page callers,
// but the SEARCH command derives it from `scope` when scope is set.
}
```
**Precedence:** if `scope` is set it wins; `include_item_types` remains for the
non-search `getItems` callers. Document this so a future reader does not send
both.
**`All` sends no filter.** Preserve the existing invariant: `All` must omit
`includeItemTypes` entirely, not send the union of every enumerated type — types
nobody listed (Person, folders) would otherwise be filtered out. This is why
`item_types()` returns `Option`, and the command must skip the filter on `None`.
### Rust owns result bucketing (result side)
Results arrive **pre-grouped**. Rust classifies each returned `MediaItem` into a
group by its type — the `GROUP_ITEM_TYPES` knowledge, moved to the authority:
```rust
#[derive(specta::Type, Serialize, Deserialize, Clone, Copy, Debug)]
#[serde(rename_all = "camelCase")]
pub enum SearchGroupId { Songs, Albums, Artists, Movies, TvShows }
#[derive(specta::Type, Serialize, Deserialize, Clone, Debug)]
#[serde(rename_all = "camelCase")]
pub struct SearchGroup { pub id: SearchGroupId, pub items: Vec<MediaItem> }
#[derive(specta::Type, Serialize, Deserialize, Clone, Debug)]
#[serde(rename_all = "camelCase")]
pub struct GroupedSearchResult { pub groups: Vec<SearchGroup> }
```
Rust emits **every** non-empty group it can classify, in a stable canonical
order. It does **not** apply the user's ordering or drop out-of-scope groups —
those are presentation and stay frontend-side (see below). Items whose type maps
to no group are omitted from grouped output (same as today's frontend filter).
### 🔴 The `search-event` wrinkle — both payloads must change
Search returns results **twice**: the command resolves with instant local-cache
results, then the merged cache+server union arrives later via the `search-event`
listener (see [library.ts](../../src/lib/stores/library.ts) `search()` and
[architecture/03-data-flow.md](../architecture/03-data-flow.md)). **Both** the
command return value **and** the `search-event` payload must carry
`GroupedSearchResult`. If only one is converted, the instant results group and
the merged ones do not (or vice versa), and the UI flickers between shapes. This
is the single largest part of the change and the easiest to half-do.
### What the frontend keeps (all pure presentation)
[searchScope.ts](../../src/lib/utils/searchScope.ts) **retains**:
- `SearchScope` type — now sourced from the generated bindings, mirroring the
Rust enum (delete the hand-written union).
- `SCOPE_LABELS`, `SEARCH_SCOPES` (chip labels / order).
- `resolveSearchScope(pathname)` — route → initial scope. Pure, DOM-free,
unit-tested. **Stays exactly as-is.**
- `SearchGroupId` (from bindings), `GROUP_LABELS`.
- `normalizeGroupOrder`, `groupsForScope`, `moveGroup`, `reorderGroups`,
`DEFAULT_GROUP_ORDER` — group-order persistence and reordering, all
presentation.
[searchScope.ts](../../src/lib/utils/searchScope.ts) **loses**:
- `SCOPE_ITEM_TYPES`, `GROUP_ITEM_TYPES` (moved to Rust).
- `scopeItemTypes()`, `groupItemTypes()`.
- The `.type`-inspecting body of `composeSearchGroups()`.
`composeSearchGroups()` shrinks to a **presentation composition over Rust's
groups** — no `.type` inspection anywhere:
```ts
// Take Rust's pre-bucketed groups; drop out-of-scope, sort by saved order,
// attach labels, omit empties. No Jellyfin type vocabulary.
composeSearchGroups(groups: SearchGroup[], scope, order): DisplayGroup[]
```
`GROUP_SCOPE` (which group belongs to which scope) is a borderline case: it is
"is Songs part of the Music scope," arguably taxonomy. But because Rust already
filtered the query by scope, out-of-scope groups will simply be **empty** and
drop out via the empty-omit rule — so the frontend does not strictly need
`GROUP_SCOPE` for correctness once Rust filters. **Recommendation:** delete
`GROUP_SCOPE` and rely on empty-omission; if kept for belt-and-suspenders, treat
it as a display hint, not authority.
### Frontend call-site changes
- [library.ts](../../src/lib/stores/library.ts) `search(query, scope)` sends
`{ scope }` in `SearchOptions` instead of computing `includeItemTypes`.
Everything else (requestId bump, stale guard, 10s timeout, empty-query clear,
event merge) is preserved.
- [SearchResults.svelte](../../src/lib/components/search/SearchResults.svelte)
consumes `SearchGroup[]` from the store instead of a flat `MediaItem[]` +
client-side `composeSearchGroups(results, …)`. The store now holds grouped
results.
- [search/+page.svelte](../../src/routes/search/+page.svelte) is unchanged in
behaviour; only the type it passes to `SearchResults` changes.
## Out of scope
- Any change to online/offline `include_item_types` **filtering** — it already
works; only the *source* of the type list moves.
- Single concrete-type list pages (see "What is not a leak").
- Ranking within or across groups.
- The UX / chip behaviour / persistence mechanism — all unchanged from
[scoped-search.md](scoped-search.md).
## Acceptance criteria
- [ ] No Jellyfin item-type string literal (`"MusicAlbum"`, `"Audio"`, …) remains
in `searchScope.ts` or any search call path. Verify:
`grep -rn '"MusicAlbum"\|"MusicArtist"\|"Audio"\|"Series"\|"Episode"\|"Movie"\|"Playlist"' src/lib/utils/searchScope.ts src/lib/stores/library.ts` returns nothing.
- [ ] `SearchScope` and `SearchGroupId` in the frontend come from the generated
`bindings.ts`, not hand-written unions.
- [ ] Search behaviour is **identical** to today for the user: same scoping, same
groups, same order, same empty/out-of-scope omission, offline included.
- [ ] Both the command return and the `search-event` payload carry the grouped
shape; no shape flicker between instant and merged results.
- [ ] `All` scope still sends no `includeItemTypes` (assert in a Rust test).
- [ ] Adding a hypothetical new type to a scope requires editing **only** Rust.
- [ ] `cargo fmt` clean, `cargo clippy` clean, `bun run test:rust` passes.
- [ ] `bun run check` and `bun run test` pass; `bindings.ts` regenerated and
committed.
## Testing
**Rust** (`src-tauri`, `cargo test`):
- `SearchScope::item_types()`: each scope's list, and `All``None`.
- Search command: `scope: Music` resolves to the four music types on the query;
`scope: All` sends no `include_item_types`.
- Bucketing: a mixed `Vec<MediaItem>` classifies into the right `SearchGroupId`s;
unknown types are dropped; groups come out in canonical order.
- The `search-event` payload is the grouped shape (guard the wrinkle).
**Frontend** (vitest, `src/lib/**/*.test.ts`) — update existing tests:
- `librarySearchScope.test.ts` currently asserts `includeItemTypes` on the
outgoing options — **rewrite** to assert `scope` is sent instead.
- `searchScope.test.ts` — drop `scopeItemTypes`/`groupItemTypes` cases; keep and
extend `resolveSearchScope`, order normalize/move/reorder, and the new
compose-over-groups (order + empty-omit, no type inspection).
- `searchGroupOrder.test.ts` — unchanged.
## TRACES
Per [CLAUDE.md](../../CLAUDE.md), tag requirement-implementing code:
- `SearchScope` enum + `item_types()` + search command scope resolution:
`UR-049 | DR-063` (revised — resolution now Rust-side).
- Grouped result shape + bucketing: `UR-050 | DR-067` (revised) + a new DR for
the wire shape.
- `library.ts` store change: `UR-049 | DR-065` (revised — sends scope not types).
## Notes for the implementer
- This spec **revises** [scoped-search.md](scoped-search.md) §Background 2 and
§Design "Scope model / Threading scope through the store," which asserted no
Rust change. Update that spec's status to note the boundary was moved, or add a
banner pointing here — do not leave the two specs contradicting silently.
- The IPC camelCase rule applies to the new enums and structs
([CLAUDE.md](../../CLAUDE.md)): `#[serde(rename_all = "camelCase")]` on structs;
the tagged-enum tag convention if any enum becomes tagged. Add/extend a
`tauriIntegration`-style test if a new command is introduced.
- Regenerate `bindings.ts` via the tauri-specta build step after changing Rust
types; do not hand-edit it.
- **Another Claude session may be active in these same files** (per project
memory). `git diff` before repairing anything unexpected; these search files
are exactly the ones a parallel session touched.
+208
View File
@@ -0,0 +1,208 @@
# Spec: Context-scoped search with filter chips and configurable group order
> ⚠️ **Superseded in part by
> [scoped-search-boundary.md](scoped-search-boundary.md).** The "frontend only,
> no Rust changes" decision below (§Background 2, §Design "Scope model" and
> "Threading scope through the store") left Jellyfin's item-type taxonomy in the
> presentation layer, which violates the backend/frontend boundary. The taxonomy
> is being moved into Rust. The **user-facing behaviour and UX in this spec are
> unchanged**; only where the scope→item-type mapping and result bucketing live
> changes. Read the boundary spec before touching search code.
>
> **Progress:** the scope→item-type mapping now lives in Rust
> (`SearchScope::item_types()`); the frontend sends an opaque scope. Result-side
> bucketing (`GROUP_ITEM_TYPES`) is still frontend-side — see
> [scoped-search-boundary-implementation.md](scoped-search-boundary-implementation.md)
> §Stage 2.
**Status:** Implemented (boundary revision: query side done, result side pending)
**Scope:** Frontend only. No Rust changes required. *(Revised — see banner.)*
**Requirements:** UR-049 → DR-063, DR-064, DR-065; UR-050 → DR-066, DR-067
(see [requirements.md](../requirements.md)).
**UX spec:** [ux-flows.md §6](../ux-flows.md) — §6.1 scope, §6.2 layout,
§6.3 group order, §6.4 current deviations.
## Summary
Two related changes to search:
1. **Scope** — a search started inside a library searches *that* library.
Started from Home, `/library`, or the search tab, it searches everything.
The active scope shows as a chip row under the search bar, preselected from
context and freely changeable without retyping.
2. **Group order** — the order result groups appear in (Songs, Albums, Artists,
Movies, TV Shows) becomes a drag-and-drop setting instead of being hardcoded.
## Motivation
Searching "office" while browsing TV currently returns music albums, because
both search entry points call the same unscoped query. The user has already
told us what they're looking at; ignoring that makes search feel indiscriminate
and pushes the relevant result below unrelated media.
## Background: what already exists
Verified in code — **most of the plumbing is already there.** This is
substantially a wiring task, not new infrastructure.
1. **`SearchOptions` already carries the filter.**
[bindings.ts](../../src/lib/api/bindings.ts) —
`SearchOptions = { limit?, includeItemTypes?, searchTerm? }`.
2. **Rust already honours `include_item_types` on both paths** — online
([online.rs](../../src-tauri/src/repository/online.rs), in the `get_items`
options mapping) and offline
([offline.rs](../../src-tauri/src/repository/offline.rs), which builds a SQL
type filter from it). **Do not add Rust code for filtering.**
3. **Per-page list search already does this correctly.**
[GenericMediaListPage.svelte](../../src/lib/components/library/GenericMediaListPage.svelte)
passes `includeItemTypes: [config.itemType]` to `repo.search(...)`. Use it as
the reference for the call shape, including the `requestId` handling.
4. **The gap is exactly one function.**
[library.ts](../../src/lib/stores/library.ts) — `search(query)` takes only a
query and calls `repo.search(query, { limit: 10000 }, requestId)`, dropping
any scope. Both callers
([search/+page.svelte](../../src/routes/search/+page.svelte) and
[library/+layout.svelte](../../src/routes/library/+layout.svelte)) go through
it.
5. **Group order is hardcoded in markup.**
[SearchResults.svelte](../../src/lib/components/search/SearchResults.svelte)
categorizes into `music{tracks,albums,artists} / movies / tvShows` and
renders three fixed sections in source order.
6. **Frontend preferences persist via `localStorage`**, per the existing
`viewMode` precedent in [library.ts](../../src/lib/stores/library.ts)
(`jellytau-view-mode`). Follow that pattern — **do not** add a Rust settings
command for this.
## Design
### Scope model
One `SearchScope` type, defined once and shared:
| Scope | `includeItemTypes` | Chip label |
|-------|--------------------|------------|
| `all` | *unset* | All |
| `music` | `MusicAlbum`, `MusicArtist`, `Audio`, `Playlist` | Music |
| `movies` | `Movie` | Movies |
| `tv` | `Series`, `Episode` | TV |
`all` must send **no** `includeItemTypes` key rather than a list of every type —
the two are not equivalent for item types not enumerated here (Person, folders).
### Route → scope resolution (DR-063)
A pure function, unit-testable without a DOM:
```ts
resolveSearchScope(pathname: string): SearchScope
```
- `/library/music*``music`
- `/library/movies*``movies`
- `/library/tv*``tv`
- `/`, `/library`, `/search`, anything else → `all`
Note `/library/shows/genres` exists as a route; treat `shows` as `tv`. Check the
current route list before finalising — do not assume this table is exhaustive.
### Scope is a starting point, not a lock (DR-064)
The resolved scope sets the **initial** chip only. Once the user taps a chip,
their choice governs until they leave the search surface. Concretely: derive the
initial value from the route, hold it in component state, and do not re-derive
it on every navigation — otherwise a user who widens to All snaps back to TV.
Changing a chip re-runs the current query at the new scope. Changing the query
keeps the current scope.
### Threading scope through the store (DR-065)
Extend the store's search signature to accept an optional scope and pass
`includeItemTypes` down to `repo.search`. Preserve the existing behaviour
exactly: the `requestId` bump, the stale-response guard, the `search-event`
listener merge, the 10s timeout, and the empty-query clear path. This is an
additive parameter — no caller should break.
### Group order (DR-066, DR-067)
Persist an ordered array of group ids:
```
["songs", "albums", "artists", "movies", "tvShows"] // shipped default
```
Rendering composes scope and order as **two independent axes**, in this order:
1. drop groups outside the active scope,
2. sort the remainder by the user's saved order,
3. omit groups that came back empty.
Scope never rewrites the saved order — narrowing to Music and back to All must
restore the user's full arrangement. See [ux-flows.md §6.3](../ux-flows.md) for
the worked example.
Settings gets a reorderable list. **Dragging alone is not sufficient**: provide
keyboard-operable move up/down controls with proper labels, or the setting is
unusable with a screen reader and on any pointerless input.
Unknown or missing ids in the stored array must not crash rendering — treat the
stored order as a hint, append any group it doesn't mention, and ignore ids that
no longer exist. A user upgrading from a build with fewer groups must not lose
the new ones.
## Out of scope
- Ranking *within* a group. Order is presentation-only.
- Server-side search ranking or the Jellyfin query itself.
- Scope chips on the per-page list search in `GenericMediaListPage` — that page
is already implicitly scoped by its own `itemType`.
- Any Rust change.
## Acceptance criteria
- [ ] Searching from inside Music returns no movies or TV; from inside TV, no music.
- [ ] Searching from Home, `/library`, or the search tab returns all types.
- [ ] The chip row renders under the search bar on both the search page and the
in-library header search, with the context-derived chip preselected.
- [ ] Tapping a chip re-runs the search with the query preserved; editing the
query preserves the selected chip.
- [ ] Tapping "All" from a context-scoped search widens results without retyping.
- [ ] Result groups render in the user's configured order, with out-of-scope and
empty groups omitted and relative order preserved.
- [ ] Group order is reorderable by drag **and** by keyboard, persists across
restarts, and ships with the documented default.
- [ ] Offline search respects scope (the offline path already filters — verify,
don't reimplement).
- [ ] `bun run check` and `bun run test` pass.
## Testing
Follow the existing frontend test conventions (vitest, `src/lib/**/*.test.ts`).
- `resolveSearchScope` — pure unit tests over the route table, including the
`/library/shows/genres` case and unknown routes falling back to `all`.
- Scope → `includeItemTypes` mapping, asserting `all` omits the key entirely.
- The compose step: scope filter + user order + empty-group omission, including
the "narrow then widen restores order" case and a stored order containing an
unknown id.
- Store-level: scoped search forwards `includeItemTypes` to the repository, and
the existing stale-`requestId` guard still discards superseded responses.
New requirement-implementing code needs `TRACES:` comments — see
[CLAUDE.md](../../CLAUDE.md). Suggested tags: the scope resolver and chip row
`UR-049 | DR-063, DR-064`, the store change `UR-049 | DR-065`, the settings list
and ordered rendering `UR-050 | DR-066, DR-067`.
## Notes for the implementer
- Read [ux-flows.md §6](../ux-flows.md) first — it is the behavioural spec; this
document is the implementation plan.
- The IPC camelCase rule applies to anything new that crosses the boundary
([CLAUDE.md](../../CLAUDE.md)) — though this change should not add commands.
- Another session may be active in this repo. Check `git diff` before
"repairing" unexpected changes.
@@ -0,0 +1,239 @@
# Spec: series navigation lands on the current episode
**Status:** Accepted
**Requirements:** UR-062 → DR-101, DR-102, DR-103, DR-104, DR-107; UR-063 → DR-105; UR-064 → DR-106
**UX spec:** [ux-flows.md §5B.1](../ux-flows.md), [§5B.2](../ux-flows.md), [§5B.4](../ux-flows.md), [§5B.5](../ux-flows.md)
## Summary
Opening a TV series lands you where you actually are in it: the seasons render
as collapsible sections with **only the current season expanded**, the current
episode highlighted and scrolled into view, and the hero button opens that
episode's focus view (labelled `Resume S2E4` / `Play S1E1`) instead of the first
season. A season stops being a destination of its own — every route that used to
land on `/library/<seasonId>` now lands on the series with that season in view,
so the full cross-season episode list is always reachable in one place. Watch
history can be erased per series and per season. Separately, each video library
collapses from three routes (landing, all-titles, genres) to one route with
in-page tabs.
## Motivation
Two problems, reported together.
**1. Series navigation dead-ends at season 1.** The series detail page's Play
button resolved its target as `$libraryItems[0]` — the first *season* child,
ordered by `SortName` — and navigated to `/player/<seasonId>`. The player route
classifies `season` as a container kind and bounces it back to
`/library/<seasonId>`. So Play on a series played nothing; it navigated you to
the season-1 page. Opening a series without pressing Play rendered every season
stacked but scrolled to the top, so a viewer 4 seasons deep had to scroll past
everything they had already watched.
The backend has been able to answer "where is this viewer in this show" the
whole time: `repository_get_next_up_episodes(handle, series_id, limit)` is wired
end-to-end to `/Shows/NextUp?SeriesId=`. **Both frontend call sites pass
`undefined` for `series_id`** — the per-series capability existed and was never
used.
**2. Seasons are an accidental page.** There is no season route. `/library/
<seasonId>` falls through the detail page's `kind` chain into the generic
"Contents" poster grid, which contradicts ux-flows §5A.2 (episodes in a season
must render as a row list). Worse, clicking an episode from that grid opens a
*bare* Episode page, which §5B.1 explicitly forbids. Four call sites fed it: the
episode breadcrumb, `handleItemClick case "season"`, the TV landing page, and
the broken Play button above.
**3. Too many video library routes.** Seven routes serve two media types, and the
naming does not even agree with itself: `/library/tv` + `/library/tv/shows` +
`/library/shows/genres` versus `/library/movies` + `/library/movies/all` +
`/library/movies/genres`. The genre routes do not share a prefix, which
`searchScope.ts:45` carries an apologetic comment about. The two "all" pages are
27-line config wrappers over the same `GenericMediaListPage`.
## Layer assignment
| Logic / responsibility | Layer | Why it belongs there |
|------------------------|-------|----------------------|
| Which episode is "current" for a series (resume → next-up → first unwatched → first) | **Rust** | Domain policy over Jellyfin user-data semantics. It changes if Jellyfin changes what `UserData.is_played` means, if Next Up's rules change, or if we decide a 98%-watched episode counts as finished. It does not change if the UI is redesigned. |
| Gathering a series' episodes across all seasons in broadcast order | **Rust** | Jellyfin's shape (episodes hang off season folders, except when a series is flat and they hang off the series) is provider vocabulary. The frontend already reimplemented this fan-out *and* its flat-series fallback; that is domain knowledge that leaked. |
| Ordering rule for "series order" (season index, then episode index, specials last) | **Rust** | Season 0 = specials is a Jellyfin convention, not a layout choice. |
| Scrolling the current episode into view; the highlight ring and `Up next` badge | Frontend | Pure presentation. Changes only if the page is redesigned. |
| Which seasons start expanded | Frontend | Consumes the backend's answer (`currentEpisode`) to decide layout. The *decision* about where the viewer is stays in Rust; only "and therefore this section opens" is here. |
| What "erase watch history" means (played flag + resume position, recursive over a container) | **Rust** | Jellyfin user-data semantics. Changes if the server's mark-unplayed behaviour changes; unaffected by any UI redesign. |
| Refusing to clear history while offline | **Rust** | A data-integrity rule, not a disabled button: history cleared only locally would be undone by the next sync. The UI disabling the button is a courtesy on top. |
| Play button *label* (`Resume S2E4` vs `Play S1E1`) | Frontend | Rendering a decision the backend already made (the returned episode plus its resume position). |
| Which route Play navigates to | Frontend | Navigation is presentation. |
| Redirecting `/library/<seasonId>` to the series anchor | Frontend | Route topology. |
| Episode-strip window size (3 before / 6 after) | Frontend | A layout constant; §5B.2 owns it. |
| Library page tabs and the `?view=` param | Frontend | View preference and route topology. |
Borderline row — **the strip's cross-season *ordering*** is Rust (it is series
order, above), but the *window* taken from that ordered list is frontend. The
tie-breaker: the list handed to the frontend is already correct and complete;
choosing how much of it fits on screen is layout.
## Design
### Rust: the current-episode policy
Two new pieces, split so the policy is unit-testable without a repository.
**Pure policy** — `src-tauri/src/repository/series_progress.rs`:
```rust
/// Series order: season index asc, then episode index asc. Specials (season 0)
/// sort after every numbered season rather than before season 1.
pub fn sort_series_order(episodes: &mut [MediaItem]);
/// The episode a viewer should land on, given everything already fetched.
/// Order: in-progress episode → Next Up → first unwatched → first episode.
pub fn pick_current_episode(
episodes: &[MediaItem], // series order
next_up: &[MediaItem],
resume: &[MediaItem],
) -> Option<MediaItem>;
```
Why that order:
- **In-progress wins** because a partially-watched episode is literally where
the viewer stopped; Next Up would skip past it. Ties break toward the earliest
in series order, so a viewer who dipped into a later episode still resumes the
one they are actually working through.
- **Next Up second** because it is the server's own answer, and it accounts for
history we do not cache.
- **First unwatched third** — the offline repository returns an empty vec for
Next Up (`offline.rs:1247`), so without this fallback the whole feature would
be online-only. This is the offline path, not dead code.
- **First episode last** so a never-watched series lands on S1E1 rather than
nothing.
A `resume`/`next_up` entry that is not among `episodes` is still honoured — it
comes from the same server and may carry an id the season fan-out missed — but
it must belong to this series.
**Fetch + command** — `src-tauri/src/commands/repository.rs`:
```rust
#[tauri::command]
#[specta::specta]
pub async fn repository_get_series_episodes(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
series_id: String,
) -> Result<Vec<MediaItem>, String>
#[tauri::command]
#[specta::specta]
pub async fn repository_get_series_current_episode(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
series_id: String,
) -> Result<Option<MediaItem>, String>
```
Frontend params are camelCase (`{ handle, seriesId }`) per the Tauri v2 rule.
`repository_get_series_episodes` performs the fan-out the frontend used to do:
`get_items(series_id)` → seasons → `get_items(season_id)` per season, plus the
flat-series fallback (a series whose children are episodes, not seasons), then
`sort_series_order`. `repository_get_series_current_episode` calls it, adds
`get_next_up_episodes(Some(series_id), Some(1))` and
`get_resume_items(Some(series_id), Some(10))`, and applies `pick_current_episode`.
Both tolerate a failing Next Up (offline) by treating it as empty rather than
failing the whole call.
### Frontend: series page
- `loadItem()` calls `repositoryGetSeriesEpisodes` once instead of fanning out
over seasons itself, and `repositoryGetSeriesCurrentEpisode` for the anchor.
Season *headers* still come from `get_items(seriesId)`; the page groups the
returned episodes under them by `parentIndexNumber`.
- No `?episode=` param → series view, `SeasonSection` receives
`currentEpisodeId`, `EpisodeRow` renders the highlight and scrolls itself into
view (`scrollIntoView({ block: "center" })`, the existing `focused` mechanism,
now distinguishing *focused* from *current*).
- Seasons are collapsible and **only the current season is expanded**
(`initialExpandedSeasons`). Without this a ten-season show renders every
episode of every season at once and buries the one the viewer came for. A
collapsed season still shows its episode count and watched count, so progress
is legible without expanding. Toggle state is local and not persisted — it is
a reading position, not a preference.
- Hero Play → `goto(/library/<seriesId>?episode=<currentId>)`, i.e. the Episode
Focus View, where an explicit Play/Resume starts playback. This follows
ux-flows §5B.5's "tap opens, never commits" rule: Play on a *container* is
navigation; Play on a *leaf* (the focus view, a movie) commits.
- Clicking an episode in a season section → `?episode=` swap, not
`/player/<id>`. §5B.1.
### Frontend: seasons are not a destination
`/library/<seasonId>` resolves the season's `seriesId` and redirects to
`/library/<seriesId>#season-<indexNumber>`; `SeasonSection` renders that anchor
id. A season with no `seriesId` (deep link into a stale cache) keeps the old
generic rendering as a fallback so the user is never stranded. Inbound links
updated: episode breadcrumb, `handleItemClick case "season"`, the TV landing
page's `case "Season"`, and `DownloadedBrowse`.
### Erasing watch history
```rust
#[tauri::command]
#[specta::specta]
pub async fn repository_clear_watch_history(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
item_id: String,
) -> Result<(), String>
```
`OnlineRepository` maps it to `DELETE /Users/{userId}/PlayedItems/{itemId}`
Jellyfin's mark-unplayed, which clears the played flag *and* zeroes the resume
position, and which the server applies recursively to a folder. One call
therefore handles a whole series or a single season; no per-episode fan-out.
`OfflineRepository` returns `RepoError::Offline` rather than clearing locally,
because divergent local history is undone by the next sync.
`ClearHistoryButton` is shared by the series hero (`scope="series"`) and each
`SeasonSection` header (`scope="season"`). It confirms first — there is no undo —
disables itself while the server is unreachable, and reloads the page on success
so the recomputed current episode is what the viewer sees. Clearing a whole
series therefore returns it to S1E1, which is the same path a never-watched
series takes through `pick_current_episode`.
### Frontend: one route per video library
`/library/tv` and `/library/movies` each gain `?view=browse|all|genres` tabs,
rendering the existing `GenericMediaListPage` / `GenericGenreBrowser` components
inline. `?view=` is omitted for `browse` (the default) to keep URLs clean —
the same convention `searchRouteUrl` uses for the `all` scope.
The four legacy routes become redirect-only `+page.ts` loads:
| Legacy | Redirects to |
|--------|--------------|
| `/library/tv/shows` | `/library/tv?view=all` |
| `/library/shows/genres` | `/library/tv?view=genres` |
| `/library/movies/all` | `/library/movies?view=all` |
| `/library/movies/genres` | `/library/movies?view=genres` |
They are kept (rather than deleted) because `GenreTags` builds links to them and
users may have them in history. `resolveSearchScope` keeps its `/library/shows`
branch for the same reason.
The "Browse" tile grid at the bottom of both landing pages is removed — the tabs
replace it, and the tiles were a second navigation affordance to the same two
destinations the carousels' "Show all" links already reach.
## Out of scope
- **Cross-season autoplay.** `player/mod.rs:fetch_next_episode_for_item` is
still season-bounded, so autoplay stops at a season boundary. Fixing it should
reuse `repository_get_series_episodes`, but it touches the playback state
machine and the Android JNI advance path (see the `AutoplayDecision` deadlock
note in CLAUDE.md) and belongs in its own change.
- **Music library routes.** `/library/music/*` has five sub-routes with the same
shape; the same consolidation applies but is not done here.
- **Marking a series' progress** (mark-watched / mark-unwatched from the series
page).
+146
View File
@@ -0,0 +1,146 @@
# Spec: streaming bitrate cap
**Status:** Implemented
**Requirements:** UR-074 → DR-162 (partially serves UR-070)
**UX spec:** n/a — the controls reuse existing patterns (Settings → Video Playback, and the player's track menus).
## Summary
The viewer picks a bandwidth ceiling for video — from `Original` (no client
limit) down to 720 kbps — and every video the app opens is fetched within it,
live TV included. The choice is made once in Settings and persists across
restarts; a single video can be moved to another ceiling from the player, which
re-opens the stream and resumes where it was without changing the saved default.
## Motivation
Every video URL the app built carried a fixed allowance —
`MaxStreamingBitrate=20000000`, `VideoBitrate=18000000` — the `PlaybackInfo`
negotiation asked for 20 Mbps, and the device profile advertised
`999999999`, which invites the server to direct-play a source of any size. On a
metered or slow connection there was no lever at all short of not watching.
The related UR-070 asks for something adjacent but different: a list of the
renditions *the server can produce for this item*. That needs per-item
`MediaSources` negotiation and is still proposed. What was missing first is
cruder and more valuable: a device-wide budget that holds regardless of what is
playing.
## Layer assignment
| Logic / responsibility | Layer | Why it belongs there |
|------------------------|-------|----------------------|
| What a quality step *is* — total ceiling, audio share, resolution cap | Rust | Jellyfin encoding vocabulary. It changes if Jellyfin's transcoder or parameter binding changes, not if the UI is redesigned. Exactly the shape of `EqPreset::gains()`. |
| Splitting the ceiling between video and audio | Rust | A domain rule about what the server is being asked to produce; getting it wrong overshoots the user's cap. |
| Choosing `MaxHeight` for a bitrate | Rust | An encoding judgement (how many pixels a budget can carry), not a display preference. |
| Where the cap is applied (URL builders, `PlaybackInfo`, live TV, audio handoff) | Rust | All four are backend concerns, and the frontend must not have to know that a cap has more than one enforcement point. |
| Whether a mid-playback change needs a stream reload, and performing it | Rust | Same decision the audio-track switch already delegates: the backend knows the playback mode and owns the queue. |
| Persisting the default | Rust | Application state in `app_settings`, alongside every other durable setting. |
| Rendering the picker, menu placement, which control is highlighted | Frontend | Pure presentation. |
The frontend holds one string — the serde token for the chosen variant — and
labels/details it received from Rust. It never encodes a bitrate, a resolution
or a parameter name.
## Design
`StreamingQuality` (`src-tauri/src/settings.rs`) is the ladder: `Original`,
`Mbps20`, `Mbps10`, `Mbps8`, `Mbps4`, `Mbps2`, `Mbps1`, `Kbps720`, serialised
camelCase (`"mbps10"`). Each step answers `max_bitrate()`, `audio_bitrate()`,
`video_bitrate()` (= total audio), `max_height()`, `label()`, `detail()`.
The active ceiling is a process-wide `RwLock<StreamingQuality>` in
`repository/online.rs`, read by every builder. Process-wide rather than a field
on `OnlineRepository` because it is a preference about *this device's
connection*: it must survive a repository rebuilt on re-login, and the URL
builders and the negotiation have to agree on it or the cap leaks. This mirrors
`offline::INCLUDE_CATALOG_BROWSE`.
Enforcement points — all four are required:
| Point | What the cap sets |
|-------|-------------------|
| `get_video_stream_url` (HLS transcode) | `MaxStreamingBitrate`, `VideoBitrate`, `AudioBitrate`, `MaxHeight` |
| `get_playback_info` | request `MaxStreamingBitrate`, and the device profile's `MaxStreamingBitrate`/`MaxStaticBitrate` |
| `open_live_stream` | `MaxStreamingBitrate` |
| `build_audio_only_stream_url_for_video` | `min(cap audio, 384 kbps)` |
The negotiation is the one that matters most. `MaxStaticBitrate` is what makes
the server refuse to *direct play* a source fatter than the ceiling; without it
a 30 Mbps remux is served untouched and no URL parameter downstream can reduce
it.
IPC:
```rust
player_get_streaming_qualities() -> Vec<(StreamingQuality, String, String)> // variant, label, detail
player_set_stream_quality(repository_handle, quality, use_html5,
current_position, media_source_id, audio_stream_index)
-> StreamQualityResponse // #[serde(tag = "strategy")]: native | reloadStream
```
`VideoSettings` gains `streaming_quality` (`#[serde(default)]`, so settings
persisted before the field existed load as uncapped).
`player_set_video_settings` applies it and writes it to `app_settings`;
`restore_streaming_quality` reads it back in the Tauri `setup` hook via
`tauri::async_runtime::spawn`, defaulting to uncapped if anything fails.
`StreamQualityResponse` keeps its Rust field names on the wire (`new_url`) —
tauri-specta only camelCases the `strategy` tag. The facade
(`playerController.setStreamQuality`) dispatches `reloadSource` for
`reloadStream` and does nothing for `native`, because the backend has already
reloaded itself.
Mid-playback the change applies to the current video **and** becomes the process
ceiling for what follows, but it is not persisted: the in-player menu is a "this
film, this connection" control and Settings owns the durable default.
## Out of scope
- Per-item rendition lists from the server's `MediaSources` (UR-070's other half).
- Connection-aware caps (separate WiFi/cellular ceilings). One cap, all connections.
- Adaptive/automatic selection from measured throughput.
- Download quality, which already has its own preset vocabulary (UR-071/DR-123).
## Acceptance criteria
- [x] `bun run check` passes.
- [x] `cargo fmt` clean, `cargo clippy` clean, Rust tests pass.
- [x] `bun run test` passes.
- [x] `bun run check:boundary` passes — no bitrate/resolution numbers in `src/`.
- [x] New code carries `// TRACES:` comments.
- [x] `bindings.ts` regenerated from Rust.
- [x] A capped step changes what the URL asks for; the uncapped default is byte-identical to the previous behaviour.
## Testing
Rust (`cargo test`):
- `test_video_stream_url_applies_bitrate_cap` — all four parameters at `Mbps2`.
- `test_video_stream_url_uncapped_keeps_legacy_allowance``Original` is unchanged and adds no `MaxHeight`.
- `test_audio_only_stream_url_takes_the_lower_of_cap_and_default`.
- `test_streaming_quality_budget_is_internally_consistent`, `..._ladder_descends`, `..._round_trips_through_json`.
The ceiling is process-wide, so tests that depend on it serialise on a guard
(`QualityFixture`) that restores `Original` on drop — including the two
pre-existing stream-URL tests, which would otherwise see another test's cap.
`get_playback_info` and `open_live_stream` need a live server and are not unit
tested; their behaviour is the enum's `max_bitrate()`, which is.
## TRACES
- `StreamingQuality`, `VideoSettings.streaming_quality``UR-074 | DR-162`
- URL builders / negotiation / live TV — `UR-004, UR-074 | DR-140, DR-162`
- Audio-only handoff — `UR-040, UR-074 | DR-162`
- Commands, facade, Settings UI, player menu — `UR-074 | DR-162`
- Tests — `UT-156`, `UT-157`
## Notes for the implementer
- `videoBitRate` with a capital R is the *download* endpoint's binding quirk
(DR-123). The streaming endpoint used here binds `VideoBitrate`/
`MaxStreamingBitrate` as spelled above — do not "correct" one to the other.
- A parallel Claude session may be active in this repo; `git diff` before
repairing unexpected changes. DR-160/161 were claimed by such a session while
this feature was in flight, which is why it is DR-162.
+238
View File
@@ -0,0 +1,238 @@
# Spec: Repair the traceability coverage gate
**Status:** Implemented
**Requirements:** DR-093 → supports the traceability practice described in CLAUDE.md
**UX spec:** n/a — developer tooling, no user-facing surface.
**Supersedes / revises:** n/a
## Summary
The CI traceability gate has been passing unconditionally for an unknown length
of time because it divides traced-requirement counts by **hardcoded denominators
that no longer match [requirements.md](../requirements.md)**. It currently
reports **158% overall coverage** (and `JA 24 / 3 = 800%`), so the 50% threshold
is mathematically unreachable and the job cannot fail. This spec makes the gate
derive its denominators from `requirements.md` at run time, so it reports the
real number (**85%** today) and can actually fail again.
## Motivation
`.gitea/workflows/traceability-check.yml` hardcodes `UR/39, IR/24, DR/48, JA/3`
and `TOTAL_REQS=114`. The real counts are **UR 61, IR 29, DR 89, JA 32 — 211
total**. Requirements were added over time; the divisors were never updated.
The consequence is not a cosmetic reporting bug. The gate is the *only*
automated defence for the traceability practice, and it is dead:
```
CI today: 181 / 114 = 158% → threshold 50% can never trip
Reality: 181 / 211 = 85% → healthy, but unguarded
```
Coverage could collapse to 30% and CI would still print a green
"✅ Coverage is acceptable". An audit of the design principles found that every
principle with a *working* automated check is in good shape, and the ones that
drifted are exactly the ones whose checks were broken or too narrow — this is
the clearest instance.
A second, related defect is handled in a sibling spec: `scripts/check-req-coverage.sh`
is separately broken and orphaned (see
[req-coverage-script-removal.md](req-coverage-script-removal.md)).
## Layer assignment
This spec touches only CI/build tooling — no application logic crosses the
Rust/Svelte boundary. The table is filled in for completeness.
| Logic / responsibility | Layer | Why it belongs there |
|------------------------|-------|----------------------|
| Counting requirement IDs defined in `requirements.md` | Build tooling (`scripts/`) | Neither runtime layer; it is repo metadata analysis. Belongs beside `extract-traces.ts`, not in the workflow YAML, so it is runnable and testable locally. |
| Counting *traced* requirement IDs | Build tooling — existing `extract-traces.ts` | Already implemented and correct; this spec consumes it rather than duplicating it. |
| Threshold policy (the 50% number) | CI workflow | Deployment policy, not analysis. Keeping it in YAML lets it be tuned without touching the script. |
No frontend or Rust logic is added, so no taxonomy leak is possible.
## Design
### 1. Denominators come from `requirements.md`, not literals
`requirements.md` defines requirements in markdown tables with a stable leading
cell, e.g.:
```
| DR-001 | Player state machine (idle, loading, …) | Player | UR-005 | Done |
| UR-002 | Access media when online or offline | High | Done |
```
Extend [scripts/extract-traces.ts](../../scripts/extract-traces.ts) to also emit
the *defined* counts, so one tool owns both sides of the fraction and CI does no
arithmetic on stale literals. Add a `defined` key to the JSON report:
```jsonc
{
"byType": { "UR": [...], "IR": [...], "DR": [...], "JA": [...] }, // traced (existing)
"defined": { "UR": 61, "IR": 29, "DR": 89, "JA": 32 }, // NEW
"coverage": { "covered": 181, "total": 211, "percent": 85 }, // NEW
"requirements": { ... }, // existing
"totalTraces": 318, "totalFiles": …, "timestamp": "…" // existing
}
```
Parsing rule for a *defined* requirement: a line in `docs/requirements.md`
matching `^\|\s*(UR|IR|DR|JA)-\d{3}\s*\|` — the ID must be the table's first
cell. This deliberately does **not** count IDs mentioned in the `Traces To`
column or in prose, which is why a naive `grep -o` over the whole file
overcounts.
`defined` counts IDs that exist in the spec; `byType` counts IDs that appear in
a `TRACES:` comment somewhere in the source. Coverage is
`|byType ∩ defined| / |defined|`.
> **Intersection, not raw length.** A `TRACES:` comment naming an ID that
> `requirements.md` does not define (a typo, or a requirement later deleted)
> must **not** inflate the numerator — that is how a ratio exceeds 100% in the
> first place. Such IDs are reported separately as `orphaned` so they get fixed
> rather than silently counted or silently dropped.
```jsonc
"orphaned": ["DR-097"] // traced in code but not defined in requirements.md
```
### 2. The workflow consumes the computed number
Replace the arithmetic in `.gitea/workflows/traceability-check.yml` (lines
4676) with reads of the precomputed fields:
```sh
COVERAGE=$(jq '.coverage.percent' traces-report.json)
COVERED=$(jq '.coverage.covered' traces-report.json)
TOTAL_REQS=$(jq '.coverage.total' traces-report.json)
for T in UR IR DR JA; do
TRACED=$(jq --arg t "$T" '.byType[$t] | length' traces-report.json)
DEFINED=$(jq --arg t "$T" '.defined[$t]' traces-report.json)
echo " $T: $TRACED / $DEFINED"
done
MIN_THRESHOLD=50
[ "$COVERAGE" -lt "$MIN_THRESHOLD" ] && { echo "❌ …"; exit 1; }
```
No hardcoded denominator survives anywhere in the workflow.
### 3. A self-check so this cannot silently rot again
The root cause was a number that drifted with nothing watching it. Add a
guard that fails the job on an arithmetically impossible result:
```sh
if [ "$COVERAGE" -gt 100 ]; then
echo "❌ Coverage > 100% — the gate is miscomputing; orphaned IDs: $(jq -c '.orphaned' traces-report.json)"
exit 1
fi
```
A >100% reading is now a hard failure rather than a green tick.
### 4. Local parity
Add a script so the gate is runnable outside CI:
```jsonc
"traces:coverage": "bun run scripts/extract-traces.ts --format coverage"
```
Prints the same table CI prints and exits non-zero below threshold.
### Threshold
Keep `MIN_THRESHOLD=50` in this spec. Real coverage is 85%, so raising the bar
is tempting, but doing it in the same change that repairs the gate conflates
"restore the safety net" with "tighten the policy" — if the build then fails, it
is ambiguous which change caused it. Ratcheting is deliberately deferred to
follow-up work once the honest number has been observed on `master` for a few
builds.
## Out of scope
- Raising `MIN_THRESHOLD` above 50 (see above).
- Fixing/removing `scripts/check-req-coverage.sh` — [req-coverage-script-removal.md](req-coverage-script-removal.md).
- Adding TRACES comments to raise the actual coverage number.
- Changing the `TRACES:` comment format or the extractor's parsing of it.
- The PR "modified files missing TRACES" step (lines 78126), which is advisory
by design and stays advisory.
## Acceptance criteria
- [ ] `bun run traces:json` emits `defined`, `coverage`, and `orphaned` keys.
- [ ] `coverage.total` equals the count of requirement IDs defined in
`requirements.md` (**211** at time of writing), not a literal.
- [ ] `coverage.percent` reports **85** (±1 for rounding) on the current tree —
i.e. the honest number, not 158.
- [ ] No hardcoded requirement denominator (`39`, `24`, `48`, `3`, `114`) remains
in `.gitea/workflows/traceability-check.yml`. Verify:
`grep -nE '/ *(39|24|48|3|114)\b' .gitea/workflows/traceability-check.yml`
returns nothing.
- [ ] Adding a new requirement row to `requirements.md` **lowers** reported
coverage until it is traced (proves the denominator is live).
- [ ] A `TRACES:` comment naming an undefined ID appears in `orphaned` and does
**not** raise `coverage.percent`.
- [ ] The job fails if coverage is forced below 50% (test by temporarily raising
`MIN_THRESHOLD` to 99 locally) — proving the gate can fail again.
- [ ] The job fails if coverage computes >100%.
- [ ] `bun run check` and `bun run test` pass.
- [ ] `bun run check:boundary` passes.
- [ ] New requirement-implementing code carries `// TRACES:` comments.
- [ ] No Rust types changed, so no `bindings.ts` regeneration needed.
## Testing
`extract-traces.ts` currently has no test coverage. Add
`scripts/extract-traces.test.ts` (vitest) over fixture strings rather than the
live `requirements.md`, so the tests do not change meaning as requirements are
added:
- **UT:** counts a well-formed table row as a defined requirement.
- **UT:** does **not** count an ID appearing only in the `Traces To` column or
in prose — the specific overcounting bug this parse rule avoids.
- **UT:** coverage is the intersection — a traced-but-undefined ID lands in
`orphaned` and does not inflate the numerator.
- **UT:** coverage of an empty trace set is 0%, not a divide-by-zero.
- **UT:** all-traced fixture reports exactly 100%, never above.
CI behaviour is verified by the acceptance criteria above (the forced-failure
check is the important one — a gate nobody has watched fail is not known to
work).
## TRACES
Allocate in `requirements.md`:
- **DR-093** — "Traceability coverage gate derives requirement denominators from
`requirements.md` at run time (not hardcoded literals), computes coverage as
the intersection of traced and defined IDs, reports IDs traced but undefined
as orphaned, and fails on an impossible >100% result." Category: Tooling.
Status: Done on merge.
Tag:
```typescript
// scripts/extract-traces.ts
// TRACES: | DR-093
```
Tests carry `@req-test: UT-089 …` onward (next free UT is **UT-089**).
## Notes for the implementer
- A parallel Claude session may be active in this repo — run `git diff` before
"repairing" unexpected changes (CLAUDE.md §Gotchas).
- **Do not add tooling to the CI image for this.** `jq` and `bun` are already in
`jellytau-builder`; this spec needs nothing else. Installing a system package
in a workflow step violates the hard CI rule in CLAUDE.md.
- Keep `traces:json`'s existing keys intact — `release-notes.ts` and
`traces:markdown` consume the same report, and the CI workflow uploads it as
an artifact. This is an additive change.
- The `head -50 docs/traceability.md` and artifact-upload steps are unaffected.
- Expect the first green build after this change to print a *lower* number than
before (85% vs 158%). That is the fix working, not a regression.
+233
View File
@@ -0,0 +1,233 @@
# Spec: Background audio for video playback (Android)
**Status:** Draft
**Scope:** Android only (v1). Linux noted as future work.
**Branch base:** `android-picture-in-picture`
**Requirements:** UR-040 → IR-025, JA-032, DR-051, DR-052 (see
[requirements.md](../requirements.md)). Tests: UT-059, UT-060, UT-061, IT-013.
## Summary
Add a per-player toggle that lets the **audio** of a video keep playing when the
app is backgrounded or the screen is locked, while **video decoding stops**.
When the app returns to the foreground, video decoding resumes from the current
audio position.
This is the audio-first counterpart to the existing Picture-in-Picture feature
(which keeps the *whole video* decoding in a floating window). The two are
mutually exclusive: enabling background audio suppresses auto-PiP.
## Motivation
Users watching talk-heavy content (podcasts-as-video, lectures, music videos,
concert films) want to lock the phone or switch apps and keep listening without
draining battery on video decode or needing a visible floating window.
## Background: how playback actually works here
Two facts drive the entire design (verified in code, not assumed):
1. **Video renders through the HTML5 `<video>` element in the WebView on both
platforms.** The native ExoPlayer *video* surface path is disabled — see the
INTERIM override in
[VideoPlayer.svelte](../../src/lib/components/player/VideoPlayer.svelte)
around the `playerPlayItem` response handling (`useHtml5Element` is forced
`true`, native backend is stopped). So "video decoding" == the WebView
`<video>` element, and the WebView is what Android suspends on background.
2. **An Android WebView `<video>` element does not keep playing audio when the
app is backgrounded / locked.** The system throttles the WebView and media
pauses. Keeping audio alive in the background requires a **native foreground
media service**, which already exists for music:
[`JellyTauPlaybackService`](../../src-tauri/android/src/main/java/com/dtourolle/jellytau/player/JellyTauPlaybackService.kt)
+
[`JellyTauPlayer`](../../src-tauri/android/src/main/java/com/dtourolle/jellytau/player/JellyTauPlayer.kt)
(ExoPlayer) + `MediaSessionCompat`.
**Therefore the design is a handoff**, not "keep the WebView alive": on
background, stop the WebView `<video>` and start audio-only playback of the same
item through the existing native ExoPlayer audio service; on foreground, hand
back to the WebView `<video>`.
This also aligns with the project's one-directional playback rule
(`CLAUDE.md` → "Playback state is one-directional"): the currently-authoritative
player (WebView element **or** native audio service) drives position; the UI and
MediaSession consume it. The handoff is a change of *which* player is
authoritative, and must transfer position cleanly.
## User-facing behavior
### The toggle
- A toggle button in the video player controls (next to the existing PiP /
fullscreen buttons in
[VideoPlayer.svelte](../../src/lib/components/player/VideoPlayer.svelte)).
- Icon: headphones / "audio-only" glyph. Two visual states (on/off).
- **Visible only when** `isPipSupported()`-equivalent conditions hold — i.e.
Android with a native audio service available. Hidden on Linux in v1.
- State is a UI preference on the player. Consider persisting the last choice
per user (see Open Questions) — v1 may default OFF each session.
### When toggle is ON and the app goes to background / screen locks
1. Auto-PiP is suppressed (see "Interaction with PiP").
2. The WebView `<video>` is paused and its decode stopped (release the media
source so the decoder is freed, not merely `pause()`).
3. Native audio-only playback of the same item starts at the current position,
through `JellyTauPlaybackService` (foreground notification + lockscreen
controls via the existing `MediaSessionCompat`).
4. Lockscreen / notification shows the item with play/pause/seek, driven by the
native player (existing music behavior — reused, not rebuilt).
### When toggle is ON and the app returns to foreground
1. Native audio playback stops; its final position is captured.
2. WebView `<video>` reloads/resumes at that position and continues as normal
audiovisual playback.
3. Playback state (playing/paused) is preserved across the handoff.
### When toggle is OFF (default)
Current behavior is unchanged: backgrounding video auto-enters PiP
(`onUserLeaveHint``PictureInPictureManager.enterPip`).
## Interaction with PiP
The toggle chooses one behavior or the other:
- Toggle **ON** → call `AndroidPictureInPicture.setAutoEnterEnabled(false)` (the
bridge already exists,
[pictureInPicture.ts](../../src/lib/utils/pictureInPicture.ts) →
`setAutoEnterEnabled`). Background → audio handoff instead of PiP.
- Toggle **OFF**`setAutoEnterEnabled(true)`. Background → PiP (status quo).
The frontend must also call `setAutoEnterEnabled(false)` on unmount if it left
it enabled, and re-assert the correct value whenever the toggle changes, so a
stale setting can't leak into the next player.
> Note: `canEnterPip()` today requires `isPlayingVideo()` on the *native*
> ExoPlayer, but video plays via the WebView, so native `isPlayingVideo()` is
> false during normal playback. Confirm during implementation how auto-PiP is
> actually triggering today (it may rely on a different signal), because the
> background-audio handoff needs the same "is a local video active" signal to
> know it should fire. **This is a load-bearing unknown — resolve it first
> (Phase 0).**
## Technical design
### The audio-only stream
Jellyfin can transcode/stream a video item as audio-only. Add a repository
method (mirroring
[`get_video_stream_url`](../../src-tauri/src/repository/online.rs) and
[`get_audio_stream_url`](../../src-tauri/src/repository/mod.rs)) that returns an
**audio-only stream URL for a video item** at a given audio-stream index — so
the currently-selected audio track (`selectedAudioTrackIndex` in the player)
carries over. Prefer direct-play of the audio stream where the container/codec
allows; transcode to a broadly-supported audio codec otherwise.
Position semantics must match between the WebView `<video>` timeline and the
audio stream (account for the transcoded-HLS `seekOffset` model already in the
player — see the `seekOffset` handling in `VideoPlayer.svelte`).
### Backend command surface (Rust)
New/extended `#[tauri::command]`s in `src-tauri/src/commands/player/` (follow the
camelCase param rule and `Result<T, String>` convention):
- `player_enter_background_audio(item_id, position_seconds, audio_stream_index)`
— stop WebView authority, start native audio-only playback at position; makes
the native player authoritative. Emits state via the existing player-event
channel so MediaSession/UI stay consumers.
- `player_exit_background_audio() -> position_seconds` — stop native audio,
return final position for the WebView to resume from; restores WebView
authority.
Reuse existing `player_play_*` / `player_stop` plumbing where possible rather
than adding a parallel path.
### Android native
- Reuse `JellyTauPlaybackService` + `JellyTauPlayer` audio path
(`MediaSessionCompat`, foreground notification, audio-becoming-noisy, etc. —
all already implemented for music).
- Add a bridge method (alongside `AndroidPictureInPicture`) or reuse an existing
one so the frontend can signal "prepare for background audio handoff" tied to
the Activity lifecycle (`onPause`/`onStop`/`onUserLeaveHint`).
- On `onUserLeaveHint` / screen-off with background-audio enabled: **do not**
enter PiP; instead trigger the handoff command.
- Respect the deadlock gotchas in `CLAUDE.md` (no sync/blocking calls from
player event callbacks; bind locked `AutoplayDecision` to a `let` before
matching).
### Frontend (VideoPlayer.svelte)
- Add toggle state + button. On change, call `setAutoEnterEnabled(!on)`.
- Listen for Android lifecycle background/foreground signals (via a bridge event
or existing visibility hooks) and:
- background + ON → `player_enter_background_audio(...)`, pause + tear down the
`<video>`/HLS decode (reuse the existing HLS teardown sequence to avoid dual
audio).
- foreground + ON → `player_exit_background_audio()`, reload `<video>` at the
returned position, restore play/pause state.
- **Follow the native-mode pitfall** (memory:
`videoplayer-native-mode-pitfalls`): no lifecycle calls after an `await` in
`onMount`. Keep the handoff logic out of that window.
- Dual-audio is the key regression risk: at every handoff exactly one of
{WebView `<video>`, native ExoPlayer} produces audio. Tear the other down
*before* starting the next, mirroring the existing HLS cleanup discipline.
## Phasing
- **Phase 0 — De-risk (do first):**
- Confirm what actually triggers today's auto-PiP given video is on the
WebView (resolve the `canEnterPip`/`isPlayingVideo` question).
- Spike: obtain an audio-only stream URL for a video item and play it through
the native audio service; measure position accuracy and that WebView audio
is fully silenced (no dual audio).
- **Phase 1 — Backend:** repository audio-only-URL method + the two player
commands + events.
- **Phase 2 — Native:** lifecycle wiring, PiP suppression, handoff trigger.
- **Phase 3 — Frontend:** toggle UI, lifecycle listeners, handoff calls,
teardown discipline.
- **Phase 4 — Polish:** persist toggle preference, subtitle/audio-track
carry-over, edge cases (calls, headphone unplug, autoplay-next during
background audio).
## Testing
- Rust: unit tests for the audio-only URL builder and the two commands
(`cargo test`, `bun run test:rust`).
- IPC param-naming integration tests for any new commands
(`bun run test -- tauriIntegration.test.ts`).
- Frontend: `bun run check`, `bun run test`, plus a VideoPlayer logic test for
the handoff state machine (mirror the existing
`VideoPlayer.logic.test.ts`).
- Manual on-device matrix:
- toggle ON: home button → audio continues, video stops decoding; return →
video resumes at position; playing/paused preserved.
- toggle ON: screen lock → audio continues; lockscreen controls work; unlock →
resumes.
- toggle OFF: background → PiP (unchanged).
- No dual audio at any transition. No audio leak after leaving the player.
- Transcoded (HEVC/10-bit) item — verify position with `seekOffset`.
- Autoplay-next fires correctly if an episode ends during background audio.
## Open questions
1. **Persist the toggle per user/series, or default OFF each session?**
(Recommend: remember last choice; series-level like the audio-track
preference is a nice-to-have.)
2. **Autoplay-next during background audio** — should the next episode start as
audio-only and stay audio until foreground, or pause at episode end? (Recommend:
continue as audio-only.)
3. **Subtitles** are irrelevant in audio-only mode but must restore on
foreground — confirm they survive the `<video>` teardown/reload.
4. Exact **Android lifecycle signal** for "screen locked" vs "app backgrounded"
`onUserLeaveHint` covers Home but not lock; may need a screen-off receiver.
## Non-goals (v1)
- Linux background audio (desktop windows keep running unfocused; low value).
- Replacing or removing PiP — it stays as the toggle-OFF behavior.
- Re-enabling the native ExoPlayer *video* surface path.
+209
View File
@@ -0,0 +1,209 @@
# Spec: Windows native audio backend
**Status:** Proposed
**Requirements:** UR-003, UR-027, UR-032, UR-033 → DR-030, DR-035, DR-036; new IR-030
**UX spec:** n/a — Settings Audio already renders the controls
**Supersedes / revises:** acts on the "audio can unify, video cannot" conclusion in [playback-backend-unification.md](playback-backend-unification.md)
## Summary
Give Windows a real native audio backend instead of the current webview
`<audio>` shim. Windows is the only platform where audio playback has no decoder
of its own: `WebviewAudioBackend` hands a URL to a frontend `<audio>` element and
relays transport commands. It cannot set volume, cannot apply any audio setting,
and reports state only via DOM events.
Audio needs no rendering surface, so **none of the webview-compositing problems
that block unified video apply here.** This is the cleanest available win.
## Motivation
`WebviewAudioBackend` was a deliberate stopgap ("audio-only playback for
platforms without a native audio backend"), and it works — but it has a hard
functional gap. From `webview_audio_backend.rs`:
```rust
fn set_volume(&mut self, volume: f32) -> Result<(), PlayerError> {
// ...stores locally only; there is no ControlCommand action for volume
}
```
So volume changes never reach the element; the frontend has to observe the player
store and apply volume itself. `set_audio_settings` likewise stores values that
nothing consumes — EQ, normalization, and gapless are all inert on Windows.
Meanwhile the backend-unification investigation established that a native *audio*
engine is unproblematic on Windows specifically: `tauri-plugin-libmpv` lists
Windows as its **fully tested** platform (in contrast to Linux, where embedding
is broken — but that is a *video surface* problem, which audio does not have).
## Layer assignment
| Logic / responsibility | Layer | Why it belongs there |
|------------------------|-------|----------------------|
| Decoding and playing the audio stream | Rust | Playback is domain logic; every other platform already decodes in Rust or a native player. The webview shim is the anomaly. |
| Applying `AudioSettings` (EQ/normalize/gapless) | Rust | Same `AudioSettings` contract as MPV/ExoPlayer; band layout and presets stay canonical in `settings.rs`. |
| Position/state reporting | Rust | Restores the project's core principle — the player is the authoritative source of state. Today Windows inverts this: the DOM element is authoritative and Rust mirrors it. |
| Volume | Rust | Currently broken precisely because it is split across the boundary. |
| Rendering the player UI | Frontend | Unchanged. |
The strongest argument for this change is the third row. CLAUDE.md states
playback state is one-directional with the player authoritative; on Windows that
is currently false, and the `player_report_*` round-trip exists to paper over it.
## Design
### Engine choice
Two viable options; **libmpv is recommended** for consistency with the Linux
audio backend.
| | libmpv | GStreamer |
|---|---|---|
| Windows status | ✅ `tauri-plugin-libmpv` reports fully tested | ✅ works, but… |
| Rust bindings | `libmpv2` 6.0.0, active | `gstreamer-rs` 0.25.x, excellent |
| Cross-MSVC from Linux | ⚠️ needs prebuilt DLL + import lib | ❌ `gstreamer-sys` uses pkg-config, fights `cargo-xwin` |
| Code reuse | ✅ `MpvBackend` logic is directly reusable | ❌ a second engine to learn |
| Crossfade capable | ❌ single-stream chain | ✅ `audiomixer` |
libmpv wins on reuse: `MpvBackend`'s `set_audio_settings` — the `af` lavfi graph
built by `build_af_filter`, `eq_filter_entries`, `normalize_filter_entry` — is
platform-independent and would apply unchanged.
The one reason to prefer GStreamer is crossfade (UR-031), which mpv structurally
cannot do. If crossfade becomes a priority, revisit; it would then argue for
GStreamer on *both* Linux and Windows, which is a much larger change.
### Structure
Rename the cfg gate so `MpvBackend` is no longer Linux-only:
```rust
// src-tauri/src/player/mod.rs
#[cfg(any(target_os = "linux", target_os = "windows"))]
pub mod mpv_backend;
```
`MpvBackend::new` needs one platform-specific branch: `detect_audio_system()`
currently probes `pactl`/`pw-cli`/`/proc/asound/cards` to pick an `ao`. On
Windows the equivalent is `wasapi` (mpv's default), so the detection is a
`#[cfg]` returning `"wasapi"` — no probing needed.
Everything else — the event loop, the 250ms position thread, the seek-suppression
window, the `af` filter graph — is unchanged.
`WebviewAudioBackend` stays for other targets (macOS and anything else hitting
the `not(any(...))` arm) and as the fallback if libmpv fails to initialize. The
existing `emit_backend_init_failed` path already handles that gracefully.
### Build
`libmpv2-sys` is well-suited to cross-compilation: no pkg-config, vendored
headers, pregenerated bindings (no libclang). It emits `cargo:rustc-link-lib=mpv`
unconditionally, so the build must supply a linkable import library for
`x86_64-pc-windows-msvc`.
Keep the `build_libmpv` feature **off** — its Unix path shells out to mpv-build
and explicitly rejects cross-compilation.
🔴 Per CLAUDE.md, the prebuilt libmpv **must be added to the builder image**
(`Dockerfile.builder` → rebuild + push via `scripts/build-builder-image.sh`), not
installed at CI job time. `libmpv-2.dll` must also be bundled into the NSIS
installer via `tauri.conf.json`'s resources.
### Verified build mechanics
The cross-compile path was tested hands-on from Linux (July 2026), not inferred:
- Neither shinchiro nor zhongfly ships an `mpv.def` or MSVC `mpv.lib` — only a
MinGW `libmpv.dll.a`. (Several online sources claim otherwise; they are wrong.)
- An MSVC-style import lib can be generated locally with LLVM tools only:
`llvm-readobj --coff-exports libmpv-2.dll` → synthesize `mpv.def`
`llvm-dlltool -m i386:x86-64 -d mpv.def -l mpv.lib`. `llvm-lib /def:` produces a
byte-identical result.
- A real `lld-link` link against that import lib **succeeds**, and the resulting
import table resolves `mpv_client_api_version` from `libmpv-2.dll`. `lld-link`
is the linker `cargo-xwin` uses, so this is the load-bearing step.
- Linking directly against the shipped MinGW `libmpv.dll.a` **also** succeeds, so
def-generation may be skippable — but that relies on lld's GNU-archive
tolerance rather than a documented contract. Keep `llvm-dlltool` as the
fallback.
- MinGW origin is not an ABI problem: libmpv exports a pure C ABI, and the x86-64
Windows calling convention is platform-defined. The upstream note that MSVC
cannot *build* mpv is frequently misread as "MSVC cannot *link* libmpv" — that
is not what it says.
- 🔴 Never free/realloc across the DLL boundary — use `mpv_free`.
Build wiring is ordinary: `cargo:rustc-link-lib=dylib=mpv` plus
`cargo:rustc-link-search`. Nothing about libmpv conflicts with `cargo-xwin`.
### Size and shipping
Measured uncompressed: **93 MiB** (zhongfly `mpv-dev-lgpl-x86_64`) vs **112 MiB**
(shinchiro, full GPL build); ~2630 MB compressed in the `.7z`.
**Ship the zhongfly LGPL build** — smaller, and there is no reason to pull the
GPL variant in for an audio-only use.
Import-table inspection confirms **no companion DLLs are needed**: every
dependency is a system DLL (`KERNEL32`, `USER32`, `d2d1`, `DWrite`, `OPENGL32`,
`vulkan-1`, UCRT `api-ms-win-*`). One file to bundle.
93 MiB is still substantial against a Tauri app's usual few MB. Since we use mpv
audio-only, investigate whether a pruned build (no video decoders, no libplacebo)
is worth producing for the builder image — but treat that as an optimization,
not a blocker.
## Out of scope
- Windows *video*. Stays in WebView2 + hls.js — it works and has ABR.
- Crossfade (UR-031/DR-034) — not implemented anywhere; needs its own spec.
- Replacing `WebviewAudioBackend` for macOS.
- MPRIS/SMTC media-key integration — worth a follow-up, not this spec.
## Acceptance criteria
- [ ] Windows build produces a `MpvBackend`-backed player; `backend-init-failed` is emitted (not a crash) if libmpv is unavailable.
- [ ] Volume control works from the UI — the current hard gap.
- [ ] EQ, normalization, and gapless audibly take effect on Windows.
- [ ] Position/state originate in Rust; the `<audio>` element is no longer in the audio path.
- [ ] Seek, next/previous, and queue advance work; sleep timer stops playback.
- [ ] `libmpv-2.dll` ships in the NSIS installer and the app runs on a clean Windows VM with no mpv installed.
- [ ] Builder image carries the Windows libmpv artefacts; **no toolchain install added to any CI step**.
- [ ] `bun run check`, `bun run test`, `bun run check:boundary` pass.
- [ ] `cargo fmt` clean, `cargo clippy` clean, `bun run test:rust` passes.
- [ ] New requirement-implementing code carries `// TRACES:` comments.
## Testing
**Rust**: the existing `mpv_backend_test.rs` and the `build_af_filter` /
`normalize_filter_entry` / `eq_filter_entries` unit tests already cover the
filter-graph logic and are platform-independent — they should pass unchanged
under a Windows `cargo check`/test. Add a test asserting `detect_audio_system()`
returns `wasapi` under `cfg(windows)`.
**Manual, on Windows**: volume, EQ preset change, normalization toggle, gapless
between two tracks, seek, queue advance, sleep timer. Then the packaging test —
install the NSIS output on a clean VM and confirm it launches and plays.
Per CLAUDE.md, the volume gap is a *bug fix*: write a failing test for
"`set_volume` reaches the backend" before implementing.
## TRACES
- Windows `MpvBackend` construction in `create_player_backend``// TRACES: UR-003 | IR-030`
- `detect_audio_system` Windows branch → `IR-030`
- Existing `set_audio_settings` gains Windows coverage → `UR-027, UR-032, UR-033 | DR-030, DR-035, DR-036`
- Allocate **IR-030** in `requirements.md` ("libmpv integration for Windows audio playback").
## Notes for the implementer
- Do this **after** [libmpv2-migration.md](libmpv2-migration.md) — porting the
current dead `libmpv` git pin to a second platform would double the migration
work.
- `libmpv2` has broken its API in every major release (4.0 removed command
helpers, 5.0 removed `mpv_node`, 6.0 changed `RenderContext` ownership). Pin an
exact version.
- Only the `render`-feature parts of `libmpv2` concern video; audio-only use does
not need it, and disabling the default `render` feature may shrink the build.
- A parallel Claude session may be active — `git diff` first.
+34 -20
View File
@@ -12,22 +12,22 @@ The CI/CD pipeline automatically validates that code changes are properly traced
## Gitea Actions Workflows
Two workflows are configured in `.gitea/workflows/`:
Traceability validation lives in `.gitea/workflows/traceability-check.yml`:
### 1. `traceability-check.yml` (Primary - Recommended)
Gitea-native workflow with:
- ✅ Automatic trace extraction
- ✅ Coverage validation against minimum threshold (50%)
- ✅ Modified file checking
- ✅ Artifact preservation
- ✅ Summary reports
**Runs on:** Every push and pull request
**Runs on:** Every push and pull request to `master`/`main`/`develop`
### 2. `traceability.yml` (Alternative)
GitHub-compatible workflow with additional features:
- Pull request comments with coverage stats
- GitHub-specific integrations
A second workflow, `traceability.yml`, previously duplicated this one as a
"GitHub-compatible alternative". It was removed: CI here is Gitea Actions, and
its only unique step (PR comments via `actions/github-script`) depended on the
GitHub REST client, which Gitea does not provide. To add PR comments, post to
Gitea's `/api/v1/repos/{owner}/{repo}/issues/{index}/comments` from
`traceability-check.yml` rather than reviving the old file.
## What Gets Validated
@@ -43,14 +43,26 @@ Extracts all TRACES comments from:
### 2. Coverage Thresholds
The workflow checks:
- **Minimum overall coverage:** 50% (57+ requirements traced)
- **Requirements by type:**
- UR (User): 23+ of 39
- IR (Integration): 5+ of 24
- DR (Development): 28+ of 48
- JA (Jellyfin API): 0+ of 3
- **Minimum overall coverage:** 50%
If coverage drops below threshold, the workflow **fails** and blocks merge.
Denominators are **derived from `docs/requirements.md` at run time** — they are
never hardcoded here or in the workflow. Run `bun run traces:coverage` for the
current per-type breakdown; any number written into this document is a snapshot
that will drift.
> **Why this matters.** The workflow used to divide by frozen literals
> (UR/39, IR/24, DR/48, JA/3, total 114) while `requirements.md` had grown past
> 200. It reported **158%** coverage, so the 50% threshold was unreachable and
> the job could not fail regardless of how far coverage dropped. See
> [specs/traceability-gate-repair.md](specs/traceability-gate-repair.md).
Coverage is the *intersection* of traced and defined IDs: an ID that appears in
a `TRACES:` comment but is not defined in `requirements.md` is reported as
**orphaned** and does not count toward coverage. UT/IT test identifiers are a
separate taxonomy and are excluded entirely.
The workflow **fails** and blocks merge if coverage drops below 50% — or if it
computes above 100%, which can only mean the gate is miscounting.
### 3. Modified File Checking
On pull requests, the workflow:
@@ -153,11 +165,13 @@ cat docs/traceability.md
## Coverage Goals
### Current Status
- Overall: 51% (56/114)
- UR: 59% (23/39)
- IR: 21% (5/24)
- DR: 58% (28/48)
- JA: 0% (0/3)
Run `bun run traces:coverage` — it prints the live figure and exits non-zero
below threshold. Numbers are deliberately not pinned here; the previous snapshot
in this section (51%, 56/114) was stale by roughly 100 requirements and was what
made the broken CI arithmetic look plausible for so long.
As of July 2026 overall coverage is ~86% (182/212).
### Targets
- **Short term** (Sprint): Maintain ≥50% overall
+8910 -828
View File
File diff suppressed because it is too large Load Diff
+776 -101
View File
@@ -36,21 +36,74 @@ On desktop (md breakpoint and above), the header contains:
- Logo (links to `/library`)
- Navigation links: Home, Library, Downloads, Settings
- Search bar (inline)
- User menu: Username, Downloads icon, Logout button
- Account menu (see §1.2)
**Mobile Navigation:**
On mobile, the header contains:
- Logo
- Three-dot overflow menu button (Android-style)
- Overflow menu includes:
- Downloads
- Settings
- Sign out
- Account menu button (see §1.2)
### 1.2 Account Menu
Account-level destinations — the ones that are *about the user* rather than
about media — live behind a single **account menu**, anchored to the user's
name/avatar at the right of the header.
**Contents, in order:**
```
┌──────────────────────────┐
│ Signed in as <name> │ ← identity, not a menu item
│ <server host> │
├──────────────────────────┤
│ ⬇ Downloads │
│ ⚙ Settings │
│ ▦ Display │ ← grid/list preference (§5A.2)
├──────────────────────────┤
│ ⇥ Sign out │
└──────────────────────────┘
```
**Rules:**
- **One menu, both platforms.** Desktop and mobile show the same items in the
same order. A user who learns where Settings lives on one form factor finds
it in the same place on the other.
- **Anchored to identity.** The trigger is the username/avatar, because that is
where users look for account actions. A bare three-dot icon does not signal
"your account".
- **Sign out is separated** by a divider and placed last — it is destructive and
must not sit adjacent to routine navigation.
- **The menu is reachable from every authenticated screen**, not only from
library routes. See §1.3.
**Access Points Summary:**
- **Downloads**Desktop: nav link + icon; Mobile: overflow menu
- **Settings**Desktop: nav link; Mobile: overflow menu
- **Downloads**header icon (desktop) + account menu (both)
- **Settings**header nav link (desktop) + account menu (both)
- **Sign out** → account menu only
### 1.3 Chrome availability
The header is shared across chrome-bearing routes. Routes fall into three groups:
| Route group | Header | Bottom nav | Account menu reachable? |
|-------------|--------|------------|-------------------------|
| `/library/*` | Yes (own layout, shared `AppHeader`) | Yes | Yes |
| `/`, `/search`, `/downloads` | Yes (root-owned `AppHeader`) | Yes | Yes |
| `/settings` | Own layout | No | n/a — already there |
| `/player/*`, `/login` | No | No | No (by design) |
The rule the app honours: every authenticated, non-immersive screen exposes the
account menu. Only the full-screen player and the login screen are chrome-free.
### 1.4 Known deviations
*(None — the account-menu and chrome-availability defects tracked here under
UR-054 were resolved. Settings, Downloads, Display, and Sign out are now reachable
from every authenticated non-immersive screen via the shared `AccountMenu`, the
username/avatar is the menu trigger, desktop and mobile share one menu, and the
Display preference has a Settings entry — UR-029, §5A.4.)*
---
@@ -293,10 +346,12 @@ flowchart TB
**User Interaction:**
- **Tap screen:** Controls reappear for 3 seconds
- **Double tap left side:** Rewind 10 seconds (shows animated feedback with "-10" indicator)
- **Double tap right side:** Forward 10 seconds (shows animated feedback with "+10" indicator)
- **Double tap right side:** Forward 30 seconds (shows animated feedback with "+30" indicator)
- **Single tap play/pause is deferred** by the 300 ms double-tap window, so a double tap
skips without also toggling pause (UR-061)
- **Swipe up/down on left side:** Adjust brightness (0.3-1.7x, shows brightness indicator with progress bar)
- **Swipe up/down on right side:** Adjust volume (0-100%, shows volume indicator with progress bar)
- **Keyboard arrows:** ← rewind 10s, → forward 10s (desktop/external keyboard)
- **Keyboard arrows:** ← rewind 10s, → forward 30s (desktop/external keyboard)
- **Keyboard space/K:** Toggle play/pause
- **Keyboard F:** Toggle fullscreen
- **Pinch:** Zoom (planned)
@@ -386,9 +441,9 @@ flowchart TB
```mermaid
flowchart TB
AlbumsGrid[Albums Grid<br/>FORCED Grid View] --> UserAction{User Action}
AlbumsGrid[Albums Grid<br/>grid/list per §5A] --> UserAction{User Action}
UserAction -->|Click Album| AlbumDetail[Album Detail Page<br/>/library/[albumId]]
UserAction -->|Click Album| AlbumDetail[Album Detail Page<br/>/library/[id]]
UserAction -->|Click Play on Card| PlayAlbum[Play Album Immediately]
AlbumDetail --> ShowAlbum[Show Album:<br/>- Album Art<br/>- Title, Artist<br/>- Track List<br/>- Download Button<br/>- Favorite Button]
@@ -445,54 +500,534 @@ flowchart TB
---
## 6. Search Flow
## 5A. Library Page Layouts
### 6.1 Search Page Navigation
Every browse page is one of two shapes: a **card grid** or a **row list**. This
section is the rule for which shape a page takes, what a card looks like, and
what the user is allowed to change.
### 5A.1 Card shape follows the media, not the page
Card aspect ratio is a property of *what the item is*, and is never overridden
per-page. This is the single most important layout rule: a user scanning a grid
recognises content type by silhouette before reading a word.
| Item type | Aspect | Rationale |
|-----------|--------|-----------|
| Album, Artist, Track, Playlist | **1:1 square** | Matches album art; the universal music convention (Spotify) |
| Movie, Series, Season | **2:3 poster** | Matches printed poster art; the universal video convention (Netflix) |
| Episode | **16:9 thumbnail** | A frame from the episode, not cover art — signals "a thing you watch next" |
| Library / collection folder | **16:9** | Reads as a container, distinct from the items inside it |
Artist cards are square but rendered **circular-masked**, so artists are
distinguishable from albums at a glance within the same music grid.
### 5A.2 Grid vs. list
```mermaid
flowchart TB
BottomNav[Bottom Nav] --> ClickSearch[Click Search Tab]
Page[Library browse page] --> Kind{Content kind}
ClickSearch --> SearchPage[Search Page<br/>/search]
Kind -->|Visual-first<br/>albums, artists, movies,<br/>shows, playlists| Grid[Card grid<br/>user may switch to list]
Kind -->|Ordinal<br/>tracks in an album,<br/>episodes in a season| List[Row list<br/>always; no toggle]
SearchPage --> EmptyState{Has Query?}
EmptyState -->|No| ShowPrompt[Show Empty State:<br/>Search for music,<br/>movies, shows...]
EmptyState -->|Yes| ShowResults[Show Results Grouped:<br/>- Songs<br/>- Albums<br/>- Artists<br/>- Movies<br/>- Episodes]
ShowPrompt --> UserTypes[User Types in Search]
UserTypes --> LiveSearch[Live Search<br/>Debounced 300ms]
LiveSearch --> ShowResults
ShowResults --> UserClick{User Clicks Result}
UserClick -->|Song| PlaySong[Play Song + Queue Results]
UserClick -->|Album| NavAlbum[Navigate to Album Detail]
UserClick -->|Artist| NavArtist[Navigate to Artist Page]
UserClick -->|Movie| NavMovie[Navigate to Movie Detail]
Grid --> Toggle[View toggle in page header]
Toggle --> Persist[Choice persists globally<br/>across all grid pages]
```
**Search Page Layout:**
- **Grids are the default** for anything with cover art worth scanning.
- **Lists are mandatory, not optional**, where position carries meaning —
a track's number within an album, an episode's number within a season.
A grid destroys that ordering cue, so these pages expose **no toggle**.
- **The toggle is global, not per-page.** A user who prefers dense lists
prefers them everywhere; making them re-set it on each page is friction.
The choice persists across launches.
**Responsive columns** (grid mode), tuned so cards stay large enough to read
cover art on a phone and don't become postage stamps on a desktop:
| Breakpoint | Columns |
|------------|---------|
| base (phone) | 2 |
| sm | 3 |
| md | 4 |
| lg | 5 |
| xl | 6 |
### 5A.3 What a card shows
```
┌─────────────┐
│ │ ← cover art (aspect per §5A.1)
│ artwork │ • progress bar overlay if partially played
│ │ • watched/played check if complete
│ [▶] │ • play affordance on hover/focus
└─────────────┘
Primary line ← title, truncated to one line
Secondary line ← artist / year+rating / SxEy — one line, dimmed
```
- **Two lines of text maximum.** Titles truncate rather than wrap; a card that
grows to fit its title breaks grid alignment and makes scanning harder.
- **Progress and watched state live on the artwork**, not in the text — they
must be readable while scanning, without reading.
- **Hover/focus reveals play**, so a card is both a navigation target and a
playback target without a second control competing for space at rest.
### 5A.4 Known deviations
These are places the implementation currently diverges from the rules above.
They are recorded here so the gap is explicit rather than mistaken for intent.
- **The view toggle is discoverable only on a browse page.** The preference is
already global and persisted, but the only control that sets it is the pair
of icon buttons in a library page header. Settings has no display section, so
there is nowhere to look for it. *(UR-029)*
---
## 5B. Video Detail Page Composition
Movie, Series, and Episode detail pages all live at `/library/[id]`. Which
surface renders is decided by item type plus the `?episode=` query param, and
**section order is part of the spec** — it is what makes "keep watching this
show" the path of least resistance.
### 5B.1 Which surface renders
```mermaid
flowchart TB
Nav[Navigate to /library/&#91;id&#93;] --> Type{Item type}
Type -->|Person| Person[PersonDetailView]
Type -->|Movie| Movie[Movie detail<br/>§5B.3]
Type -->|Series| Ep{?episode= param<br/>present?}
Ep -->|Yes| Focus[Episode Focus View<br/>§5B.2]
Ep -->|No| Series[Series detail<br/>§5B.4]
Focus -->|Back to series| Series
Series -->|Click episode| Focus
```
An episode is **never** browsed as a bare `Episode` item page. Clicking an
episode anywhere — a series' season list, a Home carousel (§5B.5), etc. —
navigates to `/library/<seriesId>?episode=<episodeId>`, so the episode is always
shown in the context of its series and the series' full episode list is already
loaded. Should an episode ever arrive without a `seriesId` (deep link, stale
cache), the bare Episode page renders as a fallback and links back to its parent
series and season by title so the user is never stranded.
### 5B.2 Episode Focus View — section order
**The next episodes appear directly below the current episode, above cast and
similar shows.** Nothing may be inserted between the episode hero and the
episode strip.
```
┌─────────────────────────────────────────────────┐
│ [←] │
│ ┌───────────────────────────────────────────┐ │
│ │ episode backdrop │ │
│ │ Series Name │ │ ← 1. HERO
│ │ Episode Title │ │
│ │ S2E4 • 48m • ★8.1 │ │
│ │ Overview… │ │
│ │ ▓▓▓▓▓░░░░░ 32m left │ │
│ │ [▶ Play] [⬇] [♡] │ │
│ └───────────────────────────────────────────┘ │
│ │
│ More Episodes │ ← 2. EPISODE STRIP
│ ┌──────┐┌──────┐┌──────┐┌──────┐ │ (immediately below hero)
│ │ E3 ││▓E4▓ ││ E5 ││ E6 │ → scroll │
│ │ ││NOW ││ ││ │ │
│ └──────┘└──────┘└──────┘└──────┘ │
│ │
│ Cast │ ← 3. CAST
│ ( ○ )( ○ )( ○ )( ○ ) │
│ │
│ More Like This │ ← 4. SIMILAR
│ ┌────┐┌────┐┌────┐┌────┐ │
└─────────────────────────────────────────────────┘
```
**Rules for the episode strip:**
- **Position is fixed.** Hero → episode strip → cast → similar. The strip sits
between the current episode and every other section; cast and related
content are *below* it, never above.
- **Window, not full list.** The strip shows a window around the current
episode — roughly 3 before and 6 after — so the immediate next episodes are
visible without scrolling, and earlier ones remain reachable by scrolling
left. It is horizontally scrollable, not a wrapped grid.
- **Forward bias.** More episodes are shown *after* the current one than
before it: the dominant intent on this screen is "watch the next one."
- **The current episode is present and marked.** It renders in-strip with a
"NOW" badge and a highlight ring, and is not clickable. It anchors the
user's position in the season rather than being hidden.
- **Cross-season continuity.** The window spans the whole series in episode
order, so the strip runs past a season boundary into the next season's first
episodes rather than dead-ending at the end of a season.
- **Per-episode state.** Each card shows a thumbnail, `SxEy` + title, a resume
progress bar when partially watched, and a watched checkmark when complete.
- **Clicking an episode swaps focus in place** (`?episode=` changes); it does
not start playback. Playback starts only from the hero's Play button.
### 5B.3 Movie detail — section order
```
Hero (poster, title, metadata, Play / Download / Favorite)
→ Crew links (Directed by / Written by / Music by)
→ Genre tags
→ Cast
→ More Like This
```
A movie has no continuation set, so cast follows the hero directly.
### 5B.4 Series detail — section order
```
Hero (poster, title, metadata, Resume SxEy / Download / Favorite / Clear history)
→ Crew links
→ Genre tags
→ Seasons (collapsible; only the current season expanded)
→ Cast
→ More Like This
```
The same principle as §5B.2: **episodes come before cast and similar shows.**
The reason a user opens a series page is to pick an episode; discovery content
is secondary and sits underneath.
**Rules for the seasons block** *(UR-062, UR-064)*:
- **The page opens where the viewer is.** The backend resolves the current
episode — in progress, else Next Up, else first unwatched, else the premiere —
and the page scrolls it into view with an `Up next` badge and a highlight ring.
Never season 1 by default, unless season 1 *is* where the viewer is.
- **Seasons collapse; only the current one is expanded.** A ten-season show
otherwise renders hundreds of rows and buries the episode the viewer came for.
A collapsed season still names its episode count and watched count, so
progress is readable without expanding it.
- **The hero button opens, it does not play.** It reads `Resume S2E4` /
`Play S1E1` — naming its target — and navigates to that episode's Focus View,
where Play commits. Play on a *container* is navigation (§5B.5); Play on a
*leaf* is the commitment.
- **A season is never its own page.** `/library/<seasonId>` redirects to
`/library/<seriesId>#season-N`. Every affordance that names a season — the
episode breadcrumb, a season card in a grid, a Downloads drill-in — lands on
the series with that season in view, so the episodes of all seasons stay one
browsable list.
- **Watch history is erasable** per series (hero) and per season (season
header). It confirms first, cannot be undone, and needs the server. Clearing a
whole series returns it to S1E1 by the same path a never-watched show takes.
### 5B.5 Home-card interaction — tap opens, long-press plays
Cards on the Home screen carousels (Next Movie, Next Episode, Continue
Watching, Recently Added, …) **do not play on tap.** A plain tap opens the
item; playback is the deliberate, second gesture.
| Card kind | Tap (short) | Long-press (~500 ms hold) |
|-----------|-------------|---------------------------|
| Movie | Movie detail page (`/library/<id>`) | Confirm → play now (`/player/<id>`) |
| Episode | Series Episode Focus View (`/library/<seriesId>?episode=<id>`, per §5B.1) | Confirm → play now (`/player/<id>`) |
| Series / Season / Album / Artist / Playlist / Folder | Detail page (`/library/<id>`) | Same as tap (no single "play now" target) |
| Channel / live leaf | Player (`/player/<id>`) — no detail page exists | Confirm → play now |
Rationale and rules:
- **Tap is navigation, not commitment.** Previously a tap on a movie/episode
jumped straight into the player, which made it easy to lose your place in a
half-watched item or start a stream you only meant to inspect. Tap now lands
on the detail/focus page, where Play is an explicit button.
- **Long-press is the shortcut for "just play it."** It surfaces a native
confirm (`Play "<name>" now?`) before starting playback, so an accidental
hold never blows away a resume position silently.
- **The long-press must not fight the carousel.** Detection cancels if the
pointer moves more than ~10 px (a horizontal scroll of the row), so holding
to scroll never triggers play.
- **Episodes still obey §5B.1** — a home tap on an episode opens the series
Focus View, never a bare Episode page, so the series context loads.
This behavior lives in `MediaCard` (`onLongPress` prop + pointer-based
detection) so any surface can opt in; today the Home carousels are the only
opt-in. Grids and other surfaces keep tap-to-open with no long-press.
---
## 5C. Favourites
Favouriting is a two-sided promise: the heart takes the input, and the app must
be able to give it back. This section covers both sides — where you can mark a
favourite, and where marked favourites resurface.
See [specs/favorites-browsing.md](specs/favorites-browsing.md) for the layer
assignment and wire shapes.
### 5C.1 The heart appears wherever an item does
A favourite is a property of an *item*, so the affordance follows the item
rather than living on one privileged screen. Any surface that shows a whole
item shows its heart.
| Surface | Heart position | Notes |
|---------|----------------|-------|
| Movie / Series detail hero | In the button row, after Play and Download | §5B.3, §5B.4 |
| Episode Focus View hero | Same row as Play / Download | §5B.2 |
| Album, Artist, Playlist detail | In the header button row | §5.2 |
| Media card (any grid or carousel) | Top-right overlay on the artwork | Hidden on server-only (greyed) cards |
| Mini player | Right of the track metadata | Existing behaviour, unchanged |
| Full player | Secondary controls row | §3.2 — **not yet built**, see §5C.5 |
Rules:
- **The heart never competes with the card.** On a media card it is its own
button and swallows the tap, so hearting an item never also opens or plays
it, and never triggers the §5B.5 long-press.
- **State is shown, not guessed.** A filled heart means the *server* considers
the item a favourite (or you just tapped it). An item favourited in Jellyfin
Web, on another device, or by another client renders filled here without
being touched in JellyTau.
- **Feedback is immediate.** The heart fills on tap and a toast confirms;
neither waits for the server round-trip.
### 5C.2 Three ways back to what you favourited
Favourites are not one destination — they are a lens, and the right surface
depends on whether the user is *browsing*, *deciding*, or *hunting*.
```mermaid
flowchart TB
User[User wants their favourites] --> How{Intent}
How -->|Passive: show me something| Home[Home carousels<br/>Favourite Movies / Shows / Music]
How -->|Deliberate: my whole collection| Page[Favourites page<br/>/library/favorites]
How -->|Narrowing: within this library| Filter[Favourites filter<br/>on a library page]
Home -->|See all| Page
Page --> Detail[Item detail page]
Filter --> Detail
```
**Home carousels.** Rows for favourite movies, shows and music sit below
*Recently Added*. A row with nothing in it **does not render** — a fresh install
shows no empty favourite rows. Each row ends with *See all*, landing on the
matching tab of the Favourites page.
**The Favourites page** (`/library/favorites`) is the complete collection,
scoped by tabs:
```
┌─────────────────────────────────────────────────┐
│ [←] Favourites │
│ ┌─────┬────────┬───────┬───────┐ │
│ │ All │ Movies │ Shows │ Music │ ← scope tabs │
│ └─────┴────────┴───────┴───────┘ │
│ │
│ ┌────┐┌────┐┌────┐┌────┐┌────┐ │
│ │ ♥ ││ ♥ ││ ♥ ││ ♥ ││ ♥ │ grid/list │
│ └────┘└────┘└────┘└────┘└────┘ per §5A │
└─────────────────────────────────────────────────┘
```
- Cards obey §5A in full — shape follows the media, so a mixed *All* tab reads
as posters, squares and thumbnails side by side rather than one forced shape.
- Reached from a card on the library overview (`/library`) and from *See all*
on any home favourites row.
- Sorted by name. Jellyfin does not record *when* an item was favourited, so
"recently favourited" is not offerable — see §5C.5.
- Empty state, per tab: *"Nothing favourited yet — tap the heart on anything
you like."*
**The in-library filter** is for narrowing where the user already is: a
favourites toggle in the header of the Movies, TV and Music browse pages,
filtering the current list in place. It is **session-scoped and not persisted**
a sticky filter that silently hides most of a library reads as data loss on the
next launch.
### 5C.3 Removing a favourite removes it everywhere, at once
Un-hearting an item on the Favourites page removes its card from the grid
immediately; the same item disappears from the home rows and shows an empty
heart on its detail page without a manual refresh. The reverse holds for
favouriting. There is no confirmation prompt — the action is one tap to undo.
### 5C.4 Offline
- **Marking works offline.** The heart fills, the toast confirms, and the change
is held locally.
- **It reaches the server on reconnect**, without the user returning to the
screen where they made it.
- **Browsing offline shows favourites among media on the device**, subject to
the same "Show all server media" gate as every other browse surface (§7.2) —
with the gate off, an empty Favourites tab means *nothing favourited is
downloaded*, and the page does not quietly fall back to the server catalog.
### 5C.5 Known deviations
- **The full player has no heart.** §3.2 and §3.3 list a Favorite button among
the full player's secondary controls; it was never built, and this pass does
not add it. The mini player heart above it is the only in-player affordance.
*(UR-067)*
- **No "recently favourited" sort.** Jellyfin's API does not expose a favourite
timestamp, so favourites can only be ordered by name. Recording the
timestamp locally at toggle time would order *this device's* favourites only,
which is worse than a consistent name sort.
- **Music is one tab, not three.** The Music scope mixes albums, artists and
tracks in a single grid rather than offering sub-tabs. Acceptable while
favourite counts are small; revisit if the tab becomes unscannable.
---
## 6. Search Flow
Search is **context-scoped**: what you are looking at when you start a search
determines what the search covers. A search begun inside the Music library
searches music. A search begun from Home or the top-level library page searches
everything. The scope is always shown, and always overridable.
### 6.1 Scope is inherited from context
```mermaid
flowchart TB
Start[User starts a search] --> Where{Where from?}
Where -->|Home &#40;/&#41;| All[Scope: All]
Where -->|Library root &#40;/library&#41;| All
Where -->|Search tab| All
Where -->|Inside Music| Music[Scope: Music]
Where -->|Inside Movies| Movies[Scope: Movies]
Where -->|Inside TV| TV[Scope: TV]
All --> Chips[Filter chips shown<br/>All chip selected]
Music --> Chips2[Filter chips shown<br/>Music chip preselected]
Movies --> Chips2
TV --> Chips2
Chips --> Results[Results, grouped by type]
Chips2 --> Results
Results --> Change{User taps a chip}
Change --> Rescope[Re-run search at new scope<br/>query preserved]
Rescope --> Results
```
**Rules:**
- **Context sets the *initial* chip, never a locked filter.** Entering search
from TV preselects the TV chip; the user can tap "All" to widen without
retyping the query. Scope is a starting point, not a cage.
- **Home, `/library`, and the search tab all start at "All".** These are the
places a user has expressed no narrower intent.
- **Changing scope preserves the query** and re-runs the search. Changing the
query preserves the scope.
- **Scope maps to item types**, resolved at the point of search:
| Chip | `includeItemTypes` |
|------|--------------------|
| All | *(unset — every type)* |
| Music | `MusicAlbum`, `MusicArtist`, `Audio`, `Playlist` |
| Movies | `Movie` |
| TV | `Series`, `Episode` |
- **Chips render under the search bar**, on both the dedicated search page and
the in-library header search. They are horizontally scrollable if they
overflow, never wrapped onto a second row.
### 6.2 Search page layout
```
┌─────────────────────────────────────────┐
│ [🔍 Search...] [✕]
│ [🔍 Search...] [✕] │
│ │
│ ( All ) (•Music•) ( Movies ) ( TV ) │ ← scope chips
│ │
│ Songs ──────────────────────────── │
│ ♪ Song Title - Artist 3:45 │
│ ♪ Song Title - Artist 4:12 │
│ See all (23) │
│ ♪ Song Title - Artist 3:45 │
│ ♪ Song Title - Artist 4:12 │
│ See all (23)
│ │
│ Albums ─────────────────────────── │
│ [Album Cover] Album Title │
[Album Cover] Album Title
│ See all (8) │
│ [Cover] Album Title
See all (8)
│ │
│ Artists ────────────────────────── │
[Photo] Artist Name
│ See all (5) │
( Photo ) Artist Name │
│ See all (5)
└─────────────────────────────────────────┘
```
- Results stay **grouped by type** even when a scope is selected — a Music
search still separates Songs / Albums / Artists.
- Each group shows a bounded preview with a **See all (n)** affordance rather
than an unbounded list, so no single type can bury the others.
- Live search is **debounced** as the user types; a query that becomes empty
clears results rather than searching for the empty string.
### 6.3 Result group order is user-configurable
Which *kind* of thing a user is usually searching for is personal: a
music-first user wants Songs at the top, a TV-first user wants Shows. Rather
than guessing, the group order is a setting.
```mermaid
flowchart TB
Settings[Settings → Search] --> List[Draggable list of result groups]
List --> Drag[User drags a group up or down]
Drag --> Persist[Order persisted]
Persist --> Render[Rendering a result set]
Scope[Active scope chip §6.1] --> Render
Render --> Filter[1 - Drop groups outside the active scope]
Filter --> Sort[2 - Sort remaining groups by user order]
Sort --> Prune[3 - Omit groups with no results]
Prune --> Show[Render]
```
**Scope and order compose — they are two independent axes.** The scope chip
decides *which* groups are eligible; the settings list decides *what sequence*
the eligible ones appear in. Order is preserved as a relative ranking, never
renumbered per scope:
- Scope **Music** with order `Movies → Songs → Albums → Artists → TV` renders
`Songs → Albums → Artists`. Movies and TV are filtered out; the surviving
groups keep their relative order.
- Scope **All** with the same setting renders all five in exactly that order.
- **Changing scope never rewrites the saved order.** A user who narrows to
Music and back to All sees their original arrangement intact.
**Rules:**
- **Drag and drop to reorder**, in a settings list showing every result group
(Songs, Albums, Artists, Movies, TV Shows).
- **The order applies to grouped results everywhere** — the search page and
the in-library header search alike.
- **Order is presentation-only.** It never changes which results are returned
or how they are ranked *within* a group, only the sequence groups appear in.
- **Empty groups are skipped, not gapped.** A group with no results is omitted
entirely; it does not reserve space or leave a stray heading.
- **A sensible default ships** (Songs → Albums → Artists → Movies → TV Shows)
so the setting is an adjustment, never a prerequisite.
- **Keyboard/accessible reordering must exist** alongside dragging — a
drag-only control is unusable with a screen reader or without a pointer.
### 6.4 Known deviations
Recorded so the gap between this spec and the build is explicit.
- **Scope is not implemented.** The in-library header search calls the same
unscoped query as the global search page, so searching inside TV returns
music. The backend already accepts `includeItemTypes` on both the online and
offline paths, and the per-page list search already uses it — only the global
path ignores it. *(UR-049)*
- **Filter chips do not exist** on either search surface. *(UR-049)*
- **Group order is hardcoded** to Music → Movies → TV in the results markup,
with no setting. *(UR-050)*
---
## 7. Download Flows
@@ -532,68 +1067,166 @@ States:
5. [⏸] Paused - Yellow pause icon
```
### 7.2 Managing Downloads Page
### 7.2 Downloads = a browsable offline library, not a flat list
**The central idea:** "my downloads" is not a list of file-transfer rows — it is
*the library, filtered to what's on the device*. A user who has downloaded three
seasons of a show and two albums thinks in terms of shows and albums, not
seventy-odd individual episode/track transfers. So the primary Downloads surface
**reuses the library browse screens**, scoped to downloaded content, and keeps
the transfer-progress list as a secondary "Transfers" view for the *act* of
downloading.
This splits one overloaded page into two clear jobs:
| Surface | Answers | Reuses |
|---------|---------|--------|
| **Downloaded** (browse) | "What do I have offline, and let me play it" | Library grids, detail pages, cards (§5A) |
| **Transfers** (activity) | "What is downloading right now, and control it" | The existing progress-row list |
```mermaid
flowchart TB
User[User] --> NavChoice{Navigation Path}
Nav[Open Downloads] --> Downloads[/downloads]
NavChoice -->|Desktop| HeaderNav[Header: Click Downloads Link]
NavChoice -->|Mobile| HeaderIcon[Header: Click Downloads Icon]
NavChoice -->|Direct| TypeURL[Type /downloads]
Downloads --> View{View}
View -->|Downloaded &#40;default&#41;| Browse[Offline library browse]
View -->|Transfers| Activity[Transfer activity list]
HeaderNav --> DownloadsPage[Downloads Page<br/>/downloads]
HeaderIcon --> DownloadsPage
TypeURL --> DownloadsPage
Browse --> Libs[Libraries — only those with<br/>downloaded content]
Libs --> Grid[Library grid, offline-scoped<br/>same cards/layout as online §5A]
Grid --> Detail[Detail page<br/>same as online]
Detail --> Play[Play from local file]
Detail --> Remove[Remove download<br/>frees space, keeps browsable? — see rules]
DownloadsPage --> ShowTabs[Show Tabs:<br/>Active | Completed]
ShowTabs --> ActiveTab{Active Tab}
ActiveTab -->|Active| ShowActive[Show Active Downloads:<br/>- Download progress bars<br/>- Pause/Resume buttons<br/>- Cancel buttons]
ActiveTab -->|Completed| ShowCompleted[Show Completed:<br/>- Downloaded items list<br/>- Delete buttons<br/>- Play buttons]
ShowActive --> UserAction1{User Action}
UserAction1 -->|Pause| PauseDownload[Pause Download]
UserAction1 -->|Cancel| CancelDialog[Show Confirm Dialog]
ShowCompleted --> UserAction2{User Action}
UserAction2 -->|Play| PlayOffline[Play from Local File]
UserAction2 -->|Delete| DeleteDialog[Show Confirm Dialog]
Activity --> Rows[Per-transfer rows:<br/>downloading / queued / paused / failed /<br/>waiting-for-WiFi]
Rows --> Ctl[Pause / Resume / Cancel / Retry]
```
**Navigation to Downloads:**
- **Desktop:** Click "Downloads" link in header navigation
- **All screen sizes:** Click download icon (⬇) button in header user menu
- **Direct:** Navigate to `/downloads` route
**Why reuse the library screens (not a bespoke list):**
- **One mental model.** Browsing offline should feel identical to browsing
online — same grids, same card shapes, same detail pages, same play action.
The only difference is *what's present*, not *how it looks*.
- **It already works in the backend.** The offline repository's `get_items`
already returns downloaded items **plus** their containers (an album with any
downloaded track, a series/season with any downloaded episode). That is a
browsable tree today — see §7.4.
- **It scales.** A flat completed-list becomes unusable at a few dozen items; a
browsable library does not.
### 7.3 The Downloaded browse surface
**Downloads Page Layout:**
```
┌─────────────────────────────────────────┐
[←] Downloads │
[Active (3)] [Completed (12)]
─ Downloading ────────────────────
Album Cover Album Title │
Artist Name
[████████░░] 80%
[⏸ Pause] [✕ Cancel]
│ │
Album Cover Album Title
Artist Name
[██░░░░░░░░] 20%
│ [⏸ Pause] [✕ Cancel] │
│ │
│ ─ Queued ───────────────────────── │
│ │
│ Album Cover Album Title │
│ Artist Name │
│ Waiting... │
└─────────────────────────────────────────┘
┌─────────────────────────────────────────────
│ Downloads
( Downloaded ) ( Transfers ) ← view switch
[~ 3.4 GB on device · 12 items] Manage ▸ ← storage summary
Music ← only libraries that
┌────┐┌────┐┌────┐ │ have downloaded content
│alb ││alb ││art │
└────┘└────┘└────┘
TV
┌────┐┌────┐
│show││show│
└────┘└────┘
└─────────────────────────────────────────────┘
```
**Rules:**
- **Libraries with nothing downloaded are omitted**, not shown empty. If only
music is downloaded, only Music appears.
- **Cards, grids, and detail pages are the library's own** (§5A) — offline
browse is the same components with an offline-scoped data source, never a
parallel re-implementation.
- **A downloaded badge / "on device" affordance** distinguishes fully-downloaded
from partially-downloaded containers (e.g. a season with 6 of 10 episodes).
- **Disk usage is shown where the user already looks**, in familiar units — see
§7.3.1.
- **Play always plays the local file** here; nothing on this surface streams.
- **Remove is available at every level** — item, album/season, series — and
states clearly what it frees. Removing the last downloaded child of a
container removes the container from the browse.
- **This surface works identically online and offline.** It is "what's on the
device," a question whose answer does not depend on connectivity. It must not
wait for, or be emptied by, server reachability.
#### 7.3.1 Disk usage — familiar, in place, not a separate audit
Users want to know what each thing costs on disk, but that information has to
feel like the storage views they already know (phone Settings → Storage, a
file browser), not a developer's byte dump.
- **Size rides along with the item, on the card and the detail page** — a small
secondary label (`1.2 GB`, `340 MB`, `48 MB`), never a separate "storage
report" screen the user has to go find.
- **Containers show their total.** A series shows the sum of its downloaded
episodes; an album the sum of its tracks; a season its own subtotal. The
number a user sees on the "Breaking Bad" card is what removing it frees.
- **Human units, rounded, consistent.** Binary or decimal is a choice — pick one
and use it everywhere. Show 23 significant figures (`1.2 GB`, not
`1,283,048,192 bytes` and not `1.28394 GB`).
- **A single device total sits at the top** of the Downloaded surface
(`3.4 GB on device · 12 items`) so the headline number is answered before the
user scans. It reconciles with the sum of what's listed.
- **Remove restates the reclaim** in the same units at the point of action
("Remove download · frees 1.2 GB"), so the cost of keeping vs. freeing is
legible exactly when the user decides.
- **Sort/filter by size is a reasonable enhancement** ("biggest first" to find
what to clear) but is not required for v1.
The bytes-on-disk per item are a backend fact (the download manager writes the
files and can stat them); this is a display and aggregation task, not new
tracking. See §7.7 deviations for what's missing today.
### 7.4 Transfers (activity) view
The existing progress-row list, unchanged in spirit, demoted to a secondary tab.
It is about *transfers in flight*, so it shows only rows that are doing or
waiting to do something:
- **States:** downloading (with progress), queued, paused, failed,
waiting-for-WiFi (§7.5).
- **Controls:** Pause / Resume / Cancel / Retry per row; the 3-concurrent cap
and auto-pump are backend concerns and are not surfaced as manual controls.
- **Completed transfers fall off this view** once done — the finished item lives
in Downloaded, not here. A transient "just finished" confirmation is fine; a
permanent completed-list is not (that's what Downloaded is for).
- **Empty state** points at the library: "Nothing downloading. Browse your
library and tap download to save media for offline."
### 7.5 Navigation & entry points
- Reached via the account menu (§1.2) and, on desktop, the header Downloads
link/icon → `/downloads`.
- `/downloads` opens on **Downloaded** by default; **Transfers** is one tap away
and should draw attention (badge/count) only while transfers are active.
- Initiating a download is unchanged (§7.1): the download button lives on
item/album/series detail pages. The Downloads page manages and browses; it is
not where you start a download.
### 7.7 Known deviations
Recorded so the gap between this spec and the build is explicit.
- **Downloads is a flat two-tab list today** (Active / Completed), rendering one
row per individual transfer with no browsing, grouping, or reuse of the
library screens. Completed downloads never collapse into their album/series.
*(UR-055)*
- **No offline-scoped browse entry point exists in the client.** All browsing
goes through the hybrid repository, which merges cache **and** server; there is
no way to ask for "downloaded content only" as a browse surface. The offline
repository supports it (§7.2) but is not reachable independently. *(UR-055,
DR-082)*
- **The "on device" storage summary and per-container remove** are absent from
the completed list. *(UR-055, UR-056)*
- **Per-item disk usage is not displayed anywhere.** Cards and detail pages show
no size; there is no device total, no container subtotal, and Remove does not
state what it frees. *(UR-056)*
---
## 8. Settings & Account Flows
@@ -627,6 +1260,13 @@ flowchart TB
- **Mobile:** Click three-dot overflow menu → Select "Settings"
- **Direct:** Navigate to `/settings` route
**Settings apply instantly.** Every control on the Settings page persists the
moment the user changes it — toggling a switch, picking a level, or releasing a
slider writes that setting immediately. There is **no "Save" button** and no
save/dirty state to reason about; leaving the page never risks losing a change.
Sliders update their live readout while dragging but only persist on release
(`change`, not each `input` tick) to avoid flooding the backend.
### 8.2 Logout Flow
```mermaid
@@ -690,24 +1330,59 @@ flowchart TB
└─────────────────────────────────────────┘
```
### 9.2 Video Playback in Background
### 9.2 Video Playback in Background (Android — PiP & Background Audio)
Leaving the app while a **local video** is playing does not simply pause it.
What happens depends on which background behaviour is active. The two are
**mutually exclusive**, and both apply **only to locally-rendering video**
audio-only playback, library/menu browsing, and remote/cast sessions never
trigger PiP (see decision gate below).
```mermaid
flowchart TB
VideoPlaying[Video Playing] --> Background{User Action}
Leave[User leaves app<br/>Home / gesture / screen lock] --> Gate{Local video surface<br/>actively rendering?<br/>canEnterPip}
Background -->|Home Button| AutoPause[Automatically Pause]
Background -->|Screen Lock| AutoPause
Gate -->|No — audio, browsing,<br/>or remote/cast| Normal[App backgrounds normally<br/>audio, if any, continues via<br/>media notification &#40;§9.1&#41;]
AutoPause --> SaveProgress[Save Progress]
SaveProgress --> ShowNotification[Show Paused Notification:<br/>"Tap to Resume"]
Gate -->|Yes| Mode{Background mode armed?}
ShowNotification --> UserReturn{User Returns?}
Mode -->|Background-audio toggle ON<br/>UR-040| Handoff[Hand off to native audio service<br/>WebView &lt;video&gt; torn down,<br/>video decode stops, audio continues]
Mode -->|Default<br/>UR-041| PiP[Auto-enter Picture-in-Picture<br/>on onUserLeaveHint]
UserReturn -->|Tap Notification| ResumeVideo[Open App to Video Player]
UserReturn -->|Later| KeepPaused[Video Remains Paused]
PiP --> PiPWindow[Floating PiP window:<br/>- Video keeps rendering into surface<br/>- WebView hidden<br/>- Play/Pause RemoteAction<br/> &#40;reflects live player state&#41;]
ResumeVideo --> AskResume[Resume from Saved Position]
PiPWindow --> PiPReturn{User action}
PiPReturn -->|Tap window| Restore[Return to full player<br/>WebView restored, surface re-fit]
PiPReturn -->|Close window| Stop[Playback stops]
Handoff --> Foreground[On return to foreground:<br/>resume WebView video at position]
```
**Key rules:**
- **Video-only gate.** Auto-PiP is guarded by the native `canEnterPip` check
(local video surface actively rendering). Audio playback and menu/library
browsing background normally; remote/cast sessions render nothing locally, so
a PiP window would be an empty box and is refused. *(UR-041, IR-026)*
- **Only one background behaviour at a time.** The background-audio toggle
(UR-040) disarms auto-PiP while it is on, so a video is either handed to the
audio service *or* floated in PiP, never both.
- **PiP controls track the player.** The play/pause RemoteAction in the PiP
window reflects the live player state and updates on every playback-state
change, not only when the button is pressed. *(DR-053)*
- **Non-disruptive transition.** ExoPlayer keeps rendering into the same
surface across enter/exit, so entering or leaving PiP never interrupts the
video; on exit the surface is re-fit to full-screen bounds. *(DR-053)*
**PiP window (Android):**
```
┌───────────────────┐
│ │
│ ▶ video frame │
│ │
│ [⏸] │ ← play/pause RemoteAction
└───────────────────┘
sized to the video's aspect ratio
```
---
+13 -2
View File
@@ -1,6 +1,6 @@
{
"name": "jellytau",
"version": "0.1.0",
"version": "0.6.0",
"description": "",
"type": "module",
"packageManager": "bun@1.3.5",
@@ -17,18 +17,29 @@
"test:e2e:dev": "wdio run ./wdio.conf.ts --watch",
"test:all": "./scripts/test-all.sh",
"test:rust": "./scripts/test-rust.sh",
"check:boundary": "bash scripts/check-frontend-boundary.sh",
"android:build": "./scripts/build-android.sh",
"android:build:release": "./scripts/build-android.sh release",
"android:build:device": "./scripts/build-android.sh --device",
"android:build:release:device": "./scripts/build-android.sh release --device",
"android:build:clean": "rm -rf node_modules/.vite dist .svelte-kit .next build target src-tauri/target && bun install && bun run build",
"android:deploy": "./scripts/deploy-android.sh",
"android:dev": "./scripts/build-and-deploy.sh",
"android:check": "./scripts/check-android.sh",
"android:logs": "./scripts/logcat.sh",
"desktop:build:linux": "./scripts/build-desktop-linux.sh",
"desktop:build:arch": "./scripts/build-arch.sh",
"desktop:build:windows": "./scripts/build-windows-cross.sh",
"docker:build:linux": "docker compose run --rm desktop-linux-build",
"docker:build:arch": "docker compose run --rm arch-build",
"docker:build:windows": "docker compose run --rm windows-cross",
"clean": "./scripts/clean.sh",
"tauri": "tauri",
"traces": "bun run scripts/extract-traces.ts",
"traces:json": "bun run scripts/extract-traces.ts --format json",
"traces:markdown": "bun run scripts/extract-traces.ts --format markdown > docs/traceability.md"
"traces:markdown": "bun run scripts/extract-traces.ts --format markdown > docs/traceability.md",
"traces:coverage": "bun run scripts/extract-traces.ts --format coverage",
"release:notes": "bun run scripts/release-notes.ts"
},
"license": "MIT",
"dependencies": {
+52
View File
@@ -0,0 +1,52 @@
# Maintainer: Duncan Tourolle <duncan@tourolle.paris>
#
# JellyTau — a cross-platform Jellyfin client (Tauri + SvelteKit).
#
# This PKGBUILD builds from the local source tree by default (see the `dev`
# convenience below), which is what scripts/build-arch.sh uses inside the Arch
# Docker stage. For AUR distribution, replace the `source=()` line with a release
# tarball/VCS URL and drop the local-copy prepare() step.
pkgname=jellytau
pkgver=0.0.18
pkgrel=1
pkgdesc="A cross-platform Jellyfin client"
arch=('x86_64')
url="https://gitea.tourolle.paris/dtourolle/jellytau"
license=('MIT')
# Runtime: libmpv for audio, webkit2gtk for the webview + HTML5 transcoded video.
depends=('webkit2gtk-4.1' 'mpv' 'gtk3' 'libayatana-appindicator')
makedepends=('rust' 'cargo' 'bun' 'nodejs' 'pkgconf' 'libsoup3')
options=('!strip' '!lto')
# Populated from the working tree by scripts/build-arch.sh (SRC env var).
_srcdir="${JELLYTAU_SRC:-$startdir/../..}"
build() {
cd "$_srcdir"
export CARGO_HOME="${CARGO_HOME:-$srcdir/cargo-home}"
bun install --frozen-lockfile || bun install
bun run build
# Only the raw binary is needed; packaging is done in package() below so we
# control the Arch filesystem layout ourselves rather than via tauri-bundler.
(cd src-tauri && cargo build --release --locked)
}
package() {
cd "$_srcdir"
install -Dm755 "src-tauri/target/release/jellytau" \
"$pkgdir/usr/bin/jellytau"
# Desktop entry
install -Dm644 "packaging/arch/jellytau.desktop" \
"$pkgdir/usr/share/applications/jellytau.desktop"
# Icons (hicolor)
install -Dm644 "src-tauri/icons/32x32.png" \
"$pkgdir/usr/share/icons/hicolor/32x32/apps/jellytau.png"
install -Dm644 "src-tauri/icons/128x128.png" \
"$pkgdir/usr/share/icons/hicolor/128x128/apps/jellytau.png"
install -Dm644 "src-tauri/icons/128x128@2x.png" \
"$pkgdir/usr/share/icons/hicolor/256x256/apps/jellytau.png"
}
+9
View File
@@ -0,0 +1,9 @@
[Desktop Entry]
Type=Application
Name=JellyTau
Comment=A cross-platform Jellyfin client
Exec=jellytau
Icon=jellytau
Terminal=false
Categories=AudioVideo;Player;Audio;Video;
StartupWMClass=jellytau
+17 -1
View File
@@ -69,13 +69,29 @@ Extract requirement IDs (TRACES) from source code and generate a traceability ma
bun run traces # Generate markdown report
bun run traces:json # Generate JSON report
bun run traces:markdown # Save to docs/traceability.md
bun run traces:coverage # Coverage gate — exits non-zero below 50%
```
The script scans all TypeScript, Svelte, and Rust files looking for `TRACES:` comments and generates a comprehensive mapping of:
The script scans all TypeScript, Svelte, and Rust files (plus `scripts/`)
looking for `TRACES:` comments and generates a comprehensive mapping of:
- Which code files implement which requirements
- Line numbers and code context
- Coverage summary by requirement type (UR, IR, DR, JA)
**`bun run traces:coverage` is the supported way to check requirement coverage
locally** — it runs the same computation CI does. Coverage denominators are
derived from `docs/requirements.md` at run time; they are never hardcoded. An ID
that appears in a `TRACES:` comment but is not defined in `requirements.md` is
reported as *orphaned* and does not count toward coverage (see DR-093).
> **Removed:** `check-req-coverage.sh`, `check-test-coverage.sh`, and
> `find-req-implementations.sh` were deleted in July 2026. They read an
> undocumented `@req:` tag convention parallel to `TRACES:`, grepped `src-tauri/`
> unscoped (hanging on ~40 GB of `target/` artifacts), and in one case reported
> "all requirements implemented" from an empty result set. `extract-traces.ts` is
> the single source of truth for requirement coverage. See
> [docs/specs/req-coverage-script-removal.md](../docs/specs/req-coverage-script-removal.md).
Example TRACES comment in code:
```typescript
// TRACES: UR-005, UR-026 | DR-029
+12 -6
View File
@@ -3,15 +3,21 @@
set -e
BUILD_TYPE="${1:-debug}"
echo "🚀 Build and Deploy Android APK"
echo ""
# Build APK
./scripts/build-android.sh "$BUILD_TYPE"
# Pass all args (build type and/or --clean) through to the build script.
./scripts/build-android.sh "$@"
echo ""
# Deploy APK
./scripts/deploy-android.sh "$BUILD_TYPE"
# Deploy APK — forward the build type and the side-by-side flag (which decides
# which package to launch), ignoring build-only flags like --clean and --device.
DEPLOY_ARGS=("debug")
for arg in "$@"; do
case "$arg" in
debug|release) DEPLOY_ARGS[0]="$arg" ;;
--debug|--side-by-side) DEPLOY_ARGS+=("--side-by-side") ;;
esac
done
./scripts/deploy-android.sh "${DEPLOY_ARGS[@]}"
+81 -9
View File
@@ -15,13 +15,75 @@ echo "Android SDK: $ANDROID_HOME"
echo "NDK: $NDK_HOME"
echo ""
# Build type: debug or release (default: debug)
BUILD_TYPE="${1:-debug}"
# Parse args: build type (debug/release) and optional --clean flag.
# By default the build is INCREMENTAL — Cargo and Vite reuse their caches.
# Pass --clean (or CLEAN=1) to wipe all caches for a from-scratch build.
#
# ABI selection: by default Tauri builds all four ABIs (arm64/arm/x86/x86_64),
# which is what a distributable universal APK needs — but for an on-device test
# it means three wasted Rust compiles. Pass --device (or ABI=aarch64) to build
# only the connected device's architecture; --abi <t> targets one explicitly.
#
# Side-by-side: the `debug` build type always installs as
# com.dtourolle.jellytau.debug ("JellyTau Debug"), so it never collides with a
# real install. `release --debug` puts a *release* build — R8-minified, exactly
# what ships — into that same slot, signed with the local debug keystore. That
# is how you validate minification (R8 stripping JNI-loaded classes has broken
# release APKs here before) without the real signing key and without
# uninstalling the app you actually use.
BUILD_TYPE="debug"
CLEAN="${CLEAN:-0}"
ABI="${ABI:-}"
SIDE_BY_SIDE="${SIDE_BY_SIDE:-0}"
next_is_abi=0
for arg in "$@"; do
if [ "$next_is_abi" = "1" ]; then
ABI="$arg"
next_is_abi=0
continue
fi
case "$arg" in
--clean) CLEAN=1 ;;
--abi) next_is_abi=1 ;;
--device) ABI="device" ;;
--debug|--side-by-side) SIDE_BY_SIDE=1 ;;
debug|release) BUILD_TYPE="$arg" ;;
esac
done
# Step 0: Clear build caches to ensure fresh builds
echo "🧹 Clearing build caches..."
rm -rf node_modules/.vite dist .svelte-kit .next build target src-tauri/target 2>/dev/null || true
npm install > /dev/null 2>&1
# The debug build type is side-by-side unconditionally; the flag only means
# something for a release build.
if [ "$BUILD_TYPE" = "debug" ]; then
SIDE_BY_SIDE=1
fi
# Resolve --device to the attached device's Rust target triple.
if [ "$ABI" = "device" ]; then
device_abi="$(adb shell getprop ro.product.cpu.abi 2>/dev/null | tr -d '\r\n')"
case "$device_abi" in
arm64-v8a) ABI="aarch64" ;;
armeabi-v7a) ABI="armv7" ;;
x86_64) ABI="x86_64" ;;
x86) ABI="i686" ;;
*)
echo "⚠️ Could not detect device ABI (got '${device_abi:-none}') — building all targets."
ABI=""
;;
esac
[ -n "$ABI" ] && echo "🎯 Device ABI $device_abi → building only '$ABI'"
fi
TARGET_ARGS=()
if [ -n "$ABI" ]; then
TARGET_ARGS=(--target "$ABI")
fi
# Step 0: Optionally clear build caches for a fully fresh build.
if [ "$CLEAN" = "1" ]; then
echo "🧹 Clearing build caches (clean build)..."
rm -rf node_modules/.vite dist .svelte-kit .next build target src-tauri/target 2>/dev/null || true
npm install > /dev/null 2>&1
fi
# Step 1: Sync Android source files
echo "🔄 Syncing Android sources..."
@@ -32,12 +94,22 @@ echo "🎨 Building frontend..."
bun run build
# Step 2: Build Android APK
if [ "$BUILD_TYPE" = "release" ]; then
if [ "$BUILD_TYPE" = "release" ] && [ "$SIDE_BY_SIDE" = "1" ]; then
# A release build in the debug slot: R8 still runs, but the applicationId is
# suffixed and the debug keystore signs it (read by build.gradle.kts from
# JT_SIDE_BY_SIDE), so the real key is not needed and it replaces any other
# .debug install cleanly. Deliberately does NOT write keystore.properties.
echo "📦 Building side-by-side release APK (com.dtourolle.jellytau.debug)..."
JT_SIDE_BY_SIDE=1 bun run tauri android build --apk true "${TARGET_ARGS[@]}"
elif [ "$BUILD_TYPE" = "release" ]; then
# Configure release signing from .env (single source of truth). Must run
# after sync-android-sources.sh, since gen/android is (re)generated there.
./scripts/write-keystore-properties.sh
echo "📦 Building release APK..."
bun run tauri android build --apk true
bun run tauri android build --apk true "${TARGET_ARGS[@]}"
else
echo "📦 Building debug APK..."
bun run tauri android build --apk true --debug
bun run tauri android build --apk true --debug "${TARGET_ARGS[@]}"
fi
echo ""
+36
View File
@@ -0,0 +1,36 @@
#!/bin/bash
# Build an Arch Linux package (.pkg.tar.zst) for JellyTau via makepkg.
#
# Tauri's bundler has no pacman target (as of tauri-cli 2.9.x), so we ship a
# hand-written PKGBUILD in packaging/arch/ and build it with makepkg. This must
# run on an Arch host / the `arch-build` Docker stage — makepkg is Arch-specific
# and refuses to run as root, so run it as a non-root user with sudo for deps.
#
# Usage (typically inside the arch-build Docker stage as a non-root user):
# scripts/build-arch.sh
# OUTPUT_DIR=/app/dist scripts/build-arch.sh
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$REPO_ROOT/packaging/arch"
echo "🏛️ Building JellyTau Arch package"
echo "=================================="
# Point the PKGBUILD at the working tree and give cargo/bun a writable home.
export JELLYTAU_SRC="$REPO_ROOT"
export CARGO_HOME="${CARGO_HOME:-$REPO_ROOT/.cargo-arch}"
# -s installs missing deps (needs sudo/root privileges for pacman), -f overwrites.
makepkg -sf --noconfirm
echo ""
echo "✅ Built Arch package(s):"
ls -1 ./*.pkg.tar.zst
if [[ -n "${OUTPUT_DIR:-}" ]]; then
mkdir -p "$OUTPUT_DIR"
cp -v ./*.pkg.tar.zst "$OUTPUT_DIR/"
echo ""
echo "📦 Copied Arch package(s) to $OUTPUT_DIR"
fi
+44
View File
@@ -0,0 +1,44 @@
#!/bin/bash
# Build Linux desktop packages (deb + rpm) for JellyTau.
#
# Produces bundles under src-tauri/target/release/bundle/{deb,rpm}.
# Runs on the existing Ubuntu builder image. NOTE: Tauri has no pacman bundle
# target — the Arch package is built separately with makepkg (scripts/build-arch.sh
# / Dockerfile.arch). `appimage` is also available if you want a portable bundle.
#
# Usage:
# scripts/build-desktop-linux.sh # deb + rpm
# BUNDLES="deb,appimage" scripts/build-desktop-linux.sh # subset / add appimage
# OUTPUT_DIR=/app/dist scripts/build-desktop-linux.sh # copy bundles out
set -euo pipefail
cd "$(dirname "$0")/.."
BUNDLES="${BUNDLES:-deb,rpm}"
echo "🐧 Building JellyTau Linux desktop packages"
echo "==========================================="
echo "Bundles: $BUNDLES"
echo ""
bun install --frozen-lockfile 2>/dev/null || bun install
bun run build
# --bundles overrides tauri.conf.json bundle.targets so this script controls
# exactly which Linux formats are produced (never NSIS here).
bun run tauri build --bundles "$BUNDLES"
BUNDLE_ROOT="src-tauri/target/release/bundle"
echo ""
echo "✅ Built packages:"
find "$BUNDLE_ROOT" -maxdepth 2 -type f \
\( -name '*.deb' -o -name '*.rpm' -o -name '*.AppImage' \) -print
if [[ -n "${OUTPUT_DIR:-}" ]]; then
mkdir -p "$OUTPUT_DIR"
find "$BUNDLE_ROOT" -maxdepth 2 -type f \
\( -name '*.deb' -o -name '*.rpm' -o -name '*.AppImage' \) \
-exec cp -v {} "$OUTPUT_DIR/" \;
echo ""
echo "📦 Copied bundles to $OUTPUT_DIR"
fi
+69
View File
@@ -0,0 +1,69 @@
#!/bin/bash
# Cross-compile JellyTau for Windows from Linux, producing an NSIS installer.
#
# Uses the OFFICIAL Tauri cross-compile path (https://v2.tauri.app/distribute/
# windows-installer/): the MSVC target driven by cargo-xwin, which downloads the
# MSVC CRT/Windows SDK headers and links with lld. This is the target Tauri
# officially supports for Windows (the mingw/GNU target is not), and unlike GNU
# it can bundle the NSIS installer from a Linux host.
#
# Playback on Windows: video renders via WebView2 and audio via the webview
# <audio> backend (WebviewAudioBackend) — see docs/build-windows.md.
#
# Requirements (present in the Docker windows-cross target / unified builder):
# - rustup target x86_64-pc-windows-msvc
# - cargo-xwin (cargo install --locked cargo-xwin)
# - lld, llvm (linker + llvm-lib used by cargo-xwin)
# - nsis (makensis) (installer generator)
#
# Usage:
# scripts/build-windows-cross.sh # exe + NSIS installer
# WIN_BUNDLES=none scripts/build-windows-cross.sh # exe only, skip bundling
# OUTPUT_DIR=/app/dist scripts/build-windows-cross.sh
set -euo pipefail
cd "$(dirname "$0")/.."
TARGET="x86_64-pc-windows-msvc"
WIN_BUNDLES="${WIN_BUNDLES:-nsis}"
echo "🪟 Cross-compiling JellyTau for Windows ($TARGET, via cargo-xwin)"
echo "================================================================"
echo "Video plays via WebView2; audio via the webview <audio> backend."
echo "Bundles: $WIN_BUNDLES"
echo ""
bun install --frozen-lockfile 2>/dev/null || bun install
bun run build
# --runner cargo-xwin + the MSVC target is what makes the Tauri CLI treat this as
# a real Windows build and enable the nsis/msi bundlers on a Linux host.
#
# IMPORTANT: do NOT pass `--bundles nsis` here. tauri-cli 2.9.x validates the
# `--bundles` flag against a static clap enum gated by the HOST OS (Linux allows
# only deb/rpm/appimage) *before* it considers --target/--runner, so `--bundles
# nsis` is rejected at arg-parse time. Instead the Windows bundle targets come
# from tauri.conf.json (bundle.targets includes "nsis"), which is not subject to
# that CLI validation — the bundler then picks nsis once it knows the target is
# Windows.
if [[ "$WIN_BUNDLES" == "none" ]]; then
bun run tauri build --runner cargo-xwin --target "$TARGET" --no-bundle
else
bun run tauri build --runner cargo-xwin --target "$TARGET"
fi
BIN_DIR="src-tauri/target/$TARGET/release"
echo ""
echo "✅ Built Windows artifacts:"
find "$BIN_DIR" -maxdepth 1 -name '*.exe' -print
find "$BIN_DIR/bundle" -type f \( -name '*.exe' -o -name '*.msi' \) -print 2>/dev/null || true
if [[ -n "${OUTPUT_DIR:-}" ]]; then
mkdir -p "$OUTPUT_DIR"
find "$BIN_DIR" -maxdepth 1 -name 'jellytau.exe' -exec cp -v {} "$OUTPUT_DIR/" \;
# NSIS setup installers land in bundle/nsis/*-setup.exe; MSI in bundle/msi/*.msi.
find "$BIN_DIR/bundle" -type f \( -name '*-setup.exe' -o -name '*.msi' \) \
-exec cp -v {} "$OUTPUT_DIR/" \; 2>/dev/null || true
echo ""
echo "📦 Copied Windows artifacts to $OUTPUT_DIR"
fi
+140
View File
@@ -0,0 +1,140 @@
#!/usr/bin/env bash
# Boundary tripwire: flag domain-taxonomy leaks in the Svelte frontend.
#
# Implements DR-094 (see docs/requirements.md).
#
# The project rule (CLAUDE.md, docs/architecture/02-svelte-frontend.md) is that
# the frontend is presentation-only and the Rust backend owns domain logic —
# including Jellyfin's item-type *taxonomy* (what the category "Music" means as a
# set of item types). See docs/specs/scoped-search-boundary.md for the incident
# that motivated this check.
#
# ⚠️ This is a TRIPWIRE, NOT A PROOF. A grep cannot distinguish taxonomy-as-policy
# (a leak) from taxonomy-as-display (legitimate: "is this a music card?"). It
# targets the machine-detectable signature of the leak class and defers
# everything subtler to the human spec-review checklist
# (docs/specs/SPEC-REVIEW-CHECKLIST.md). A clean run here does not mean the
# boundary is respected; it means the crudest violation isn't present.
#
# What it flags: an array literal naming two or more Jellyfin item types,
# ANYWHERE in src/ — i.e. the frontend deciding that a *category* maps to a *set*
# of Jellyfin types, which is domain knowledge the backend should own.
# Single-type arrays (`includeItemTypes: ["Movie"]`) are a page saying "I show
# movies" and are allowed. Type *inspection* (`item.type === "Audio"`) is display
# logic and is not matched.
#
# 🔴 What it still CANNOT see (do not read a green run as proof):
# - a type set built at run time: [...musicTypes, "Playlist"]
# - types split across variables: const A = "Audio"; [A, B]
# - taxonomy as control flow: switch (t) { case "Audio": … }
# t === "Audio" || t === "MusicAlbum"
# - an item type absent from ITEM_TYPES below (false negative by design)
#
# This check was hardened in July 2026 after the audit found it passing on the
# very leak it was written for: the original pattern was anchored to
# `includeItemTypes:` at the query site, so assigning the same array to a named
# const evaded it entirely. See docs/specs/boundary-tripwire-hardening.md (DR-094).
#
# Escaping a genuine exception: add the file+reason to the ALLOWLIST below.
set -euo pipefail
cd "$(dirname "$0")/.."
# Files permitted to contain a multi-type item-type array, with the reason.
# Keep this SHORT. A growing allowlist means the boundary is eroding — that is a
# signal to push taxonomy into Rust, not to keep appending here.
ALLOWLIST=(
# "Things a person appeared in" is arguably taxonomy, but it is a fixed
# two-type filmography query with no category-configuration behind it. Tracked
# as acceptable pending any person-scope work; revisit if it grows.
"src/lib/components/library/PersonDetailView.svelte"
# Grid styling predicate over `config.itemType`, a value the page already
# declares about itself. Selects a *look*, issues no query, and would only
# change if the UI were redesigned — presentation, not taxonomy-as-policy.
"src/lib/components/library/GenericMediaListPage.svelte"
# "Is this item a container?" predicate for downloads browsing.
# BORDERLINE — leans domain: the container set grows when Jellyfin adds a
# container type. TODO: replace with a backend-supplied `MediaItem.isContainer`
# flag and remove this entry. Tracked in
# docs/specs/boundary-tripwire-hardening.md §Out of scope.
"src/lib/components/downloads/DownloadedBrowse.svelte"
)
# Hard cap so erosion is caught mechanically rather than by whoever notices.
# Deliberately just above the current count: the next exception forces a
# conversation instead of a one-line append.
MAX_ALLOWLIST=4
if [[ "${#ALLOWLIST[@]}" -gt "$MAX_ALLOWLIST" ]]; then
echo "❌ Allowlist has ${#ALLOWLIST[@]} entries (max $MAX_ALLOWLIST)."
echo " Push taxonomy into Rust instead of appending here."
exit 1
fi
is_allowed() {
local file="$1"
for allowed in "${ALLOWLIST[@]}"; do
[[ "$file" == "$allowed" ]] && return 0
done
return 1
}
# Two or more adjacent Jellyfin item-type string literals inside a bracket.
#
# NOT anchored to `includeItemTypes:` — that was the original rule, and it missed
# the real leak: `searchScope.ts` assigned the same array to a named const and
# dereferenced it one indirection away from the query, so the grep never saw it
# while CI stayed green. Matching the array literal itself catches a const, a
# Record value, a function return, and an inline query alike.
#
# Deliberate limits:
# - requires TWO adjacent types, so single-type presentation
# (`itemType: "Movie"`) stays legal — the rule targets *category* taxonomy;
# - requires string literals, so `item.type === "Audio"` (display inspection)
# does not match;
# - uses an explicit type list rather than a generic capitalised-word pattern,
# so unrelated string arrays (`["High","Low"]`) produce no noise.
#
# An item type missing from this list is a false *negative*, never a false
# positive — the check degrades safely as Jellyfin adds types.
ITEM_TYPES='Movie|Series|Episode|Audio|MusicAlbum|MusicArtist|MusicVideo|Season|BoxSet|Playlist|Book|AudioBook|Video|Person|Folder|CollectionFolder|TvChannel|LiveTvChannel'
PATTERN="\[[[:space:]]*\"($ITEM_TYPES)\"[[:space:]]*,[[:space:]]*\"($ITEM_TYPES)\""
echo "🔎 Checking frontend for domain-taxonomy leaks (item-type array literals)…"
# Collect hits, excluding tests and the allowlist.
violations=""
while IFS= read -r line; do
[[ -z "$line" ]] && continue
file="${line%%:*}"
case "$file" in
*.test.*) continue ;;
esac
if is_allowed "$file"; then
echo " ⏭️ allowlisted: $line"
continue
fi
violations+="$line"$'\n'
done < <(grep -rInE "$PATTERN" src/ 2>/dev/null || true)
if [[ -n "$violations" ]]; then
echo ""
echo "❌ Frontend boundary violation: an item-type array literal defines a"
echo " category in the presentation layer. That taxonomy belongs in Rust —"
echo " send an opaque scope/enum and let the backend expand it to item types"
echo " (see SearchScope::item_types() in src-tauri/src/repository/types.rs)."
echo " Assigning the array to a const does not make it presentation."
echo " See docs/specs/scoped-search-boundary.md and CLAUDE.md."
echo ""
echo "$violations" | sed 's/^/ /'
echo " If this is a genuine exception, add the file + reason to ALLOWLIST in"
echo " scripts/check-frontend-boundary.sh — but prefer moving it to Rust."
exit 1
fi
echo "✅ No multi-type taxonomy queries in the frontend."
echo " (Reminder: this is a tripwire, not a proof — the spec-review checklist is"
echo " the real gate for subtler leaks.)"
-84
View File
@@ -1,84 +0,0 @@
#!/bin/bash
#
# Requirements Coverage Checker
# Extracts @req tags from codebase and compares with README.md
#
set -e
REQUIREMENTS_FILE="README.md"
SOURCE_DIRS="src-tauri/ src/"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo " Requirements Coverage Report"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
# Extract requirement IDs from README.md (UR-, IR-, DR-, JA-)
echo "📊 Scanning requirements from $REQUIREMENTS_FILE..."
requirements=$(grep -E "^\| (UR|IR|DR|JA)-[0-9]+" "$REQUIREMENTS_FILE" | \
sed -E 's/^\| ([A-Z]+-[0-9]+).*/\1/' | \
sort -u)
total_reqs=$(echo "$requirements" | wc -l)
implemented=0
partial=0
planned=0
missing=0
echo ""
echo "Category Breakdown:"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
for category in UR IR DR JA; do
cat_count=$(echo "$requirements" | grep "^$category-" | wc -l)
printf "%-4s %3d requirements\n" "$category:" "$cat_count"
done
echo ""
echo "Implementation Status:"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
for req in $requirements; do
# Count full implementations
full_count=$(grep -r "@req: $req" $SOURCE_DIRS 2>/dev/null | grep -v "@req-partial" | grep -v "@req-planned" | wc -l)
# Count partial implementations
partial_count=$(grep -r "@req-partial: $req" $SOURCE_DIRS 2>/dev/null | wc -l)
# Count planned
planned_count=$(grep -r "@req-planned: $req" $SOURCE_DIRS 2>/dev/null | wc -l)
if [ "$full_count" -gt 0 ]; then
echo "$req: $full_count implementation(s)"
((implemented++))
elif [ "$partial_count" -gt 0 ]; then
echo "🔶 $req: $partial_count partial implementation(s)"
((partial++))
elif [ "$planned_count" -gt 0 ]; then
echo "📋 $req: Planned (not yet implemented)"
((planned++))
else
echo "$req: No implementation found"
((missing++))
fi
done
echo ""
echo "Summary:"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
printf "Total Requirements: %3d\n" "$total_reqs"
printf "✅ Fully Implemented: %3d (%.0f%%)\n" "$implemented" "$(echo "scale=0; $implemented * 100 / $total_reqs" | bc)"
printf "🔶 Partially Implemented: %3d (%.0f%%)\n" "$partial" "$(echo "scale=0; $partial * 100 / $total_reqs" | bc)"
printf "📋 Planned: %3d (%.0f%%)\n" "$planned" "$(echo "scale=0; $planned * 100 / $total_reqs" | bc)"
printf "❌ Missing: %3d (%.0f%%)\n" "$missing" "$(echo "scale=0; $missing * 100 / $total_reqs" | bc)"
echo ""
# Exit code based on missing critical requirements
if [ "$missing" -gt 0 ]; then
echo "⚠️ Warning: $missing requirements have no implementation"
exit 1
else
echo "✨ All requirements have implementations!"
exit 0
fi
-40
View File
@@ -1,40 +0,0 @@
#!/bin/bash
#
# Test Coverage Report
# Links test requirements to implementations
#
echo "Test Coverage Report"
echo "===================="
echo ""
test_reqs=$(grep -rh "@req-test:" src-tauri/ 2>/dev/null | \
sed 's/.*@req-test: \([A-Z][A-Z]-[0-9]*\).*/\1/' | \
sort -u)
total_tests=0
covered=0
uncovered=0
for req in $test_reqs; do
test_count=$(grep -r "@req-test: $req" src-tauri/ 2>/dev/null | wc -l)
impl_count=$(grep -r "@req: $req" src-tauri/ src/ 2>/dev/null | wc -l)
((total_tests++))
if [ "$test_count" -gt 0 ] && [ "$impl_count" -gt 0 ]; then
echo "$req: $test_count test(s), $impl_count implementation(s)"
((covered++))
elif [ "$impl_count" -eq 0 ]; then
echo "⚠️ $req: $test_count test(s) but no implementation"
((uncovered++))
fi
done
echo ""
echo "Summary:"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
printf "Total Test Requirements: %3d\n" "$total_tests"
printf "✅ With Implementation: %3d (%.0f%%)\n" "$covered" "$(echo "scale=0; $covered * 100 / $total_tests" | bc)"
printf "⚠️ No Implementation: %3d (%.0f%%)\n" "$uncovered" "$(echo "scale=0; $uncovered * 100 / $total_tests" | bc)"
echo ""
+40 -5
View File
@@ -13,25 +13,60 @@ if ! adb devices | grep -q "device$"; then
exit 1
fi
# Build type: debug or release (default: debug)
BUILD_TYPE="${1:-debug}"
# Build type: debug or release (default: debug). `--debug` alongside `release`
# means the side-by-side release build — same APK path, but it was packaged
# under the .debug applicationId, so the package to launch differs.
BUILD_TYPE="debug"
SIDE_BY_SIDE=0
for arg in "$@"; do
case "$arg" in
--debug|--side-by-side) SIDE_BY_SIDE=1 ;;
debug|release) BUILD_TYPE="$arg" ;;
esac
done
[ "$BUILD_TYPE" = "debug" ] && SIDE_BY_SIDE=1
# The .debug applicationId (see src-tauri/android/app/build.gradle.kts) is a
# separate package, so it installs alongside a real release build — no
# uninstall dance needed.
if [ "$BUILD_TYPE" = "release" ]; then
APK_PATH="src-tauri/gen/android/app/build/outputs/apk/universal/release/app-universal-release.apk"
else
APK_PATH="src-tauri/gen/android/app/build/outputs/apk/universal/debug/app-universal-debug.apk"
fi
if [ "$SIDE_BY_SIDE" = "1" ]; then
APP_PACKAGE="com.dtourolle.jellytau.debug"
else
APP_PACKAGE="com.dtourolle.jellytau"
fi
# Check if APK exists
if [ ! -f "$APK_PATH" ]; then
echo "❌ APK not found at: $APK_PATH"
echo "Run './scripts/build-android.sh $BUILD_TYPE' first"
if [ "$BUILD_TYPE" = "release" ] && [ "$SIDE_BY_SIDE" = "1" ]; then
echo "Run './scripts/build-android.sh release --debug' first"
else
echo "Run './scripts/build-android.sh $BUILD_TYPE' first"
fi
exit 1
fi
echo "📦 Installing APK: $APK_PATH"
adb install -r "$APK_PATH"
echo "📛 Package: $APP_PACKAGE"
if ! adb install -r "$APK_PATH"; then
echo ""
echo "❌ Install failed."
echo " If it says INSTALL_FAILED_UPDATE_INCOMPATIBLE, an older build of"
echo " '$APP_PACKAGE' signed with a different key is still installed."
echo " Uninstall just that one and retry:"
echo " adb uninstall $APP_PACKAGE"
exit 1
fi
echo ""
echo "✅ Deployment complete!"
echo "🚀 Launch the app on your device"
echo "🚀 Launching..."
adb shell monkey -p "$APP_PACKAGE" -c android.intent.category.LAUNCHER 1 > /dev/null 2>&1 \
|| echo " (auto-launch failed — start it from the launcher)"
+182
View File
@@ -0,0 +1,182 @@
/**
* Tests for the traceability coverage computation.
*
* These run over fixture strings rather than the live docs/requirements.md, so
* their meaning does not drift as requirements are added.
*
* Background: the CI gate divided traced-requirement counts by hardcoded
* denominators (UR/39, IR/24, DR/48, JA/3, total 114) that had fallen out of
* date, reporting 158% coverage and making the 50% threshold unreachable. These
* tests pin the parsing and arithmetic that replace those literals.
*
* @req-test: UT-089 - Requirement definitions parsed from requirements.md
* @req-test: UT-090 - Coverage is the intersection of traced and defined IDs
*/
import { describe, it, expect } from "vitest";
import { countDefinedRequirements, computeCoverage } from "./extract-traces";
describe("countDefinedRequirements", () => {
it("counts a well-formed table row as a defined requirement", () => {
const md = `
| ID | Requirement | Priority | Status |
|----|-------------|----------|--------|
| UR-001 | Run the app on multiple platforms | High | In Progress |
| UR-002 | Access media when online or offline | High | Done |
`;
const defined = countDefinedRequirements(md);
expect(defined.UR).toBe(2);
expect(defined.DR).toBe(0);
});
it("does not count IDs that appear only in the Traces To column", () => {
// The bug this rule avoids: a naive grep for /DR-\d{3}/ over the whole file
// counts DR-001 here as "defined", inflating the denominator with IDs that
// are merely referenced.
const md = `
| DR-001 | Player state machine | Player | UR-005 | Done |
| DR-002 | MediaItem struct | Player | UR-003, UR-004 | Done |
`;
const defined = countDefinedRequirements(md);
expect(defined.DR).toBe(2);
// UR-005/UR-003/UR-004 are referenced, never defined here.
expect(defined.UR).toBe(0);
});
it("does not count IDs mentioned in prose", () => {
const md = `
Some prose explaining that UR-005 relates to DR-001 and JA-002.
| UR-005 | Control media playback | High | Done |
`;
const defined = countDefinedRequirements(md);
expect(defined.UR).toBe(1);
expect(defined.DR).toBe(0);
expect(defined.JA).toBe(0);
});
it("deduplicates an ID listed in both the spec table and the traceability matrix", () => {
// requirements.md lists every UR twice: once in §1 (definition) and again in
// §3 (traceability matrix), both as a leading table cell. Counting rows
// instead of unique IDs double-counts the UR denominator (121 vs 61).
const md = `
| UR-005 | Control media playback | High | Done |
| UR-006 | Browse the library | High | Done |
### Traceability Matrix
| UR-005 | - | DR-001, DR-005, DR-009 |
| UR-006 | - | DR-012 |
`;
const defined = countDefinedRequirements(md);
expect(defined.UR).toBe(2);
});
it("collects the defined ID set, not just counts", () => {
const md = `
| UR-001 | A | High | Done |
| DR-050 | B | Player | UR-001 | Done |
`;
const defined = countDefinedRequirements(md);
expect(defined.ids.has("UR-001")).toBe(true);
expect(defined.ids.has("DR-050")).toBe(true);
expect(defined.ids.has("UR-999")).toBe(false);
});
});
describe("computeCoverage", () => {
const defined = {
UR: 2,
IR: 0,
DR: 2,
JA: 0,
total: 4,
ids: new Set(["UR-001", "UR-002", "DR-001", "DR-002"]),
};
it("computes coverage as traced ∩ defined over defined", () => {
const traced = ["UR-001", "DR-001"];
const cov = computeCoverage(traced, defined);
expect(cov.covered).toBe(2);
expect(cov.total).toBe(4);
expect(cov.percent).toBe(50);
});
it("does not let a traced-but-undefined ID inflate the numerator", () => {
// This is how a ratio exceeds 100%: a TRACES comment naming a typo'd or
// deleted requirement counted as covered.
const traced = ["UR-001", "DR-001", "DR-097"];
const cov = computeCoverage(traced, defined);
expect(cov.covered).toBe(2);
expect(cov.percent).toBe(50);
});
it("reports traced-but-undefined IDs as orphaned so they get fixed", () => {
const traced = ["UR-001", "DR-097", "JA-404"];
const cov = computeCoverage(traced, defined);
expect(cov.orphaned).toEqual(["DR-097", "JA-404"]);
});
it("has no orphans when every traced ID is defined", () => {
const cov = computeCoverage(["UR-001", "UR-002"], defined);
expect(cov.orphaned).toEqual([]);
});
it("ignores UT/IT test IDs entirely — they are a separate taxonomy", () => {
// UT/IT are defined in §4 of requirements.md, not among the four
// requirement types. Treating them as orphans buries real typos in ~60
// lines of noise, and counting them would corrupt the ratio.
const cov = computeCoverage(["UR-001", "UT-088", "IT-017"], defined);
expect(cov.orphaned).toEqual([]);
expect(cov.covered).toBe(1);
});
it("reports 0% rather than dividing by zero for an empty trace set", () => {
const cov = computeCoverage([], defined);
expect(cov.covered).toBe(0);
expect(cov.percent).toBe(0);
});
it("reports 0% rather than NaN when nothing is defined", () => {
const empty = { UR: 0, IR: 0, DR: 0, JA: 0, total: 0, ids: new Set<string>() };
const cov = computeCoverage([], empty);
expect(cov.percent).toBe(0);
expect(Number.isNaN(cov.percent)).toBe(false);
});
it("reports exactly 100% when all defined requirements are traced, never above", () => {
const traced = ["UR-001", "UR-002", "DR-001", "DR-002"];
const cov = computeCoverage(traced, defined);
expect(cov.percent).toBe(100);
});
it("ignores duplicate traced IDs", () => {
const traced = ["UR-001", "UR-001", "UR-001"];
const cov = computeCoverage(traced, defined);
expect(cov.covered).toBe(1);
});
});
describe("live requirements.md", () => {
it("parses the real file to the counts the CI gate must use", () => {
// Guards the specific regression: CI hardcoded UR/39, IR/24, DR/48, JA/3
// (total 114) while the real file had grown to 211. Update these numbers
// deliberately when requirements are added — that edit is the signal the
// denominator is live rather than frozen.
const fs = require("fs");
const path = require("path");
// import.meta.dir is Bun-only; derive from import.meta.url under vitest.
const here = path.dirname(new URL(import.meta.url).pathname);
const md = fs.readFileSync(
path.resolve(here, "../docs/requirements.md"),
"utf-8"
);
const defined = countDefinedRequirements(md);
expect(defined.UR).toBe(75);
expect(defined.IR).toBe(32);
expect(defined.DR).toBe(185);
expect(defined.JA).toBe(35);
expect(defined.total).toBe(327);
});
});
+193 -16
View File
@@ -34,11 +34,20 @@ interface TracesData {
DR: string[];
JA: string[];
};
/** Requirements *defined* in requirements.md — the coverage denominators. */
defined?: { UR: number; IR: number; DR: number; JA: number; total: number };
coverage?: CoverageResult;
}
// Repo root, derived from this script's location (scripts/ -> repo root).
// Must NOT be hardcoded to a developer's machine, or CI checkouts see no files.
const BASE_DIR = path.resolve(import.meta.dir, "..");
//
// `import.meta.dir` is a Bun extension and is undefined when this module is
// imported by vitest (which runs it as an ordinary ESM module), so fall back to
// import.meta.url — this file must stay importable for extract-traces.test.ts.
const SCRIPT_DIR =
import.meta.dir ?? path.dirname(new URL(import.meta.url).pathname);
const BASE_DIR = path.resolve(SCRIPT_DIR, "..");
const TRACES_PATTERN = /TRACES:\s*([^\n]+)/gi;
const REQ_ID_PATTERN = /([A-Z]{2})-(\d{3})/g;
@@ -50,7 +59,10 @@ function extractRequirementIds(tracesString: string): string[] {
function getAllSourceFiles(): string[] {
const baseDir = BASE_DIR;
const patterns = ["src", "src-tauri/src"];
// `scripts` is scanned too: build tooling implements requirements (e.g.
// DR-093, the coverage engine itself) and would otherwise be invisible to the
// very matrix it generates.
const patterns = ["src", "src-tauri/src", "scripts"];
const files: string[] = [];
function walkDir(dir: string) {
@@ -192,6 +204,109 @@ function extractTraces(): TracesData {
};
}
// ---------------------------------------------------------------------------
// Coverage: how many *defined* requirements are actually traced.
//
// The denominators MUST be derived from requirements.md, never hardcoded. The
// CI gate previously divided by frozen literals (UR/39, IR/24, DR/48, JA/3,
// total 114) while the real file had grown to 211 requirements, so it reported
// 158% coverage and the 50% threshold became unreachable — the gate could not
// fail. See docs/specs/traceability-gate-repair.md.
//
// TRACES: | DR-093
// ---------------------------------------------------------------------------
export interface DefinedRequirements {
UR: number;
IR: number;
DR: number;
JA: number;
total: number;
ids: Set<string>;
}
export interface CoverageResult {
covered: number;
total: number;
percent: number;
/** Traced in code but not defined in requirements.md (typo, or deleted req). */
orphaned: string[];
}
/**
* A requirement is *defined* only where its ID is the leading cell of a markdown
* table row: `| DR-001 | … |`.
*
* This deliberately ignores IDs in the "Traces To" column and in prose a
* naive scan for /DR-\d{3}/ counts those as definitions and inflates the
* denominator. IDs are deduplicated because requirements.md lists each UR twice
* (once in §1 as a definition, again in §3's traceability matrix), which would
* otherwise double the UR count from 61 to 121.
*
* TRACES: | DR-093
*/
export function countDefinedRequirements(markdown: string): DefinedRequirements {
const ids = new Set<string>();
const ROW_ID = /^\|\s*(UR|IR|DR|JA)-(\d{3})\s*\|/;
for (const line of markdown.split("\n")) {
const match = line.match(ROW_ID);
if (match) ids.add(`${match[1]}-${match[2]}`);
}
const countOf = (type: string) =>
[...ids].filter((id) => id.startsWith(`${type}-`)).length;
return {
UR: countOf("UR"),
IR: countOf("IR"),
DR: countOf("DR"),
JA: countOf("JA"),
total: ids.size,
ids,
};
}
/**
* Coverage is the *intersection* of traced and defined IDs over defined IDs.
*
* Using the raw traced count as the numerator is what lets a ratio exceed 100%:
* a TRACES comment naming a requirement that no longer exists would count as
* covered. Those IDs are reported as `orphaned` so they get fixed rather than
* silently counted or silently dropped.
*
* TRACES: | DR-093
*/
export function computeCoverage(
tracedIds: string[],
defined: DefinedRequirements
): CoverageResult {
// Only the four *requirement* types participate in coverage. UT/IT are test
// identifiers defined in §4 of requirements.md — a different taxonomy, and
// flagging them as orphans would bury real typos in ~60 lines of noise.
const isRequirement = (id: string) => /^(UR|IR|DR|JA)-\d{3}$/.test(id);
const traced = new Set(tracedIds.filter(isRequirement));
const covered = [...traced].filter((id) => defined.ids.has(id));
const orphaned = [...traced].filter((id) => !defined.ids.has(id)).sort();
return {
covered: covered.length,
total: defined.total,
percent:
defined.total === 0
? 0
: Math.round((covered.length / defined.total) * 100),
orphaned,
};
}
/** Read requirements.md from the repo and count what it defines. */
export function readDefinedRequirements(): DefinedRequirements {
const reqPath = path.join(BASE_DIR, "docs", "requirements.md");
return countDefinedRequirements(fs.readFileSync(reqPath, "utf-8"));
}
function generateMarkdown(data: TracesData): string {
let md = `# Code Traceability Matrix
@@ -265,21 +380,83 @@ function generateJson(data: TracesData): string {
return JSON.stringify(data, null, 2);
}
// Main
const args = Bun.argv.slice(2);
const format = args.includes("--format")
? args[args.indexOf("--format") + 1]
: "markdown";
/**
* Human-readable coverage report; exits non-zero below the threshold so this is
* runnable as a local gate (`bun run traces:coverage`), not just in CI.
*
* TRACES: | DR-093
*/
function reportCoverage(data: TracesData, minThreshold: number): number {
const defined = data.defined!;
const cov = data.coverage!;
console.error("🔍 Extracting TRACES from codebase...");
const data = extractTraces();
const definedIds = readDefinedRequirements().ids;
if (format === "json") {
console.log(generateJson(data));
} else {
console.log(generateMarkdown(data));
console.log("📋 Requirement coverage (traced / defined):");
for (const type of ["UR", "IR", "DR", "JA"] as const) {
const traced = data.byType[type].filter((id) => definedIds.has(id)).length;
console.log(` ${type}: ${traced} / ${defined[type]}`);
}
console.log("");
console.log(`📈 Overall: ${cov.covered} / ${cov.total} (${cov.percent}%)`);
if (cov.orphaned.length > 0) {
console.log("");
console.log(
`⚠️ Traced but not defined in requirements.md: ${cov.orphaned.join(", ")}`
);
console.log(" Fix the TRACES comment or add the requirement.");
}
// A ratio above 100% means the computation is broken (the condition that hid
// the stale-denominator bug for so long). Fail loudly rather than report it.
if (cov.percent > 100) {
console.log("");
console.log(`❌ Coverage > 100% — the gate is miscomputing.`);
return 1;
}
if (cov.percent < minThreshold) {
console.log("");
console.log(`❌ Coverage (${cov.percent}%) is below minimum (${minThreshold}%)`);
return 1;
}
console.log("");
console.log(`✅ Coverage is acceptable (${cov.percent}% >= ${minThreshold}%)`);
return 0;
}
console.error(
`\n✅ Complete! Found ${data.totalTraces} TRACES across ${data.totalFiles} files`
);
// Main — guarded so this module stays importable from extract-traces.test.ts.
if (import.meta.main) {
const args = process.argv.slice(2);
const format = args.includes("--format")
? args[args.indexOf("--format") + 1]
: "markdown";
console.error("🔍 Extracting TRACES from codebase...");
const data = extractTraces();
const defined = readDefinedRequirements();
const allTraced = Object.keys(data.requirements);
data.defined = {
UR: defined.UR,
IR: defined.IR,
DR: defined.DR,
JA: defined.JA,
total: defined.total,
};
data.coverage = computeCoverage(allTraced, defined);
if (format === "json") {
console.log(generateJson(data));
} else if (format === "coverage") {
process.exit(reportCoverage(data, 50));
} else {
console.log(generateMarkdown(data));
}
console.error(
`\n✅ Complete! Found ${data.totalTraces} TRACES across ${data.totalFiles} files`
);
}
-56
View File
@@ -1,56 +0,0 @@
#!/bin/bash
#
# Find all files implementing a specific requirement
#
# Usage: ./find-req-implementations.sh UR-004
#
if [ $# -eq 0 ]; then
echo "Usage: $0 <REQUIREMENT_ID>"
echo "Example: $0 UR-004"
exit 1
fi
REQ_ID=$1
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo " Implementations of $REQ_ID"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
# Full implementations
echo "Full Implementations:"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
grep -rn "@req: $REQ_ID" src-tauri/ src/ 2>/dev/null | \
grep -v "@req-partial" | \
grep -v "@req-planned" | \
sed 's/src-tauri\/src\///' | \
sed 's/src\///' || echo " (none)"
echo ""
# Partial implementations
echo "Partial Implementations:"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
grep -rn "@req-partial: $REQ_ID" src-tauri/ src/ 2>/dev/null | \
sed 's/src-tauri\/src\///' | \
sed 's/src\///' || echo " (none)"
echo ""
# Planned
echo "Planned Implementations:"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
grep -rn "@req-planned: $REQ_ID" src-tauri/ src/ 2>/dev/null | \
sed 's/src-tauri\/src\///' | \
sed 's/src\///' || echo " (none)"
echo ""
# Tests
echo "Test Cases:"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
grep -rn "@req-test: $REQ_ID" src-tauri/ 2>/dev/null | \
sed 's/src-tauri\/src\///' || echo " (none)"
echo ""
+25 -4
View File
@@ -1,13 +1,34 @@
#!/bin/bash
# View Android logcat output filtered for the app
# View Android logcat output filtered for the app.
#
# Usage: ./scripts/logcat.sh [debug|release] (default: debug)
#
# The debug build has applicationIdSuffix ".debug" so it can be installed
# alongside a release build; pick the package to follow accordingly.
set -e
APP_PACKAGE="com.jellytau.app"
BUILD_TYPE="${1:-debug}"
if [ "$BUILD_TYPE" = "release" ]; then
APP_PACKAGE="com.dtourolle.jellytau"
else
APP_PACKAGE="com.dtourolle.jellytau.debug"
fi
echo "📱 Showing logcat for $APP_PACKAGE"
echo "Press Ctrl+C to stop"
echo ""
# Filter logcat for the app's package name
adb logcat | grep -i "$APP_PACKAGE\|tauri\|rust"
# Prefer PID-scoped output when the app is running — it drops the noise that a
# text grep can't. Fall back to the old keyword filter when it isn't (so you can
# start the script first and then launch the app).
PID="$(adb shell pidof "$APP_PACKAGE" 2>/dev/null | tr -d '\r\n' | awk '{print $1}')"
if [ -n "$PID" ]; then
echo " (attached to pid $PID)"
adb logcat --pid="$PID"
else
echo " (app not running — falling back to keyword filter)"
adb logcat | grep -i "$APP_PACKAGE\|jellytau\|tauri\|rust"
fi
+137
View File
@@ -0,0 +1,137 @@
#!/usr/bin/env bun
/**
* release-notes.ts turn a commit range into capability-level release notes
* using the TRACES graph instead of raw commit subjects.
*
* Usage:
* bun run scripts/release-notes.ts [<range>]
* bun run scripts/release-notes.ts v0.0.15..HEAD
*
* With no argument it uses <latest tag>..HEAD (or the whole history if untagged).
*
* How it works:
* 1. `git diff --name-only <range>` files the range changed.
* 2. Read each changed file's `TRACES:` comments requirement IDs.
* 3. Resolve IDs to descriptions from docs/requirements.md.
* 4. Group: UR Features, DR/IR Improvements. Deduped, so many commits
* touching one requirement collapse to one line.
*
* This is a drafting aid for docs/release-checklist.md review the output,
* it does not invent descriptions for untraced changes (those are listed
* separately so nothing is silently dropped).
*/
import { execSync } from "node:child_process";
import { readFileSync, existsSync } from "node:fs";
const TRACE_RE = /TRACES:\s*([^\n*]+)/g;
const ID_RE = /\b(UR|IR|DR|JA|UT|IT)-\d+\b/g;
const REQ_ROW_RE = /^\|\s*((?:UR|IR|DR|JA)-\d+)\s*\|\s*([^|]+?)\s*\|/;
function sh(cmd: string): string {
return execSync(cmd, { encoding: "utf8" }).trim();
}
function defaultRange(): string {
try {
const tag = sh("git describe --tags --abbrev=0");
return `${tag}..HEAD`;
} catch {
return ""; // no tags: fall through to whole-history diff
}
}
/** Map requirement ID → human description, parsed from docs/requirements.md. */
function loadRequirementDescriptions(): Map<string, string> {
const map = new Map<string, string>();
const text = readFileSync("docs/requirements.md", "utf8");
for (const line of text.split("\n")) {
const m = line.match(REQ_ROW_RE);
// First definition wins: the descriptive tables come before the later
// cross-reference tables, whose cells hold linked IDs (or "-"), not prose.
if (m && !map.has(m[1])) map.set(m[1], m[2].trim());
}
return map;
}
function changedFiles(range: string): string[] {
const cmd = range
? `git diff --name-only ${range}`
: "git ls-files"; // untagged repo: describe everything currently traced
return sh(cmd)
.split("\n")
.filter((f) => f && existsSync(f));
}
/** Collect requirement IDs referenced by TRACES comments in the given files. */
function idsFromFiles(files: string[]): Set<string> {
const ids = new Set<string>();
for (const file of files) {
let content: string;
try {
content = readFileSync(file, "utf8");
} catch {
continue;
}
for (const trace of content.matchAll(TRACE_RE)) {
for (const id of trace[1].matchAll(ID_RE)) ids.add(id[0]);
}
}
return ids;
}
function main() {
const range = process.argv[2] ?? defaultRange();
const descriptions = loadRequirementDescriptions();
const files = changedFiles(range);
const ids = idsFromFiles(files);
const features: string[] = []; // UR
const improvements: string[] = []; // DR / IR
const unknown: string[] = []; // traced but not in requirements.md
for (const id of [...ids].sort()) {
const desc = descriptions.get(id);
if (id.startsWith("UT") || id.startsWith("IT")) continue; // tests aren't notes
if (!desc) {
if (!id.startsWith("UT") && !id.startsWith("IT")) unknown.push(id);
continue;
}
const line = `- ${desc} (${id})`;
if (id.startsWith("UR")) features.push(line);
else improvements.push(line);
}
const header = range || "(entire history — no tags found)";
const out: string[] = [`## Release notes — ${header}`, ""];
if (features.length) out.push("### ✨ Features", ...features, "");
if (improvements.length) out.push("### 🚀 Improvements", ...improvements, "");
if (unknown.length)
out.push(
"### ⚠️ Traced IDs missing from requirements.md",
...unknown.map((id) => `- ${id}`),
"",
);
const untraced = files.filter((f) => {
try {
return !/TRACES:/.test(readFileSync(f, "utf8"));
} catch {
return false;
}
});
if (untraced.length)
out.push(
`### 📝 Changed files without TRACES (${untraced.length}) — review manually`,
...untraced.map((f) => `- ${f}`),
"",
);
if (!features.length && !improvements.length)
out.push("_No traced requirements in this range._", "");
console.log(out.join("\n"));
}
main();
+119
View File
@@ -0,0 +1,119 @@
#!/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.
#
# The floor has to clear the highest code actually in the field, which is not the
# same as the highest this formula has produced. v0.5.2 shipped versionCode
# **5002** under an earlier `minor*1000` scheme; the `minor*100` formula that
# replaced it yields only 1502 for that same version, and 1503 for 0.5.3 — so
# every 0.5.x release built from it was an un-installable downgrade for anyone
# already on v0.5.2, which is exactly the failure this block exists to prevent.
# The multipliers are widened and the floor raised past 5002 accordingly.
#
# code = 10000 + major*1000000 + minor*1000 + patch
# e.g. 0.0.14 -> 10014, 0.1.0 -> 11000, 0.5.3 -> 15003, 1.0.0 -> 1010000.
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=$(( 10000 + MAJ*1000000 + MIN*1000 + 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
+181
View File
@@ -0,0 +1,181 @@
/**
* 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", () => {
// A newer release must never produce a smaller number than an older one, or
// Android refuses the update. The floor tracks the highest code actually in
// the field, which is NOT the same as the highest this formula has produced:
// v0.5.2 shipped versionCode 5002 from an earlier `minor*1000` scheme, while
// the `minor*100` formula that replaced it yields only 1502 for that same
// version — so every 0.5.x release built from it was an un-installable
// downgrade for anyone already on v0.5.2. The floor is raised to clear it.
it("clears the highest code shipped by earlier builds", () => {
run("0.0.1");
// v0.5.2 shipped 5002; anything at or below that cannot install over it.
expect(versionCode()).toBeGreaterThan(5002);
});
it("keeps 0.5.3 installable over the 5002 that shipped as v0.5.2", () => {
run("0.5.3");
expect(versionCode()).toBeGreaterThan(5002);
});
it("uses 10000 + major*1000000 + minor*1000 + patch", () => {
const cases: Array<[string, number]> = [
["0.0.14", 10014],
["0.0.15", 10015],
["0.1.0", 11000],
["0.4.8", 14008],
["0.5.0", 15000],
["0.5.3", 15003],
["1.0.0", 1010000],
];
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(16000);
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");
});
});
});
+99
View File
@@ -41,4 +41,103 @@ if [ -f "$APP_GRADLE_SRC" ]; then
echo " Copied: app/build.gradle.kts"
fi
# AndroidManifest.xml. `tauri android init` regenerates gen/android from
# tauri.conf.json and would drop our hand-maintained entries (media playback
# service + permissions, hardware acceleration, picture-in-picture attributes
# on MainActivity), so this tracked copy is the source of truth and must be
# restored after a regen. Gradle reads ONLY the gen/ copy - there is no
# manifest-merger hook here, so this must be the complete manifest.
MANIFEST_SRC="$PROJECT_ROOT/src-tauri/android/src/main/AndroidManifest.xml"
MANIFEST_DST="$PROJECT_ROOT/src-tauri/gen/android/app/src/main/AndroidManifest.xml"
if [ -f "$MANIFEST_SRC" ]; then
cp "$MANIFEST_SRC" "$MANIFEST_DST"
echo " Copied: app/src/main/AndroidManifest.xml"
fi
# Custom ProGuard/R8 keep rules. Required for minified release builds:
# the player/ and security/ Kotlin classes are loaded by name via JNI from
# Rust, so R8 can't see the references and would strip them without this.
# build.gradle.kts globs **/*.pro, so dropping it in app/ is enough.
PROGUARD_SRC="$PROJECT_ROOT/src-tauri/android/app/proguard-jellytau.pro"
PROGUARD_DST="$PROJECT_ROOT/src-tauri/gen/android/app/proguard-jellytau.pro"
if [ -f "$PROGUARD_SRC" ]; then
cp "$PROGUARD_SRC" "$PROGUARD_DST"
echo " Copied: app/proguard-jellytau.pro"
fi
# Launcher icons / adaptive-icon mipmaps. `tauri android init` generates
# low-quality launcher icons from tauri.conf.json (which has no high-res
# Android source), so overwrite them with the real committed mipmaps.
RES_SRC="$PROJECT_ROOT/src-tauri/android/src/main/res"
RES_DST="$PROJECT_ROOT/src-tauri/gen/android/app/src/main/res"
if [ -d "$RES_SRC" ]; then
for dir in "$RES_SRC"/mipmap-*; do
[ -d "$dir" ] || continue
name="$(basename "$dir")"
mkdir -p "$RES_DST/$name"
cp "$dir"/* "$RES_DST/$name/"
echo " Copied res: $name"
done
# values/ (themes.xml): status-bar styling that `tauri android init` does
# not generate. Previously this directory was tracked but never copied, so
# the theme customizations below never reached a build.
if [ -d "$RES_SRC/values" ]; then
mkdir -p "$RES_DST/values"
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
# ic_launcher_monochrome.png would just be dead weight.
rm -f "$RES_DST"/mipmap-*/ic_launcher_monochrome.png
# `tauri android init` also emits the Android Studio DEFAULT adaptive icon
# as API-qualified VECTOR drawables:
# drawable/ic_launcher_background.xml (solid #3DDC84 green)
# drawable-v24/ic_launcher_foreground.xml (the Android robot)
# Because drawable-v24 is a more specific match than our unqualified
# mipmap-*/ic_launcher_*.png, on API 24+ the vector WINS and the app ships
# the green square robot instead of our jellyfish. Remove them so the
# adaptive-icon xml resolves @mipmap/ic_launcher_{background,foreground}
# to the real committed PNGs.
rm -f "$RES_DST"/drawable/ic_launcher_background.xml \
"$RES_DST"/drawable-v24/ic_launcher_foreground.xml \
"$RES_DST"/drawable*/ic_launcher_foreground.xml \
"$RES_DST"/drawable*/ic_launcher_background.xml
fi
# Gradle wrapper distribution. `tauri android init` regenerates the wrapper
# pointing at services.gradle.org, so each build downloads ~130MB of Gradle —
# slow, and a hard failure when the CDN drops the connection mid-transfer
# ("Unexpected end of file from server"), which is what broke the release APK
# job. The builder image ships the matching distribution under /opt/gradle/dist,
# so when it's present repoint the wrapper at that local zip and build offline.
# Outside the image (dev machines) the properties file is left untouched and the
# wrapper downloads as usual.
WRAPPER_PROPS="$PROJECT_ROOT/src-tauri/gen/android/gradle/wrapper/gradle-wrapper.properties"
if [ -f "$WRAPPER_PROPS" ]; then
WANTED_VERSION="$(sed -n 's#.*/gradle-\([0-9.]*\)-\(bin\|all\)\.zip.*#\1#p' "$WRAPPER_PROPS")"
LOCAL_DIST="/opt/gradle/dist/gradle-${WANTED_VERSION}-bin.zip"
if [ -n "$WANTED_VERSION" ] && [ -f "$LOCAL_DIST" ]; then
# distributionUrl is a java.util.Properties value: ':' must stay escaped.
sed -i "s#^distributionUrl=.*#distributionUrl=file\\\\:///opt/gradle/dist/gradle-${WANTED_VERSION}-bin.zip#" \
"$WRAPPER_PROPS"
echo " Gradle wrapper -> local distribution ($WANTED_VERSION, offline)"
elif [ -n "$WANTED_VERSION" ]; then
echo " Gradle wrapper: $WANTED_VERSION not in image, will download"
fi
fi
echo "✓ Android sources synced successfully"
+8 -1
View File
@@ -7,7 +7,7 @@ echo "🧪 Running all tests..."
echo ""
echo "📦 Running frontend tests..."
bun run test
bun run test --run
echo ""
echo "🦀 Running Rust tests..."
@@ -15,5 +15,12 @@ cd src-tauri
cargo test
cd ..
echo ""
echo "🚧 Checking architectural gates..."
# Boundary tripwire (DR-094): no Jellyfin taxonomy in the presentation layer.
bun run check:boundary
# Traceability coverage (DR-093): fails below 50%, or above 100% (miscount).
bun run traces:coverage
echo ""
echo "✅ All tests passed!"
+52
View File
@@ -0,0 +1,52 @@
#!/bin/bash
# Regenerate src-tauri/gen/android/keystore.properties from the gitignored .env.
#
# .env is the single source of truth for local release signing. `tauri android
# init` wipes/regenerates gen/android, so keystore.properties must be rewritten
# from .env before every release build (this is the local mirror of what the CI
# workflow does from Gitea secrets).
#
# Required .env vars:
# ANDROID_KEY_ALIAS, ANDROID_KEYSTORE_PASSWORD, ANDROID_KEY_PASSWORD,
# ANDROID_KEYSTORE_FILE (absolute path to the .jks)
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
ENV_FILE="$PROJECT_ROOT/.env"
PROPS="$PROJECT_ROOT/src-tauri/gen/android/keystore.properties"
if [ ! -f "$ENV_FILE" ]; then
echo "$ENV_FILE not found — cannot configure release signing." >&2
echo " Create it with ANDROID_KEY_ALIAS / ANDROID_KEYSTORE_PASSWORD /" >&2
echo " ANDROID_KEY_PASSWORD / ANDROID_KEYSTORE_FILE." >&2
exit 1
fi
# Load .env without leaking it into the caller's environment beyond what we need.
set -a
# shellcheck disable=SC1090
. "$ENV_FILE"
set +a
: "${ANDROID_KEY_ALIAS:?ANDROID_KEY_ALIAS missing from .env}"
: "${ANDROID_KEYSTORE_PASSWORD:?ANDROID_KEYSTORE_PASSWORD missing from .env}"
: "${ANDROID_KEY_PASSWORD:?ANDROID_KEY_PASSWORD missing from .env}"
: "${ANDROID_KEYSTORE_FILE:?ANDROID_KEYSTORE_FILE missing from .env}"
if [ ! -f "$ANDROID_KEYSTORE_FILE" ]; then
echo "❌ Keystore not found at ANDROID_KEYSTORE_FILE=$ANDROID_KEYSTORE_FILE" >&2
exit 1
fi
mkdir -p "$(dirname "$PROPS")"
umask 077
cat > "$PROPS" <<EOF
storeFile=$ANDROID_KEYSTORE_FILE
storePassword=$ANDROID_KEYSTORE_PASSWORD
keyAlias=$ANDROID_KEY_ALIAS
keyPassword=$ANDROID_KEY_PASSWORD
EOF
echo "🔐 Wrote release signing config to keystore.properties (from .env)"
+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.1.0"
version = "0.6.0"
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.1.0"
version = "0.6.0"
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]
+51
View File
@@ -41,6 +41,57 @@ When you need to modify Android/Kotlin files:
- If you only edit `src-tauri/android/`, your changes won't be in the build
- **You must edit both** (or edit source and copy to generated)
### Debug and release install side by side
The **debug** build type sets `applicationIdSuffix = ".debug"` in
`app/build.gradle.kts`, so a debug build is a genuinely separate Android app:
| build | applicationId | launcher name | versionName | signed with |
|---|---|---|---|---|
| `release` | `com.dtourolle.jellytau` | jellytau | `0.5.5` | real key (`.env`) |
| `release --debug` | `com.dtourolle.jellytau.debug` | JellyTau Debug | `0.5.5-debug-release` | debug keystore |
| `debug` | `com.dtourolle.jellytau.debug` | JellyTau Debug | `0.5.5-debug` | debug keystore |
`release --debug` is the **side-by-side release**: fully R8-minified, exactly
what ships, but packaged into the debug slot and signed with the local debug
keystore. It exists because R8 has broken release APKs here before (stripping
JNI-loaded player/security classes), and reproducing that previously meant
building with the real key and clobbering your working install. It shares the
applicationId *and* signature with the plain debug build, so the two replace
each other cleanly; only the versionName suffix tells you which is installed.
```bash
./scripts/build-and-deploy.sh release --device --debug # build + install it
```
The flag is plumbed through as `JT_SIDE_BY_SIDE=1`, read by `build.gradle.kts`.
CI never sets it, so distributable release builds are untouched.
That means:
- **No uninstall step.** Debug builds are signed with the local auto-generated
`~/.android/debug.keystore`, release builds with the real key. Two different
keys on the *same* package is `INSTALL_FAILED_UPDATE_INCOMPATIBLE`; two
different packages is just two apps.
- Each has **its own data directory** — separate settings, credentials,
downloads and offline cache. A debug experiment cannot corrupt the state of
the build you actually use. This is not optional and cannot be shared:
Android gives each applicationId its own UID and enforces the boundary in the
kernel. (`sharedUserId` is deprecated since API 29 and cannot be added to an
already-installed app anyway.) You log in again in the debug app, once.
- Only the *application* id changes. Kotlin classes stay in the `namespace`
package `com.dtourolle.jellytau`, so the JNI class lookups in
`src-tauri/src/player/android/mod.rs`, the manifest `<service>` entry and the
R8 keep rules in `proguard-jellytau.pro` are all unaffected. The FileProvider
authority is `${applicationId}.fileprovider`, so it follows the suffix
automatically.
- The launcher labels come from the `appLabel` / `activityLabel`
manifestPlaceholders (`AndroidManifest.xml` uses `${appLabel}`), *not* from
`resValue`, which would collide with Tauri's generated `strings.xml`.
Follow the right log stream with `./scripts/logcat.sh [debug|release]`
(defaults to debug).
### Key Files
Player-related Kotlin files:
+41 -1
View File
@@ -22,11 +22,25 @@ val keystoreProperties = Properties().apply {
}
}
// Side-by-side release: set by `scripts/build-android.sh release --debug`, which
// exports JT_SIDE_BY_SIDE=1. It puts a fully R8-minified release build into the
// debug applicationId slot, signed with the local debug keystore — so you can
// test what minification actually produces (R8 stripping JNI-loaded classes has
// broken release APKs here before) without the real signing key and without
// uninstalling your working install. Unset in CI, so distributable release
// builds are untouched.
val sideBySideRelease = System.getenv("JT_SIDE_BY_SIDE").let { it == "1" || it == "true" }
android {
compileSdk = 36
namespace = "com.dtourolle.jellytau"
defaultConfig {
manifestPlaceholders["usesCleartextTraffic"] = "false"
// Launcher/app names come from placeholders so the debug build can
// rename itself without touching the generated strings.xml (a
// resValue() override there would collide with Tauri's own entries).
manifestPlaceholders["appLabel"] = "@string/app_name"
manifestPlaceholders["activityLabel"] = "@string/main_activity_title"
applicationId = "com.dtourolle.jellytau"
minSdk = 24
targetSdk = 36
@@ -45,6 +59,21 @@ android {
}
buildTypes {
getByName("debug") {
// Distinct applicationId so the debug build installs SIDE BY SIDE
// with a release/store install instead of demanding an uninstall
// (different signing keys on the same package = INSTALL_FAILED_
// UPDATE_INCOMPATIBLE). It gets its own data dir, its own settings
// and its own offline cache — the two are fully independent apps.
//
// This changes only the *application* id. The Kotlin/JNI classes
// stay in the `namespace` package (com.dtourolle.jellytau), so the
// fully-qualified class names Rust looks up over JNI, the manifest
// <service> entry and the R8 keep rules are all unaffected. The
// FileProvider authority is already ${applicationId}-relative.
applicationIdSuffix = ".debug"
versionNameSuffix = "-debug"
manifestPlaceholders["appLabel"] = "JellyTau Debug"
manifestPlaceholders["activityLabel"] = "JellyTau Debug"
manifestPlaceholders["usesCleartextTraffic"] = "true"
isDebuggable = true
isJniDebuggable = true
@@ -56,7 +85,18 @@ android {
}
}
getByName("release") {
if (keystoreProperties.getProperty("storeFile") != null) {
if (sideBySideRelease) {
// Same slot, name and version scheme as the debug build type,
// plus "-release" so you can tell from Settings > Apps which of
// the two is currently sitting there. Signed with the debug
// keystore: it shares a signature with the debug build, so the
// two replace each other cleanly instead of colliding.
applicationIdSuffix = ".debug"
versionNameSuffix = "-debug-release"
manifestPlaceholders["appLabel"] = "JellyTau Debug"
manifestPlaceholders["activityLabel"] = "JellyTau Debug"
signingConfig = signingConfigs.getByName("debug")
} else if (keystoreProperties.getProperty("storeFile") != null) {
signingConfig = signingConfigs.getByName("release")
}
isMinifyEnabled = true
@@ -0,0 +1,25 @@
# JellyTau custom keep rules.
#
# These classes are loaded by name from the Rust backend via JNI
# (env.find_class / class-loader lookups), so R8 cannot see the
# references and would otherwise strip or rename them in a minified
# release build causing an instant ClassNotFoundException crash on
# startup. See src-tauri/src/player/android/mod.rs and
# src-tauri/src/credentials.rs.
-keep class com.dtourolle.jellytau.player.** { *; }
-keep class com.dtourolle.jellytau.security.** { *; }
# Picture-in-picture is driven from the WebView through an
# @JavascriptInterface bridge, so the only references to these methods live
# in JavaScript. R8 sees them as unused and would strip them, silently
# breaking the PiP button in release builds only.
-keep class com.dtourolle.jellytau.PictureInPictureManager { *; }
-keep class com.dtourolle.jellytau.VideoOverlayManager { *; }
-keep class com.dtourolle.jellytau.WindowInsetsBridge { *; }
-keepclassmembers class * {
@android.webkit.JavascriptInterface <methods>;
}
# Media3 / ExoPlayer is accessed reflectively in places; keep it intact.
-keep class androidx.media3.** { *; }
-dontwarn androidx.media3.**
-39
View File
@@ -1,39 +0,0 @@
plugins {
id("com.android.library")
id("org.jetbrains.kotlin.android")
}
android {
namespace = "com.dtourolle.jellytau.player"
compileSdk = 36
defaultConfig {
minSdk = 24
}
buildTypes {
getByName("debug") {
}
getByName("release") {
isMinifyEnabled = false
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
}
}
dependencies {
implementation("androidx.media3:media3-exoplayer:1.5.1")
implementation("androidx.media3:media3-exoplayer-hls:1.5.1")
implementation("androidx.media3:media3-common:1.5.1")
implementation("androidx.media3:media3-session:1.5.1")
implementation("androidx.media:media:1.7.0") // For MediaSessionCompat and VolumeProviderCompat
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")
}
+73 -2
View File
@@ -1,5 +1,76 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Authoritative AndroidManifest for JellyTau.
NOTE: this is NOT a manifest-merger fragment. Gradle only ever reads
gen/android/app/src/main/AndroidManifest.xml, and `tauri android init`
regenerates that file from tauri.conf.json - dropping everything below.
scripts/sync-android-sources.sh copies this file over the generated one,
so this is the full manifest and the single source of truth.
(An earlier version of this file was a partial <application> fragment on the
assumption that Tauri merged it. It did not: the hardwareAccelerated flag it
declared never reached any built APK. It is folded in properly below.)
${appLabel} / ${activityLabel} are manifestPlaceholders set in
app/build.gradle.kts: they resolve to @string/app_name and
@string/main_activity_title for release, and to "JellyTau Debug" for the
debug build type (which also carries applicationIdSuffix ".debug" so it
installs alongside a release build).
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Enable hardware acceleration for video playback performance -->
<application android:hardwareAccelerated="true" />
<uses-permission android:name="android.permission.INTERNET" />
<!-- Required to read NetworkCapabilities for the WiFi-only download gate (UR-053) -->
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- AndroidTV support -->
<uses-feature android:name="android.software.leanback" android:required="false" />
<application
android:icon="@mipmap/ic_launcher"
android:label="${appLabel}"
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"
android:launchMode="singleTask"
android:label="${activityLabel}"
android:name=".MainActivity"
android:exported="true"
android:supportsPictureInPicture="true"
android:resizeableActivity="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<!-- AndroidTV support -->
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
</activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
<!-- Media playback service for lockscreen controls -->
<service
android:name="com.dtourolle.jellytau.player.JellyTauPlaybackService"
android:foregroundServiceType="mediaPlayback"
android:exported="true">
<intent-filter>
<action android:name="androidx.media3.session.MediaSessionService" />
</intent-filter>
</service>
</application>
</manifest>
@@ -0,0 +1,67 @@
package com.dtourolle.jellytau
import android.app.Activity
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
/**
* Hides and restores the Android system bars for full-screen video.
*
* TRACES: UR-066 | DR-157
*
* ## Why the web layer cannot do this
*
* `document.documentElement.requestFullscreen()` is the only fullscreen control
* the frontend has, and inside an Android WebView it does nothing to the
* *Activity*: it expands the fullscreen element within the web viewport and
* leaves the window exactly as it was. Combined with `enableEdgeToEdge()` which
* MainActivity must call, and which SDK 36 makes non-optional the WebView
* already spans the whole window, so "fullscreen" was a no-op that changed
* nothing on screen while the status bar and navigation/gesture bar stayed
* painted over the video.
*
* Hiding them requires `WindowInsetsControllerCompat` on the Activity's window,
* which is reachable only from native code. Hence this bridge.
*
* ## Behaviour
*
* [enter] hides both bars and selects `BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE`, so
* a swipe from either edge brings them back *transiently* over the video,
* auto-hiding again rather than permanently resizing the window mid-playback.
* That is the standard behaviour for immersive video and keeps the system's own
* back/home gestures reachable.
*
* [exit] restores them. It must be called when leaving fullscreen **and** when
* the player is torn down, or the bars stay hidden on the library screens behind
* it.
*
* Both must run on the main thread; the callers in MainActivity post them there,
* since `@JavascriptInterface` methods arrive on a WebView binder thread.
*
* Note the `--jt-inset-*` custom properties follow automatically: hiding the bars
* fires the decor view's inset listener with zeroes, so [WindowInsetsBridge]
* republishes them and the player's control layer stops reserving space it no
* longer needs.
*/
object ImmersiveModeBridge {
private fun controller(activity: Activity): WindowInsetsControllerCompat =
WindowCompat.getInsetsController(activity.window, activity.window.decorView)
/** Hide the status and navigation bars, swipe-to-reveal transiently. */
fun enter(activity: Activity) {
controller(activity).apply {
systemBarsBehavior =
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
hide(WindowInsetsCompat.Type.systemBars())
}
android.util.Log.d("ImmersiveMode", "system bars hidden")
}
/** Restore the system bars. Safe to call when they are already showing. */
fun exit(activity: Activity) {
controller(activity).show(WindowInsetsCompat.Type.systemBars())
android.util.Log.d("ImmersiveMode", "system bars restored")
}
}
@@ -1,10 +1,5 @@
package com.dtourolle.jellytau
import android.content.Context
import android.media.AudioAttributes
import android.media.AudioFocusRequest
import android.media.AudioManager
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
@@ -15,17 +10,111 @@ import android.webkit.WebView
import android.view.View
import androidx.activity.enableEdgeToEdge
class MainActivity : TauriActivity() {
private val handler = Handler(Looper.getMainLooper())
private var configAttempts = 0
private val maxConfigAttempts = 10
private var audioFocusRequest: AudioFocusRequest? = null
private val audioManager by lazy { getSystemService(Context.AUDIO_SERVICE) as AudioManager }
/**
* Coarse override for whether backgrounding the app should auto-enter PiP.
*
* This is NOT what excludes audio/browsing/cast from PiP that is the
* PictureInPictureManager.canEnterPip guard, which requires a local video
* surface to be actively rendering and is re-checked in onUserLeaveHint. This
* flag is only toggled by the background-audio feature (via
* AndroidPictureInPicture.setAutoEnterEnabled) so background-audio mode and
* auto-PiP stay mutually exclusive.
*/
@Volatile
private var autoEnterPipEnabled = true
/**
* Whether the user armed background-audio mode on the current video (UR-040).
* When true, leaving the app hands audio off to the native ExoPlayer audio
* service (frontend-driven) instead of entering PiP, and video decode stops.
* The frontend sets this via AndroidBackgroundAudio.setEnabled.
*/
@Volatile
private var backgroundAudioEnabled = false
/**
* The WebView carrying the Svelte UI, cached once found so lifecycle overrides
* can dispatch DOM events into it (native frontend signalling).
*/
private var mediaWebView: WebView? = null
/**
* The WebView the @JavascriptInterface bridges have been injected into.
*
* addJavascriptInterface must run once per WebView instance: re-injecting
* over an already-loaded page hands JS a stale proxy whose methods are gone.
* Compared by identity so a genuinely new WebView still gets its bridges.
*/
private var bridgesInstalledOn: WebView? = null
/**
* wry hands us the WebView here, and this is the only point at which the
* bridges can be installed *deterministically*.
*
* WebView binds an injected object into JS at **page-load time**: an
* addJavascriptInterface call that lands after the page has loaded does not
* appear to that page at all. The bridges used to be installed from
* [configureWebViewForMedia], which finds the WebView by walking the view
* tree 500 ms after onCreate a race against Tauri's own page load, and one
* that is *permanent* when lost, because the identity guard then declines to
* re-inject on the resume passes. The whole set (`AndroidVideoSurface`,
* `AndroidPictureInPicture`, `AndroidBackgroundAudio`, `AndroidNetworkType`,
* `AndroidImmersive`, `AndroidInsets`) simply would not exist in `window`,
* silently: every one of them is called through an optional chain, so a
* missing bridge is a no-op rather than an error. That is a candidate
* explanation for DR-172's central piece of evidence native video shipped
* with `WebView transparent = false` logged and `= true` never appearing,
* i.e. the enable call never reaching Kotlin.
*
* `WryActivity.setWebView()` calls this immediately before wry issues the
* first `loadUrl`, so a bridge installed here is bound by the time any page
* runs. Note this can fire during `super.onCreate()`, i.e. *before* the rest
* of our own onCreate so only work that needs nothing but the WebView
* belongs here. Insets are deliberately left to
* [configureWebViewForMedia], which runs later and on every resume.
*
* TRACES: UR-003, UR-004 | DR-183
*/
override fun onWebViewCreate(webView: WebView) {
super.onWebViewCreate(webView)
android.util.Log.d("MainActivity", "onWebViewCreate - installing bridges before first page load")
mediaWebView = webView
installJavascriptBridges(webView)
configureWebViewSettings(webView)
}
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
// enableEdgeToEdge() puts the WebView under the status bar, the navigation/
// gesture bar and the display cutout — and targeting SDK 36 makes that
// non-optional anyway. Android WebView never surfaces the *system bar*
// insets to CSS (only the display cutout), so the web layer has to be told.
// Without this the bottom nav renders underneath the navigation bar, badly
// 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()
@@ -37,9 +126,116 @@ class MainActivity : TauriActivity() {
configureWebViewForMedia()
}
/**
* Called when the user leaves the app via Home or the gesture equivalent
* (but NOT via Back). This is the standard hook for auto-entering PiP so
* video keeps playing in a floating window instead of being backgrounded.
*
* TRACES: UR-041 | IR-026 | DR-053
*/
override fun onUserLeaveHint() {
super.onUserLeaveHint()
// Never enter PiP while background-audio mode is armed — the two are mutually
// exclusive (the handoff runs from onStop instead).
if (autoEnterPipEnabled && !backgroundAudioEnabled &&
PictureInPictureManager.canEnterPip(this)) {
android.util.Log.d("MainActivity", "User leaving with video active - entering PiP")
PictureInPictureManager.enterPip(this)
}
}
/**
* The app is no longer visible (Home, app switch, or screen lock). When
* background-audio mode is armed, tell the frontend to hand video playback off
* to the native audio service. onStop (rather than onUserLeaveHint) is used
* because it fires on screen-lock too, which is the primary use case (UR-040).
*
* TRACES: UR-040 | IR-025
*/
override fun onStop() {
super.onStop()
if (backgroundAudioEnabled) {
dispatchWebEvent("jellytau-background")
}
}
/** The app is visible again — tell the frontend to resume WebView video. */
override fun onStart() {
super.onStart()
if (backgroundAudioEnabled) {
dispatchWebEvent("jellytau-foreground")
}
}
/**
* Dispatch a DOM CustomEvent into the WebView (native frontend). Mirrors the
* evaluateJavascript pattern already used to unmute video elements. Posted to
* the WebView thread; safe no-op if the WebView isn't found yet.
*/
private fun dispatchWebEvent(name: String) {
val webView = mediaWebView ?: run {
android.util.Log.w("MainActivity", "dispatchWebEvent('$name'): no WebView")
return
}
webView.post {
webView.evaluateJavascript(
"window.dispatchEvent(new CustomEvent('$name'));",
null
)
android.util.Log.d("MainActivity", "Dispatched web event: $name")
}
}
override fun onDestroy() {
NetworkTypeMonitor.stopWatching(this)
super.onDestroy()
}
/**
* Rotation (and any other config change this Activity handles itself).
*
* Two things have to happen here rather than later, and both are about the
* *previous* video frame surviving the transition:
*
* - The video view is hidden until a new frame arrives. The equivalent call
* in `fitSurfaceToScreen` runs from the content view's layout listener,
* which is after the rotation by then the stale frame has been on screen
* for the whole transition.
* - The window's rotation animation is a **cross-fade of a screenshot** of
* the old orientation, and that screenshot contains the old video frame at
* the old size. No amount of TextureView bookkeeping can touch it, which is
* why hiding on frame-arrival alone did not stop the flash. `JUMPCUT` drops
* the cross-fade, so there is no old frame to fade through; it is set only
* while native compositing is active (see setTransparent) so the rest of
* the app keeps the normal animation.
*
* TRACES: UR-003, UR-066 | DR-194
*/
override fun onConfigurationChanged(newConfig: android.content.res.Configuration) {
super.onConfigurationChanged(newConfig)
try {
if (com.dtourolle.jellytau.player.JellyTauPlayer.isInitialized()) {
com.dtourolle.jellytau.player.JellyTauPlayer.getInstance().hideUntilFreshFrame()
}
} catch (e: Exception) {
android.util.Log.w("MainActivity", "hideUntilFreshFrame on config change failed", e)
}
}
override fun onPictureInPictureModeChanged(
isInPictureInPictureMode: Boolean,
newConfig: android.content.res.Configuration
) {
super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig)
android.util.Log.d("MainActivity", "PiP mode changed: $isInPictureInPictureMode")
PictureInPictureManager.onPipModeChanged(this, isInPictureInPictureMode)
}
private fun configureWebViewForMedia() {
try {
val webView = findWebView(window.decorView)
// onWebViewCreate normally got here first; the tree walk is the fallback
// for a WebView we were never handed.
val webView = mediaWebView ?: findWebView(window.decorView)
if (webView == null) {
android.util.Log.w("MainActivity", "WebView not found (attempt ${configAttempts + 1}/$maxConfigAttempts)")
@@ -55,21 +251,251 @@ class MainActivity : TauriActivity() {
}
android.util.Log.d("MainActivity", "WebView found! Configuring settings...")
mediaWebView = webView
// Add JavaScript interface for audio focus control
// Re-push the safe-area insets. Unlike addJavascriptInterface this is
// idempotent and MUST re-run: a page load discards the inline style the
// last push set, so the WebView would otherwise be left with no insets.
WindowInsetsBridge.attachWebView(webView)
// Normally already done by onWebViewCreate; this is the fallback path.
installJavascriptBridges(webView)
configureWebViewSettings(webView)
} catch (e: Exception) {
android.util.Log.e("MainActivity", "Failed to configure WebView for media", e)
}
}
/**
* Register the @JavascriptInterface bridges EXACTLY ONCE per WebView.
*
* This runs from [onWebViewCreate] the only point early enough to be bound
* before the first page load and from [configureWebViewForMedia] as a
* fallback. The latter runs from onCreate's delayed post AND from every
* onResume (plus each WebView re-find), so without the identity guard this
* re-injected every bridge repeatedly 5 times in a 45s session. WebView
* binds injected objects at page-load time; re-injecting over a live page
* leaves JS holding a stale proxy. The object stays truthy while its methods
* vanish, which surfaced as a flood of "WebView: Unknown object" chromium
* errors and, in JS, "TypeError: setEnabled is not a function".
*
* The visible bug: the background-audio toggle turned blue but never reached
* native, so backgroundAudioEnabled stayed false, onStop never dispatched
* 'jellytau-background', and a locked screen killed audio instantly (UR-040).
* Audio focus and PiP broke the same way.
*
* Settings/WebChromeClient work is idempotent and must keep running on
* resume, so it lives in [configureWebViewSettings], not here.
*
* TRACES: UR-003, UR-004, UR-040, UR-041 | DR-183
*/
private fun installJavascriptBridges(webView: WebView) {
try {
if (webView === bridgesInstalledOn) {
android.util.Log.d("MainActivity", "JS bridges already installed on this WebView - skipping re-injection")
return
}
bridgesInstalledOn = webView
// NOTE: there is deliberately no "AndroidAudioFocus" bridge. Manual focus
// requests from the WebView competed with Chromium's own
// AudioFocusDelegate and with ExoPlayer, and the resulting
// AUDIOFOCUS_LOSS paused playback. See the comment on the video listeners
// in configureWebViewSettings().
// Add JavaScript interface for picture-in-picture control.
// enterPip/canEnterPip must run on the main thread; @JavascriptInterface
// methods are invoked on a WebView binder thread.
webView.addJavascriptInterface(object : Any() {
@JavascriptInterface
fun requestAudioFocus() {
handler.post { this@MainActivity.requestAudioFocus() }
fun enterPip() {
handler.post { PictureInPictureManager.enterPip(this@MainActivity) }
}
/** Whether the PiP button should be offered in the player UI at all. */
@JavascriptInterface
fun abandonAudioFocus() {
handler.post { this@MainActivity.abandonAudioFocus() }
fun isSupported(): Boolean {
return PictureInPictureManager.isPipSupported(this@MainActivity)
}
}, "AndroidAudioFocus")
android.util.Log.d("MainActivity", "JavaScript interface 'AndroidAudioFocus' added")
/** Whether entering PiP would work right now (local video playing). */
@JavascriptInterface
fun canEnterPip(): Boolean {
return PictureInPictureManager.canEnterPip(this@MainActivity)
}
/** Let the frontend opt out of auto-PiP (e.g. while casting). */
@JavascriptInterface
fun setAutoEnterEnabled(enabled: Boolean) {
autoEnterPipEnabled = enabled
}
/**
* Report the WebView `<video>` state.
*
* Without this PiP only ever knew about the native ExoPlayer surface,
* which is behind an experimental flag that defaults to off so in the
* shipping configuration nothing ever satisfied canEnterPip and the
* button did nothing. (DR-160)
*/
@JavascriptInterface
fun setHtml5VideoState(active: Boolean, width: Int, height: Int, playing: Boolean) {
PictureInPictureManager.setHtml5VideoState(active, width, height, playing)
}
}, "AndroidPictureInPicture")
android.util.Log.d("MainActivity", "JavaScript interface 'AndroidPictureInPicture' added")
// Add JavaScript interface for background-audio mode (UR-040). The frontend
// arms/disarms it via the player toggle; the Activity uses the flag in its
// lifecycle overrides to decide between the audio handoff and PiP.
webView.addJavascriptInterface(object : Any() {
/** Frontend arms/disarms background-audio mode for the current video. */
@JavascriptInterface
fun setEnabled(enabled: Boolean) {
backgroundAudioEnabled = enabled
android.util.Log.d("MainActivity", "backgroundAudioEnabled = $enabled")
}
}, "AndroidBackgroundAudio")
android.util.Log.d("MainActivity", "JavaScript interface 'AndroidBackgroundAudio' added")
// Network transport reporting for the WiFi-only download gate (UR-053).
// The frontend polls these on demand and re-pumps the download queue when
// the 'jellytau-network-changed' event fires.
webView.addJavascriptInterface(object : Any() {
/** Active transport: wifi | ethernet | cellular | other | none | unknown. */
@JavascriptInterface
fun currentType(): String = NetworkTypeMonitor.currentType(this@MainActivity)
/** Whether the active network is unmetered. */
@JavascriptInterface
fun isUnmetered(): Boolean = NetworkTypeMonitor.isUnmetered(this@MainActivity)
/** Whether downloads may run given the wifi-only preference. */
@JavascriptInterface
fun isAcceptable(wifiOnly: Boolean): Boolean =
NetworkTypeMonitor.isAcceptable(this@MainActivity, wifiOnly)
/** Whether native network detection is available at all (false on non-Android). */
@JavascriptInterface
fun isSupported(): Boolean = true
}, "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)
)
// Drop the rotation cross-fade while a native video surface is
// composited behind the page. The animation fades a *screenshot* of
// the old orientation, which still holds the previous video frame at
// the old size — that is the "previous frame flashing in the black
// bars", and it lives in the window animation rather than in
// anything the TextureView owns. (DR-194)
val attrs = window.attributes
attrs.rotationAnimation = if (transparent) {
android.view.WindowManager.LayoutParams.ROTATION_ANIMATION_JUMPCUT
} else {
android.view.WindowManager.LayoutParams.ROTATION_ANIMATION_ROTATE
}
window.attributes = attrs
// `rotationAnimation` is honoured only for a **fullscreen** window —
// the platform says so out loud, logging
// "VRI[MainActivity]: setLayoutParams: not fullscreen" when the
// attribute is set on ours, and then animating normally regardless.
// Without this the JUMPCUT above is accepted and ignored, and the
// cross-fade keeps showing the old orientation's screenshot, stale
// video frame and all. FLAG_FULLSCREEN is deprecated for *hiding
// system bars* (immersive mode does that, on player entry), but it
// is still what marks the window fullscreen for this decision.
@Suppress("DEPRECATION")
if (transparent) {
window.addFlags(android.view.WindowManager.LayoutParams.FLAG_FULLSCREEN)
} else {
window.clearFlags(android.view.WindowManager.LayoutParams.FLAG_FULLSCREEN)
}
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")
// Full-screen video: hide the system bars (UR-066). requestFullscreen()
// inside a WebView cannot touch the Activity window, so without this the
// status and navigation bars stayed painted over full-screen video.
webView.addJavascriptInterface(object : Any() {
/** Hide the system bars for full-screen playback. */
@JavascriptInterface
fun enter() {
handler.post { ImmersiveModeBridge.enter(this@MainActivity) }
}
/** Restore the system bars on leaving fullscreen or the player. */
@JavascriptInterface
fun exit() {
handler.post { ImmersiveModeBridge.exit(this@MainActivity) }
}
/** Whether native immersive mode exists (false on non-Android). */
@JavascriptInterface
fun isSupported(): Boolean = true
}, "AndroidImmersive")
android.util.Log.d("MainActivity", "JavaScript interface 'AndroidImmersive' 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")
android.util.Log.d("MainActivity", "JavaScript interface 'AndroidInsets' added")
// Push network changes into the WebView so a queue blocked on "waiting for
// WiFi" resumes the moment an acceptable network appears.
NetworkTypeMonitor.startWatching(this) {
dispatchWebEvent("jellytau-network-changed")
}
} catch (e: Exception) {
android.util.Log.e("MainActivity", "Failed to install JavaScript bridges", e)
}
}
/**
* WebView settings, chrome client and the video-unmute script.
*
* Split out from the bridge injection because this half is idempotent and
* must re-run on every resume, whereas addJavascriptInterface must not.
*/
private fun configureWebViewSettings(webView: WebView) {
try {
// Set WebChromeClient to handle video playback and audio focus
webView.webChromeClient = object : WebChromeClient() {
override fun onShowCustomView(view: View?, callback: CustomViewCallback?) {
@@ -81,6 +507,21 @@ class MainActivity : TauriActivity() {
super.onHideCustomView()
android.util.Log.d("MainActivity", "Video exited fullscreen")
}
/**
* Forward WebView console output to logcat under the "JellyTauWeb" tag.
*
* Without this the frontend is invisible to `adb logcat`, which makes
* diagnosing anything that spans the JS/native boundary (the
* background-audio handoff in particular) guesswork.
*/
override fun onConsoleMessage(msg: android.webkit.ConsoleMessage): Boolean {
android.util.Log.d(
"JellyTauWeb",
"${msg.message()} (${msg.sourceId()}:${msg.lineNumber()})"
)
return true
}
}
android.util.Log.d("MainActivity", "WebChromeClient configured")
@@ -93,7 +534,6 @@ class MainActivity : TauriActivity() {
domStorageEnabled = true
allowFileAccess = true
allowContentAccess = true
setRenderPriority(WebSettings.RenderPriority.HIGH)
mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW
android.util.Log.d("MainActivity", "WebView fully configured for media playback")
@@ -110,29 +550,18 @@ class MainActivity : TauriActivity() {
video.volume = 1.0;
console.log('[Android] Video unmuted, volume:', video.volume, 'muted:', video.muted);
// Add event listeners to manage audio focus
video.addEventListener('play', function() {
console.log('[Android] Video play event - requesting audio focus');
if (typeof AndroidAudioFocus !== 'undefined') {
AndroidAudioFocus.requestAudioFocus();
}
console.log('[Android] Video state - muted:', this.muted, 'volume:', this.volume);
});
video.addEventListener('pause', function() {
console.log('[Android] Video pause event - abandoning audio focus');
if (typeof AndroidAudioFocus !== 'undefined') {
AndroidAudioFocus.abandonAudioFocus();
}
});
video.addEventListener('ended', function() {
console.log('[Android] Video ended event - abandoning audio focus');
if (typeof AndroidAudioFocus !== 'undefined') {
AndroidAudioFocus.abandonAudioFocus();
}
});
// NOTE: deliberately no audio-focus calls here.
//
// WebView already manages audio focus for <video> through
// Chromium's own AudioFocusDelegate. Requesting AUDIOFOCUS_GAIN
// again from MainActivity made two requesters compete inside one
// uid: the grant was immediately followed by AUDIOFOCUS_LOSS
// (~45ms), whose handler paused playback - so arming background
// audio, or simply pressing play, paused the video in a loop.
//
// ExoPlayer is the third potential owner and stays authoritative
// for native playback (JellyTauPlayer manages its own focus).
// Leave focus to whichever engine is actually rendering.
video.addEventListener('volumechange', function() {
console.log('[Android] Video volume changed - volume:', this.volume, 'muted:', this.muted);
});
@@ -179,48 +608,4 @@ class MainActivity : TauriActivity() {
return null
}
private fun requestAudioFocus() {
android.util.Log.d("MainActivity", "Requesting audio focus for video playback")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val audioAttributes = AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_MEDIA)
.setContentType(AudioAttributes.CONTENT_TYPE_MOVIE)
.build()
audioFocusRequest = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN)
.setAudioAttributes(audioAttributes)
.setAcceptsDelayedFocusGain(true)
.setOnAudioFocusChangeListener { focusChange ->
android.util.Log.d("MainActivity", "Audio focus changed: $focusChange")
}
.build()
val result = audioManager.requestAudioFocus(audioFocusRequest!!)
android.util.Log.d("MainActivity", "Audio focus request result: $result")
} else {
@Suppress("DEPRECATION")
val result = audioManager.requestAudioFocus(
{ focusChange ->
android.util.Log.d("MainActivity", "Audio focus changed: $focusChange")
},
AudioManager.STREAM_MUSIC,
AudioManager.AUDIOFOCUS_GAIN
)
android.util.Log.d("MainActivity", "Audio focus request result (legacy): $result")
}
}
private fun abandonAudioFocus() {
android.util.Log.d("MainActivity", "Abandoning audio focus")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
audioFocusRequest?.let {
audioManager.abandonAudioFocusRequest(it)
}
} else {
@Suppress("DEPRECATION")
audioManager.abandonAudioFocus { }
}
}
}
@@ -0,0 +1,152 @@
package com.dtourolle.jellytau
import android.content.Context
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
/**
* Reports the *kind* of network the device is on, so downloads can be gated on
* "unmetered only" (the WiFi-only setting).
*
* This is deliberately separate from the Rust-side ConnectivityMonitor, which
* answers a different question: whether the Jellyfin *server* is reachable,
* derived from real request outcomes. Reachability and transport type are
* orthogonal you can be on WiFi with a dead server, or on cellular with a
* perfectly reachable one.
*
* Requires ACCESS_NETWORK_STATE; without it getNetworkCapabilities returns null
* and we report UNKNOWN (which the gate treats as "not acceptable" when
* wifi-only is on, failing closed rather than burning mobile data).
*
* TRACES: UR-053 | DR-074
*/
object NetworkTypeMonitor {
private const val TAG = "NetworkTypeMonitor"
/** Transport classification, mirrored by the Rust `NetworkType` enum. */
const val TYPE_NONE = "none"
const val TYPE_WIFI = "wifi"
const val TYPE_ETHERNET = "ethernet"
const val TYPE_CELLULAR = "cellular"
const val TYPE_OTHER = "other"
const val TYPE_UNKNOWN = "unknown"
private var callback: ConnectivityManager.NetworkCallback? = null
/** Invoked on any network change; set by [startWatching]. */
@Volatile
private var onChange: (() -> Unit)? = null
private fun connectivityManager(context: Context): ConnectivityManager? =
context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
/**
* Current transport type of the active network.
*
* Returns UNKNOWN (not NONE) when capabilities can't be read, so callers can
* distinguish "definitely offline" from "couldn't tell".
*/
fun currentType(context: Context): String {
val cm = connectivityManager(context) ?: return TYPE_UNKNOWN
val network = cm.activeNetwork ?: return TYPE_NONE
val caps = cm.getNetworkCapabilities(network) ?: return TYPE_UNKNOWN
return when {
caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> TYPE_WIFI
caps.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> TYPE_ETHERNET
caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> TYPE_CELLULAR
else -> TYPE_OTHER
}
}
/**
* Whether the active network is unmetered.
*
* This is the bit that actually matters for the WiFi-only gate: a phone
* hotspot reports TRANSPORT_WIFI but is metered, and is backed by exactly the
* cellular data the setting exists to protect. Checking NOT_METERED rather
* than the transport alone means tethering doesn't quietly burn a data plan.
*/
fun isUnmetered(context: Context): Boolean {
val cm = connectivityManager(context) ?: return false
val network = cm.activeNetwork ?: return false
val caps = cm.getNetworkCapabilities(network) ?: return false
return caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED)
}
/**
* Whether downloads may run right now given the wifi-only preference.
*
* Ethernet counts as acceptable (it is unmetered in practice and is what
* Android TV devices use). Cellular never does. When wifi-only is off this is
* always true the gate simply isn't engaged.
*/
fun isAcceptable(context: Context, wifiOnly: Boolean): Boolean {
if (!wifiOnly) return true
val type = currentType(context)
if (type == TYPE_CELLULAR || type == TYPE_NONE || type == TYPE_UNKNOWN) return false
// WiFi/Ethernet/other: require unmetered so metered hotspots are excluded.
return isUnmetered(context)
}
/**
* Register a callback that fires whenever the network changes, so a blocked
* download queue can be re-pumped the moment an acceptable network appears.
* Without this the queue would stall until some unrelated event pumped it.
*
* Idempotent: a second call replaces the previous callback.
*/
fun startWatching(context: Context, onNetworkChanged: () -> Unit) {
val cm = connectivityManager(context) ?: run {
android.util.Log.w(TAG, "No ConnectivityManager; network changes won't be observed")
return
}
stopWatching(context)
onChange = onNetworkChanged
val request = NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.build()
val cb = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
android.util.Log.d(TAG, "Network available")
onChange?.invoke()
}
override fun onLost(network: Network) {
android.util.Log.d(TAG, "Network lost")
onChange?.invoke()
}
override fun onCapabilitiesChanged(network: Network, caps: NetworkCapabilities) {
// Fires when e.g. metered-ness flips without the network itself changing.
onChange?.invoke()
}
}
try {
cm.registerNetworkCallback(request, cb)
callback = cb
android.util.Log.d(TAG, "Network callback registered")
} catch (e: Exception) {
android.util.Log.e(TAG, "Failed to register network callback", e)
}
}
/** Unregister the network callback, if one is active. */
fun stopWatching(context: Context) {
val cb = callback ?: return
val cm = connectivityManager(context)
try {
cm?.unregisterNetworkCallback(cb)
} catch (e: Exception) {
android.util.Log.w(TAG, "Failed to unregister network callback", e)
}
callback = null
onChange = null
}
}
@@ -0,0 +1,420 @@
package com.dtourolle.jellytau
import android.app.Activity
import android.app.PictureInPictureParams
import android.app.RemoteAction
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.graphics.drawable.Icon
import android.os.Build
import android.util.Rational
import android.view.ViewGroup
import android.webkit.WebView
import androidx.annotation.RequiresApi
import com.dtourolle.jellytau.player.JellyTauPlayer
/**
* Drives Android picture-in-picture for native (ExoPlayer) video playback.
*
* TRACES: UR-041 | IR-026 | DR-053
*
* PiP shrinks the whole Activity into a floating window, so the only thing that
* should remain visible is the video SurfaceView that [VideoOverlayManager]
* attached at the bottom of the z-order. The WebView carrying the Svelte UI is
* hidden for the duration - it is opaque and sits *above* the surface, so
* leaving it visible would occlude the video entirely.
*
* Playback itself is untouched: ExoPlayer keeps rendering into the same surface
* across the transition, so entering and leaving PiP never interrupts the video.
*/
object PictureInPictureManager {
private const val TAG = "PictureInPictureManager"
/** Action for the play/pause RemoteAction shown inside the PiP window. */
private const val ACTION_MEDIA_CONTROL = "com.dtourolle.jellytau.PIP_MEDIA_CONTROL"
private const val EXTRA_CONTROL_TYPE = "control_type"
private const val CONTROL_PLAY = 1
private const val CONTROL_PAUSE = 2
/** Request codes must differ per action or the PendingIntents collapse into one. */
private const val REQUEST_PLAY = 101
private const val REQUEST_PAUSE = 102
private var receiver: BroadcastReceiver? = null
private var hiddenWebView: WebView? = null
/**
* State of an HTML5 `<video>` playing inside the WebView, reported by the
* frontend.
*
* PiP was written for the native ExoPlayer surface only [canEnterPip]
* required a SurfaceView to be attached and rendering. But native video is
* behind `experimentalNativeVideo`, which defaults to **off**, so in the
* shipping configuration video plays in the WebView's `<video>` element and
* every one of those conditions is false. `enterPip` therefore always bailed
* with "no local video playing": PiP could not work at all, however the
* button was pressed.
*
* On this path the WebView *is* the video, which inverts two things: the
* WebView must stay visible in PiP rather than be hidden, and play/pause has
* to reach the element rather than ExoPlayer. Both are handled below.
*
* TRACES: UR-041 | DR-160
*/
@Volatile
private var html5VideoActive = false
@Volatile
private var html5VideoPlaying = false
@Volatile
private var html5AspectRatio: Rational? = null
/**
* Report the WebView `<video>` state from the frontend.
*
* @param active whether a video element is currently the playback surface
* @param width intrinsic video width, for the PiP window's aspect ratio
* @param height intrinsic video height
* @param playing whether it is playing right now, for the PiP play/pause action
*/
fun setHtml5VideoState(active: Boolean, width: Int, height: Int, playing: Boolean) {
html5VideoActive = active
html5VideoPlaying = playing
html5AspectRatio = if (active && width > 0 && height > 0) {
clampedRatio(width.toDouble() / height.toDouble())
} else {
null
}
}
/** True when PiP would be showing the native surface rather than the WebView. */
private fun isNativeVideoPath(): Boolean = try {
val player = JellyTauPlayer.getInstance()
player.isPlayingVideo() &&
player.getSurfaceView() != null &&
VideoOverlayManager.isVideoSurfaceAttached()
} catch (e: Exception) {
android.util.Log.w(TAG, "native video path check failed", e)
false
}
/**
* Whether this device/OS can do PiP at all. Android 8.0 introduced the API,
* and the user (or device manufacturer) can disable the feature per-app.
*/
fun isPipSupported(activity: Activity): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return false
return activity.packageManager.hasSystemFeature(
android.content.pm.PackageManager.FEATURE_PICTURE_IN_PICTURE
)
}
/**
* Whether entering PiP right now makes sense: a native video must actually
* be playing locally. Audio-only playback and remote/cast sessions render
* nothing on this device, so a PiP window would be an empty black box.
*/
fun canEnterPip(activity: Activity): Boolean {
if (!isPipSupported(activity)) return false
// Either surface will do: the native one, or the WebView's `<video>`,
// which is what actually plays while experimentalNativeVideo is off.
// (DR-160)
return isNativeVideoPath() || html5VideoActive
}
/**
* Enter picture-in-picture, sizing the window to the video's aspect ratio.
*
* @return true if the system accepted the transition.
*/
fun enterPip(activity: Activity): Boolean {
if (!canEnterPip(activity)) {
android.util.Log.d(TAG, "Not entering PiP: no local video playing")
return false
}
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return false
return try {
val params = buildParams(activity)
val entered = activity.enterPictureInPictureMode(params)
android.util.Log.d(TAG, "enterPictureInPictureMode returned $entered")
entered
} catch (e: Exception) {
// IllegalStateException here means PiP is disallowed (e.g. the user
// turned it off in system settings). Never crash over it.
android.util.Log.e(TAG, "Failed to enter PiP", e)
false
}
}
/**
* Build PiP params: aspect ratio from the current video, plus a play/pause
* RemoteAction reflecting the live playback state.
*/
@RequiresApi(Build.VERSION_CODES.O)
private fun buildParams(activity: Activity): PictureInPictureParams {
val builder = PictureInPictureParams.Builder()
aspectRatioFor()?.let { builder.setAspectRatio(it) }
builder.setActions(listOf(buildPlayPauseAction(activity)))
return builder.build()
}
/**
* The video's aspect ratio, clamped to the range Android accepts.
*
* The platform rejects ratios outside roughly 1:2.39 - 2.39:1 with an
* IllegalArgumentException, which would otherwise take down the Activity on
* unusually tall or wide content.
*/
private fun aspectRatioFor(): Rational? {
val player = try {
JellyTauPlayer.getInstance()
} catch (e: Exception) {
null
}
// The surface has already been letterboxed to the video's aspect ratio
// by fitSurfaceToScreen(), so its measured bounds are the video shape.
val surface = player?.getSurfaceView()
if (surface != null && surface.width > 0 && surface.height > 0) {
return clampedRatio(surface.width.toDouble() / surface.height.toDouble())
}
// No native surface: the WebView is the video, so use the intrinsic size
// the frontend reported. (DR-160)
return html5AspectRatio
}
/**
* Clamp a ratio to the range Android accepts and express it as a [Rational].
*
* The platform rejects ratios outside roughly 1:2.39 - 2.39:1 with an
* IllegalArgumentException, which would otherwise take down the Activity on
* unusually tall or wide content. Scaled to integers because
* `Rational(width, height)` can overflow for large surfaces, and the clamped
* value may not match the raw pixels anyway.
*/
private fun clampedRatio(ratio: Double): Rational {
val clamped = ratio.coerceIn(1.0 / 2.39, 2.39)
return Rational((clamped * 1000).toInt(), 1000)
}
@RequiresApi(Build.VERSION_CODES.O)
private fun buildPlayPauseAction(activity: Activity): RemoteAction {
// On the HTML5 path ExoPlayer is idle, so its `isPlaying` is always false
// and the button would be stuck showing "Play" mid-playback. (DR-160)
val isPlaying = if (isNativeVideoPath()) {
try {
JellyTauPlayer.getInstance().getExoPlayer().isPlaying
} catch (e: Exception) {
false
}
} else {
html5VideoPlaying
}
val (iconRes, title, controlType, requestCode) = if (isPlaying) {
Quad(
android.R.drawable.ic_media_pause,
"Pause",
CONTROL_PAUSE,
REQUEST_PAUSE
)
} else {
Quad(
android.R.drawable.ic_media_play,
"Play",
CONTROL_PLAY,
REQUEST_PLAY
)
}
val intent = Intent(ACTION_MEDIA_CONTROL)
.putExtra(EXTRA_CONTROL_TYPE, controlType)
// Explicit package keeps the broadcast internal to the app.
.setPackage(activity.packageName)
val flags = android.app.PendingIntent.FLAG_UPDATE_CURRENT or
android.app.PendingIntent.FLAG_IMMUTABLE
val pendingIntent = android.app.PendingIntent.getBroadcast(
activity,
requestCode,
intent,
flags
)
return RemoteAction(
Icon.createWithResource(activity, iconRes),
title,
title,
pendingIntent
)
}
private data class Quad<A, B, C, D>(
val first: A,
val second: B,
val third: C,
val fourth: D
)
/**
* Refresh the PiP window's action button so it tracks play/pause state
* while the window is open. Safe to call when not in PiP (no-op).
*/
fun updatePipActions(activity: Activity) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
if (!activity.isInPictureInPictureMode) return
try {
activity.setPictureInPictureParams(buildParams(activity))
} catch (e: Exception) {
android.util.Log.w(TAG, "Failed to update PiP actions", e)
}
}
/**
* Called from MainActivity.onPictureInPictureModeChanged.
*
* Entering: hide the WebView so only the video surface shows, and register
* the receiver backing the PiP play/pause button.
* Leaving: restore the WebView and unregister.
*/
fun onPipModeChanged(activity: Activity, isInPipMode: Boolean) {
if (isInPipMode) {
// Hiding the WebView is correct only when the video is *behind* it on
// the native surface. On the HTML5 path the WebView is the video, so
// hiding it would leave an empty black PiP window — the frontend
// instead strips its own chrome when it hears the event below.
// (DR-160)
if (isNativeVideoPath()) {
hideWebView(activity)
}
registerReceiver(activity)
dispatchWebEvent(activity, "jellytau-pip-entered")
} else {
unregisterReceiver(activity)
showWebView()
dispatchWebEvent(activity, "jellytau-pip-exited")
// The surface was laid out against the tiny PiP bounds; re-fit it to
// the restored full-screen bounds or the video stays postage-stamp sized.
try {
JellyTauPlayer.getInstance().fitSurfaceToScreen()
} catch (e: Exception) {
android.util.Log.w(TAG, "Failed to re-fit surface after PiP", e)
}
}
}
/**
* Fire a DOM event into the WebView.
*
* The HTML5 PiP path is a conversation with the frontend rather than
* something native can do alone: it has to be told to strip its chrome when
* the window shrinks, and to play/pause the element. (DR-160)
*/
private fun dispatchWebEvent(activity: Activity, name: String) {
val webView = findWebView(activity.window.decorView) ?: return
webView.post {
webView.evaluateJavascript(
"window.dispatchEvent(new CustomEvent('$name'));",
null
)
}
}
private fun hideWebView(activity: Activity) {
val webView = findWebView(activity.window.decorView)
if (webView == null) {
android.util.Log.w(TAG, "No WebView found to hide for PiP")
return
}
// GONE rather than INVISIBLE: the WebView is opaque, and GONE also stops
// it from consuming layout space in the shrunken window.
webView.visibility = android.view.View.GONE
hiddenWebView = webView
android.util.Log.d(TAG, "WebView hidden for PiP")
}
private fun showWebView() {
hiddenWebView?.let {
it.visibility = android.view.View.VISIBLE
android.util.Log.d(TAG, "WebView restored after PiP")
}
hiddenWebView = null
}
private fun registerReceiver(activity: Activity) {
if (receiver != null) return
val r = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
if (intent?.action != ACTION_MEDIA_CONTROL) return
val control = intent.getIntExtra(EXTRA_CONTROL_TYPE, 0)
if (isNativeVideoPath()) {
val player = try {
JellyTauPlayer.getInstance()
} catch (e: Exception) {
return
}
when (control) {
CONTROL_PLAY -> player.play()
CONTROL_PAUSE -> player.pause()
}
} else {
// The WebView owns playback here, so the command has to reach
// the `<video>` element. Driving ExoPlayer instead would do
// nothing at all, which is what a PiP button on the HTML5 path
// used to do. (DR-160)
val name = when (control) {
CONTROL_PLAY -> "jellytau-pip-play"
CONTROL_PAUSE -> "jellytau-pip-pause"
else -> return
}
dispatchWebEvent(activity, name)
html5VideoPlaying = control == CONTROL_PLAY
}
// Swap the button to reflect the new state.
updatePipActions(activity)
}
}
val filter = IntentFilter(ACTION_MEDIA_CONTROL)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
activity.registerReceiver(r, filter, Context.RECEIVER_NOT_EXPORTED)
} else {
@Suppress("UnspecifiedRegisterReceiverFlag")
activity.registerReceiver(r, filter)
}
receiver = r
android.util.Log.d(TAG, "PiP media control receiver registered")
}
private fun unregisterReceiver(activity: Activity) {
receiver?.let {
try {
activity.unregisterReceiver(it)
} catch (e: IllegalArgumentException) {
// Already unregistered - harmless.
}
}
receiver = null
}
private fun findWebView(view: android.view.View): WebView? {
if (view is WebView) return view
if (view is ViewGroup) {
for (i in 0 until view.childCount) {
findWebView(view.getChildAt(i))?.let { return it }
}
}
return null
}
}
@@ -1,7 +1,7 @@
package com.dtourolle.jellytau
import android.app.Activity
import android.view.SurfaceView
import android.view.TextureView
import android.view.ViewGroup
import android.widget.FrameLayout
import com.dtourolle.jellytau.player.JellyTauPlayer
@@ -14,13 +14,18 @@ import com.dtourolle.jellytau.player.JellyTauPlayer
*/
object VideoOverlayManager {
private var attachedSurfaceView: SurfaceView? = null
private var attachedSurfaceView: TextureView? = null
private var contentLayoutListener: android.view.View.OnLayoutChangeListener? = null
private var listenerContentView: ViewGroup? = null
/**
* Attach the video SurfaceView to the Activity's content view.
* Attach the video view to the Activity's content view.
*
* The SurfaceView is added at index 0 (bottom of z-order) so it renders
* behind the Tauri WebView, allowing Svelte controls to overlay on top.
* Added at index 0 (bottom of the z-order) so it renders behind the Tauri
* WebView, allowing the Svelte controls to overlay on top. Since DR-192 this
* is a TextureView, so "behind" is ordinary view z-order within one window
* rather than a separate surface punched through it which is what makes
* the overlay above it repaint reliably.
*
* @param activity The Activity to attach the surface to
*/
@@ -51,6 +56,23 @@ object VideoOverlayManager {
contentView.addView(surfaceView, 0, layoutParams)
attachedSurfaceView = surfaceView
// Re-fit the video whenever the content view's bounds change (e.g. on
// device rotation) so the video is letterboxed to fit instead of being
// stretched/cropped by the MATCH_PARENT surface.
removeLayoutListener()
val listener = android.view.View.OnLayoutChangeListener {
_, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom ->
if (right - left != oldRight - oldLeft || bottom - top != oldBottom - oldTop) {
player.fitSurfaceToScreen()
}
}
contentView.addOnLayoutChangeListener(listener)
contentLayoutListener = listener
listenerContentView = contentView
// Fit once now that the surface is attached and the parent is sized.
player.fitSurfaceToScreen()
android.util.Log.d("VideoOverlayManager", "Video surface attached to view hierarchy")
} catch (e: Exception) {
android.util.Log.e("VideoOverlayManager", "Failed to attach video surface", e)
@@ -58,15 +80,29 @@ object VideoOverlayManager {
}
/**
* Detach the video SurfaceView from the Activity's view hierarchy.
* Detach the video SurfaceView from the view hierarchy.
*
* @param activity The Activity to detach the surface from
* Must be called on the main thread.
*
* This had **no callers at all**, which made [attachVideoSurface] one-way:
* `JellyTauPlayer.clearVideoSurface()` dropped its `surfaceView` reference
* without removing the view, so every native video left its SurfaceView
* parented to the content view for the life of the process and the next one
* added another beneath it. The stack was invisible while the WebView was
* opaque, and [isVideoSurfaceAttached] which gates
* `PictureInPictureManager.canEnterPip` stayed true forever afterwards.
*
* Removes from the view's *own* parent rather than looking the content view
* up from an Activity, so it cannot leave a view behind when the Activity
* has been recreated under it.
*
* TRACES: UR-003, UR-041 | DR-184
*/
fun detachVideoSurface(activity: Activity) {
fun detachVideoSurface() {
try {
removeLayoutListener()
attachedSurfaceView?.let { surfaceView ->
val contentView = activity.window.decorView.findViewById<ViewGroup>(android.R.id.content)
contentView.removeView(surfaceView)
(surfaceView.parent as? ViewGroup)?.removeView(surfaceView)
attachedSurfaceView = null
android.util.Log.d("VideoOverlayManager", "Video surface detached from view hierarchy")
}
@@ -83,4 +119,12 @@ object VideoOverlayManager {
fun isVideoSurfaceAttached(): Boolean {
return attachedSurfaceView != null
}
private fun removeLayoutListener() {
contentLayoutListener?.let { listener ->
listenerContentView?.removeOnLayoutChangeListener(listener)
}
contentLayoutListener = null
listenerContentView = null
}
}
@@ -0,0 +1,140 @@
package com.dtourolle.jellytau
import android.app.Activity
import android.webkit.JavascriptInterface
import android.webkit.WebView
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
/**
* Publishes the Activity's real window insets to the WebView as CSS custom
* properties.
*
* TRACES: UR-066 | IR-031, DR-112
*
* ## Why this is necessary
*
* MainActivity calls `enableEdgeToEdge()`, and the app targets SDK 36
* edge-to-edge is mandatory from SDK 35 and the opt-out is ignored from SDK 36
* so the Tauri WebView always spans the whole window, underneath the status
* bar, the navigation/gesture bar and the display cutout.
*
* The web layer cannot discover that by itself. Android WebView maps only the
* **display cutout** into `env(safe-area-inset-*)` (and only with
* `viewport-fit=cover`); the status bar and navigation bar are never reported.
* Unlike iOS Safari there is no CSS-visible system-bar inset. So the frontend's
* `env()`-based padding evaluated to 0 on every device and the bottom nav
* rendered underneath the navigation bar.
*
* How badly that showed depended entirely on the device's navigation mode: a
* thin translucent gesture pill overlaps almost harmlessly, while a tall opaque
* 3-button bar swallows the nav outright.
*
* ## Contract with the frontend
*
* Insets are reported in **CSS pixels** (density-independent), because that is
* the unit CSS will use them in dividing by `displayMetrics.density` here is
* what keeps the padding correct across screen densities.
*
* - **Push**: on every inset change (rotation, navigation-mode switch, PiP
* enter/exit) the four `--jt-inset-*` custom properties are written onto
* `document.documentElement` and `jellytau-insets-changed` is dispatched.
* - **Pull**: `AndroidInsets.get()` returns the same payload as JSON. Required
* because the first inset pass normally lands before the SvelteKit document
* exists, and a page load discards any inline style a push had set.
*
* See `src/lib/utils/safeArea.ts` and the `--safe-*` vars in `src/app.css`.
*/
object WindowInsetsBridge {
/** Latest insets in CSS pixels. Written on the main thread, read from the WebView binder thread. */
@Volatile
private var top = 0
@Volatile
private var right = 0
@Volatile
private var bottom = 0
@Volatile
private var left = 0
/** Cached so a WebView found later (or re-found on resume) can be primed. */
private var webView: WebView? = null
/**
* Start listening for window insets on [activity].
*
* Call from `onCreate` right after `enableEdgeToEdge()`. The listener
* returns the insets **unconsumed** so the WebView still receives them for
* its own display-cutout handling.
*/
fun install(activity: Activity) {
val density = activity.resources.displayMetrics.density
ViewCompat.setOnApplyWindowInsetsListener(activity.window.decorView) { _, insets ->
// systemBars() covers the status bar and the navigation/gesture bar;
// displayCutout() covers notches and punch-holes, which in landscape
// land on a side edge that systemBars() does not describe.
val i = insets.getInsets(
WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout()
)
val toCssPx = { px: Int -> if (density > 0f) Math.round(px / density) else px }
top = toCssPx(i.top)
right = toCssPx(i.right)
bottom = toCssPx(i.bottom)
left = toCssPx(i.left)
android.util.Log.d(
"WindowInsetsBridge",
"insets (css px): top=$top right=$right bottom=$bottom left=$left"
)
push()
// Do NOT return CONSUMED - other views (and the WebView's own cutout
// handling) still need to see these.
insets
}
// The first pass may already have happened before the listener existed.
ViewCompat.requestApplyInsets(activity.window.decorView)
}
/**
* Adopt the WebView carrying the UI and push the current insets into it.
*
* Safe to call repeatedly (MainActivity re-finds the WebView on every
* resume): this only writes CSS properties, unlike `addJavascriptInterface`,
* which must run exactly once per WebView.
*/
fun attachWebView(view: WebView) {
webView = view
push()
}
/** Current insets as JSON in CSS pixels — the payload `AndroidInsets.get()` returns. */
fun currentJson(): String =
"""{"top":$top,"right":$right,"bottom":$bottom,"left":$left}"""
/** The `AndroidInsets` @JavascriptInterface object for the pull path. */
fun jsInterface(): Any = object : Any() {
@JavascriptInterface
fun get(): String = currentJson()
}
/** Write the custom properties into the live document and signal the change. */
private fun push() {
val view = webView ?: return
val js = """
(function() {
var s = document.documentElement.style;
s.setProperty('--jt-inset-top', '${top}px');
s.setProperty('--jt-inset-right', '${right}px');
s.setProperty('--jt-inset-bottom', '${bottom}px');
s.setProperty('--jt-inset-left', '${left}px');
window.dispatchEvent(new CustomEvent('jellytau-insets-changed'));
})();
""".trimIndent()
view.post { view.evaluateJavascript(js, null) }
}
}
@@ -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.
*
@@ -87,48 +87,87 @@ class JellyTauPlaybackService : MediaSessionService() {
val jellyTauPlayer = JellyTauPlayer.getInstance()
val exoPlayer = jellyTauPlayer.getExoPlayer()
// Wrap the ExoPlayer to intercept commands
// Wrap the ExoPlayer to intercept commands from Media3 controllers
// (e.g. Android Auto / Wear / system surfaces that bind to the Media3
// session rather than the MediaSessionCompat).
//
// We do NOT execute on ExoPlayer directly here. Every transport command
// is routed to Rust via nativeOnMediaCommand, which is the single decision
// point: in local mode Rust drives ExoPlayer, in remote (cast) mode Rust
// forwards to the remote Jellyfin session. Executing on ExoPlayer here too
// would double-handle local commands and incorrectly drive the local
// player while casting.
wrappedPlayer = object : ForwardingPlayer(exoPlayer) {
override fun play() {
// Execute immediately for instant lockscreen response
super.play()
// Then notify Rust for state management
nativeOnMediaCommand("play")
}
override fun pause() {
// Execute immediately for instant lockscreen response
super.pause()
// Then notify Rust for state management
nativeOnMediaCommand("pause")
}
override fun seekToNext() {
// Execute immediately for instant lockscreen response
super.seekToNext()
// Then notify Rust for queue management
nativeOnMediaCommand("next")
}
override fun seekToPrevious() {
// Execute immediately for instant lockscreen response
super.seekToPrevious()
// Then notify Rust for queue management
nativeOnMediaCommand("previous")
}
override fun seekTo(positionMs: Long) {
// Execute immediately for instant lockscreen response
super.seekTo(positionMs)
// Then notify Rust of seek
val positionSeconds = positionMs / 1000.0
nativeOnMediaCommand("seek:$positionSeconds")
}
// media3 seeks by more routes than seekTo(long), and the ones below
// reach the *real* ExoPlayer if they are not overridden — bypassing
// Rust entirely and operating on the handoff stream's relative
// timeline. That is the same mechanism as the truncation bug, reached
// by a different door.
//
// seekToDefaultPosition is deliberately swallowed rather than
// forwarded. Util.handlePlayButtonAction calls it on an ended or idle
// player and then calls play(); on a handoff stream the seek lands at
// stream zero — the point the screen was locked at — which is exactly
// the reported jump-back. Sending "seek:0.0" instead would be worse
// still, restarting the whole episode. Rust already owns what "play
// after the stream ended" means (truncation recovery, or advancing to
// the next episode), and the play() that follows reaches it, so the
// right move here is to not move at all.
//
// TRACES: UR-040, UR-005 | DR-159
override fun seekToDefaultPosition() {
android.util.Log.d(
"JellyTauPlaybackService",
"Ignoring seekToDefaultPosition — Rust owns end-of-stream handling"
)
}
override fun seekToDefaultPosition(mediaItemIndex: Int) {
android.util.Log.d(
"JellyTauPlaybackService",
"Ignoring seekToDefaultPosition(index) — Rust owns end-of-stream handling"
)
}
// `currentPosition` is ExoPlayer's own, so it is relative during a
// handoff; the base makes the target absolute, which is what Rust
// expects from every command on this boundary.
override fun seekBack() {
val target =
((currentPosition + handoffBaseMs - seekBackIncrement) / 1000.0)
.coerceAtLeast(0.0)
nativeOnMediaCommand("seek:$target")
}
override fun seekForward() {
val target =
((currentPosition + handoffBaseMs + seekForwardIncrement) / 1000.0)
.coerceAtLeast(0.0)
nativeOnMediaCommand("seek:$target")
}
override fun stop() {
// Execute immediately for instant lockscreen response
super.stop()
// Then notify Rust for state management
nativeOnMediaCommand("stop")
}
}
@@ -160,36 +199,47 @@ class JellyTauPlaybackService : MediaSessionService() {
)
isActive = true
// Set callback to handle lock screen button presses
// Set callback to handle lock screen button presses.
//
// All transport commands are routed through Rust via nativeOnMediaCommand
// rather than directly to ExoPlayer. Rust is the single decision point:
// in local mode it drives ExoPlayer, in remote (cast) mode it forwards
// the command to the remote Jellyfin session. This keeps the lockscreen
// working identically for both, and avoids the ExoPlayer-only behaviour
// that left remote playback uncontrollable from the lockscreen.
setCallback(object : MediaSessionCompat.Callback() {
override fun onPlay() {
android.util.Log.d("JellyTauPlaybackService", "Lock screen: Play pressed")
wrappedPlayer?.play()
nativeOnMediaCommand("play")
}
override fun onPause() {
android.util.Log.d("JellyTauPlaybackService", "Lock screen: Pause pressed")
wrappedPlayer?.pause()
nativeOnMediaCommand("pause")
}
override fun onSkipToNext() {
android.util.Log.d("JellyTauPlaybackService", "Lock screen: Next pressed")
wrappedPlayer?.seekToNext()
nativeOnMediaCommand("next")
}
override fun onSkipToPrevious() {
android.util.Log.d("JellyTauPlaybackService", "Lock screen: Previous pressed")
wrappedPlayer?.seekToPrevious()
nativeOnMediaCommand("previous")
}
override fun onStop() {
android.util.Log.d("JellyTauPlaybackService", "Lock screen: Stop pressed")
wrappedPlayer?.stop()
nativeOnMediaCommand("stop")
}
override fun onSeekTo(position: Long) {
android.util.Log.d("JellyTauPlaybackService", "Lock screen: Seek to $position")
wrappedPlayer?.seekTo(position)
// The scrubber is absolute; Rust owns the seek in absolute terms
// (in a background-audio handoff it rebuilds the stream at this
// StartTimeTicks). Send the absolute position as-is.
val positionSeconds = position / 1000.0
nativeOnMediaCommand("seek:$positionSeconds")
}
})
}
@@ -253,9 +303,47 @@ class JellyTauPlaybackService : MediaSessionService() {
.build()
}
// Last-known metadata/state, retained so lightweight position ticks can
// rebuild a correct PlaybackState without re-sending the (heavier) metadata
// and notification. Kept in sync by updateMediaMetadata().
private var lastTitle: String = ""
private var lastArtist: String = ""
private var lastIsPlaying: Boolean = false
// The handoff base (ms): during a background-audio handoff the audio stream is
// requested with StartTimeTicks = the handoff point, so ExoPlayer's timeline
// starts at 0 *there* and every position it reports is relative to it. This
// is the number that converts one back to a real position on the episode.
//
// It is deliberately read, not applied, here. This used to be a display-only
// correction added at the two setPlaybackState calls below, which left every
// other consumer — progress reporting to Jellyfin, the frontend, media3's own
// seeks — working in the relative timeline while treating it as absolute, each
// crossing silently losing exactly `base` seconds. The conversion now happens
// once, in JellyTauPlayer's position tick, so everything downstream of it
// speaks the episode's timeline; applying it again here would double-count.
//
// TRACES: UR-040 | DR-159
@Volatile
var handoffBaseMs: Long = 0L
private set
/**
* Update the MediaSession metadata and playback state.
* This updates both the MediaSession and the notification.
* Set the handoff base (seconds). Called by the native layer when entering or
* leaving a background-audio handoff; 0 clears it for normal playback, where
* ExoPlayer's position is already absolute.
*/
fun setHandoffBase(offsetSeconds: Double) {
handoffBaseMs = (offsetSeconds * 1000.0).toLong().coerceAtLeast(0L)
android.util.Log.d("JellyTauPlaybackService", "Handoff base set to ${handoffBaseMs}ms")
}
/**
* Update the MediaSession metadata and playback state, plus the notification.
*
* Call this when the track or play/pause state changes. For frequent position
* updates during playback, use [updatePlaybackPosition] instead, which is much
* cheaper (no metadata rebuild, no notification rebuild).
*/
fun updateMediaMetadata(
title: String,
@@ -267,6 +355,10 @@ class JellyTauPlaybackService : MediaSessionService() {
) {
val session = mediaSessionCompat ?: return
lastTitle = title
lastArtist = artist
lastIsPlaying = isPlaying
// Update MediaSession metadata
val metadataBuilder = android.support.v4.media.MediaMetadataCompat.Builder()
.putString(android.support.v4.media.MediaMetadataCompat.METADATA_KEY_TITLE, title)
@@ -279,8 +371,62 @@ class JellyTauPlaybackService : MediaSessionService() {
session.setMetadata(metadataBuilder.build())
// Update MediaSession playback state
val stateBuilder = PlaybackStateCompat.Builder()
// Already absolute: this call comes from Rust, whose stored position is on
// the episode's timeline. (DR-159)
session.setPlaybackState(buildPlaybackState(isPlaying, position))
// While casting, re-assert the remote volume provider. Metadata pushes
// arrive on the session poller thread and can race with (or arrive
// before) enableRemoteVolume(); this keeps the session routed to the
// remote (absolute) volume slider instead of the local media stream.
if (isRemoteVolumeEnabled) {
volumeProvider?.let { session.setPlaybackToRemote(it) }
}
// Update the notification
updateNotification(title, artist, isPlaying)
}
/**
* Update only the playback position (and play/pause state) on the MediaSession.
*
* This is the cheap path used for the periodic (250ms) position ticks: it
* refreshes the lockscreen scrubber without rebuilding metadata or the
* notification. Without this, the lockscreen scrubber freezes at the position
* from the last play/pause and drifts out of sync with actual playback.
*
* @param position Absolute position in milliseconds, on the item's own
* timeline the caller has already applied [handoffBaseMs].
* @param isPlaying Whether playback is currently active
*/
fun updatePlaybackPosition(position: Long, isPlaying: Boolean) {
val session = mediaSessionCompat ?: return
val notificationStateChanged = isPlaying != lastIsPlaying
lastIsPlaying = isPlaying
session.setPlaybackState(buildPlaybackState(isPlaying, position))
// Only rebuild the notification when the play/pause icon actually flips.
if (notificationStateChanged) {
updateNotification(lastTitle, lastArtist, isPlaying)
}
}
/**
* Build a PlaybackStateCompat with the standard transport actions.
*
* The reported playback speed is 1.0 while playing and 0.0 while paused so
* Android does not extrapolate the position past a paused track.
*
* While remote volume control is enabled (casting), the state is forced to
* STATE_PLAYING regardless of [isPlaying]. Android only surfaces the remote
* (absolute) volume slider for a session that is actively playing; if a
* periodic metadata/position push reports paused (e.g. before the remote
* session has actually started), reporting STATE_PAUSED here makes the
* system tear down the remote slider set up by setPlaybackToRemote() and
* fall back to the local media-stream volume.
*/
private fun buildPlaybackState(isPlaying: Boolean, position: Long): PlaybackStateCompat {
val playing = isPlaying || isRemoteVolumeEnabled
return PlaybackStateCompat.Builder()
.setActions(
PlaybackStateCompat.ACTION_PLAY or
PlaybackStateCompat.ACTION_PAUSE or
@@ -290,15 +436,11 @@ class JellyTauPlaybackService : MediaSessionService() {
PlaybackStateCompat.ACTION_SEEK_TO
)
.setState(
if (isPlaying) PlaybackStateCompat.STATE_PLAYING else PlaybackStateCompat.STATE_PAUSED,
if (playing) PlaybackStateCompat.STATE_PLAYING else PlaybackStateCompat.STATE_PAUSED,
position,
1.0f
if (playing) 1.0f else 0.0f
)
session.setPlaybackState(stateBuilder.build())
// Update the notification
updateNotification(title, artist, isPlaying)
.build()
}
/**
@@ -8,8 +8,7 @@ import android.net.Uri
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.view.SurfaceHolder
import android.view.SurfaceView
import android.view.TextureView
import android.view.ViewGroup
import android.widget.FrameLayout
import androidx.annotation.OptIn
@@ -36,6 +35,59 @@ class JellyTauPlayer(private val appContext: Context) {
/** Position update interval in milliseconds */
private const val POSITION_UPDATE_INTERVAL_MS = 250L
/** AudioEffect priority. Positive = higher priority than the default. */
private const val EFFECT_PRIORITY = 1000
/**
* How long to wait for a fresh frame after a resize before revealing the
* view anyway. Playback may be paused, in which case no frame is coming.
*/
private const val FRESH_FRAME_TIMEOUT_MS = 400L
/**
* Canonical 10-band ISO centre frequencies (Hz), mirroring EQ_BANDS in
* settings.rs. Kept in sync deliberately: Rust owns the band layout, this
* is only the lookup table used to map those gains onto whatever bands
* the device's equalizer actually has.
*/
private val CANONICAL_BAND_CENTRES_HZ =
intArrayOf(31, 62, 125, 250, 500, 1000, 2000, 4000, 8000, 16000)
/**
* Map canonical band gains onto a device's band centres by nearest
* centre frequency.
*
* Pure function so it can be unit-tested without a device device band
* counts vary (commonly 5) and getting this wrong silently mis-shapes the
* EQ curve rather than failing.
*
* TRACES: UR-027 | DR-030
*/
@JvmStatic
fun resampleBands(
canonicalGains: FloatArray,
canonicalCentresHz: IntArray,
deviceCentresHz: IntArray
): FloatArray {
if (canonicalGains.isEmpty() || deviceCentresHz.isEmpty()) {
return FloatArray(deviceCentresHz.size)
}
val usable = minOf(canonicalGains.size, canonicalCentresHz.size)
return FloatArray(deviceCentresHz.size) { d ->
val target = deviceCentresHz[d]
var nearest = 0
var bestDelta = Int.MAX_VALUE
for (c in 0 until usable) {
val delta = kotlin.math.abs(canonicalCentresHz[c] - target)
if (delta < bestDelta) {
bestDelta = delta
nearest = c
}
}
canonicalGains[nearest]
}
}
/** Singleton instance for JNI access */
@Volatile
private var instance: JellyTauPlayer? = null
@@ -83,7 +135,7 @@ class JellyTauPlayer(private val appContext: Context) {
}
// Detect and report hardware codec capabilities to Rust
detectAndReportCodecs()
detectAndReportCodecs(context.applicationContext)
}
}
}
@@ -94,23 +146,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.
@@ -135,6 +195,18 @@ class JellyTauPlayer(private val appContext: Context) {
private val coroutineScope = CoroutineScope(Dispatchers.Main + SupervisorJob())
private var positionUpdateJob: Job? = null
/** Graphic EQ bound to the current audio session, or null if not attached. */
private var equalizer: android.media.audiofx.Equalizer? = null
/** Loudness/normalization effect bound to the current audio session. */
private var loudnessEnhancer: android.media.audiofx.LoudnessEnhancer? = null
/**
* Last settings pushed from Rust, replayed when the audio session is rebuilt.
* Held as the raw payload so re-application needs no second parse contract.
*/
private var lastAudioSettings: org.json.JSONObject? = null
/** Current media ID being played */
private var currentMediaId: String? = null
@@ -158,9 +230,21 @@ class JellyTauPlayer(private val appContext: Context) {
/** Media type enum */
enum class MediaType { AUDIO, VIDEO }
/** SurfaceView for video playback */
private var surfaceView: SurfaceView? = null
private var surfaceHolder: SurfaceHolder? = null
/** TextureView for video playback — see getOrCreateSurfaceView() for why. */
private var videoView: TextureView? = null
/** The Surface handed to ExoPlayer, owned here rather than by the player. */
private var videoSurface: android.view.Surface? = null
/**
* True while the view is hidden waiting for a new frame after a resize.
* See fitSurfaceToScreen (DR-194).
*/
@Volatile
private var awaitingFreshFrame = false
/** Last reported video frame size, used to fit the surface to the screen preserving aspect ratio */
private var videoWidth: Int = 0
private var videoHeight: Int = 0
private var currentMediaType: MediaType = MediaType.AUDIO
private var currentActivity: java.lang.ref.WeakReference<android.app.Activity>? = null
@@ -170,6 +254,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()
@@ -262,7 +360,11 @@ class JellyTauPlayer(private val appContext: Context) {
}
override fun onVideoSizeChanged(videoSize: androidx.media3.common.VideoSize) {
android.util.Log.d("JellyTauPlayer", "▶ Video size: ${videoSize.width}x${videoSize.height}")
android.util.Log.d("JellyTauPlayer", "▶ Video size: ${videoSize.width}x${videoSize.height} par=${videoSize.pixelWidthHeightRatio}")
// Apply pixel aspect ratio so anamorphic content isn't distorted
videoWidth = (videoSize.width * videoSize.pixelWidthHeightRatio).toInt()
videoHeight = videoSize.height
fitSurfaceToScreen()
}
override fun onRenderedFirstFrame() {
@@ -295,29 +397,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)
}
@@ -327,6 +461,11 @@ class JellyTauPlayer(private val appContext: Context) {
override fun onAudioSessionIdChanged(audioSessionId: Int) {
android.util.Log.d("JellyTauPlayer", "▶▶▶ AUDIO SESSION ID CHANGED: $audioSessionId")
// ExoPlayer rebuilt its audio sink (e.g. on a format change), so
// effects bound to the old session are dead. Re-attach, or the EQ
// silently stops applying mid-queue.
releaseAudioEffects()
lastAudioSettings?.let { applyAudioEffects(it) }
}
})
}
@@ -352,6 +491,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()
}
}
@@ -409,6 +557,133 @@ class JellyTauPlayer(private val appContext: Context) {
}
}
/**
* Apply audio settings pushed from Rust as JSON.
*
* Rust owns *what* the values are (band layout, preset curves, normalization
* presets); this owns *when* the Android AudioEffect objects exist, since
* that needs the live audio session id and must survive a sink rebuild.
*
* Posted to the main handler rather than run inline: AudioEffect construction
* from a player callback can re-enter the player and deadlock.
*
* TRACES: UR-027, UR-032, UR-033 | DR-030, DR-035, DR-036
*/
fun setAudioSettings(json: String) {
mainHandler.post {
try {
val settings = org.json.JSONObject(json)
lastAudioSettings = settings
// Gapless: ExoPlayer is gapless by default for compatible
// formats, so honouring the setting means disabling it when off.
exoPlayer.pauseAtEndOfMediaItems = !settings.optBoolean("gaplessPlayback", true)
applyAudioEffects(settings)
} catch (e: Exception) {
android.util.Log.e("JellyTauPlayer", "Failed to apply audio settings", e)
}
}
}
/** Attach/update the EQ and loudness effects for the current audio session. */
private fun applyAudioEffects(settings: org.json.JSONObject) {
val sessionId = exoPlayer.audioSessionId
if (sessionId == C.AUDIO_SESSION_ID_UNSET) {
// No sink yet; onAudioSessionIdChanged will re-drive this.
return
}
try {
applyEqualizer(sessionId, settings)
} catch (e: Exception) {
android.util.Log.e("JellyTauPlayer", "Equalizer unavailable on this device", e)
}
try {
applyNormalization(sessionId, settings)
} catch (e: Exception) {
android.util.Log.e("JellyTauPlayer", "LoudnessEnhancer unavailable on this device", e)
}
}
private fun applyEqualizer(sessionId: Int, settings: org.json.JSONObject) {
val enabled = settings.optBoolean("equalizerEnabled", false)
if (!enabled) {
equalizer?.enabled = false
return
}
val eq = equalizer ?: android.media.audiofx.Equalizer(EFFECT_PRIORITY, sessionId).also {
equalizer = it
}
val bandsJson = settings.optJSONArray("equalizerBands")
val canonicalGains = FloatArray(bandsJson?.length() ?: 0) { i ->
bandsJson!!.optDouble(i, 0.0).toFloat()
}
if (canonicalGains.isEmpty()) {
eq.enabled = false
return
}
// The device's band count/centres are device-dependent (commonly 5) and
// will not match our canonical 10-band ISO layout, so resample.
val deviceBandCount = eq.numberOfBands.toInt()
val deviceCentresHz = IntArray(deviceBandCount) { i ->
eq.getCenterFreq(i.toShort()) / 1000 // device reports milliHertz
}
val levelRange = eq.bandLevelRange // millibels, [min, max]
val resampled = resampleBands(canonicalGains, CANONICAL_BAND_CENTRES_HZ, deviceCentresHz)
for (i in 0 until deviceBandCount) {
val millibels = (resampled[i] * 100f)
.coerceIn(levelRange[0].toFloat(), levelRange[1].toFloat())
eq.setBandLevel(i.toShort(), millibels.toInt().toShort())
}
eq.enabled = true
}
private fun applyNormalization(sessionId: Int, settings: org.json.JSONObject) {
val enabled = settings.optBoolean("normalizeVolume", false)
if (!enabled) {
loudnessEnhancer?.enabled = false
return
}
val enhancer = loudnessEnhancer
?: android.media.audiofx.LoudnessEnhancer(sessionId).also { loudnessEnhancer = it }
// Approximate parity with the Linux dynaudnorm path: LoudnessEnhancer is
// a gain stage, not a true EBU R128 normalizer, so these are relative
// offsets preserving the Loud > Normal > Quiet ordering.
val targetGainMb = when (settings.optString("volumeLevel", "normal")) {
"loud" -> 600
"quiet" -> -600
else -> 0
}
enhancer.setTargetGain(targetGainMb)
enhancer.enabled = true
}
private fun releaseAudioEffects() {
try {
equalizer?.release()
} catch (e: Exception) {
android.util.Log.w("JellyTauPlayer", "Equalizer release failed", e)
}
try {
loudnessEnhancer?.release()
} catch (e: Exception) {
android.util.Log.w("JellyTauPlayer", "LoudnessEnhancer release failed", e)
}
equalizer = null
loudnessEnhancer = null
}
/**
* Get the current playback position in seconds.
*/
@@ -613,14 +888,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)
@@ -690,8 +968,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()
@@ -749,6 +1032,7 @@ class JellyTauPlayer(private val appContext: Context) {
mainHandler.post {
stopPositionUpdates()
coroutineScope.cancel()
releaseAudioEffects()
exoPlayer.release()
instance = null
}
@@ -760,10 +1044,41 @@ class JellyTauPlayer(private val appContext: Context) {
android.util.Log.d("JellyTauPlayer", "Started position updates coroutine")
while (isActive) {
if (exoPlayer.isPlaying) {
val position = exoPlayer.currentPosition / 1000.0
val duration = if (exoPlayer.duration > 0) exoPlayer.duration / 1000.0 else 0.0
// THE boundary between the two timelines, and the only place
// the conversion happens.
//
// During a background-audio handoff the stream is requested
// with StartTimeTicks = the handoff point, so ExoPlayer's zero
// is that point and everything it reports is relative to it.
// The base used to be added only where a position was *shown*
// (the lockscreen scrubber), leaving progress reports to
// Jellyfin, the frontend and the truncation maths all working
// in the relative timeline while treating it as absolute —
// each crossing losing exactly `base` seconds, which is why the
// jump-back distance varied with where the screen was locked.
// Shifting once, here, means every consumer downstream speaks
// the episode's timeline and none of them needs to know a
// handoff happened.
//
// The duration is shifted with it, so position and duration
// stay on the same timeline — the stream's own length is only
// what remains after the handoff point.
//
// TRACES: UR-040 | DR-159
val service = JellyTauPlaybackService.getInstance()
val baseMs = service?.handoffBaseMs ?: 0L
val positionMs = exoPlayer.currentPosition.coerceAtLeast(0) + baseMs
val position = positionMs / 1000.0
val duration =
if (exoPlayer.duration > 0) (exoPlayer.duration + baseMs) / 1000.0 else 0.0
android.util.Log.v("JellyTauPlayer", "Position update: $position / $duration")
nativeOnPositionUpdate(position, duration)
// Keep the lockscreen scrubber live. Without this the
// MediaSession position only refreshes on play/pause, so the
// scrubber freezes mid-track and drifts out of sync.
service?.updatePlaybackPosition(positionMs, true)
}
delay(POSITION_UPDATE_INTERVAL_MS)
}
@@ -777,52 +1092,114 @@ class JellyTauPlayer(private val appContext: Context) {
}
/**
* Get or create the SurfaceView for video playback.
* Returns the view ID that can be attached to the view hierarchy.
* Get or create the video view, and hand it to ExoPlayer.
*
* Note: The surface is created but not automatically attached to the view hierarchy.
* Call attachSurfaceToActivity() or use VideoOverlayManager to attach it.
* This is a **TextureView**, not a SurfaceView, and that is the whole point.
*
* A SurfaceView renders on its own layer *outside* the app window and punches
* a transparent hole through the window to show it. Anything drawn above
* that hole for us, the entire Svelte UI in a transparent WebView is at
* the mercy of that composition path, and Android's own graphics
* documentation says plainly that "overlays do not currently work correctly
* with SurfaceView or TextureView". On device that showed up as the WebView
* overlay silently dropping its incremental damage: the clock text stopped
* advancing on screen while the DOM kept updating (slider 476 479 across
* three seconds behind a display showing neither), the control bar would not
* fade, and rotation lost the transport UI. Only *structural* DOM changes
* got through, which is why the play overlay an `{#if}` block that is added
* and removed always appeared to work while the progress bar never did.
*
* A TextureView is an ordinary view: its frames are drawn as a texture inside
* the window's normal rendering pass, so there is no second layer, no
* transparent region, and the WebView above composites like it would over any
* other view. This is the standard remedy for ExoPlayer overlay problems and
* is why media3 offers `surface_type="texture_view"` at all.
*
* The cost is real and accepted: TextureView uses more power and memory than
* SurfaceView and adds a frame of latency. Hardware decode through MediaCodec
* is unaffected only presentation changes so the reason native video
* exists survives the trade.
*
* `setVideoTextureView` installs ExoPlayer's own `SurfaceTextureListener`, so
* there is deliberately no listener of ours here; adding one would displace
* it and the video would never appear.
*
* Note: the view is created but not attached to the hierarchy. Call
* attachSurfaceToActivity() or use VideoOverlayManager to attach it.
*
* TRACES: UR-003, UR-004 | DR-192
*/
fun getOrCreateSurfaceView(): Int {
if (surfaceView == null) {
surfaceView = SurfaceView(appContext).apply {
if (videoView == null) {
videoView = TextureView(appContext).apply {
layoutParams = FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
// Render BEHIND WebView - video shows through transparent areas
setZOrderMediaOverlay(false)
// The view is opaque where video is drawn; the WebView above it
// is what supplies transparency, exactly as before.
isOpaque = true
// Set up SurfaceHolder callbacks
holder.addCallback(object : SurfaceHolder.Callback {
override fun surfaceCreated(holder: SurfaceHolder) {
android.util.Log.d("JellyTauPlayer", "Surface created")
surfaceHolder = holder
exoPlayer.setVideoSurfaceHolder(holder)
// Own the listener rather than calling `setVideoTextureView`,
// which installs ExoPlayer's own and leaves us blind to frame
// arrival. `onSurfaceTextureUpdated` is the only honest signal
// that a NEW frame has landed in the texture, and that is
// precisely what the letterbox artefact waits on — see
// fitSurfaceToScreen. Handing ExoPlayer the Surface directly is
// the same wiring `setVideoTextureView` does internally.
//
// TRACES: UR-003, UR-004 | DR-194
surfaceTextureListener = object : TextureView.SurfaceTextureListener {
override fun onSurfaceTextureAvailable(
texture: android.graphics.SurfaceTexture,
width: Int,
height: Int
) {
videoSurface?.release()
videoSurface = android.view.Surface(texture)
exoPlayer.setVideoSurface(videoSurface)
android.util.Log.d("JellyTauPlayer", "Video surface attached to ExoPlayer")
}
override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {
android.util.Log.d("JellyTauPlayer", "Surface changed: ${width}x${height}")
override fun onSurfaceTextureSizeChanged(
texture: android.graphics.SurfaceTexture,
width: Int,
height: Int
) {
}
override fun surfaceDestroyed(holder: SurfaceHolder) {
android.util.Log.d("JellyTauPlayer", "Surface destroyed")
exoPlayer.clearVideoSurfaceHolder(holder)
surfaceHolder = null
override fun onSurfaceTextureDestroyed(
texture: android.graphics.SurfaceTexture
): Boolean {
exoPlayer.setVideoSurface(null)
videoSurface?.release()
videoSurface = null
return true
}
})
override fun onSurfaceTextureUpdated(
texture: android.graphics.SurfaceTexture
) {
// A genuinely new frame is now in the texture, so
// whatever was retained from before the resize is gone.
if (awaitingFreshFrame) {
awaitingFreshFrame = false
videoView?.alpha = 1f
}
}
}
}
android.util.Log.d("JellyTauPlayer", "Video TextureView created")
}
return surfaceView!!.hashCode()
return videoView!!.hashCode()
}
/**
* Get the SurfaceView instance (for VideoOverlayManager).
* Returns null if no surface has been created yet.
* Get the video view instance (for VideoOverlayManager).
* Returns null if none has been created yet.
*/
fun getSurfaceView(): SurfaceView? {
return surfaceView
fun getSurfaceView(): TextureView? {
return videoView
}
/**
@@ -837,7 +1214,7 @@ class JellyTauPlayer(private val appContext: Context) {
* This should be called from MainActivity when video playback is active.
*/
fun attachSurfaceToActivity(activity: android.app.Activity) {
if (surfaceView != null && currentMediaType == MediaType.VIDEO) {
if (videoView != null && currentMediaType == MediaType.VIDEO) {
com.dtourolle.jellytau.VideoOverlayManager.attachVideoSurface(activity)
android.util.Log.d("JellyTauPlayer", "Surface attached to Activity")
}
@@ -867,37 +1244,161 @@ class JellyTauPlayer(private val appContext: Context) {
/**
* Resize the video surface (for orientation changes).
*
* Re-fits the surface to the screen preserving the video's aspect ratio so
* nothing is cropped when the device rotates.
*/
fun resizeSurface(width: Int, height: Int) {
fitSurfaceToScreen()
}
/**
* Size the video SurfaceView so the video fits entirely inside its parent
* (the full-screen content view) while preserving aspect ratio (letterbox/
* pillarbox). A raw SurfaceView with MATCH_PARENT otherwise stretches the
* video to the surface bounds, which crops the bottom on rotation.
*/
/**
* Hide the video view now, and keep it hidden until a genuinely new frame
* arrives (or the timeout fires).
*
* Called from `MainActivity.onConfigurationChanged`, i.e. at the *start* of a
* rotation. [fitSurfaceToScreen] is too late for this: it runs from the
* content view's layout listener, after the rotation has already happened,
* so the stale frame has been on screen for the whole transition by then.
*
* TRACES: UR-003, UR-066 | DR-194
*/
fun hideUntilFreshFrame() {
mainHandler.post {
surfaceView?.let { view ->
view.layoutParams = view.layoutParams.apply {
this.width = width
this.height = height
val view = videoView ?: return@post
awaitingFreshFrame = true
view.alpha = 0f
mainHandler.postDelayed({
if (awaitingFreshFrame) {
awaitingFreshFrame = false
videoView?.alpha = 1f
}
view.requestLayout()
android.util.Log.d("JellyTauPlayer", "Video surface resized to ${width}x${height}")
}, FRESH_FRAME_TIMEOUT_MS)
}
}
fun fitSurfaceToScreen() {
mainHandler.post {
val view = videoView ?: return@post
val parent = view.parent as? ViewGroup
// Available area: prefer the parent's measured size, fall back to the screen.
val availW = parent?.width?.takeIf { it > 0 }
?: appContext.resources.displayMetrics.widthPixels
val availH = parent?.height?.takeIf { it > 0 }
?: appContext.resources.displayMetrics.heightPixels
if (videoWidth <= 0 || videoHeight <= 0 || availW <= 0 || availH <= 0) {
return@post
}
val videoAspect = videoWidth.toFloat() / videoHeight.toFloat()
val viewAspect = availW.toFloat() / availH.toFloat()
val targetW: Int
val targetH: Int
if (videoAspect > viewAspect) {
// Video is wider than the screen → fit width, letterbox top/bottom
targetW = availW
targetH = (availW / videoAspect).toInt()
} else {
// Video is taller than the screen → fit height, pillarbox sides
targetH = availH
targetW = (availH * videoAspect).toInt()
}
val lp = view.layoutParams
// FrameLayout child: center the fitted surface within the full-screen parent.
if (lp is FrameLayout.LayoutParams) {
lp.gravity = android.view.Gravity.CENTER
}
// Hide the view across a resize, and reveal it when a genuinely NEW
// video frame lands in the texture.
//
// A TextureView retains its last frame. Between a rotation and this
// re-fit landing, that retained frame is stretched across the OLD
// rect — larger than the new one along at least one axis — so the
// previous frame flashes in what should be the letterbox bars.
//
// Waiting a fixed number of animation frames does NOT fix it, which
// the first attempt at this proved on device: an animation frame is
// not a video frame, and at 24fps the next decoded frame can be
// several vsyncs away. The tell was that pausing and playing cleared
// the artefact by hand — that forces a fresh frame, which is the
// real precondition. So the reveal is driven by
// `onSurfaceTextureUpdated` instead.
//
// The timeout is not belt-and-braces, it is required: if playback is
// paused when the resize happens, no new frame is coming and the
// video would stay invisible forever. Revealing a stale frame after
// a beat is strictly better than a permanently black player.
//
// Scoped to an actual size change so steady-state playback never
// touches alpha.
//
// TRACES: UR-003, UR-066 | DR-194
val sizeChanged = lp.width != targetW || lp.height != targetH
if (sizeChanged) {
awaitingFreshFrame = true
view.alpha = 0f
mainHandler.postDelayed({
if (awaitingFreshFrame) {
awaitingFreshFrame = false
videoView?.alpha = 1f
}
}, FRESH_FRAME_TIMEOUT_MS)
}
lp.width = targetW
lp.height = targetH
view.layoutParams = lp
view.requestLayout()
android.util.Log.d(
"JellyTauPlayer",
"Video surface fitted to ${targetW}x${targetH} (video ${videoWidth}x${videoHeight}, avail ${availW}x${availH})"
)
}
}
/**
* Clear the video surface when switching to audio playback.
* Clear the video surface when switching to audio playback, or on stop.
*
* Detaching is not optional bookkeeping: dropping the reference without
* removing the view left the SurfaceView parented to the content view for
* the life of the process, and the next video stacked another one under it.
* See VideoOverlayManager.detachVideoSurface.
*
* Always called on the main thread (every caller runs inside a
* `mainHandler.post`), which is what touching the view hierarchy requires.
*
* TRACES: UR-003, UR-041 | DR-184
*/
private fun clearVideoSurface() {
surfaceView?.let {
videoView?.let {
exoPlayer.clearVideoSurface()
surfaceView = null
surfaceHolder = null
android.util.Log.d("JellyTauPlayer", "Video surface cleared")
com.dtourolle.jellytau.VideoOverlayManager.detachVideoSurface()
videoView = null
android.util.Log.d("JellyTauPlayer", "Video surface cleared and detached")
}
}
/**
* 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) {
@@ -914,17 +1415,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 -> {
@@ -935,16 +1448,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 {
@@ -956,10 +1478,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
}
}
}
@@ -970,6 +1497,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,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@mipmap/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 870 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 476 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Some files were not shown because too many files have changed in this diff Show More