Compare commits

..
38 Commits
Author SHA1 Message Date
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
86 changed files with 8360 additions and 1474 deletions
+30 -15
View File
@@ -42,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
+6
View File
@@ -64,3 +64,9 @@ src-tauri/.cargo/config.toml
/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
+104
View File
@@ -0,0 +1,104 @@
# Changelog
All notable changes to JellyTau are documented here.
Entries are grouped by the capability they change, not by commit. Requirement
IDs in parentheses point at [docs/requirements.md](docs/requirements.md); the
generated trace matrix lives in [docs/traceability.md](docs/traceability.md).
## v0.2.0
### ✨ Features
- **Audio settings now work on Android.** The equalizer, volume normalization
and gapless playback controls in Settings Audio previously rendered on
Android and did nothing — `ExoPlayerBackend` was the only backend that never
implemented `set_audio_settings`, and the trait's default silently reported
success while applying nothing. All three now take effect:
- **Equalizer** — the canonical 10-band ISO curve is resampled onto whatever
bands the device's equalizer actually exposes (commonly 5), by nearest
centre frequency.
- **Volume normalization** — via `LoudnessEnhancer`. Note this is a gain
stage, not a true EBU R128 normalizer like the Linux `dynaudnorm` path, so
it approximates rather than matches Linux behaviour.
- **Gapless playback** — honours the setting via `pauseAtEndOfMediaItems`
(ExoPlayer is gapless by default, so this disables it when you turn it off).
The effects re-attach automatically when ExoPlayer rebuilds its audio sink on
a format change, so the equalizer no longer stops applying part-way through a
queue. (UR-027, UR-032, UR-033 → DR-030, DR-035, DR-036, IR-004)
⚠️ **Not yet verified on a physical device.** `AudioEffect` availability and
band layouts vary by device and OEM ROM; where an effect is unavailable it is
logged and skipped rather than crashing playback.
### 📋 Documentation
- **Playback backend unification investigation.** Six new specs in
[docs/specs/](docs/specs/) record why the playback backends cannot be unified
onto a single engine: every candidate (mpv, GStreamer, libVLC) fails the same
webview-compositing constraint, because WebKitGTK/WebView2/Android WebView each
own their compositor surface and native video cannot interleave with HTML.
Audio *can* unify; video cannot. Also specifies the Android native-video spike,
a Windows native audio backend, and the `libmpv2` migration.
### 🐛 Corrected requirement statuses
These were documented as working and were not. No behaviour changed — the docs
were wrong.
- **Crossfade (UR-031, DR-034) was marked "Done (Linux only)". It is implemented
nowhere**, and is architecturally blocked on mpv: its audio chain is
single-stream, and FFmpeg's `acrossfade` requires two inputs. Real crossfade
would need two libmpv instances.
- The platform parity matrix listed crossfade as a Linux/Android gap (it is
neither) and omitted the equalizer (which was a genuine gap, now closed).
- `nativeAdapter.ts` cited tauri#10152 as blocking native Android video. That
issue is a stale feature request; the capability shipped in September 2024.
What remains unproven is SurfaceView-behind-WebView compositing, now tracked
by a spec rather than asserted as an upstream blocker.
<!--
Note: v0.1.3v0.1.5 have no entries here. Their changes are in the git log
and docs/traceability.md.
-->
## v0.1.2
### ✨ Features
- **Search results are ordered by how well they match.** A name that *starts*
with the query now 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, so a series lands above its own episodes.
Ranking is applied to the instant cached results and to the merged
cache+server list alike, so the list no longer reshuffles when server results
arrive. (UR-060, DR-090)
- **Separate Shows, Episodes and People result groups.** The combined "TV Shows"
group splits into Shows and Episodes so a show never competes with its own
episodes for a slot, and a new People group means searching an actor's name
reaches their bio page. Default order is Shows → Episodes → Movies → Songs →
Albums → Artists → People; a group order saved before the split keeps the
position it was dragged to. (UR-060, DR-091)
### 🐛 Bug Fixes
- **The library header search bar works on every library page.** It previously
searched in place and depended on `/library` rendering results inline, so on
any other `/library/**` route the results were fetched and never shown.
`/search` is now the single surface that renders results, and the header bar
hands its query and scope over via the URL. (UR-049, DR-063)
- **Video smaller than the window is scaled up to fit.** Sizing only ever shrank
oversized media, so a 480p source on a 1080p display played as a small picture
in the middle of a black frame. The picture now fits whichever axis constrains
it, in both directions, preserving aspect ratio. (UR-005)
### 📋 Requirements
**Linux:** 64-bit, GLIBC 2.29+
**Android:** 8.0+
## v0.1.1 and earlier
Released before this file existed — see the git history and the release notes on
each tag.
+25 -2
View File
@@ -183,8 +183,14 @@ and [docs/build-release.md](docs/build-release.md).
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. See [scoped-search-boundary.md](docs/specs/scoped-search-boundary.md)
for the incident this rule came from.
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
@@ -266,6 +272,23 @@ tagged responses keep the Rust field names as-is (e.g. `new_url`, not `newUrl`).
## 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
+7 -3
View File
@@ -117,9 +117,13 @@ RUN cd src-tauri && cargo fetch && cd .. && \
# 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, mingw-w64 + nsis + the x86_64-pc-windows-gnu rust target
# for Windows). ONE source of dependency truth, shared with CI — no per-stage
# apt/rustup here.
# (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
+50 -14
View File
@@ -41,7 +41,7 @@ For a narrative overview of the system design, see
| 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 |
@@ -69,6 +69,9 @@ For a narrative overview of the system design, see
| 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 |
---
@@ -190,7 +193,7 @@ Internal architecture, components, and application logic.
| 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 |
@@ -217,10 +220,10 @@ Internal architecture, components, and application logic.
| 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 resolver mapping the originating route to an `includeItemTypes` set (All / Music / Movies / TV), defaulting to All for Home, `/library`, and the search tab | UI | UR-049 | Implemented |
| 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 (Songs → Albums → Artists → Movies → TV Shows), and empty-group omission | Settings | UR-050 | 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 |
@@ -239,6 +242,17 @@ Internal architecture, components, and application logic.
| 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-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-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 |
---
@@ -306,6 +320,8 @@ Internal architecture, components, and application logic.
| UR-056 | - | DR-085 |
| UR-057 | - | DR-086 |
| UR-058 | - | DR-087 |
| UR-060 | - | DR-090, DR-091 |
| UR-061 | - | DR-092 |
---
@@ -376,6 +392,7 @@ Internal architecture, components, and application logic.
| 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 |
@@ -395,6 +412,11 @@ Internal architecture, components, and application logic.
| 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-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 |
### Integration Tests
@@ -484,22 +506,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
+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.
+196
View File
@@ -0,0 +1,196 @@
# Spec: Android native video — transparent-webview spike
**Status:** Proposed (spike — timeboxed, may conclude "not viable")
**Requirements:** IR-004, UR-003, UR-004 → DR-001, DR-023, DR-024
**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.
### 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.
### 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**
- [ ] Transparent WebView confirmed working on a physical device.
- [ ] `experimentalNativeVideo` off → behaviour byte-identical to today.
- [ ] `webviewAudio.ts` no longer inspects `navigator.userAgent`; the platform's audio backend is read from Rust.
- [ ] `experimentalNativeVideo` on → video plays via ExoPlayer/MediaCodec, correctly positioned, with working seek, audio-track switch, and subtitle selection through the existing `PlayerAdapter` contract.
- [ ] No artefacts on rotation, background/foreground, or mini-player transition.
- [ ] `adb shell dumpsys media.metrics` (or logcat) confirms a hardware decoder is in use.
- [ ] 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:
- [ ] `bun run check`, `bun run test`, `bun run check:boundary` pass.
- [ ] `cargo fmt` / `cargo clippy` clean; `bun run test:rust` passes.
## 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-023, DR-024`
- 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.
+5
View File
@@ -4,6 +4,7 @@
**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
@@ -115,6 +116,10 @@ fields ride along. `NullBackend`/Android inherit the trait default (no-op).
## 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
+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.
+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.
+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.
+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.
+7 -1
View File
@@ -8,8 +8,14 @@
> 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 pending — see banner above)
**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)).
+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.
+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.
+26 -12
View File
@@ -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
+1078 -759
View File
File diff suppressed because it is too large Load Diff
+4 -2
View File
@@ -346,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)
+4 -1
View File
@@ -1,6 +1,6 @@
{
"name": "jellytau",
"version": "0.1.0",
"version": "0.2.7",
"description": "",
"type": "module",
"packageManager": "bun@1.3.5",
@@ -20,6 +20,8 @@
"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",
@@ -36,6 +38,7 @@
"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:coverage": "bun run scripts/extract-traces.ts --format coverage",
"release:notes": "bun run scripts/release-notes.ts"
},
"license": "MIT",
+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
+37 -2
View File
@@ -18,15 +18,50 @@ echo ""
# 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.
BUILD_TYPE="debug"
CLEAN="${CLEAN:-0}"
ABI="${ABI:-}"
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|release) BUILD_TYPE="$arg" ;;
esac
done
# 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)..."
@@ -48,10 +83,10 @@ if [ "$BUILD_TYPE" = "release" ]; then
# 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 ""
+74 -19
View File
@@ -1,6 +1,8 @@
#!/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
@@ -9,18 +11,29 @@
#
# ⚠️ 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 one machine-detectable signature of the leak class — a *query* that
# names a multi-type category — 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.
# 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 `includeItemTypes: [ ... , ... ]` array literal with two or
# more types — 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
# query 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 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.
@@ -28,7 +41,7 @@ set -euo pipefail
cd "$(dirname "$0")/.."
# Files permitted to contain a multi-type includeItemTypes query, with the reason.
# 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=(
@@ -36,8 +49,31 @@ ALLOWLIST=(
# 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
@@ -46,11 +82,28 @@ is_allowed() {
return 1
}
# Multi-element includeItemTypes array: `includeItemTypes: [ <x> , <y> ... ]`.
# The comma inside the brackets is what makes it multi-type.
PATTERN='includeItemTypes:[[:space:]]*\[[^]]*,[^]]*\]'
# 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 (multi-type query arrays)…"
echo "🔎 Checking frontend for domain-taxonomy leaks (item-type array literals)…"
# Collect hits, excluding tests and the allowlist.
violations=""
@@ -69,9 +122,11 @@ done < <(grep -rInE "$PATTERN" src/ 2>/dev/null || true)
if [[ -n "$violations" ]]; then
echo ""
echo "❌ Frontend boundary violation: a multi-type includeItemTypes query defines"
echo " a category in the presentation layer. That taxonomy belongs in Rust —"
echo " send an opaque scope and let the backend expand it to item types."
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/^/ /'
-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 ""
+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(61);
expect(defined.IR).toBe(29);
expect(defined.DR).toBe(95);
expect(defined.JA).toBe(32);
expect(defined.total).toBe(217);
});
});
+189 -12
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")
/**
* 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!;
const definedIds = readDefinedRequirements().ids;
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;
}
// 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();
console.error("🔍 Extracting TRACES from codebase...");
const data = extractTraces();
if (format === "json") {
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 {
} else if (format === "coverage") {
process.exit(reportCoverage(data, 50));
} else {
console.log(generateMarkdown(data));
}
}
console.error(
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 ""
+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!"
+1 -1
View File
@@ -1994,7 +1994,7 @@ dependencies = [
[[package]]
name = "jellytau"
version = "0.1.0"
version = "0.2.7"
dependencies = [
"aes-gcm",
"async-trait",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "jellytau"
version = "0.1.0"
version = "0.2.7"
description = "A Tauri App"
authors = ["you"]
edition = "2021"
@@ -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
@@ -19,8 +14,6 @@ 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.
@@ -50,6 +43,15 @@ class MainActivity : TauriActivity() {
*/
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
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
@@ -159,19 +161,36 @@ class MainActivity : TauriActivity() {
android.util.Log.d("MainActivity", "WebView found! Configuring settings...")
mediaWebView = webView
// Add JavaScript interface for audio focus control
webView.addJavascriptInterface(object : Any() {
@JavascriptInterface
fun requestAudioFocus() {
handler.post { this@MainActivity.requestAudioFocus() }
// Register the @JavascriptInterface bridges EXACTLY ONCE per WebView.
//
// configureWebViewForMedia() runs from onCreate's delayed post AND from
// every onResume (plus each WebView re-find), so this used to re-inject
// all four bridges 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.
//
// The settings/WebChromeClient work below is idempotent and must keep
// running on resume; only the bridge injection is one-shot.
if (webView === bridgesInstalledOn) {
android.util.Log.d("MainActivity", "JS bridges already installed on this WebView - skipping re-injection")
configureWebViewSettings(webView)
return
}
bridgesInstalledOn = webView
@JavascriptInterface
fun abandonAudioFocus() {
handler.post { this@MainActivity.abandonAudioFocus() }
}
}, "AndroidAudioFocus")
android.util.Log.d("MainActivity", "JavaScript interface 'AndroidAudioFocus' added")
// 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
@@ -212,10 +231,6 @@ class MainActivity : TauriActivity() {
backgroundAudioEnabled = enabled
android.util.Log.d("MainActivity", "backgroundAudioEnabled = $enabled")
}
/** Whether background audio is available on this device (needs PiP-era APIs unnecessary; audio service always present on Android). */
@JavascriptInterface
fun isSupported(): Boolean = true
}, "AndroidBackgroundAudio")
android.util.Log.d("MainActivity", "JavaScript interface 'AndroidBackgroundAudio' added")
@@ -248,6 +263,21 @@ class MainActivity : TauriActivity() {
dispatchWebEvent("jellytau-network-changed")
}
configureWebViewSettings(webView)
} catch (e: Exception) {
android.util.Log.e("MainActivity", "Failed to configure WebView for media", 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?) {
@@ -259,6 +289,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")
@@ -287,29 +332,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);
});
@@ -356,48 +390,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 { }
}
}
}
@@ -36,6 +36,53 @@ 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
/**
* 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
@@ -135,6 +182,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
@@ -334,6 +393,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) }
}
})
}
@@ -416,6 +480,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.
*/
@@ -756,6 +947,7 @@ class JellyTauPlayer(private val appContext: Context) {
mainHandler.post {
stopPositionUpdates()
coroutineScope.cancel()
releaseAudioEffects()
exoPlayer.release()
instance = null
}
+13 -2
View File
@@ -205,6 +205,15 @@ pub struct PlayItemRequest {
/// zero-duration session renders no scrubber, even with ACTION_SEEK_TO set.
#[serde(default)]
pub duration_seconds: Option<f64>,
/// Item type (e.g. "Episode", "Movie", "Audio"). Carried through the
/// background-audio handoff so an episode played as audio-only is still
/// recognised as an episode by autoplay (UR-040) and advances to the next one.
#[serde(default)]
pub item_type: Option<String>,
/// Series ID for TV episodes. Needed alongside `item_type` so the backend can
/// look up the next episode when a background-audio track ends.
#[serde(default)]
pub series_id: Option<String>,
}
/// Queue context for remote transfer - what type of queue is this?
@@ -601,7 +610,9 @@ pub async fn player_enter_background_audio(
artists: None,
primary_image_tag: item.primary_image_tag.clone(),
image_id: item.primary_image_tag.clone(),
item_type: None,
// Carry episode identity so autoplay can advance to the next episode when
// this audio-only handoff ends while backgrounded (UR-040).
item_type: item.item_type.clone(),
playlist_id: None,
// Carry the real duration so the lockscreen MediaSession can draw a scrubber.
duration: item.duration_seconds,
@@ -616,7 +627,7 @@ pub async fn player_enter_background_audio(
video_width: None,
video_height: None,
subtitles: vec![],
series_id: None,
series_id: item.series_id.clone(),
server_id: item.server_id.clone(),
};
+29 -3
View File
@@ -12,6 +12,7 @@ use serde::{Deserialize, Serialize};
use tauri::{AppHandle, Emitter, State};
use uuid::Uuid;
use crate::domain::rank_search_results;
use crate::jellyfin::HttpClient;
use crate::repository::{
types::*, HybridRepository, MediaRepository, OfflineRepository, OnlineRepository,
@@ -394,7 +395,12 @@ pub struct SearchUpdateEvent {
pub result: SearchResult,
}
/// Search for items
/// Search for items.
///
/// Resolves `SearchOptions::scope` into concrete Jellyfin item types before
/// dispatching, so scope taxonomy stays in Rust.
///
/// TRACES: UR-049, UR-050 | DR-063
#[tauri::command]
#[specta::specta]
pub async fn repository_search(
@@ -407,9 +413,19 @@ pub async fn repository_search(
) -> Result<SearchResult, String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
// Expand the opaque scope into item types HERE — once, before the cache and
// server paths diverge — so both phases filter identically. Doing it later
// (or in only one path) makes offline results disagree with online ones.
// The frontend sends `scope` and never names a Jellyfin item type for
// search; see docs/specs/scoped-search-boundary.md.
let options = options.map(|mut o| {
o.resolve_scope();
o
});
// Phase 1: instant local results from the cache (downloaded content) so the
// UI can render immediately while the server is still being queried.
let cache_result = repo
let mut cache_result = repo
.search_cache_only(&query, options.clone())
.await
.unwrap_or_else(|e| {
@@ -420,6 +436,12 @@ pub async fn repository_search(
}
});
// Neither backend orders by *where* the query matched, so a mid-word hit
// ("Sparks" for "parks") can outrank a prefix hit ("Parks and Recreation").
// Both phases are ranked with the same rules so the list does not reshuffle
// when the server results land.
rank_search_results(&mut cache_result.items, &query);
// Phase 2: query the live server in the background, merge with the cache,
// and push the union to the frontend via a `search-event`. Tagged with
// `request_id` so the frontend can discard results from superseded queries.
@@ -428,7 +450,11 @@ pub async fn repository_search(
tauri::async_runtime::spawn(async move {
match repo_bg.search_server_only(&query, options).await {
Ok(server_result) => {
let merged = HybridRepository::merge_search_results(cache_for_merge, server_result);
let mut merged =
HybridRepository::merge_search_results(cache_for_merge, server_result);
// Rank the union, not each half: a server-only prefix match must
// be able to outrank a cached mid-word one.
rank_search_results(&mut merged.items, &query);
let event = SearchUpdateEvent {
request_id,
result: merged,
+2
View File
@@ -5,6 +5,8 @@
pub mod from_jellyfin;
pub mod media;
pub mod search_rank;
pub use from_jellyfin::{kind_from_jellyfin, stream_kind_from_jellyfin, ticks_to_ms};
pub use media::{MediaKind, StreamKind};
pub use search_rank::rank_search_results;
+313
View File
@@ -0,0 +1,313 @@
//! Relevance ranking for search results.
//!
//! Both search paths (the SQLite FTS cache and the Jellyfin server) return items
//! in an order that ignores *where* in the name the query matched: a server
//! substring hit like "Sparks of Love" can outrank "Parks and Recreation" for
//! the query "parks". Neither backend is going to change, so the app imposes its
//! own ordering on the union.
//!
//! Ranking is domain logic, not presentation: it encodes what a "better match"
//! means and which media kinds outrank which. The frontend only renders the
//! order it is given.
//!
//! Two rules, in priority order:
//!
//! 1. **Match position** — a prefix match beats a word-start match, which beats
//! a mid-word substring match. This is what makes "parks" find
//! "Parks and Recreation" before "Sparks of Love".
//! 2. **Kind** — containers before their contents at equal match quality, so a
//! series outranks its own episodes.
//!
//! Ties fall back to the input order, so a backend's own relevance signal (FTS
//! `rank`) still breaks ties it was never overruled on.
use crate::domain::MediaKind;
use crate::repository::types::MediaItem;
/// How well a query matched an item's name — better matches sort first.
///
/// Ordered by discriminant: `Prefix` is the strongest. Derived `Ord` gives the
/// comparison for free, so adding a tier in the right position is all it takes.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum MatchQuality {
/// The name starts with the query — "parks" in "Parks and Recreation".
Prefix,
/// Some later *word* starts with the query — "recreation" in "Parks and
/// Recreation". Still a deliberate hit: users type whole words.
WordStart,
/// The query appears mid-word — "parks" in "Sparks of Love". Weakest hit
/// that still counts as a match.
Substring,
/// No match on the name at all. The backend returned it for some other
/// reason (overview, artist, album), so it is kept but sorted last.
None,
}
/// Rank of a media kind when match quality ties — lower sorts first.
///
/// Containers outrank the items they contain: searching a show's name should
/// surface the show, not an arbitrary episode of it. Within a tier the order is
/// arbitrary but stable, and equal ranks fall through to input order.
fn kind_rank(kind: MediaKind) -> u8 {
match kind {
// Top-level containers a user is most likely to be looking for.
MediaKind::Series | MediaKind::Movie | MediaKind::Album | MediaKind::Artist => 0,
// Sub-containers and standalone collections.
MediaKind::Season | MediaKind::Playlist | MediaKind::Channel | MediaKind::Folder => 1,
// Leaves — an episode/track is a match *inside* something bigger.
MediaKind::Episode | MediaKind::Track | MediaKind::LiveChannel | MediaKind::ChannelItem => {
2
}
// Peripheral matches.
MediaKind::Person | MediaKind::Other => 3,
}
}
/// Classify how `query` matches `name`, case-insensitively.
///
/// Both sides are trimmed and lowercased; an empty query matches everything
/// equally (`Prefix`), which leaves the input order untouched.
pub fn match_quality(name: &str, query: &str) -> MatchQuality {
let query = query.trim().to_lowercase();
if query.is_empty() {
return MatchQuality::Prefix;
}
let name = name.trim().to_lowercase();
let Some(index) = name.find(&query) else {
return MatchQuality::None;
};
if index == 0 {
return MatchQuality::Prefix;
}
// A word start is any match preceded by a non-alphanumeric character, so
// "the-office" and "The Office" behave the same. Indexing back one char is
// safe on the byte index `find` returned only via `char_indices`, since a
// multi-byte char would panic on a raw slice.
let preceded_by_boundary = name[..index]
.chars()
.next_back()
.is_some_and(|c| !c.is_alphanumeric());
if preceded_by_boundary {
MatchQuality::WordStart
} else {
MatchQuality::Substring
}
}
/// Sort search results by relevance to `query`, in place.
///
/// Stable, so items the rules rank equally keep the order the backend supplied
/// (FTS `rank` for cache hits, Jellyfin's own ordering for server hits).
///
/// TRACES: UR-060 | DR-090
pub fn rank_search_results(items: &mut [MediaItem], query: &str) {
// An empty query carries no relevance signal, so there is nothing to rank
// by — reordering on kind alone would shuffle the backend's own ordering
// for no reason.
if query.trim().is_empty() {
return;
}
items.sort_by_key(|item| (match_quality(&item.name, query), kind_rank(item.kind)));
}
#[cfg(test)]
mod tests {
use super::*;
fn item(name: &str, kind: MediaKind) -> MediaItem {
let mut item = MediaItem::default();
item.id = format!("id-{}-{:?}", name, kind);
item.name = name.to_string();
item.kind = kind;
item
}
fn names(items: &[MediaItem]) -> Vec<&str> {
items.iter().map(|i| i.name.as_str()).collect()
}
/// UT-085: a prefix match outranks a mid-word substring match.
#[test]
fn prefix_match_beats_midword_substring() {
assert_eq!(
match_quality("Parks and Recreation", "parks"),
MatchQuality::Prefix
);
assert_eq!(
match_quality("Sparks of Love", "parks"),
MatchQuality::Substring
);
assert!(MatchQuality::Prefix < MatchQuality::Substring);
}
/// UT-085: the reported bug — "parks" must find the show, not "Sparks".
#[test]
fn ranks_prefix_match_before_substring_match() {
let mut items = vec![
item("Sparks of Love", MediaKind::Series),
item("Parks and Recreation", MediaKind::Series),
];
rank_search_results(&mut items, "parks");
assert_eq!(
names(&items),
vec!["Parks and Recreation", "Sparks of Love"]
);
}
/// A match at a later word start beats a mid-word one but loses to a prefix.
#[test]
fn word_start_ranks_between_prefix_and_substring() {
assert_eq!(
match_quality("The Office", "office"),
MatchQuality::WordStart
);
assert_eq!(match_quality("Bofficer", "office"), MatchQuality::Substring);
let mut items = vec![
item("Bofficer", MediaKind::Series),
item("The Office", MediaKind::Series),
item("Office Space", MediaKind::Movie),
];
rank_search_results(&mut items, "office");
assert_eq!(
names(&items),
vec!["Office Space", "The Office", "Bofficer"]
);
}
/// UT-086: at equal match quality a series outranks an episode.
#[test]
fn series_ranks_before_episode_at_equal_match_quality() {
let mut items = vec![
item("Parks and Recreation S01E01", MediaKind::Episode),
item("Parks and Recreation", MediaKind::Series),
];
rank_search_results(&mut items, "parks");
assert_eq!(
names(&items),
vec!["Parks and Recreation", "Parks and Recreation S01E01"]
);
}
/// Albums outrank their tracks for the same reason series outrank episodes.
#[test]
fn album_ranks_before_track_at_equal_match_quality() {
let mut items = vec![
item("Rumours", MediaKind::Track),
item("Rumours", MediaKind::Album),
];
rank_search_results(&mut items, "rumours");
assert_eq!(items[0].kind, MediaKind::Album);
}
/// Match quality dominates kind: a better-matching episode beats a
/// worse-matching series, so kind never drags an irrelevant show to the top.
#[test]
fn match_quality_outranks_kind() {
let mut items = vec![
item("Sparks of Love", MediaKind::Series),
item("Parks Cleanup", MediaKind::Episode),
];
rank_search_results(&mut items, "parks");
assert_eq!(names(&items), vec!["Parks Cleanup", "Sparks of Love"]);
}
/// Items the backend returned for a non-name reason (overview, artist) are
/// kept, but sort below everything that actually matched the name.
#[test]
fn non_matching_names_sort_last_without_being_dropped() {
let mut items = vec![
item("Unrelated Documentary", MediaKind::Movie),
item("Parks and Recreation", MediaKind::Series),
];
rank_search_results(&mut items, "parks");
assert_eq!(
names(&items),
vec!["Parks and Recreation", "Unrelated Documentary"]
);
}
/// Ranking is stable: equally-ranked items keep the backend's order, so the
/// FTS/server relevance signal still breaks ties.
#[test]
fn equal_rank_preserves_input_order() {
let mut items = vec![
item("Parks A", MediaKind::Series),
item("Parks B", MediaKind::Series),
item("Parks C", MediaKind::Series),
];
rank_search_results(&mut items, "parks");
assert_eq!(names(&items), vec!["Parks A", "Parks B", "Parks C"]);
}
/// Case and surrounding whitespace never change the tier.
#[test]
fn matching_is_case_and_whitespace_insensitive() {
assert_eq!(
match_quality("PARKS AND RECREATION", " parks "),
MatchQuality::Prefix
);
assert_eq!(
match_quality("Parks and Recreation", "PARKS"),
MatchQuality::Prefix
);
}
/// An empty query leaves the order alone rather than reshuffling on kind.
#[test]
fn empty_query_preserves_input_order() {
let mut items = vec![
item("Zebra", MediaKind::Episode),
item("Apple", MediaKind::Series),
];
rank_search_results(&mut items, "");
assert_eq!(names(&items), vec!["Zebra", "Apple"]);
}
/// A multi-byte name must not panic when the match is mid-string — the
/// boundary check walks chars rather than slicing raw bytes.
#[test]
fn handles_multibyte_names_without_panicking() {
assert_eq!(
match_quality("Pokémon Journeys", "journeys"),
MatchQuality::WordStart
);
assert_eq!(
match_quality("Café Parks", "parks"),
MatchQuality::WordStart
);
}
/// Punctuation counts as a word boundary, so "office" hits "The-Office".
#[test]
fn punctuation_counts_as_a_word_boundary() {
assert_eq!(
match_quality("The-Office", "office"),
MatchQuality::WordStart
);
assert_eq!(
match_quality("Show: Parks", "parks"),
MatchQuality::WordStart
);
}
}
+85 -1
View File
@@ -18,6 +18,7 @@ use super::events::{PlayerStatusEvent, SharedEventEmitter};
use super::media::{MediaItem, MediaType};
use super::state::PlayerState;
use crate::playback_reporting::{EventThrottler, PlaybackOperation, PlaybackReporter};
use crate::settings::{audio_settings_jni_payload, AudioSettings};
use crate::utils::conversions::seconds_to_ticks;
/// Global reference to the JavaVM for JNI callbacks
@@ -148,6 +149,10 @@ struct ExoPlayerState {
volume: f32,
is_loaded: bool,
current_media: Option<MediaItem>,
/// Last applied audio settings. Unlike the fields above (which JNI callbacks
/// push *in*), this is commanded *out* — audio settings are never reported
/// by the player, so this is the authoritative copy for `audio_settings()`.
audio_settings: AudioSettings,
}
impl ExoPlayerState {
@@ -159,6 +164,7 @@ impl ExoPlayerState {
volume: 1.0,
is_loaded: false,
current_media: None,
audio_settings: AudioSettings::default(),
}
}
}
@@ -529,6 +535,56 @@ impl PlayerBackend for ExoPlayerBackend {
self.shared_state.lock_safe().volume
}
/// Apply audio settings to ExoPlayer (equalizer, normalization, gapless).
///
/// Sent as JSON rather than a wide JNI signature so new fields do not change
/// the method signature — the same approach `load()` uses for subtitles. The
/// Kotlin side owns the *mechanics* (attaching AudioEffects to the audio
/// session); the canonical band layout and preset curves stay in Rust.
///
/// TRACES: UR-027, UR-032, UR-033 | DR-030, DR-035, DR-036
fn set_audio_settings(&mut self, settings: &AudioSettings) -> Result<(), PlayerError> {
let json = audio_settings_jni_payload(settings).map_err(|e| {
PlayerError::playback_failed(format!("Failed to serialize audio settings: {}", e))
})?;
let vm = JAVA_VM
.get()
.ok_or_else(|| PlayerError::playback_failed("JavaVM not initialized"))?;
let mut env = vm
.attach_current_thread()
.map_err(|e| PlayerError::playback_failed(format!("Failed to attach thread: {}", e)))?;
let json_jstring = env.new_string(&json).map_err(|e| {
PlayerError::playback_failed(format!("Failed to create settings string: {}", e))
})?;
env.call_method(
&self.player_ref,
"setAudioSettings",
"(Ljava/lang/String;)V",
&[JValue::Object(&json_jstring)],
)
.map_err(|e| {
PlayerError::playback_failed(format!("Failed to call setAudioSettings: {}", e))
})?;
// Store the sanitised form so audio_settings() reflects what was applied,
// not what was requested.
self.shared_state.lock_safe().audio_settings = settings
.clone()
.with_crossfade_clamped()
.with_equalizer_normalised();
Ok(())
}
/// TRACES: UR-027, UR-032, UR-033 | DR-030, DR-035, DR-036
fn audio_settings(&self) -> AudioSettings {
self.shared_state.lock_safe().audio_settings.clone()
}
fn set_audio_track(&mut self, stream_index: i32) -> Result<(), PlayerError> {
let vm = JAVA_VM
.get()
@@ -858,14 +914,42 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
});
}
// Start countdown if auto_advance enabled
if auto_advance {
// Background audio-only episode: the frontend that normally
// performs the advance (goto /player/<id>) is suspended, so
// the backend must load the next episode's audio-only stream
// itself — otherwise playback just stops at the boundary.
let is_bg_audio_episode =
controller.lock().await.current_is_audio_episode();
if is_bg_audio_episode {
log::info!(
"[Autoplay] Background audio episode — advancing to {} in backend",
next_episode.id
);
let ctrl = controller.lock().await;
if let Err(e) = ctrl
.advance_to_next_episode_audio_only(&next_episode.id)
.await
{
log::error!(
"[Autoplay] Background audio advance failed: {} — stopping",
e
);
if let Some(emitter) = EVENT_EMITTER.get() {
emitter.emit(PlayerStatusEvent::PlaybackEnded);
}
} else {
ctrl.emit_queue_changed();
}
} else {
// Foreground: frontend drives the advance off the countdown.
controller
.lock()
.await
.start_autoplay_countdown(next_episode, countdown_seconds);
}
}
}
Err(e) => {
log::error!("[Autoplay] Decision failed: {}", e);
// Emit PlaybackEnded event on error
+502 -11
View File
@@ -152,6 +152,17 @@ pub struct PlayerController {
// Auto-play episode counter (session-based, resets on manual play)
autoplay_episode_count: Arc<Mutex<u32>>,
// Last state reported by a webview-rendered HTML5 <video>/<audio> element.
//
// Webview-rendered media is played by an element the native backend cannot
// reach, so the backend's own state() says nothing about it. Tracking the
// REPORTED state here is what lets transport (play/pause/toggle) be decided
// in Rust for that media instead of the frontend reading `el.paused` off the
// DOM — a value that flips transiently while buffering/seeking and caused
// competing intents to take opposing actions. `None` means no webview media
// is active and the native backend is authoritative. See DR-097.
html5_playing: Arc<Mutex<Option<bool>>>,
}
impl PlayerController {
@@ -174,6 +185,7 @@ impl PlayerController {
position_throttler,
end_reason: Arc::new(Mutex::new(None)),
autoplay_episode_count: Arc::new(Mutex::new(0)),
html5_playing: Arc::new(Mutex::new(None)),
};
// Start background timer thread for sleep timer countdown
@@ -243,6 +255,23 @@ impl PlayerController {
self.end_reason.lock_safe().take()
}
/// Record that playback is being stopped by an expiring sleep timer.
///
/// Stopping the backend makes it fire its ended callback (ExoPlayer does on
/// Android), which lands in `on_playback_ended`. Without an end reason that
/// reads as a natural finish and autoplay advances — defeating the timer.
/// `UserStop` is the honest label: the stop was user-initiated, just via the
/// timer they set rather than the stop button.
///
/// Takes the shared slot rather than `&self` so the sleep-timer thread —
/// which owns clones, not the controller — records it the same way.
///
/// TRACES: UR-023, UR-026 | DR-029
fn note_sleep_timer_stop(end_reason: &Arc<Mutex<Option<EndReason>>>) {
log::debug!("[PlayerController] Sleep timer stop: marking end reason UserStop");
*end_reason.lock_safe() = Some(EndReason::UserStop);
}
/// Increment autoplay episode counter. Returns true if limit is reached.
fn increment_autoplay_count(&self) -> bool {
let max = self.autoplay_settings.lock_safe().max_episodes;
@@ -459,21 +488,72 @@ impl PlayerController {
Ok(())
}
/// True while webview-rendered media (HTML5 `<video>`/`<audio>`) is the real
/// player, so transport must be routed to it rather than the native backend.
///
/// TRACES: UR-005 | DR-097
pub fn is_html5_active(&self) -> bool {
self.html5_playing.lock_safe().is_some()
}
/// Whether the webview element last reported itself as playing. Meaningless
/// unless [`Self::is_html5_active`] is true.
///
/// TRACES: UR-005 | DR-097
pub fn html5_is_playing(&self) -> bool {
self.html5_playing.lock_safe().unwrap_or(false)
}
/// Send a transport intent to the webview element that is rendering media.
fn emit_html5_control(&self, action: &str) {
if let Some(emitter) = self.event_emitter.lock_safe().as_ref() {
emitter.emit(PlayerStatusEvent::ControlCommand {
action: action.to_string(),
position: None,
});
}
}
/// Play/resume playback
pub fn play(&self) -> Result<(), PlayerError> {
debug!("[PlayerController] play");
// Webview-rendered media: the native backend isn't playing it, so drive
// the element via a ControlCommand instead (DR-097).
if self.is_html5_active() {
self.emit_html5_control("play");
return Ok(());
}
let mut backend = self.backend.lock_safe();
backend.play()
}
/// Pause playback
pub fn pause(&self) -> Result<(), PlayerError> {
if self.is_html5_active() {
self.emit_html5_control("pause");
return Ok(());
}
let mut backend = self.backend.lock_safe();
backend.pause()
}
/// Toggle play/pause
/// Toggle play/pause.
///
/// The decision is made HERE, from authoritative state — the reported webview
/// state for HTML5-rendered media, or the native backend's state otherwise.
/// The frontend must never decide this from the DOM (see DR-097).
///
/// TRACES: UR-005 | DR-097
pub fn toggle_playback(&self) -> Result<(), PlayerError> {
if self.is_html5_active() {
let action = if self.html5_is_playing() {
"pause"
} else {
"play"
};
self.emit_html5_control(action);
return Ok(());
}
let mut backend = self.backend.lock_safe();
if backend.state().is_playing() {
backend.pause()
@@ -658,6 +738,24 @@ impl PlayerController {
self.queue.clone()
}
/// True when the current item is a TV episode being played in audio-only
/// (background) mode — i.e. an `item_type == "Episode"` item loaded as
/// `MediaType::Audio`. Used to decide whether the backend must drive the
/// next-episode advance itself (the frontend is suspended in the background).
///
/// Only *called* from the Android autoplay dispatch (`#[cfg(android)]`), but
/// compiled and unit-tested on the host, hence `allow(dead_code)` off-Android.
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
pub fn current_is_audio_episode(&self) -> bool {
self.queue
.lock_safe()
.current()
.map(|item| {
item.media_type == MediaType::Audio && item.item_type.as_deref() == Some("Episode")
})
.unwrap_or(false)
}
/// Clear the queue entirely (used when playback genuinely stops, e.g. the
/// sleep timer fires or the queue ends with repeat off). Pair with
/// `emit_queue_changed` so the frontend hides the mini player.
@@ -749,6 +847,7 @@ impl PlayerController {
let sleep_timer = self.sleep_timer.clone();
let event_emitter = self.event_emitter.clone();
let backend = self.backend.clone();
let end_reason = self.end_reason.clone();
std::thread::spawn(move || {
loop {
@@ -765,6 +864,14 @@ impl PlayerController {
debug!("[SleepTimer] Time-based timer expired, stopping playback");
timer.cancel();
// Mark the stop *before* it reaches the backend. Stopping
// makes the native player fire its ended callback, and
// cancelling the timer above means on_playback_ended can no
// longer tell this apart from a natural end — without this
// it would show the next-episode popup / autoplay right
// after the sleep timer fired.
Self::note_sleep_timer_stop(&end_reason);
// Emit cancelled state
if let Some(emitter) = event_emitter.lock_safe().as_ref() {
emitter.emit(PlayerStatusEvent::SleepTimerChanged {
@@ -846,6 +953,23 @@ impl PlayerController {
/// Re-emits a `StateChanged` event identical to what MpvBackend/ExoPlayer
/// would emit, so `playerEvents.ts` needs no HTML5-specific branch.
pub fn report_html5_state(&self, state: String, media_id: Option<String>) {
// Track it: this is the authoritative play/pause state for
// webview-rendered media, and what transport decisions read (DR-097).
// "stopped"/"idle" mean the element is gone, so hand authority back to
// the native backend — otherwise music playback would keep emitting
// ControlCommands at a element that no longer exists.
{
let mut tracked = self.html5_playing.lock_safe();
*tracked = match state.as_str() {
"playing" => Some(true),
// "loading" counts as active-but-not-playing so a toggle during
// load resolves to "play" rather than falling through to the
// native backend.
"paused" | "loading" => Some(false),
// "stopped"/"idle": element is gone, native backend resumes authority.
_ => None,
};
}
if let Some(emitter) = self.event_emitter.lock_safe().as_ref() {
emitter.emit(PlayerStatusEvent::StateChanged { state, media_id });
}
@@ -963,9 +1087,11 @@ impl PlayerController {
return Ok(AutoplayDecision::Stop);
}
SleepTimerMode::Episodes { .. } => {
// Only count TV episodes (not audio tracks or movies)
let is_episode =
current.media_type == MediaType::Video && self.is_episode_item(&current).await;
// Only count TV episodes (not audio tracks or movies). Note an
// episode played in background-audio mode is MediaType::Audio, so
// rely on is_episode_item (which checks item_type) rather than the
// media_type alone.
let is_episode = self.is_episode_item(&current).await;
if is_episode {
let should_stop = self.sleep_timer.lock_safe().decrement_episode();
@@ -981,10 +1107,12 @@ impl PlayerController {
}
}
// For video episodes, fetch next episode and show popup
// For episodes, fetch next episode and show popup.
// Note: This path is typically not hit for HTML5 video (which uses on_video_playback_ended).
// It's here for the Android ExoPlayer path where video items may be in the backend queue.
if current.media_type == MediaType::Video && self.is_episode_item(&current).await {
// It's here for the Android ExoPlayer path where episode items sit in the
// backend queue — including background-audio mode, where the episode is a
// MediaType::Audio item, so gate on is_episode_item (item_type), not media_type.
if self.is_episode_item(&current).await {
let repo = self.repository.lock_safe().clone();
let jellyfin_id = current.jellyfin_id().unwrap_or(&current.id);
let next_ep_result = if let Some(repo) = &repo {
@@ -1042,6 +1170,77 @@ impl PlayerController {
}
}
/// Advance to the next episode while playing audio-only in the background.
///
/// The normal autoplay-next path navigates the frontend to `/player/<id>`,
/// which is unavailable when the app is backgrounded and the WebView is
/// suspended. This drives the advance entirely in the backend: build the next
/// episode's *audio-only* stream URL and load it into the native audio player,
/// so playback continues without any frontend involvement (UR-040).
///
/// `next_episode_id` is the Jellyfin item ID of the episode to play next.
///
/// Called from the Android autoplay dispatch (`#[cfg(android)]`); compiled and
/// unit-tested on the host, hence `allow(dead_code)` off-Android.
/// TRACES: UR-040, UR-023 | DR-052
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
pub async fn advance_to_next_episode_audio_only(
&self,
next_episode_id: &str,
) -> Result<(), String> {
let repo = self
.repository
.lock_safe()
.clone()
.ok_or_else(|| "No repository for background episode advance".to_string())?;
// Details for session metadata (title/series/artwork) and the stream URL.
let next = repo
.get_item(next_episode_id)
.await
.map_err(|e| format!("Failed to fetch next episode {}: {}", next_episode_id, e))?;
// Audio-only transcode from the start of the episode (no resume offset —
// a freshly-started next episode always plays from the beginning).
let stream_url = repo
.get_audio_only_stream_url_for_video(next_episode_id, None, None, None)
.await
.map_err(|e| format!("Failed to build audio-only URL for next episode: {}", e))?;
let media_item = MediaItem {
id: next.id.clone(),
title: next.name.clone(),
name: Some(next.name.clone()),
artist: next.series_name.clone(),
album: None,
album_name: None,
album_id: None,
artist_items: None,
artists: None,
primary_image_tag: next.primary_image_tag.clone(),
image_id: next.image_id.clone().or(next.primary_image_tag.clone()),
// Preserve episode identity so the NEXT end-of-track also advances.
item_type: Some("Episode".to_string()),
playlist_id: None,
duration: next.duration_ms.map(|ms| ms as f64 / 1000.0),
artwork_url: None,
media_type: MediaType::Audio,
source: MediaSource::Remote {
stream_url,
jellyfin_item_id: next.id.clone(),
},
video_codec: None,
needs_transcoding: false,
video_width: None,
video_height: None,
subtitles: vec![],
series_id: next.series_id.clone(),
server_id: Some(next.server_id.clone()),
};
self.play_item(media_item).map_err(|e| e.to_string())
}
/// Handle video playback ended from HTML5 video element.
///
/// HTML5 video plays independently of the Rust backend, so the backend
@@ -1135,11 +1334,19 @@ impl PlayerController {
Ok(AutoplayDecision::Stop)
}
/// Check if a media item is an episode (has Jellyfin ID to query)
/// Check if a media item is an episode (has Jellyfin ID to query).
///
/// An explicit `item_type == "Episode"` wins so that a TV episode handed off
/// to the audio path for background playback (UR-040) is still recognised as
/// an episode — otherwise autoplay would fall through to the queue-based
/// audio path, find nothing next, and stop at the episode boundary. When the
/// type is unknown we fall back to the historical heuristic (video == episode).
async fn is_episode_item(&self, item: &MediaItem) -> bool {
// For now, assume video items are episodes
// In production, we'd check item metadata or query Jellyfin
item.media_type == MediaType::Video
match item.item_type.as_deref() {
Some("Episode") => true,
Some(_) => item.media_type == MediaType::Video,
None => item.media_type == MediaType::Video,
}
}
/// Fetch next episode for a series by looking up the season's episodes
@@ -1362,6 +1569,156 @@ mod tests {
}
}
// ===== HTML5 transport authority (DR-097) =====
//
// Webview-rendered video is played by an element the native backend cannot
// reach, so transport for it must be decided from the state the element
// REPORTS and executed by emitting a ControlCommand. Previously the frontend
// decided play-vs-pause itself by reading `el.paused` off the DOM, which
// flips transiently while buffering/seeking — two intents ~150ms apart read
// different values, took opposing actions, and self-sustained a pause loop.
#[test]
fn test_html5_state_is_tracked_from_reports() {
let controller = PlayerController::default();
let emitter = Arc::new(CapturingEmitter::new());
controller.set_event_emitter(emitter.clone());
// No HTML5 media reported yet: the native backend stays authoritative.
assert!(!controller.is_html5_active());
controller.report_html5_state("playing".to_string(), Some("item-1".to_string()));
assert!(controller.is_html5_active());
assert!(controller.html5_is_playing());
controller.report_html5_state("paused".to_string(), Some("item-1".to_string()));
assert!(controller.is_html5_active());
assert!(!controller.html5_is_playing());
}
#[test]
fn test_html5_toggle_from_paused_emits_play_control() {
let controller = PlayerController::default();
let emitter = Arc::new(CapturingEmitter::new());
controller.set_event_emitter(emitter.clone());
controller.report_html5_state("paused".to_string(), Some("item-1".to_string()));
controller.toggle_playback().unwrap();
let controls: Vec<_> = emitter
.events()
.into_iter()
.filter_map(|e| match e {
PlayerStatusEvent::ControlCommand { action, .. } => Some(action),
_ => None,
})
.collect();
assert_eq!(controls, vec!["play".to_string()]);
}
#[test]
fn test_html5_toggle_from_playing_emits_pause_control() {
let controller = PlayerController::default();
let emitter = Arc::new(CapturingEmitter::new());
controller.set_event_emitter(emitter.clone());
controller.report_html5_state("playing".to_string(), Some("item-1".to_string()));
controller.toggle_playback().unwrap();
let controls: Vec<_> = emitter
.events()
.into_iter()
.filter_map(|e| match e {
PlayerStatusEvent::ControlCommand { action, .. } => Some(action),
_ => None,
})
.collect();
assert_eq!(controls, vec!["pause".to_string()]);
}
#[test]
fn test_html5_repeated_toggles_alternate_and_never_repeat_an_action() {
// The loop signature: two intents in quick succession must NOT both
// resolve the same way, and must not produce opposing actions from a
// stale read. Rust's own tracked state makes the sequence deterministic
// as long as the element reports back between intents.
let controller = PlayerController::default();
let emitter = Arc::new(CapturingEmitter::new());
controller.set_event_emitter(emitter.clone());
controller.report_html5_state("playing".to_string(), Some("item-1".to_string()));
controller.toggle_playback().unwrap();
// Element confirms the pause it was told to do.
controller.report_html5_state("paused".to_string(), Some("item-1".to_string()));
controller.toggle_playback().unwrap();
let controls: Vec<_> = emitter
.events()
.into_iter()
.filter_map(|e| match e {
PlayerStatusEvent::ControlCommand { action, .. } => Some(action),
_ => None,
})
.collect();
assert_eq!(controls, vec!["pause".to_string(), "play".to_string()]);
}
#[test]
fn test_html5_play_and_pause_emit_control_commands() {
let controller = PlayerController::default();
let emitter = Arc::new(CapturingEmitter::new());
controller.set_event_emitter(emitter.clone());
controller.report_html5_state("paused".to_string(), Some("item-1".to_string()));
controller.play().unwrap();
controller.pause().unwrap();
let controls: Vec<_> = emitter
.events()
.into_iter()
.filter_map(|e| match e {
PlayerStatusEvent::ControlCommand { action, .. } => Some(action),
_ => None,
})
.collect();
assert_eq!(controls, vec!["play".to_string(), "pause".to_string()]);
}
#[test]
fn test_html5_stopped_report_releases_transport_to_native_backend() {
// When webview video goes away, transport must fall back to the native
// backend (music playback must not keep emitting ControlCommands).
let controller = PlayerController::default();
let emitter = Arc::new(CapturingEmitter::new());
controller.set_event_emitter(emitter.clone());
controller.report_html5_state("playing".to_string(), Some("item-1".to_string()));
assert!(controller.is_html5_active());
controller.report_html5_state("stopped".to_string(), None);
assert!(!controller.is_html5_active());
}
#[test]
fn test_html5_transport_emits_exactly_one_control_per_intent() {
// Guards against a double-drive on platforms where the *backend* is also
// webview-based (WebviewAudioBackend on Windows): the html5 short-circuit
// must replace the backend call, not run in addition to it.
let controller = PlayerController::default();
let emitter = Arc::new(CapturingEmitter::new());
controller.set_event_emitter(emitter.clone());
controller.report_html5_state("playing".to_string(), Some("item-1".to_string()));
controller.pause().unwrap();
let controls = emitter
.events()
.into_iter()
.filter(|e| matches!(e, PlayerStatusEvent::ControlCommand { .. }))
.count();
assert_eq!(controls, 1, "one intent must produce exactly one control");
}
#[test]
fn test_controller_volume_default() {
let controller = PlayerController::default();
@@ -1939,6 +2296,51 @@ mod tests {
}
}
/// A time-based sleep timer that fires mid-episode must not let the ended
/// callback fall through to autoplay.
///
/// The timer thread stops the backend directly, which makes ExoPlayer emit
/// its ended callback. That callback races the thread's own `timer.cancel()`:
/// by the time `on_playback_ended` inspects the sleep timer it reads `Off`,
/// so the timer branch is skipped and the episode path runs — showing a
/// next-episode popup (or advancing) after the user's sleep timer expired.
#[tokio::test]
async fn test_expired_time_sleep_timer_stops_without_autoplay() {
let controller = PlayerController::default();
let items = create_test_items(3);
controller.play_queue(items, 0).unwrap();
controller.take_end_reason();
// Arm a time-based timer that is already due, then let the real timer
// thread (started in the constructor, 1s tick) observe the expiry and
// run its stop path. Driving the actual thread is the point: the bug was
// that this path stopped the backend without recording an end reason.
let now = chrono::Utc::now().timestamp_millis();
controller.set_sleep_timer(SleepTimerMode::Time { end_time: now });
// Wait for the timer thread to process the expiry (tick is 1s).
for _ in 0..40 {
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
if !controller.sleep_timer.lock_safe().is_active() {
break;
}
}
assert!(
!controller.sleep_timer.lock_safe().is_active(),
"Timer thread should have expired and cancelled the sleep timer"
);
// The backend stop above makes the native player fire its ended callback.
let decision = controller.on_playback_ended().await.unwrap();
assert!(
matches!(decision, AutoplayDecision::Stop),
"Expected Stop after an expired time-based sleep timer, got {:?}",
decision
);
}
#[tokio::test]
async fn test_empty_queue_stops() {
let controller = PlayerController::default();
@@ -2311,6 +2713,15 @@ mod tests {
async fn get_audio_stream_url(&self, _: &str) -> Result<String, repo_types::RepoError> {
unimplemented!()
}
async fn get_audio_only_stream_url_for_video(
&self,
item_id: &str,
_media_source_id: Option<&str>,
_start_time_seconds: Option<f64>,
_audio_stream_index: Option<i32>,
) -> Result<String, repo_types::RepoError> {
Ok(format!("http://example.com/{}-audio.mp3", item_id))
}
async fn get_live_tv_channels(
&self,
) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
@@ -2503,6 +2914,86 @@ mod tests {
}
}
/// Background audio-only mode (UR-040): a video episode is handed off to the
/// native ExoPlayer *audio* path as a `MediaType::Audio` item so it keeps
/// playing while the app is backgrounded. When that audio track ends, autoplay
/// must STILL recognise it as an episode and offer the next one — otherwise
/// playback just pauses at the episode boundary (the reported bug). The item
/// carries its episode identity via `item_type: "Episode"` + `series_id`.
#[tokio::test]
async fn test_playback_ended_background_audio_episode_advances() {
let controller = PlayerController::default();
controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
// Mirrors what player_enter_background_audio builds: the episode as AUDIO.
let episode = MediaItem {
item_type: Some("Episode".to_string()),
media_type: MediaType::Audio, // audio-only handoff, not Video
series_id: Some("series1".to_string()),
source: MediaSource::Remote {
stream_url: "http://example.com/ep2-audio.m3u8".to_string(),
jellyfin_item_id: "ep2".to_string(),
},
..create_test_items(1).remove(0)
};
controller.play_queue(vec![episode], 0).unwrap();
// Clear the NewTrackLoaded reason to simulate natural track end.
controller.take_end_reason();
let decision = controller.on_playback_ended().await.unwrap();
match decision {
AutoplayDecision::ShowNextEpisodePopup { next_episode, .. } => {
assert_eq!(next_episode.id, "ep3");
}
other => panic!(
"background-audio episode end must advance to the next episode, got {:?}",
other
),
}
}
/// The backend-driven advance (used when backgrounded) must load the next
/// episode as an AUDIO item carrying its episode identity, so the *following*
/// end-of-track also advances rather than stopping.
#[tokio::test]
async fn test_advance_to_next_episode_audio_only_loads_audio_episode() {
let controller = PlayerController::default();
controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
controller
.advance_to_next_episode_audio_only("ep2")
.await
.expect("advance should succeed");
let current = controller
.queue
.lock_safe()
.current()
.cloned()
.expect("an item should be loaded");
assert_eq!(current.id, "ep2");
assert_eq!(current.media_type, MediaType::Audio);
assert_eq!(current.item_type.as_deref(), Some("Episode"));
assert_eq!(current.series_id.as_deref(), Some("series1"));
// Uses the audio-only URL, not a video stream.
match &current.source {
MediaSource::Remote { stream_url, .. } => {
assert!(
stream_url.contains("audio"),
"expected audio-only URL, got {}",
stream_url
);
}
other => panic!("expected Remote source, got {:?}", other),
}
// The controller now considers itself mid background-audio episode, so the
// next end-of-track will advance again rather than stop.
assert!(controller.current_is_audio_episode());
}
/// Without a controller repository the Android episode path must still
/// stop gracefully (previous behavior) rather than error.
#[tokio::test]
+38
View File
@@ -641,6 +641,24 @@ impl MediaRepository for HybridRepository {
self.online.get_audio_stream_url(item_id).await
}
async fn get_audio_only_stream_url_for_video(
&self,
item_id: &str,
media_source_id: Option<&str>,
start_time_seconds: Option<f64>,
audio_stream_index: Option<i32>,
) -> Result<String, RepoError> {
// Audio-only transcode of a video requires the server - delegate to online.
self.online
.build_audio_only_stream_url_for_video(
item_id,
media_source_id,
start_time_seconds,
audio_stream_index,
)
.await
}
async fn get_live_tv_channels(&self) -> Result<Vec<MediaItem>, RepoError> {
// Live TV requires server communication - delegate to online repository
self.online.get_live_tv_channels().await
@@ -1028,6 +1046,16 @@ mod tests {
unimplemented!()
}
async fn get_audio_only_stream_url_for_video(
&self,
_item_id: &str,
_media_source_id: Option<&str>,
_start_time_seconds: Option<f64>,
_audio_stream_index: Option<i32>,
) -> Result<String, RepoError> {
unimplemented!()
}
async fn get_live_tv_channels(&self) -> Result<Vec<MediaItem>, RepoError> {
unimplemented!()
}
@@ -1276,6 +1304,16 @@ mod tests {
unimplemented!()
}
async fn get_audio_only_stream_url_for_video(
&self,
_item_id: &str,
_media_source_id: Option<&str>,
_start_time_seconds: Option<f64>,
_audio_stream_index: Option<i32>,
) -> Result<String, RepoError> {
unimplemented!()
}
async fn get_live_tv_channels(&self) -> Result<Vec<MediaItem>, RepoError> {
unimplemented!()
}
+16
View File
@@ -117,6 +117,22 @@ pub trait MediaRepository: Send + Sync {
/// @req: JA-007 - Get playback info and stream URL
async fn get_audio_stream_url(&self, item_id: &str) -> Result<String, RepoError>;
/// Get an audio-only stream URL for a *video* item (background-audio handoff).
///
/// Used when autoplay advances to the next episode while the app is playing a
/// video in audio-only mode in the background: the backend needs the next
/// episode's audio-only URL without any frontend round-trip. Online-only;
/// offline/cache repositories return an error.
///
/// TRACES: UR-040 | JA-032
async fn get_audio_only_stream_url_for_video(
&self,
item_id: &str,
media_source_id: Option<&str>,
start_time_seconds: Option<f64>,
audio_stream_index: Option<i32>,
) -> Result<String, RepoError>;
/// Get Live TV channels (broadcast / IPTV) for browsing.
async fn get_live_tv_channels(&self) -> Result<Vec<MediaItem>, RepoError>;
+11
View File
@@ -1518,6 +1518,17 @@ impl MediaRepository for OfflineRepository {
Err(RepoError::Offline)
}
async fn get_audio_only_stream_url_for_video(
&self,
_item_id: &str,
_media_source_id: Option<&str>,
_start_time_seconds: Option<f64>,
_audio_stream_index: Option<i32>,
) -> Result<String, RepoError> {
// Audio-only transcode requires the server; offline downloads play locally.
Err(RepoError::Offline)
}
async fn get_live_tv_channels(&self) -> Result<Vec<MediaItem>, RepoError> {
// Live TV is inherently online-only.
Err(RepoError::Offline)
+17 -1
View File
@@ -450,7 +450,7 @@ impl OnlineRepository {
/// `/universal` endpoint (no `.m3u8` in the path) fails its progressive
/// loader with `ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED`. mp3 is universally
/// decodable and supports mid-stream `StartTimeTicks`.
pub async fn get_audio_only_stream_url_for_video(
pub async fn build_audio_only_stream_url_for_video(
&self,
item_id: &str,
media_source_id: Option<&str>,
@@ -1355,6 +1355,22 @@ impl MediaRepository for OnlineRepository {
Ok(url)
}
async fn get_audio_only_stream_url_for_video(
&self,
item_id: &str,
media_source_id: Option<&str>,
start_time_seconds: Option<f64>,
audio_stream_index: Option<i32>,
) -> Result<String, RepoError> {
self.build_audio_only_stream_url_for_video(
item_id,
media_source_id,
start_time_seconds,
audio_stream_index,
)
.await
}
async fn get_live_tv_channels(&self) -> Result<Vec<MediaItem>, RepoError> {
// Live TV channels (broadcast tuners / IPTV M3U). Returned as items with
// type "TvChannel" — playable via open_live_stream.
+194
View File
@@ -294,6 +294,53 @@ pub struct GetItemsOptions {
pub genres: Option<Vec<String>>,
}
/// An opaque search scope the frontend selects; Rust owns what it *means*.
///
/// The expansion table below is Jellyfin domain vocabulary: it changes when
/// Jellyfin adds or renames an item type, never when the UI is redesigned. It
/// previously lived in the frontend (`searchScope.ts`), which is the boundary
/// leak documented in docs/specs/scoped-search-boundary.md. The frontend now
/// sends the enum and never names an item type in connection with search.
///
/// TRACES: UR-049 | DR-063
#[derive(specta::Type, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum SearchScope {
All,
Music,
Movies,
Tv,
}
impl SearchScope {
/// The Jellyfin item types this scope requests, or `None` for `All`.
///
/// `All` returns `None` rather than the union of every listed type on
/// purpose: an explicit `includeItemTypes` list filters out anything not
/// named in it, so a union would silently drop People, folders and any type
/// nobody enumerated. Callers must omit the filter entirely on `None`.
///
/// TRACES: UR-049 | DR-063
pub fn item_types(self) -> Option<Vec<String>> {
match self {
SearchScope::All => None,
SearchScope::Music => Some(
["MusicAlbum", "MusicArtist", "Audio", "Playlist"]
.into_iter()
.map(String::from)
.collect(),
),
SearchScope::Movies => Some(vec!["Movie".to_string()]),
SearchScope::Tv => Some(
["Series", "Episode"]
.into_iter()
.map(String::from)
.collect(),
),
}
}
}
/// Options for search queries
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
@@ -304,6 +351,28 @@ pub struct SearchOptions {
pub include_item_types: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub search_term: Option<String>,
/// Opaque scope selected by the UI. When set it **wins** over
/// `include_item_types`, which remains for the non-search `get_items`
/// callers that legitimately request a single concrete type.
#[serde(skip_serializing_if = "Option::is_none")]
pub scope: Option<SearchScope>,
}
impl SearchOptions {
/// Expand `scope` into `include_item_types` in place.
///
/// Call this once, in the search command, *before* dispatching to the
/// cache and server paths — both already honour `include_item_types`, and
/// resolving in one place keeps online and offline results identical.
///
/// TRACES: UR-049 | DR-063
pub fn resolve_scope(&mut self) {
if let Some(scope) = self.scope {
// `All` yields None, which clears the filter — the correct
// behaviour, not an omission.
self.include_item_types = scope.item_types();
}
}
}
/// Playback information
@@ -455,6 +524,131 @@ impl MeaningfulContent for PlaylistCreatedResult {
}
}
#[cfg(test)]
mod search_scope_tests {
use super::*;
/// Music expands to the four Jellyfin types that make up the category.
///
/// This table is the domain vocabulary that used to live in the frontend
/// (`searchScope.ts`'s `SCOPE_ITEM_TYPES`) — the boundary leak that
/// docs/specs/scoped-search-boundary.md was written about.
///
/// @req-test: UT-089 - SearchScope expands to Jellyfin item types
#[test]
fn music_scope_expands_to_music_item_types() {
assert_eq!(
SearchScope::Music.item_types(),
Some(vec![
"MusicAlbum".to_string(),
"MusicArtist".to_string(),
"Audio".to_string(),
"Playlist".to_string(),
])
);
}
/// @req-test: UT-089 - SearchScope expands to Jellyfin item types
#[test]
fn movies_scope_expands_to_movie_only() {
assert_eq!(
SearchScope::Movies.item_types(),
Some(vec!["Movie".to_string()])
);
}
/// @req-test: UT-089 - SearchScope expands to Jellyfin item types
#[test]
fn tv_scope_expands_to_series_and_episode() {
assert_eq!(
SearchScope::Tv.item_types(),
Some(vec!["Series".to_string(), "Episode".to_string()])
);
}
/// `All` must send NO filter — not the union of the other scopes.
///
/// Sending a union would silently drop every type nobody enumerated
/// (Person, folders, …), which an explicit `includeItemTypes` list filters
/// out. This is why `item_types()` returns Option rather than Vec.
///
/// @req-test: UT-090 - All scope sends no item-type filter
#[test]
fn all_scope_sends_no_filter() {
assert_eq!(SearchScope::All.item_types(), None);
}
/// Scope wins over an explicitly supplied include_item_types.
///
/// @req-test: UT-091 - Scope takes precedence over include_item_types
#[test]
fn resolve_scope_overrides_include_item_types() {
let mut options = SearchOptions {
include_item_types: Some(vec!["Movie".to_string()]),
scope: Some(SearchScope::Music),
..Default::default()
};
options.resolve_scope();
assert_eq!(
options.include_item_types,
Some(vec![
"MusicAlbum".to_string(),
"MusicArtist".to_string(),
"Audio".to_string(),
"Playlist".to_string(),
])
);
}
/// `All` clears any include_item_types so no filter reaches the query.
///
/// @req-test: UT-090 - All scope sends no item-type filter
#[test]
fn resolve_all_scope_clears_include_item_types() {
let mut options = SearchOptions {
include_item_types: Some(vec!["Movie".to_string()]),
scope: Some(SearchScope::All),
..Default::default()
};
options.resolve_scope();
assert_eq!(options.include_item_types, None);
}
/// With no scope set, include_item_types passes through untouched — the
/// non-search `getItems` callers rely on this.
///
/// @req-test: UT-091 - Scope takes precedence over include_item_types
#[test]
fn resolve_without_scope_preserves_include_item_types() {
let mut options = SearchOptions {
include_item_types: Some(vec!["MusicAlbum".to_string()]),
scope: None,
..Default::default()
};
options.resolve_scope();
assert_eq!(
options.include_item_types,
Some(vec!["MusicAlbum".to_string()])
);
}
/// The frontend sends the enum as camelCase over IPC.
///
/// @req-test: UT-089 - SearchScope expands to Jellyfin item types
#[test]
fn scope_deserializes_from_camel_case() {
let options: SearchOptions =
serde_json::from_str(r#"{"scope": "music", "limit": 10}"#).unwrap();
assert!(matches!(options.scope, Some(SearchScope::Music)));
let all: SearchOptions = serde_json::from_str(r#"{"scope": "all"}"#).unwrap();
assert!(matches!(all.scope, Some(SearchScope::All)));
}
}
#[cfg(test)]
mod tests {
use super::*;
+70
View File
@@ -179,10 +179,80 @@ impl VideoSettings {
}
}
/// Serialise `AudioSettings` into the JSON payload handed to the Android player
/// over JNI.
///
/// Sanitises first (crossfade clamped, band vector normalised) so a malformed
/// vector can never reach the Kotlin parser. JSON is used rather than a wide JNI
/// signature so that adding a field does not change the method signature — the
/// same approach `load()` already uses for subtitles.
///
/// The emitted keys are camelCase (serde) and `volumeLevel` is lowercase; the
/// Kotlin side matches on those literals. Both are pinned by tests.
///
/// TRACES: UR-027, UR-032, UR-033 | DR-030, DR-035, DR-036
pub fn audio_settings_jni_payload(settings: &AudioSettings) -> Result<String, serde_json::Error> {
let sanitised = settings
.clone()
.with_crossfade_clamped()
.with_equalizer_normalised();
serde_json::to_string(&sanitised)
}
#[cfg(test)]
mod tests {
use super::*;
/// The JNI payload must sanitise before serialising: an over-long crossfade
/// is clamped and a wrong-length band vector is normalised to EQ_BANDS.len().
/// Sending raw values would let a malformed vector reach the Kotlin parser.
///
/// TRACES: UR-027, UR-032, UR-033 | DR-030, DR-035, DR-036 | UT-AUDIO-JNI-1
#[test]
fn test_audio_settings_jni_payload_is_sanitised() {
let settings = AudioSettings {
crossfade_duration: 30.0,
equalizer_bands: vec![20.0, -30.0],
..AudioSettings::default()
};
let json = audio_settings_jni_payload(&settings).expect("serialises");
let v: serde_json::Value = serde_json::from_str(&json).expect("valid JSON");
assert_eq!(v["crossfadeDuration"], 12.0, "crossfade clamped to 12s");
let bands = v["equalizerBands"].as_array().expect("bands array");
assert_eq!(bands.len(), EQ_BANDS.len(), "band vector normalised to 10");
assert_eq!(bands[0], EQ_GAIN_MAX as f64, "gain clamped to +12dB");
assert_eq!(bands[1], EQ_GAIN_MIN as f64, "gain clamped to -12dB");
}
/// The Kotlin side parses these exact keys. camelCase is what serde emits
/// for AudioSettings; a rename here silently breaks the Android parser,
/// which is why the contract is pinned by a test rather than by convention.
///
/// TRACES: UR-027, UR-032, UR-033 | DR-030, DR-035, DR-036 | UT-AUDIO-JNI-2
#[test]
fn test_audio_settings_jni_payload_key_contract() {
let json = audio_settings_jni_payload(&AudioSettings::default()).expect("serialises");
let v: serde_json::Value = serde_json::from_str(&json).expect("valid JSON");
for key in [
"crossfadeDuration",
"gaplessPlayback",
"normalizeVolume",
"volumeLevel",
"equalizerEnabled",
"equalizerBands",
] {
assert!(v.get(key).is_some(), "JNI payload must carry `{key}`");
}
// VolumeLevel is #[serde(rename_all = "lowercase")]; Kotlin matches on
// these literals.
assert_eq!(v["volumeLevel"], "normal");
}
#[test]
fn test_default_settings() {
let settings = AudioSettings::default();
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "jellytau",
"version": "0.1.0",
"version": "0.2.7",
"identifier": "com.dtourolle.jellytau",
"build": {
"beforeDevCommand": "bun run dev",
+37 -3
View File
@@ -1290,7 +1290,12 @@ async repositoryGetGenres(handle: string, parentId: string | null) : Promise<Gen
return await TAURI_INVOKE("repository_get_genres", { handle, parentId });
},
/**
* Search for items
* Search for items.
*
* Resolves `SearchOptions::scope` into concrete Jellyfin item types before
* dispatching, so scope taxonomy stays in Rust.
*
* TRACES: UR-049, UR-050 | DR-063
*/
async repositorySearch(handle: string, query: string, options: SearchOptions | null, requestId: number) : Promise<SearchResult> {
return await TAURI_INVOKE("repository_search", { handle, query, options, requestId });
@@ -2055,7 +2060,18 @@ artist?: string | null; primaryImageTag?: string | null; serverId?: string | nul
* handoff so the lockscreen MediaSession advertises a real duration a
* zero-duration session renders no scrubber, even with ACTION_SEEK_TO set.
*/
durationSeconds?: number | null }
durationSeconds?: number | null;
/**
* Item type (e.g. "Episode", "Movie", "Audio"). Carried through the
* background-audio handoff so an episode played as audio-only is still
* recognised as an episode by autoplay (UR-040) and advances to the next one.
*/
itemType?: string | null;
/**
* Series ID for TV episodes. Needed alongside `item_type` so the backend can
* look up the next episode when a background-audio track ends.
*/
seriesId?: string | null }
/**
* Queue context for remote transfer - what type of queue is this?
*/
@@ -2506,11 +2522,29 @@ failed: number }
/**
* Options for search queries
*/
export type SearchOptions = { limit?: number | null; includeItemTypes?: string[] | null; searchTerm?: string | null }
export type SearchOptions = { limit?: number | null; includeItemTypes?: string[] | null; searchTerm?: string | null;
/**
* Opaque scope selected by the UI. When set it **wins** over
* `include_item_types`, which remains for the non-search `get_items`
* callers that legitimately request a single concrete type.
*/
scope?: SearchScope | null }
/**
* Search result with pagination
*/
export type SearchResult = { items: MediaItem[]; totalRecordCount: number }
/**
* An opaque search scope the frontend selects; Rust owns what it *means*.
*
* The expansion table below is Jellyfin domain vocabulary: it changes when
* Jellyfin adds or renames an item type, never when the UI is redesigned. It
* previously lived in the frontend (`searchScope.ts`), which is the boundary
* leak documented in docs/specs/scoped-search-boundary.md. The frontend now
* sends the enum and never names an item type in connection with search.
*
* TRACES: UR-049 | DR-063
*/
export type SearchScope = "all" | "music" | "movies" | "tv"
/**
* Security status info
*/
@@ -4,6 +4,7 @@
import { truncateMiddle } from "$lib/utils/truncateMiddle";
import type { MediaItem } from "$lib/api/types";
import CachedImage from "$lib/components/common/CachedImage.svelte";
import { isCurrentEpisode as isSameEpisode, adjacentEpisodes as computeAdjacent } from "./episodeStrip";
interface Props {
episode: MediaItem;
@@ -14,63 +15,12 @@
let { episode, series, allEpisodes, onBack }: Props = $props();
// Check if an episode matches the focused episode (by ID or season/episode number)
// Pure logic lives in ./episodeStrip.ts (unit-tested). Wrap for local use.
function isCurrentEpisode(ep: MediaItem): boolean {
if (ep.id === episode.id) return true;
// Also match by season/episode number in case IDs differ
return ep.parentIndexNumber === episode.parentIndexNumber &&
ep.indexNumber === episode.indexNumber;
return isSameEpisode(ep, episode);
}
// Find adjacent episodes - use season/episode numbers if ID not found
const adjacentEpisodes = $derived(() => {
// First, try to find the episode by ID
let idx = allEpisodes.findIndex((e) => e.id === episode.id);
// If not found by ID, try to find by season/episode number
if (idx === -1 && episode.parentIndexNumber !== undefined && episode.indexNumber !== undefined) {
idx = allEpisodes.findIndex(
(e) => e.parentIndexNumber === episode.parentIndexNumber && e.indexNumber === episode.indexNumber
);
}
// If still not found, filter to same season and show those centered around the episode number
if (idx === -1) {
const sameSeasonEpisodes = allEpisodes
.filter((e) => e.parentIndexNumber === episode.parentIndexNumber)
.sort((a, b) => (a.indexNumber || 0) - (b.indexNumber || 0));
if (sameSeasonEpisodes.length > 0) {
// Find position based on episode number
const epNum = episode.indexNumber || 1;
const centerIdx = sameSeasonEpisodes.findIndex((e) => (e.indexNumber || 0) >= epNum);
const actualIdx = centerIdx === -1 ? sameSeasonEpisodes.length - 1 : centerIdx;
const start = Math.max(0, actualIdx - 3);
const end = Math.min(sameSeasonEpisodes.length, actualIdx + 7);
const result = sameSeasonEpisodes.slice(start, end);
// Insert the focused episode if not already present (by season/episode number match)
const hasCurrentEpisode = result.some(isCurrentEpisode);
if (!hasCurrentEpisode) {
// Insert at correct position based on episode number
const insertIdx = result.findIndex((e) => (e.indexNumber || 0) > epNum);
if (insertIdx === -1) {
result.push(episode);
} else {
result.splice(insertIdx, 0, episode);
}
}
return result;
}
// Last resort: return focused episode with first 9 episodes
return [episode, ...allEpisodes.slice(0, 9)];
}
// Get 3 before and 6 after (or adjust based on position)
const start = Math.max(0, idx - 3);
const end = Math.min(allEpisodes.length, idx + 7);
return allEpisodes.slice(start, end);
});
const adjacentEpisodes = $derived(() => computeAdjacent(episode, allEpisodes));
// Compute best backdrop source (no fetch, pure derivation)
const backdropSource = $derived.by(() => {
@@ -0,0 +1,97 @@
import { describe, it, expect } from "vitest";
import type { MediaItem } from "$lib/api/types";
import { isCurrentEpisode, adjacentEpisodes } from "./episodeStrip";
// Minimal episode factory — only the fields the strip logic reads.
function ep(
id: string,
season: number | null,
number: number | null,
): MediaItem {
return {
id,
name: `S${season}E${number}`,
kind: "episode",
parentIndexNumber: season,
indexNumber: number,
} as unknown as MediaItem;
}
function season(n: number, count: number): MediaItem[] {
return Array.from({ length: count }, (_, i) => ep(`s${n}e${i + 1}`, n, i + 1));
}
describe("isCurrentEpisode", () => {
const current = ep("abc", 1, 3);
it("matches by id", () => {
expect(isCurrentEpisode(ep("abc", 9, 9), current)).toBe(true);
});
it("matches by season+episode number when id differs", () => {
expect(isCurrentEpisode(ep("other", 1, 3), current)).toBe(true);
});
it("does not match a different episode number", () => {
expect(isCurrentEpisode(ep("other", 1, 4), current)).toBe(false);
});
it("does NOT treat two number-less episodes as the same (the reported bug)", () => {
const a = ep("a", null, null);
const b = ep("b", null, null);
expect(isCurrentEpisode(a, b)).toBe(false);
});
it("does not match when only one side has numbers", () => {
expect(isCurrentEpisode(ep("a", null, null), current)).toBe(false);
expect(isCurrentEpisode(ep("a", 1, 3), ep("b", null, null))).toBe(false);
});
});
describe("adjacentEpisodes", () => {
it("returns just the current episode when there are no others", () => {
const current = ep("only", 1, 1);
expect(adjacentEpisodes(current, [])).toEqual([current]);
});
it("returns siblings, not just the current episode", () => {
const eps = season(1, 8);
const current = eps[2]; // S1E3
const strip = adjacentEpisodes(current, eps);
expect(strip.length).toBeGreaterThan(1);
expect(strip).toContain(current);
});
it("windows to 3 before and 6 after the current episode", () => {
const eps = season(1, 20);
const current = eps[9]; // S1E10, index 9
const strip = adjacentEpisodes(current, eps);
// start = max(0, 9-3)=6 (E7), end = min(20, 9+7)=16 → E7..E16 (10 items)
expect(strip.map((e) => e.indexNumber)).toEqual([7, 8, 9, 10, 11, 12, 13, 14, 15, 16]);
expect(strip).toContain(current);
});
it("restricts to the current season when multiple seasons are present", () => {
const eps = [...season(1, 5), ...season(2, 5)];
const current = eps[6]; // S2E2
const strip = adjacentEpisodes(current, eps);
expect(strip.every((e) => e.parentIndexNumber === 2)).toBe(true);
});
it("splices in a directly-fetched episode absent from the list (ID mismatch)", () => {
const eps = season(1, 5);
// Focused episode has a different id than any in the list but same numbers.
const current = ep("fetched-directly", 1, 3);
const strip = adjacentEpisodes(current, eps);
// It should appear once, anchored at its numeric position, alongside siblings.
expect(strip.filter((e) => e.indexNumber === 3).length).toBe(1);
expect(strip.length).toBeGreaterThan(1);
});
it("falls back to the full list when the current season is unknown", () => {
const eps = season(1, 5);
const current = ep("mystery", null, 3); // no season number
const strip = adjacentEpisodes(current, eps);
expect(strip.length).toBeGreaterThan(1);
});
});
@@ -0,0 +1,61 @@
// Pure logic for the "More Episodes" strip in EpisodeFocusView.
//
// Extracted from the component so it can be unit-tested: the strip must never
// collapse to just the current episode while real siblings exist, and it must
// not mistake number-less episodes for the current one.
//
// TRACES: UR-048 | DR-062
import type { MediaItem } from "$lib/api/types";
/**
* Does `ep` refer to the same episode as `current`?
*
* Matches by id first. Falls back to season+episode number, but ONLY when both
* numbers are known on both sides otherwise `undefined === undefined` would
* mark every number-less episode as the current one (the bug that made the
* whole strip look like the current episode).
*/
export function isCurrentEpisode(ep: MediaItem, current: MediaItem): boolean {
if (ep.id === current.id) return true;
if (
ep.indexNumber == null || current.indexNumber == null ||
ep.parentIndexNumber == null || current.parentIndexNumber == null
) {
return false;
}
return (
ep.parentIndexNumber === current.parentIndexNumber &&
ep.indexNumber === current.indexNumber
);
}
/**
* The window of episodes shown under the hero: up to 3 before and 6 after the
* current episode. Degrades gracefully:
* - prefers the current season, falling back to the full list when the season
* is unknown (e.g. the episode was fetched directly on an API-ID mismatch);
* - splices the current episode into the pool at its numeric position when it
* isn't present, so it still anchors the window;
* - returns just `[current]` only when there genuinely are no other episodes.
*/
export function adjacentEpisodes(current: MediaItem, allEpisodes: MediaItem[]): MediaItem[] {
const seasonMatches = allEpisodes.filter(
(e) => current.parentIndexNumber != null && e.parentIndexNumber === current.parentIndexNumber
);
const pool = (seasonMatches.length > 0 ? seasonMatches : allEpisodes)
.slice()
.sort((a, b) => (a.indexNumber ?? 0) - (b.indexNumber ?? 0));
let idx = pool.findIndex((e) => isCurrentEpisode(e, current));
if (idx === -1) {
const epNum = current.indexNumber ?? 0;
const insertAt = pool.findIndex((e) => (e.indexNumber ?? 0) > epNum);
idx = insertAt === -1 ? pool.length : insertAt;
pool.splice(idx, 0, current);
}
const start = Math.max(0, idx - 3);
const end = Math.min(pool.length, idx + 7);
return pool.slice(start, end);
}
+174 -53
View File
@@ -1,4 +1,4 @@
<!-- TRACES: UR-003, UR-005, UR-020, UR-021, UR-026, UR-040 | DR-010, DR-023, DR-024, DR-051, DR-052 -->
<!-- TRACES: UR-003, UR-005, UR-020, UR-021, UR-026, UR-040, UR-061 | DR-010, DR-023, DR-024, DR-051, DR-052, DR-092 -->
<script lang="ts">
import { onMount, onDestroy, untrack } from "svelte";
import { goto } from "$app/navigation";
@@ -12,6 +12,7 @@
import SleepTimerModal from "./SleepTimerModal.svelte";
import SleepTimerIndicator from "./SleepTimerIndicator.svelte";
import CachedImage from "../common/CachedImage.svelte";
import { videoFitClass } from "./videoFit";
import { sleepTimerActive, sleepTimerExpiredSignal } from "$lib/stores/sleepTimer";
import { playbackPosition } from "$lib/stores/player";
import * as html5Adapter from "$lib/player/html5Adapter";
@@ -19,6 +20,17 @@
import { Html5PlayerAdapter, type Html5ElementBridge } from "$lib/player/adapters";
import { createRustReportHost } from "$lib/player/adapters/rustReportHost";
import { isPipSupported, enterPip, setAutoEnterEnabled } from "$lib/utils/pictureInPicture";
import {
createTapGestureState,
registerTap,
resolveSeekTarget,
clampSeekTarget,
isSynthesizedTouchClick,
isControlSurfaceTouch,
SEEK_FORWARD_SECONDS,
SEEK_BACKWARD_SECONDS,
type TapFeedback,
} from "./tapGestures";
import {
setBackgroundAudioEnabled,
subscribeAppBackgrounded,
@@ -101,11 +113,16 @@
let touchStartX = $state(0);
let touchStartY = $state(0);
let touchStartTime = $state(0);
let lastTapTime = $state(0);
let tapTimeout: ReturnType<typeof setTimeout> | null = null;
let tapGestures = createTapGestureState();
// When a touch tap last ran the gesture handler, so the compatibility click
// the browser synthesizes afterwards can be ignored (see handleVideoClick).
let lastTouchTapAt = 0;
let brightness = $state(1); // 0-2, default 1
let showDoubleTapFeedback = $state<"left" | "right" | null>(null);
let showDoubleTapFeedback = $state<TapFeedback | null>(null);
let doubleTapFeedbackTimeout: ReturnType<typeof setTimeout> | null = null;
// Target of a skip already requested but not yet reported back by the player,
// so back-to-back double taps chain instead of stacking on a stale position.
let pendingSeekTarget: number | null = null;
let swipeGestureActive = $state(false);
// Backend info from Rust (Rust decides which backend to use based on platform)
@@ -673,15 +690,19 @@
bufferedRanges.push(`[${buffered.start(i).toFixed(1)} - ${buffered.end(i).toFixed(1)}]`);
}
console.log("[VideoPlayer Debug]", {
currentTime: videoElement.currentTime.toFixed(2),
displayTime: currentTime.toFixed(2),
buffered: bufferedRanges.join(", "),
readyState: videoElement.readyState,
paused: videoElement.paused,
seeking: videoElement.seeking,
playbackRate: videoElement.playbackRate,
});
// Flattened to a single string on purpose: the Android WebView console
// bridge stringifies objects as "[object Object]" in logcat, which made
// this whole payload useless when diagnosing over adb.
console.log(
`[VideoPlayer Debug] t=${videoElement.currentTime.toFixed(2)}` +
` display=${currentTime.toFixed(2)}` +
` readyState=${videoElement.readyState}` +
` networkState=${videoElement.networkState}` +
` paused=${videoElement.paused}` +
` seeking=${videoElement.seeking}` +
` rate=${videoElement.playbackRate}` +
` buffered=${bufferedRanges.join(", ")}`
);
}
}, 1000);
});
@@ -702,6 +723,11 @@
if (debugLogInterval) {
clearInterval(debugLogInterval);
}
tapGestures.cancel();
if (doubleTapFeedbackTimeout) {
clearTimeout(doubleTapFeedbackTimeout);
doubleTapFeedbackTimeout = null;
}
// Remove native backend event listeners (incl. background-audio lifecycle subs)
for (const unlisten of nativeUnlisteners) {
@@ -1078,6 +1104,21 @@
}
function handlePause() {
// The element pausing is normally user intent, but a stall, a source change,
// or a competing controller can also do it — and the pause itself carries no
// reason. Log the element state so an unexplained pause/resume loop can be
// attributed from an adb capture instead of guessed at.
const el = videoElement;
console.log(
`[VideoPlayer] pause event — t=${el ? el.currentTime.toFixed(2) : "?"}` +
` readyState=${el?.readyState}` +
` networkState=${el?.networkState}` +
` seeking=${el?.seeking}` +
` ended=${el?.ended}` +
` isSeeking=${isSeeking}` +
` isBuffering=${isBuffering}` +
` handoff=${handoffState.active}`
);
isPlaying = false;
stopTimeUpdates(); // Stop RAF loop when paused
html5Adapter.reportState("paused", reportMediaId ?? null);
@@ -1125,7 +1166,10 @@
async function handleSeekBarChange(e: Event) {
const input = e.target as HTMLInputElement;
const targetTime = parseFloat(input.value);
// Clamp strictly inside the media: the range input's max IS the duration, so
// dragging fully right would otherwise request a segment past the media end,
// which the server never produces (see END_SEEK_MARGIN_SECONDS).
const targetTime = clampSeekTarget(parseFloat(input.value), duration);
// Set isSeeking immediately to prevent timeupdate from interfering
isSeeking = true;
@@ -1191,9 +1235,13 @@
function toggleBackgroundAudio() {
backgroundAudioOn = !backgroundAudioOn;
console.log("[VideoPlayer] Background-audio toggle ->", backgroundAudioOn);
// Arm/disarm native background-audio mode AND flip auto-PiP the other way,
// so exactly one background behavior is active.
setBackgroundAudioEnabled(backgroundAudioOn);
const armed = setBackgroundAudioEnabled(backgroundAudioOn);
if (!armed) {
console.warn("[VideoPlayer] Background audio NOT armed natively (no bridge)");
}
setAutoEnterEnabled(!backgroundAudioOn);
}
@@ -1233,6 +1281,10 @@
serverId: media.serverId ?? null,
// Real duration so the lockscreen scrubber has a range to draw.
durationSeconds: duration > 0 ? duration : null,
// Episode identity so the backend can auto-advance to the next episode
// when this audio-only stream ends while backgrounded (UR-040).
itemType: media.type ?? null,
seriesId: media.seriesId ?? null,
},
pos,
);
@@ -1337,7 +1389,16 @@
async function seekRelative(seconds: number) {
isSeeking = true;
const newTime = Math.max(0, Math.min(duration, currentTime + seconds));
// The facade seeks by absolute position, so resolve the delta here —
// chaining off a still-in-flight target so rapid double taps accumulate
// instead of all resolving against the same not-yet-updated position.
const newTime = resolveSeekTarget({
delta: seconds,
reportedPosition: currentTime,
duration,
pendingTarget: pendingSeekTarget,
});
pendingSeekTarget = newTime;
console.log("[VideoPlayer] Relative seek:", {
offset: `${seconds > 0 ? "+" : ""}${seconds}s`,
@@ -1353,7 +1414,12 @@
}
} as unknown as Event;
try {
await handleSeekBarChange(syntheticEvent);
} finally {
// The player is authoritative again from here on.
if (pendingSeekTarget === newTime) pendingSeekTarget = null;
}
}
function handleKeydown(e: KeyboardEvent) {
@@ -1370,39 +1436,72 @@
}
} else if (e.key === "ArrowLeft") {
e.preventDefault();
seekRelative(-10);
seekRelative(SEEK_BACKWARD_SECONDS);
} else if (e.key === "ArrowRight") {
e.preventDefault();
seekRelative(10);
seekRelative(SEEK_FORWARD_SECONDS);
}
}
/**
* Walk up from the touch target collecting the tag/attribute pairs
* `isControlSurfaceTouch` needs, so the rule itself stays DOM-free and testable.
*/
function ancestorChain(target: EventTarget | null) {
const chain: Array<{
tag: string;
isPlayerControls?: boolean;
isPlayerSurface?: boolean;
}> = [];
let node = target as HTMLElement | null;
// Bounded walk: controls live a few levels below the player root, and
// stopping at <body> keeps this cheap and avoids depending on a bound ref.
while (node && node.tagName !== "BODY") {
chain.push({
tag: node.tagName ?? "",
isPlayerControls: node.dataset?.playerControls !== undefined,
isPlayerSurface: node.dataset?.playerSurface !== undefined,
});
node = node.parentElement;
}
return chain;
}
// Touch gesture handlers
function handleTouchStart(e: TouchEvent) {
// Taps on the controls belong to those controls. This listener is on the
// container and touch events bubble, so without this a tap on the bottom
// play button would toggle here AND again via the button's own click — the
// two cancelling out and leaving the control apparently dead (DR-098).
if (isControlSurfaceTouch(ancestorChain(e.target))) return;
const touch = e.touches[0];
touchStartX = touch.clientX;
touchStartY = touch.clientY;
touchStartTime = Date.now();
const now = Date.now();
const timeSinceLastTap = now - lastTapTime;
const outcome = registerTap(tapGestures, {
x: touch.clientX,
screenWidth: window.innerWidth,
now: Date.now(),
});
// Double tap detection (within 300ms)
if (timeSinceLastTap < 300 && timeSinceLastTap > 0) {
// Suppress the compatibility click this touch will synthesize.
lastTouchTapAt = Date.now();
if (outcome.action === "seek") {
e.preventDefault();
handleDoubleTap(touch.clientX);
lastTapTime = 0; // Reset to prevent triple-tap
if (tapTimeout) {
clearTimeout(tapTimeout);
tapTimeout = null;
}
} else {
lastTapTime = now;
// Set timeout to clear if no second tap
tapTimeout = setTimeout(() => {
lastTapTime = 0;
}, 300);
handleDoubleTap(outcome.seekSeconds, outcome.feedback);
// Re-toggle so the first tap's toggle is undone: a double tap seeks and
// leaves the play state as it was (playing keeps playing, paused stays
// paused).
if (outcome.togglePlayPause) togglePlayPause();
return;
}
// First tap: act now. Nothing is deferred, so there is no timer to race the
// compatibility click Android synthesizes after a touch tap (see DR-098).
togglePlayPause();
}
function handleTouchMove(e: TouchEvent) {
@@ -1415,6 +1514,15 @@
// Minimum movement to register as swipe (50px)
if (Math.abs(deltaY) > 50 && timeDelta > 50) {
// Only on the frame the gesture is first recognised as a swipe — this runs
// on every touchmove, and the correction below must happen exactly once.
if (!swipeGestureActive) {
// The touchstart already toggled play/pause (taps act immediately now),
// so undo it: a swipe must not change the play state. Forget the tap too,
// so it cannot pair with a later tap into a spurious seek.
togglePlayPause();
tapGestures.cancel();
}
swipeGestureActive = true;
// Brightness control on vertical swipe
@@ -1433,20 +1541,24 @@
swipeType = null;
}
function handleDoubleTap(x: number) {
const screenWidth = window.innerWidth;
const isLeftSide = x < screenWidth / 2;
if (isLeftSide) {
// Double tap left: rewind 10 seconds
seekRelative(-10);
showDoubleTapFeedback = "left";
} else {
// Double tap right: forward 10 seconds
seekRelative(10);
showDoubleTapFeedback = "right";
/**
* Mouse clicks toggle play/pause immediately. Touch taps are handled fully by
* `handleTouchStart`, so the compatibility click the browser synthesizes after
* a tap must be ignored or every tap toggles twice.
*
* Used by EVERY click target layered over the video, not just the <video>:
* pausing renders the full-screen play overlay, so the synthesized click lands
* on that button instead and would re-toggle straight back to playing.
*/
function handleSurfaceClick(e: MouseEvent) {
if (isSynthesizedTouchClick(e.detail, Date.now(), lastTouchTapAt)) return;
togglePlayPause();
}
function handleDoubleTap(seekSeconds: number, feedback: TapFeedback) {
seekRelative(seekSeconds);
showDoubleTapFeedback = feedback;
// Hide feedback after animation
if (doubleTapFeedbackTimeout) {
clearTimeout(doubleTapFeedbackTimeout);
@@ -1591,7 +1703,7 @@
<video
bind:this={videoElement}
src={currentStreamUrl.includes('.m3u8') && Hls.isSupported() ? '' : currentStreamUrl}
class="max-w-full max-h-full"
class={videoFitClass()}
class:invisible={!isMediaReady}
style="filter: brightness({brightness})"
playsinline
@@ -1607,7 +1719,7 @@
onwaiting={handleWaiting}
onplaying={handlePlaying}
onloadstart={handleLoadStart}
onclick={togglePlayPause}
onclick={handleSurfaceClick}
>
<!-- Temporarily disabled to debug playback issues
{#each subtitleTracks() as track}
@@ -1660,7 +1772,7 @@
<div class="bg-white/20 rounded-full p-6 backdrop-blur-sm">
<svg class="w-12 h-12 text-white" fill="currentColor" viewBox="0 0 24 24">
<path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm1-13H11v6l5.25 3.15.75-1.23-4-2.42z" />
<text x="12" y="14" text-anchor="middle" font-size="6" fill="white" font-weight="bold">-10</text>
<text x="12" y="14" text-anchor="middle" font-size="6" fill="white" font-weight="bold">{SEEK_BACKWARD_SECONDS}</text>
</svg>
</div>
</div>
@@ -1671,7 +1783,7 @@
<div class="bg-white/20 rounded-full p-6 backdrop-blur-sm">
<svg class="w-12 h-12 text-white" fill="currentColor" viewBox="0 0 24 24">
<path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm1-13H11v6l5.25 3.15.75-1.23-4-2.42z" />
<text x="12" y="14" text-anchor="middle" font-size="6" fill="white" font-weight="bold">+10</text>
<text x="12" y="14" text-anchor="middle" font-size="6" fill="white" font-weight="bold">+{SEEK_FORWARD_SECONDS}</text>
</svg>
</div>
</div>
@@ -1700,10 +1812,17 @@
<div class="w-12 h-12 border-4 border-white border-t-transparent rounded-full animate-spin"></div>
</div>
{:else if !isPlaying}
<!-- Play/Pause overlay -->
<!-- Play overlay. Visually this IS the video surface, so it is marked
`data-player-surface`: it must keep participating in tap gestures even
though it is a <button>, or the second tap of a double tap (which lands
here, because the first tap paused and raised this overlay) is
discarded as "a tap on a control" and seeking dies. It still shares the
synthesized-click guard, since it appears exactly when a tap pauses.
See DR-098. -->
<button
data-player-surface
class="absolute inset-0 flex items-center justify-center bg-black/30"
onclick={togglePlayPause}
onclick={handleSurfaceClick}
aria-label="Play"
>
<svg class="w-20 h-20 text-white" fill="currentColor" viewBox="0 0 24 24">
@@ -1751,8 +1870,10 @@
{/if}
</div>
<!-- Controls -->
<!-- Controls. `data-player-controls` marks this subtree as interactive so
container-level tap gestures ignore touches here (see DR-098). -->
<div
data-player-controls
class="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 to-transparent p-4 transition-opacity duration-300"
class:opacity-0={!showControls}
class:pointer-events-none={!showControls}
@@ -0,0 +1,211 @@
/**
* Behavioural regression tests for the video tap surface rendered against the
* REAL component, not a hand-modelled DOM.
*
* TRACES: UR-005, UR-061 | DR-098 | UT-092
*
* Why this file exists:
*
* `tapGestures.test.ts` tests `registerTap` / `isControlSurfaceTouch` /
* `isSynthesizedTouchClick` as isolated pure functions. Every one of those tests
* passed while, on the device, in sequence: the player pause-looped, then
* pausing became impossible, then the bottom controls went dead, then
* double-tap-to-seek stopped working. The helpers were each behaving exactly as
* specified the bugs were all in the *composition*: which element actually
* receives a tap once Svelte has re-rendered.
*
* Testing my own helpers could not catch that, and modelling the DOM by hand in
* a test just re-encodes the same wrong assumption. So these tests render
* VideoPlayer and dispatch real touch/click events at whatever element is
* genuinely on top, asserting user-visible outcomes ("a double tap seeks")
* rather than internals.
*
* The specific traps encoded here, each a bug that shipped:
* - pausing renders a full-screen <button> play overlay OVER the video, so the
* second tap of a double tap lands on a button, not the video;
* - the browser synthesizes a `click` after a touch tap, which must not toggle
* a second time, on ANY layered target;
* - the bottom controls bar must drive its own buttons and NOT the container's
* tap gestures.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render } from "@testing-library/svelte";
import { tick } from "svelte";
import VideoPlayer from "./VideoPlayer.svelte";
import { SEEK_FORWARD_SECONDS } from "./tapGestures";
// --- Mocks: everything VideoPlayer reaches for that is not the tap surface. ---
const toggleSpy = vi.fn();
const seekVideoSpy = vi.fn();
const seekSpy = vi.fn();
vi.mock("$app/navigation", () => ({ goto: vi.fn() }));
vi.mock("$lib/player", () => ({
playerController: {
toggle: (...a: unknown[]) => {
toggleSpy(...a);
return Promise.resolve();
},
seekVideo: (...a: unknown[]) => {
seekVideoSpy(...a);
return Promise.resolve();
},
seek: (...a: unknown[]) => {
seekSpy(...a);
return Promise.resolve();
},
setActiveAdapter: vi.fn(),
clearActiveAdapter: vi.fn(),
getActiveAdapter: vi.fn(() => null),
},
}));
vi.mock("$lib/player/adapters/rustReportHost", () => ({
createRustReportHost: () => ({
onState: vi.fn(),
onPosition: vi.fn(),
onMediaLoaded: vi.fn(),
onEnded: vi.fn(),
onError: vi.fn(),
onStreamUrlChanged: vi.fn(),
onBuffering: vi.fn(),
onReady: vi.fn(),
}),
}));
vi.mock("$lib/player/html5Adapter", () => ({
reportState: vi.fn(),
reportPosition: vi.fn(),
reportMediaLoaded: vi.fn(),
resetReporting: vi.fn(),
}));
vi.mock("$lib/utils/pictureInPicture", () => ({
isPipSupported: () => false,
enterPip: vi.fn(),
setAutoEnterEnabled: vi.fn(),
}));
vi.mock("$lib/stores/auth", () => ({
auth: {
getRepository: () => ({ getHandle: () => "h", jrayActorsAt: async () => [] }),
subscribe: (fn: (v: unknown) => void) => {
fn({ isAuthenticated: true });
return () => {};
},
},
}));
const MEDIA = {
id: "item-1",
name: "Test Episode",
type: "Episode",
runTimeTicks: 6_000_000_000, // 600s
} as any;
/** Dispatch a touch at (x, y) on whatever element is topmost there. */
function touchAt(el: Element, x: number) {
const touch = { clientX: x, clientY: 300 } as Touch;
el.dispatchEvent(
new TouchEvent("touchstart", {
bubbles: true,
cancelable: true,
touches: [touch] as unknown as Touch[],
})
);
}
function renderPlayer() {
return render(VideoPlayer, {
props: { media: MEDIA, streamUrl: "http://x/master.m3u8", onClose: vi.fn() },
});
}
describe("VideoPlayer tap surface (real component)", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("a single tap on the video toggles play/pause exactly once", async () => {
const { container } = renderPlayer();
const video = container.querySelector("video");
expect(video).toBeTruthy();
touchAt(video!, 900);
expect(toggleSpy).toHaveBeenCalledTimes(1);
});
it("the synthesized click after a tap does not toggle a second time", async () => {
const { container } = renderPlayer();
const video = container.querySelector("video")!;
touchAt(video, 900);
// The compatibility click the browser fires after a touch tap. detail=0 is
// how engines mark it; a late real-detail click is covered by the recency
// guard, which this exercises too since it lands immediately.
video.dispatchEvent(new MouseEvent("click", { bubbles: true, detail: 0 }));
expect(toggleSpy).toHaveBeenCalledTimes(1);
});
it("a double tap seeks even though the first tap raised the play overlay", async () => {
// THE regression this file exists for. On device the first tap pauses, which
// makes Svelte render a full-screen <button> play overlay over the video —
// so the SECOND tap lands on a button, not the video. A control-surface
// guard that does not know about that overlay discards it and seeking dies.
//
// Reproducing it requires the overlay to actually render, which means
// driving `isPlaying` the way the real element does: via its `pause` event.
vi.useFakeTimers();
try {
const { container } = renderPlayer();
const video = container.querySelector("video")!;
// Tap 1 on the video.
touchAt(video, 900);
// The element reports it paused → isPlaying=false → overlay renders.
video.dispatchEvent(new Event("pause"));
await Promise.resolve();
await tick();
const overlay = container.querySelector("[data-player-surface]");
expect(overlay, "the play overlay should be covering the video").toBeTruthy();
vi.advanceTimersByTime(120); // inside DOUBLE_TAP_WINDOW_MS
// Tap 2 lands on the OVERLAY, exactly as on device.
touchAt(overlay!, 900);
// Either seek route is acceptable — which one runs depends on whether a
// video adapter is registered. What must hold is that a seek happened, to
// roughly the forward-skip target.
const calls = [...seekVideoSpy.mock.calls, ...seekSpy.mock.calls];
expect(calls.length).toBe(1);
const [position] = calls[0];
expect(position).toBeGreaterThan(0);
expect(position).toBeLessThanOrEqual(SEEK_FORWARD_SECONDS);
} finally {
vi.useRealTimers();
}
});
it("tapping the bottom play/pause button toggles once, not twice", async () => {
const { container } = renderPlayer();
const controls = container.querySelector("[data-player-controls]");
expect(controls).toBeTruthy();
const playBtn = controls!.querySelector("button");
expect(playBtn).toBeTruthy();
// A real press: touchstart bubbles to the container's gesture handler, then
// the button's own click fires. Only ONE toggle may result.
touchAt(playBtn!, 40);
playBtn!.dispatchEvent(new MouseEvent("click", { bubbles: true, detail: 1 }));
expect(toggleSpy).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,276 @@
import { describe, it, expect } from "vitest";
import {
DOUBLE_TAP_WINDOW_MS,
SEEK_FORWARD_SECONDS,
SEEK_BACKWARD_SECONDS,
createTapGestureState,
registerTap,
resolveSeekTarget,
clampSeekTarget,
END_SEEK_MARGIN_SECONDS,
isSynthesizedTouchClick,
isControlSurfaceTouch,
TOUCH_CLICK_SUPPRESS_MS,
} from "./tapGestures";
const SCREEN_WIDTH = 1000;
const LEFT = 100;
const RIGHT = 900;
function tap(state: ReturnType<typeof createTapGestureState>, x: number, at: number) {
return registerTap(state, { x, screenWidth: SCREEN_WIDTH, now: at });
}
/** Narrow a tap outcome to the seek variant, failing the test if it is not one. */
function asSeek(outcome: ReturnType<typeof tap>) {
if (outcome.action !== "seek") {
throw new Error(`expected a seek outcome, got "${outcome.action}"`);
}
return outcome;
}
describe("tap gesture resolution", () => {
// Every tap acts IMMEDIATELY — there is no deferral and no timer.
//
// 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 -> jump and keep playing; paused ->
// jump and stay paused. The old design deferred the first tap behind a 300ms
// timer, which raced the synthesized click and produced a pause/unpause loop.
it("toggles play/pause immediately on the first tap", () => {
const state = createTapGestureState();
expect(tap(state, RIGHT, 1000)).toEqual({ action: "togglePlayPause" });
});
it("seeks forward 30s AND toggles again on a second right-side tap", () => {
const state = createTapGestureState();
tap(state, RIGHT, 1000);
const second = asSeek(tap(state, RIGHT, 1150));
expect(second.seekSeconds).toBe(SEEK_FORWARD_SECONDS);
expect(second.seekSeconds).toBe(30);
expect(second.feedback).toBe("right");
// The re-toggle is what preserves the play state across a double tap.
expect(second.togglePlayPause).toBe(true);
});
it("seeks back 10s AND toggles again on a second left-side tap", () => {
const state = createTapGestureState();
tap(state, LEFT, 1000);
const second = asSeek(tap(state, LEFT, 1100));
expect(second.seekSeconds).toBe(SEEK_BACKWARD_SECONDS);
expect(second.seekSeconds).toBe(-10);
expect(second.feedback).toBe("left");
expect(second.togglePlayPause).toBe(true);
});
it("net play state is unchanged by a double tap (two toggles cancel out)", () => {
const state = createTapGestureState();
let playing = true;
const apply = (outcome: ReturnType<typeof tap>) => {
if (outcome.action === "togglePlayPause") playing = !playing;
else if (outcome.action === "seek" && outcome.togglePlayPause) playing = !playing;
};
apply(tap(state, RIGHT, 1000)); // toggle -> paused
apply(tap(state, RIGHT, 1100)); // seek + toggle -> playing again
expect(playing).toBe(true);
// And from paused, a double tap leaves it paused.
playing = false;
apply(tap(state, RIGHT, 2000));
apply(tap(state, RIGHT, 2100));
expect(playing).toBe(false);
});
it("treats a tap after the window as a fresh first tap", () => {
const state = createTapGestureState();
tap(state, RIGHT, 1000);
const late = tap(state, RIGHT, 1000 + DOUBLE_TAP_WINDOW_MS + 1);
expect(late.action).toBe("togglePlayPause");
});
it("only ever has first and second taps — the tap after a pair is a fresh toggle", () => {
const state = createTapGestureState();
tap(state, RIGHT, 1000);
expect(tap(state, RIGHT, 1100).action).toBe("seek");
// The pair is consumed. The next tap is a FIRST tap again, so it toggles
// play/pause — there is no "third tap" concept.
expect(tap(state, RIGHT, 1200).action).toBe("togglePlayPause");
});
it("accumulates repeated double taps on the same side", () => {
const state = createTapGestureState();
tap(state, RIGHT, 1000);
const a = asSeek(tap(state, RIGHT, 1100));
tap(state, RIGHT, 1200);
const b = asSeek(tap(state, RIGHT, 1300));
expect(a.seekSeconds).toBe(30);
expect(b.seekSeconds).toBe(30);
});
it("uses the tap side, so a double tap split across halves follows the second tap", () => {
const state = createTapGestureState();
tap(state, LEFT, 1000);
const second = asSeek(tap(state, RIGHT, 1100));
expect(second.seekSeconds).toBe(SEEK_FORWARD_SECONDS);
expect(second.feedback).toBe("right");
});
it("cancel() makes the next tap a fresh first tap (swipe interrupted the pair)", () => {
const state = createTapGestureState();
tap(state, RIGHT, 1000);
state.cancel();
// Without cancel() this would have been the seeking second tap.
expect(tap(state, RIGHT, 1100).action).toBe("togglePlayPause");
});
});
describe("seek target resolution", () => {
const DURATION = 600;
it("adds the delta to the reported position", () => {
expect(resolveSeekTarget({ delta: 30, reportedPosition: 100, duration: DURATION })).toBe(130);
});
it("clamps to zero when rewinding past the start", () => {
expect(resolveSeekTarget({ delta: -10, reportedPosition: 4, duration: DURATION })).toBe(0);
});
it("clamps short of the duration when skipping past the end", () => {
// Never land exactly on `duration`: hls.js would then request the segment
// that starts at/after the media end, which the server never produces —
// the fetch times out and the gap-controller stalls in a pause loop.
expect(resolveSeekTarget({ delta: 30, reportedPosition: 590, duration: DURATION })).toBe(
DURATION - END_SEEK_MARGIN_SECONDS
);
});
it("keeps the end clamp strictly inside the media for a long transcoded item", () => {
// Regression: seeking near the end of a ~105min transcoded item clamped to
// the exact runtime (6330.324s), making hls.js fetch segment 1055 which
// starts at 6336.33s — past the end. That segment 404s/times out forever.
const runtime = 6330.324;
const target = resolveSeekTarget({ delta: 30, reportedPosition: 6320, duration: runtime });
expect(target).toBeLessThan(runtime);
expect(target).toBeCloseTo(runtime - END_SEEK_MARGIN_SECONDS, 5);
});
it("does not clamp below zero for media shorter than the end margin", () => {
expect(resolveSeekTarget({ delta: 30, reportedPosition: 1, duration: 1 })).toBe(0);
});
it("chains off a pending target so rapid taps do not compound off a stale position", () => {
// The player has not yet reported the first seek's result, so the
// reported position is still the pre-seek value.
const first = resolveSeekTarget({ delta: 30, reportedPosition: 100, duration: DURATION });
const second = resolveSeekTarget({
delta: 30,
reportedPosition: 100,
duration: DURATION,
pendingTarget: first,
});
expect(second).toBe(160);
});
it("ignores a pending target once the player has caught up past it", () => {
const target = resolveSeekTarget({
delta: 30,
reportedPosition: 200,
duration: DURATION,
pendingTarget: 130,
});
expect(target).toBe(230);
});
it("falls back to the delta alone when duration is unknown", () => {
expect(resolveSeekTarget({ delta: 30, reportedPosition: 100, duration: 0 })).toBe(130);
});
});
describe("control-surface touches are not gestures", () => {
// Regression: the gesture listener is on the outer container and touch events
// bubble, so tapping the bottom play/pause button ran the gesture handler
// (toggle #1) AND the button's own click handler (toggle #2). The two
// cancelled out and the control appeared dead.
it("treats a tap on a button as a control, not a gesture", () => {
expect(isControlSurfaceTouch([{ tag: "svg" }, { tag: "button" }, { tag: "div" }])).toBe(true);
});
it("treats the seek bar input as a control", () => {
expect(isControlSurfaceTouch([{ tag: "input" }, { tag: "div" }])).toBe(true);
});
it("treats anything inside the controls bar as a control", () => {
expect(
isControlSurfaceTouch([{ tag: "span" }, { tag: "div", isPlayerControls: true }])
).toBe(true);
});
it("lets a tap on the bare video surface through as a gesture", () => {
expect(isControlSurfaceTouch([{ tag: "video" }, { tag: "div" }, { tag: "div" }])).toBe(false);
});
it("is case-insensitive about tag names", () => {
expect(isControlSurfaceTouch([{ tag: "BUTTON" }])).toBe(true);
});
});
describe("synthesized touch-click suppression", () => {
// Regression: pausing renders a full-screen play-overlay button over the
// video, so the compatibility click Android synthesizes from the tap lands on
// the OVERLAY, not the <video>. With no guard there it re-toggled and undid
// the pause — pausing looked impossible while unpausing worked fine (the
// overlay is removed when playing, so nothing intercepted that direction).
it("suppresses a click with detail 0 (clearly synthesized)", () => {
expect(isSynthesizedTouchClick(0, 10_000, 0)).toBe(true);
});
it("suppresses a real-detail click that closely follows a touch tap", () => {
const tapAt = 10_000;
expect(isSynthesizedTouchClick(1, tapAt + 120, tapAt)).toBe(true);
expect(isSynthesizedTouchClick(1, tapAt + TOUCH_CLICK_SUPPRESS_MS - 1, tapAt)).toBe(true);
});
it("allows a genuine mouse click well after any touch", () => {
const tapAt = 10_000;
expect(isSynthesizedTouchClick(1, tapAt + TOUCH_CLICK_SUPPRESS_MS + 1, tapAt)).toBe(false);
});
it("allows a genuine mouse click when no touch has ever happened", () => {
expect(isSynthesizedTouchClick(1, 10_000, 0)).toBe(false);
});
});
describe("seek target clamping (shared by skip and seek-bar drag)", () => {
it("keeps a mid-stream target untouched", () => {
expect(clampSeekTarget(100, 600)).toBe(100);
});
it("pulls a drag to the very end back inside the media", () => {
// The seek bar's max IS the duration, so dragging fully right yields
// exactly `duration` — the value that triggers the dead-segment stall.
expect(clampSeekTarget(6330.324, 6330.324)).toBeCloseTo(6330.324 - END_SEEK_MARGIN_SECONDS, 5);
});
it("clamps negative and non-finite targets to zero", () => {
expect(clampSeekTarget(-5, 600)).toBe(0);
expect(clampSeekTarget(NaN, 600)).toBe(0);
});
it("leaves the target alone when the duration is unknown", () => {
expect(clampSeekTarget(500, 0)).toBe(500);
});
});
+234
View File
@@ -0,0 +1,234 @@
/**
* Tap-gesture interpretation for the video player surface.
*
* Every tap acts IMMEDIATELY there are only first and second taps, and no
* deferral:
*
* 1st tap: toggle play/pause
* 2nd tap (within the window): 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 started playing stays playing, paused stays paused.
*
* This replaced a design that deferred the first tap behind a 300ms timer so it
* could be cancelled if a second tap arrived. That deferral raced the
* compatibility `click` Android's WebView synthesizes after a touch tap: the
* timer cleared its own handle *before* running the toggle, reopening the guard
* that was meant to suppress the late click, which then toggled a second time.
* The result was a play/pause loop about a second apart. Acting immediately
* removes the timer, the window race, and the loop.
*
* TRACES: UR-005, UR-061 | DR-092, DR-095, DR-098 | UT-085, UT-086, UT-087, UT-088
*/
/** A second tap within this window pairs with the previous one (seek + re-toggle). */
export const DOUBLE_TAP_WINDOW_MS = 300;
/**
* How long after a touch tap a mouse `click` is assumed to be the compatibility
* event the browser synthesizes from that touch. Android's WebView can deliver it
* noticeably late, so this is generous.
*/
export const TOUCH_CLICK_SUPPRESS_MS = 700;
/**
* Whether a touch landed on an interactive control rather than the bare video
* surface, and so must NOT be interpreted as a play/pause or seek gesture.
*
* The gesture listener sits on the outer container, and touch events bubble, so
* without this a tap on the bottom control bar runs the gesture handler (toggle
* #1) *and* the button's own click handler (toggle #2) the two cancel out and
* the button appears dead. Buttons, links, inputs (the seek bar), and anything
* inside an element marked `data-player-controls` are treated as controls.
*
* Takes the ancestor chain as plain tag/attribute pairs so the rule is unit
* testable without a DOM.
*/
export function isControlSurfaceTouch(
ancestors: Array<{ tag: string; isPlayerControls?: boolean; isPlayerSurface?: boolean }>
): boolean {
const INTERACTIVE = new Set(["button", "a", "input", "select", "textarea", "label"]);
for (const node of ancestors) {
// `data-player-surface` wins over the tag check: the full-screen play overlay
// is a <button> but is visually the video itself, and must keep taking tap
// gestures — otherwise the second tap of a double tap (which lands on it,
// because the first tap paused and raised it) is discarded and seeking dies.
if (node.isPlayerSurface === true) return false;
if (node.isPlayerControls === true) return true;
if (INTERACTIVE.has(node.tag.toLowerCase())) return true;
}
return false;
}
/**
* Whether a `click` should be ignored because a touch tap already handled it.
*
* EVERY click target layered over the video must consult this not just the
* `<video>` element. Pausing swaps in a full-screen play-overlay button, so the
* synthesized click lands on *that* button rather than the video, and an
* unguarded handler there re-toggles and undoes the pause (pause appeared
* impossible while unpause worked, because unpausing removes the overlay).
*
* `detail === 0` catches the synthesized click on engines that report it; the
* recency check covers engines that report a real `detail`.
*/
export function isSynthesizedTouchClick(
detail: number,
now: number,
lastTouchTapAt: number
): boolean {
if (detail === 0) return true;
return now - lastTouchTapAt < TOUCH_CLICK_SUPPRESS_MS;
}
/** Double tap on the right half: skip forward. */
export const SEEK_FORWARD_SECONDS = 30;
/** Double tap on the left half: skip back. */
export const SEEK_BACKWARD_SECONDS = -10;
export type TapFeedback = "left" | "right";
export type TapOutcome =
/** First tap: toggle play/pause right now. */
| { action: "togglePlayPause" }
/**
* Second tap: seek, and toggle play/pause again so the first tap's toggle is
* undone and the play state survives the double tap unchanged.
*/
| {
action: "seek";
seekSeconds: number;
feedback: TapFeedback;
togglePlayPause: true;
};
export interface TapInput {
/** Tap x position, viewport pixels. */
x: number;
screenWidth: number;
now: number;
}
export interface TapGestureState {
/**
* Forget the previous tap, so the next one is treated as a first tap. Used
* when the gesture turns out to be a swipe.
*/
cancel(): void;
}
interface InternalState extends TapGestureState {
lastTapTime: number;
}
export function createTapGestureState(): TapGestureState {
const state: InternalState = {
lastTapTime: 0,
cancel() {
state.lastTapTime = 0;
},
};
return state;
}
/**
* Classify a tap and return the action to perform *now*.
*
* A tap that closely follows another is the second of a pair: it seeks and
* re-toggles play/pause (undoing the first tap's toggle). Any other tap is a
* first tap and simply toggles. Nothing is deferred, so there is no window to
* race and no third-tap case a consumed pair resets the state.
*/
export function registerTap(state: TapGestureState, input: TapInput): TapOutcome {
const s = state as InternalState;
const sinceLastTap = input.now - s.lastTapTime;
if (s.lastTapTime > 0 && sinceLastTap > 0 && sinceLastTap < DOUBLE_TAP_WINDOW_MS) {
s.lastTapTime = 0; // pair consumed; the next tap is a first tap again
const isLeftSide = input.x < input.screenWidth / 2;
return isLeftSide
? {
action: "seek",
seekSeconds: SEEK_BACKWARD_SECONDS,
feedback: "left",
togglePlayPause: true,
}
: {
action: "seek",
seekSeconds: SEEK_FORWARD_SECONDS,
feedback: "right",
togglePlayPause: true,
};
}
s.lastTapTime = input.now;
return { action: "togglePlayPause" };
}
/**
* Safety margin (seconds) kept between a clamped seek target and the media end.
*
* Landing *exactly* on `duration` makes hls.js request the segment whose start
* time is at/after the end of the media. The server never produces that segment,
* so the fetch times out and hls.js' gap-controller stalls forever at the last
* buffered position surfacing as "unpausing bounces straight back to paused".
* One segment length (~6s for Jellyfin's ts segments) is comfortably clear of
* the final segment boundary.
*/
export const END_SEEK_MARGIN_SECONDS = 6;
/**
* Clamp an absolute seek target into the safely-playable range.
*
* Shared by the relative-skip path ({@link resolveSeekTarget}) and the seek-bar
* drag path, which can otherwise land exactly on `duration` because the range
* input's `max` is the duration itself.
*/
export function clampSeekTarget(target: number, duration: number): number {
if (!Number.isFinite(target) || target < 0) return 0;
if (duration > 0 && target > duration - END_SEEK_MARGIN_SECONDS) {
return Math.max(0, duration - END_SEEK_MARGIN_SECONDS);
}
return target;
}
export interface SeekTargetInput {
/** Relative offset in seconds (negative rewinds). */
delta: number;
/** Latest position reported by the player — the authoritative source. */
reportedPosition: number;
/** Media duration; 0/unknown disables the upper clamp. */
duration: number;
/**
* Target of a seek already requested but not yet reflected in
* `reportedPosition`. Consecutive double taps chain off this so they add up
* instead of all resolving against the same stale position.
*/
pendingTarget?: number | null;
}
/**
* Resolve a relative skip to the absolute position the facade expects.
*
* The player facade seeks by absolute position only (the backend picks the seek
* strategy), so the delta is applied here against the pending target when one
* is still in flight and still ahead of what the player has reported.
*/
export function resolveSeekTarget(input: SeekTargetInput): number {
const { delta, reportedPosition, duration, pendingTarget } = input;
const base =
pendingTarget != null && Math.abs(pendingTarget - reportedPosition) > 0.5 && pendingTarget > reportedPosition
? pendingTarget
: reportedPosition;
const target = base + delta;
if (target < 0) return 0;
// Clamp strictly inside the media — see END_SEEK_MARGIN_SECONDS. Guard against
// going negative on media shorter than the margin itself.
if (duration > 0 && target > duration) {
return Math.max(0, duration - END_SEEK_MARGIN_SECONDS);
}
return target;
}
@@ -0,0 +1,57 @@
import { describe, it, expect } from "vitest";
import { videoFitClass, fittedVideoSize } from "./videoFit";
describe("videoFitClass", () => {
it("fills the container instead of capping at the source's intrinsic size", () => {
const cls = videoFitClass();
// max-w/max-h only shrink oversized media; a 480p source would stay a small
// box in the middle of a large window.
expect(cls).not.toContain("max-w-full");
expect(cls).not.toContain("max-h-full");
expect(cls).toContain("w-full");
expect(cls).toContain("h-full");
});
it("preserves aspect ratio while fitting (letterbox, never crop)", () => {
const cls = videoFitClass();
expect(cls).toContain("object-contain");
expect(cls).not.toContain("object-cover");
expect(cls).not.toContain("object-fill");
});
});
describe("fittedVideoSize", () => {
it("scales a 480p source up to fill a larger window (the reported bug)", () => {
// Exact 16:9 480p in a 1920x1080 window -> scales up to fill, rather than
// staying a 854x480 box in the middle.
const size = fittedVideoSize(853.33, 480, 1920, 1080);
expect(size.width).toBeCloseTo(1920, 0);
expect(size.height).toBeCloseTo(1080, 0);
});
it("fits to the constraining dimension when aspect ratios differ", () => {
// 4:3 source in a 16:9 window -> height-constrained, pillarboxed.
const size = fittedVideoSize(640, 480, 1920, 1080);
expect(size.height).toBeCloseTo(1080, 0);
expect(size.width).toBeCloseTo(1440, 0);
expect(size.width).toBeLessThan(1920);
});
it("fits to width when the source is wider than the window", () => {
// 21:9 source in a 16:9 window -> width-constrained, letterboxed.
const size = fittedVideoSize(2560, 1080, 1920, 1080);
expect(size.width).toBeCloseTo(1920, 0);
expect(size.height).toBeCloseTo(810, 0);
expect(size.height).toBeLessThan(1080);
});
it("shrinks oversized media to fit rather than overflowing", () => {
const size = fittedVideoSize(3840, 2160, 1280, 720);
expect(size.width).toBeCloseTo(1280, 0);
expect(size.height).toBeCloseTo(720, 0);
});
it("returns a zero size for unknown intrinsic dimensions", () => {
expect(fittedVideoSize(0, 0, 1920, 1080)).toEqual({ width: 0, height: 0 });
});
});
+49
View File
@@ -0,0 +1,49 @@
// Sizing rules for the HTML5 <video> element in the full-screen player.
// Extracted from VideoPlayer.svelte so the fit behaviour is unit-testable.
/**
* Classes applied to the <video> element so it fits the player viewport.
*
* TRACES: UR-005
*
* `max-w-full max-h-full` only ever *shrinks* oversized media, so a source
* smaller than the window (e.g. 480p on a 1080p display) rendered at its
* intrinsic size - a small box in the middle of a black screen. Filling the
* container and letting `object-contain` do the scaling fits the picture to
* whichever axis constrains it, in both directions, preserving aspect ratio.
*/
export function videoFitClass(): string {
return "w-full h-full object-contain";
}
export interface FittedSize {
width: number;
height: number;
}
/**
* The rendered size of a video of the given intrinsic dimensions once it has
* been fitted into the container - i.e. scaled (up or down) so that it touches
* the container on its constraining axis, with the other axis letter/pillar
* boxed. Mirrors what `object-fit: contain` on a full-size element does.
*/
export function fittedVideoSize(
intrinsicWidth: number,
intrinsicHeight: number,
containerWidth: number,
containerHeight: number,
): FittedSize {
if (intrinsicWidth <= 0 || intrinsicHeight <= 0) {
return { width: 0, height: 0 };
}
const scale = Math.min(
containerWidth / intrinsicWidth,
containerHeight / intrinsicHeight,
);
return {
width: intrinsicWidth * scale,
height: intrinsicHeight * scale,
};
}
@@ -53,7 +53,7 @@
<MediaCard
{item}
size="medium"
showProgress={group.id !== "artists"}
showProgress={group.id !== "artists" && group.id !== "people"}
onclick={() => onItemClick?.(item)}
/>
{/each}
@@ -95,6 +95,63 @@ describe("Html5PlayerAdapter", () => {
expect(video.play).toHaveBeenCalledTimes(1);
});
// A stalling HLS stream makes hls.js' gap-controller nudge the element, which
// aborts an in-flight play(). That AbortError is transient — the element is
// still trying to play — so it must not be surfaced as a player error, or the
// UI reports failure ~once a second for the whole stall.
it("play() does not report an interrupted-by-pause AbortError as an error", async () => {
const abort = new DOMException(
"The play() request was interrupted by a call to pause().",
"AbortError"
);
video.play = vi.fn(async () => {
throw abort;
});
await adapter.play();
expect(host.onError).not.toHaveBeenCalled();
});
it("play() still reports a genuine failure", async () => {
video.play = vi.fn(async () => {
throw new DOMException("no supported source", "NotSupportedError");
});
await adapter.play();
expect(host.onError).toHaveBeenCalledTimes(1);
expect(String((host.onError as any).mock.calls[0][0])).toContain("play() failed");
});
it("play() coalesces concurrent attempts into one element.play() call", async () => {
// During a stall the UI and recovery paths can both ask to play. Stacking
// element.play() calls is what generates the AbortError storm.
let resolvePlay: () => void = () => {};
video.play = vi.fn(
() =>
new Promise<void>((r) => {
resolvePlay = () => {
video.paused = false;
r();
};
})
);
const first = adapter.play();
const second = adapter.play();
resolvePlay();
await Promise.all([first, second]);
expect(video.play).toHaveBeenCalledTimes(1);
});
it("play() works again after a previous attempt settled", async () => {
await adapter.play();
await adapter.play();
expect(video.play).toHaveBeenCalledTimes(2);
});
it("pause() calls element.pause()", async () => {
video.paused = false;
await adapter.pause();
+34 -1
View File
@@ -16,7 +16,7 @@
* intents flowing through the PlayerAdapter interface while preserving the
* hard-won element behavior verbatim.
*
* TRACES: UR-003, UR-005, UR-020, UR-021 | DR-001, DR-023, DR-024, DR-028
* TRACES: UR-003, UR-005, UR-020, UR-021 | DR-001, DR-023, DR-024, DR-028, DR-096
*/
import type { AdapterHost, PlayerAdapter, PlayerLoadOptions } from "./types";
@@ -41,10 +41,24 @@ export interface Html5ElementBridge {
getMediaSourceId(): string | null;
}
/**
* True for the `AbortError` the browser raises when a pending `play()` promise is
* cancelled by a `pause()` (or a source/seek change). It signals "that specific
* play attempt was superseded", not "playback failed" hls.js' stall recovery
* produces it routinely, so it must not reach the player's error channel.
*/
function isPlayInterruptedError(err: unknown): boolean {
if (!err || typeof err !== "object") return false;
const { name, message } = err as { name?: string; message?: string };
return name === "AbortError" || (message ?? "").includes("interrupted");
}
export class Html5PlayerAdapter implements PlayerAdapter {
readonly kind = "html5" as const;
private attachedElement: HTMLVideoElement | null = null;
/** In-flight play() attempt, so concurrent callers share one element.play(). */
private pendingPlay: Promise<void> | null = null;
private host: AdapterHost;
private bridge: Html5ElementBridge;
@@ -81,12 +95,31 @@ export class Html5PlayerAdapter implements PlayerAdapter {
async play(): Promise<void> {
const el = this.element;
if (!el) return;
// Coalesce concurrent attempts. While an HLS stream stalls, the UI and the
// gap-controller recovery path can both ask to play; stacking element.play()
// calls is what turns one stall into an AbortError storm.
if (this.pendingPlay) return this.pendingPlay;
this.pendingPlay = (async () => {
try {
await el.play();
// handlePlay on the element reports "playing"; no double-report here.
} catch (err) {
// A play() aborted by a pause() is transient, not a failure: hls.js
// nudges the element to recover from a stall, which cancels the pending
// play promise while the element keeps trying. Surfacing it would report
// an error roughly once a second for the duration of the stall.
if (isPlayInterruptedError(err)) {
console.debug("[Html5PlayerAdapter] play() interrupted by pause (stall recovery)");
} else {
this.host.onError(`play() failed: ${err}`);
}
} finally {
this.pendingPlay = null;
}
})();
return this.pendingPlay;
}
async pause(): Promise<void> {
+13 -5
View File
@@ -7,11 +7,19 @@
* hls.js. State reporting is unnecessary here because the native backend emits
* events directly the adapter's job is only to forward control intents.
*
* NOTE: On current Tauri, native Android video rendering is blocked upstream
* (transparent webview / SurfaceView compositing tauri#10152), so video on
* Android currently runs through the HTML5 adapter via the interim override in
* the factory. This adapter exists for the audio/native path and for when that
* upstream limitation is resolved.
* NOTE: This adapter is currently unreachable `createAdapter()` hardcodes the
* HTML5 kind, so Android video runs through Html5PlayerAdapter.
*
* That override was introduced citing tauri#10152 as an upstream blocker. That
* is no longer accurate: #10152 is a stale *feature request* (dead since
* 2024-07-01) asking that `transparent` not be desktop-only, and the capability
* shipped in tauri commit 27d01834 (2024-09-02). The related black/white-screen
* bug (tauri#8381, #9408) was a broken JNI signature for setBackgroundColor,
* fixed in wry 0.39.4; we ship wry 0.55.x.
*
* What is genuinely unproven is SurfaceView-behind-WebView *compositing* on
* Tauri Android nothing upstream blocks it, and nothing upstream demonstrates
* it either. docs/specs/android-native-video-spike.md tracks that experiment.
*
* TRACES: UR-003, UR-005 | DR-004, DR-028
*/
+15 -4
View File
@@ -12,7 +12,7 @@
* derived + merged (remote-session-aware) stores so UI can import state and
* actions from one place, in both local and remote modes.
*
* TRACES: UR-005 | DR-001, DR-009
* TRACES: UR-005 | DR-001, DR-009, DR-097 | UT-091
*/
import { get } from "svelte/store";
@@ -83,18 +83,29 @@ function requireHandle(): string {
// Transport controls (no repository handle required)
// ---------------------------------------------------------------------------
// Transport intents ALWAYS go to the backend, in both native and HTML5 modes.
//
// These used to short-circuit into the active video adapter, which made the
// webview the decider: `adapter.toggle()` read `el.paused` off the DOM and
// flipped the element, so Rust never saw the intent. `el.paused` flips
// transiently while an element buffers or settles a seek, so two intents
// ~150ms apart could read different values and take opposing actions — a
// self-sustaining play/pause loop.
//
// Now Rust decides from PlayerController state and drives the element back
// through a `ControlCommand` event (handled in playerEvents.ts), the same
// "backend decides, adapter executes the primitive" split used by
// player_seek_video. Do NOT reintroduce an adapter short-circuit here.
async function play() {
if (activeAdapter) return void (await activeAdapter.play());
await commands.playerPlay();
}
async function pause() {
if (activeAdapter) return void (await activeAdapter.pause());
await commands.playerPause();
}
async function toggle() {
if (activeAdapter) return void (await activeAdapter.toggle());
await commands.playerToggle();
}
+113
View File
@@ -0,0 +1,113 @@
/**
* Transport authority: play/pause/toggle are DECIDED in Rust, never in the webview.
*
* TRACES: UR-005 | DR-097 | UT-091
*
* The frontend used to short-circuit transport controls whenever a video adapter
* was registered: `toggle()` read `el.paused` off the DOM and flipped the element
* directly, so the Rust `PlayerController` never saw the intent and could not
* serialise competing ones. Because `el.paused` flips transiently while an HTML5
* element buffers or settles a seek, two intents arriving ~150ms apart could read
* *different* values and perform *opposing* actions one playing, one pausing
* which is the self-sustaining play/pause loop observed on Android.
*
* The rule these tests pin: a transport intent always reaches the backend. Rust
* decides play-vs-pause from controller state and drives the webview element back
* through a ControlCommand event (the same "backend decides, adapter executes"
* split `player_seek_video` already uses).
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
const mockCommands = {
playerPlay: vi.fn(async () => ({})),
playerPause: vi.fn(async () => ({})),
playerToggle: vi.fn(async () => ({})),
playerStop: vi.fn(async () => ({})),
};
vi.mock("$lib/api/bindings", () => ({
commands: mockCommands,
// Stores pulled in transitively subscribe to typed events at module load.
events: {
playerStatusEvent: { listen: vi.fn(async () => () => {}) },
downloadEvent: { listen: vi.fn(async () => () => {}) },
searchEvent: { listen: vi.fn(async () => () => {}) },
},
}));
vi.mock("$lib/stores/auth", () => ({
auth: {
subscribe: (fn: (v: unknown) => void) => {
fn({ isAuthenticated: true });
return () => {};
},
getRepository: () => ({ getHandle: () => "handle-1" }),
},
}));
/** A video adapter that records whether the facade reached into it directly. */
function makeAdapter() {
return {
kind: "html5" as const,
play: vi.fn(async () => {}),
pause: vi.fn(async () => {}),
toggle: vi.fn(async () => true),
seekElement: vi.fn(async () => {}),
reloadSource: vi.fn(async () => {}),
attach: vi.fn(),
dispose: vi.fn(async () => {}),
setVolume: vi.fn(),
setMuted: vi.fn(),
selectSubtitle: vi.fn(async () => {}),
getPosition: vi.fn(() => 0),
load: vi.fn(async () => {}),
};
}
describe("transport authority lives in Rust", () => {
let playerController: any;
let adapter: ReturnType<typeof makeAdapter>;
beforeEach(async () => {
vi.clearAllMocks();
vi.resetModules();
({ playerController } = await import("./index"));
adapter = makeAdapter();
playerController.setActiveAdapter(adapter);
});
it("routes toggle to the backend even when a video adapter is active", async () => {
await playerController.toggle();
expect(mockCommands.playerToggle).toHaveBeenCalledTimes(1);
// The webview must NOT decide play-vs-pause from the DOM.
expect(adapter.toggle).not.toHaveBeenCalled();
});
it("routes play to the backend even when a video adapter is active", async () => {
await playerController.play();
expect(mockCommands.playerPlay).toHaveBeenCalledTimes(1);
expect(adapter.play).not.toHaveBeenCalled();
});
it("routes pause to the backend even when a video adapter is active", async () => {
await playerController.pause();
expect(mockCommands.playerPause).toHaveBeenCalledTimes(1);
expect(adapter.pause).not.toHaveBeenCalled();
});
it("still routes transport to the backend with no adapter (audio path unchanged)", async () => {
playerController.clearActiveAdapter();
await playerController.toggle();
await playerController.play();
await playerController.pause();
expect(mockCommands.playerToggle).toHaveBeenCalledTimes(1);
expect(mockCommands.playerPlay).toHaveBeenCalledTimes(1);
expect(mockCommands.playerPause).toHaveBeenCalledTimes(1);
});
});
+10 -4
View File
@@ -5,7 +5,7 @@
* frontend stores accordingly. This enables push-based updates instead
* of polling.
*
* TRACES: UR-005, UR-019, UR-023, UR-026 | DR-001, DR-028, DR-047
* TRACES: UR-005, UR-019, UR-023, UR-026 | DR-001, DR-028, DR-047, DR-097
*/
import { type UnlistenFn } from "@tauri-apps/api/event";
@@ -306,9 +306,15 @@ function handleSleepTimerChanged(mode: SleepTimerMode, remainingSeconds: number)
/**
* Route a backend-originated control command to the active player adapter, so a
* backend intent (lockscreen/remote/sleep) can drive the webview <video> element
* that Rust cannot reach directly. No-op when no video adapter is active (audio
* playback is already fully backend-driven).
* backend intent can drive the webview <video>/<audio> element that Rust cannot
* reach directly. No-op when no adapter is active (native playback is already
* fully backend-driven).
*
* This is the EXECUTION half of transport authority: for webview-rendered media
* the Rust controller decides play-vs-pause from the state the element reported
* and emits it here as a ControlCommand. UI intents go *to* the backend (see the
* facade in $lib/player) and come back through this path never short-circuited
* in the webview, which is what caused the DR-097 pause loop.
*/
function handleControlCommand(action: string, position: number | null): void {
const adapter = playerController.getActiveAdapter();
+88
View File
@@ -0,0 +1,88 @@
/**
* Skip-to-next-episode reporting tests.
*
* Regression: pressing "skip to next episode" left the outgoing episode with a
* mid-episode resume position, so it showed a partial progress bar and offered
* to resume. A manual skip means the user is done with that episode it must
* be recorded as fully watched.
*
* TRACES: UR-059, UR-025 | DR-088
*/
import { describe, it, expect, beforeEach, vi } from "vitest";
const markAsPlayed = vi.fn(async (_itemId: string) => undefined);
const reportPlaybackStopped = vi.fn(
async (_itemId: string, _positionSeconds: number) => undefined
);
vi.mock("./playbackReporting", () => ({
markAsPlayed: (itemId: string) => markAsPlayed(itemId),
reportPlaybackStopped: (itemId: string, positionSeconds: number) =>
reportPlaybackStopped(itemId, positionSeconds),
}));
import {
shouldSuppressStopReport,
markSkipped,
reportSkippedEpisode,
resetSkipState,
} from "./skipReporting";
describe("skip reporting", () => {
beforeEach(() => {
vi.clearAllMocks();
resetSkipState();
});
describe("reportSkippedEpisode", () => {
it("marks the skipped episode as fully played", async () => {
await reportSkippedEpisode("ep-1");
expect(markAsPlayed).toHaveBeenCalledWith("ep-1");
});
it("does not stamp the mid-episode position as a resume point", async () => {
await reportSkippedEpisode("ep-1");
expect(reportPlaybackStopped).not.toHaveBeenCalled();
});
it("ignores a null item id", async () => {
await reportSkippedEpisode(null);
expect(markAsPlayed).not.toHaveBeenCalled();
});
});
describe("shouldSuppressStopReport", () => {
it("suppresses the unmount stop report for the skipped episode", async () => {
await reportSkippedEpisode("ep-1");
// VideoPlayer.onDestroy fires after navigation with the mid-episode time.
expect(shouldSuppressStopReport("ep-1")).toBe(true);
});
it("only suppresses the episode that was actually skipped", async () => {
await reportSkippedEpisode("ep-1");
expect(shouldSuppressStopReport("ep-2")).toBe(false);
});
it("suppresses only once, so a later real stop still reports", async () => {
await reportSkippedEpisode("ep-1");
expect(shouldSuppressStopReport("ep-1")).toBe(true);
expect(shouldSuppressStopReport("ep-1")).toBe(false);
});
it("does not suppress when nothing was skipped", () => {
expect(shouldSuppressStopReport("ep-1")).toBe(false);
});
it("does not suppress a null item id", () => {
markSkipped("ep-1");
expect(shouldSuppressStopReport(null)).toBe(false);
});
});
});
+64
View File
@@ -0,0 +1,64 @@
// Skip-to-next-episode reporting.
//
// Skipping an episode is a "done with it" signal, not a "stopped here" one:
// the user is moving on because they've already seen it. So a manual skip
// records the outgoing episode as fully played rather than saving the
// mid-episode position as a resume point.
//
// The suppression handshake exists because VideoPlayer.onDestroy fires its
// final reportStop *after* the skip navigation, with the mid-episode time. If
// that landed, it would overwrite the just-written 100% progress and the
// episode would look partially watched again. markSkipped() arms a one-shot
// suppression that the stop handler consumes.
//
// TRACES: UR-059, UR-025 | DR-088
import { markAsPlayed, reportPlaybackStopped } from "./playbackReporting";
/** Item id whose next stop report should be dropped, if any. */
let suppressedItemId: string | null = null;
/**
* Arm suppression of the next stop report for `itemId`.
*
* Exported separately from `reportSkippedEpisode` so callers that already
* handled their own reporting can still silence the unmount stop.
*/
export function markSkipped(itemId: string | null): void {
if (!itemId) return;
suppressedItemId = itemId;
}
/**
* Should the pending stop report for `itemId` be dropped?
*
* One-shot: consumes the armed suppression, so a later genuine stop on the
* same episode still reports its position normally.
*/
export function shouldSuppressStopReport(itemId: string | null): boolean {
if (!itemId) return false;
if (suppressedItemId !== itemId) return false;
suppressedItemId = null;
return true;
}
/**
* Record a manually skipped episode as fully watched.
*
* Deliberately does NOT call `reportPlaybackStopped` that would write the
* partial position we are trying to avoid.
*/
export async function reportSkippedEpisode(itemId: string | null): Promise<void> {
if (!itemId) return;
markSkipped(itemId);
await markAsPlayed(itemId);
}
/** Test hook: clear armed suppression between cases. */
export function resetSkipState(): void {
suppressedItemId = null;
}
// Re-exported so the module owns the full skip story; callers that need the
// normal stop path keep importing it from playbackReporting directly.
export { reportPlaybackStopped };
@@ -0,0 +1,124 @@
/**
* Continue Watching stale-entry suppression tests.
*
* A partially-watched episode should drop off Continue Watching once the user
* has moved past it i.e. when Next Up for that series points at a *later*
* episode. Otherwise skipping an episode leaves it lingering as a resume
* suggestion behind the episode the user is actually on.
*
* TRACES: UR-059 | DR-089
*/
import { describe, it, expect } from "vitest";
import type { MediaItem } from "$lib/api/types";
import { filterSupersededResumeItems } from "./continueWatchingFilter";
function episode(
id: string,
seriesId: string,
season: number | undefined,
index: number | undefined
): MediaItem {
return {
id,
name: `Episode ${index}`,
kind: "episode",
seriesId,
parentIndexNumber: season,
indexNumber: index,
} as MediaItem;
}
function movie(id: string): MediaItem {
return { id, name: "A Movie", kind: "movie" } as MediaItem;
}
describe("filterSupersededResumeItems", () => {
it("drops a partially-watched episode when next up is later in the same season", () => {
const resume = [episode("s1e2", "series-a", 1, 2)];
const nextUp = [episode("s1e5", "series-a", 1, 5)];
const result = filterSupersededResumeItems(resume, nextUp);
expect(result).toEqual([]);
});
it("drops it when next up is in a later season", () => {
const resume = [episode("s1e9", "series-a", 1, 9)];
const nextUp = [episode("s2e1", "series-a", 2, 1)];
expect(filterSupersededResumeItems(resume, nextUp)).toEqual([]);
});
it("keeps the episode the user is actually mid-way through", () => {
const resume = [episode("s1e4", "series-a", 1, 4)];
const nextUp = [episode("s1e4", "series-a", 1, 4)];
expect(filterSupersededResumeItems(resume, nextUp)).toHaveLength(1);
});
it("keeps an episode ahead of next up (user jumped forward)", () => {
const resume = [episode("s1e7", "series-a", 1, 7)];
const nextUp = [episode("s1e3", "series-a", 1, 3)];
expect(filterSupersededResumeItems(resume, nextUp)).toHaveLength(1);
});
it("only compares within the same series", () => {
const resume = [episode("a-s1e2", "series-a", 1, 2)];
const nextUp = [episode("b-s1e9", "series-b", 1, 9)];
expect(filterSupersededResumeItems(resume, nextUp)).toHaveLength(1);
});
it("never suppresses movies", () => {
const resume = [movie("movie-1")];
const nextUp = [episode("s1e5", "series-a", 1, 5)];
expect(filterSupersededResumeItems(resume, nextUp)).toHaveLength(1);
});
it("keeps items when ordering is unknown on either side", () => {
const resume = [episode("s1e2", "series-a", undefined, undefined)];
const nextUp = [episode("s1e5", "series-a", 1, 5)];
expect(filterSupersededResumeItems(resume, nextUp)).toHaveLength(1);
});
it("treats a missing season number as season 1 only when both sides agree", () => {
// Flat series (no season folders): episode numbers alone must still order.
const resume = [episode("e2", "series-a", undefined, 2)];
const nextUp = [episode("e6", "series-a", undefined, 6)];
expect(filterSupersededResumeItems(resume, nextUp)).toEqual([]);
});
it("is a no-op when next up is empty", () => {
const resume = [episode("s1e2", "series-a", 1, 2)];
expect(filterSupersededResumeItems(resume, [])).toHaveLength(1);
});
it("preserves the original order of surviving items", () => {
const resume = [
episode("a-s1e2", "series-a", 1, 2),
episode("b-s1e1", "series-b", 1, 1),
episode("c-s1e3", "series-c", 1, 3),
];
const nextUp = [episode("b-s1e4", "series-b", 1, 4)];
const result = filterSupersededResumeItems(resume, nextUp);
expect(result.map(i => i.id)).toEqual(["a-s1e2", "c-s1e3"]);
});
it("uses the furthest-ahead next-up entry for a series", () => {
const resume = [episode("s1e2", "series-a", 1, 2)];
const nextUp = [
episode("s1e1", "series-a", 1, 1),
episode("s1e8", "series-a", 1, 8),
];
expect(filterSupersededResumeItems(resume, nextUp)).toEqual([]);
});
});
+75
View File
@@ -0,0 +1,75 @@
// Continue Watching stale-entry suppression.
//
// Continue Watching is built from raw resume positions, so an episode the user
// has moved past keeps showing up as a resume suggestion — most visibly after
// skipping an episode, which leaves a partial position behind. Next Up already
// tells us where the user actually is in each series, so an in-progress episode
// that sits *behind* its series' Next Up entry is stale and gets suppressed.
//
// This is presentation-layer de-duplication over two lists the frontend already
// holds — no Jellyfin taxonomy involved, so it stays in `src/`.
//
// TRACES: UR-059 | DR-089
import type { MediaItem } from "$lib/api/types";
/**
* Position of an episode within its series, as (season, episode).
*
* Returns null when the episode number is unknown without it there is no
* defensible ordering and we must not suppress anything. A missing *season*
* number is normal for flat series (no season folders), so it is only usable
* when both sides are equally season-less; callers compare via `isAheadOf`.
*/
function episodeOrder(item: MediaItem): { season: number | null; index: number } | null {
if (item.indexNumber == null) return null;
return { season: item.parentIndexNumber ?? null, index: item.indexNumber };
}
/** Is `a` strictly later in series order than `b`? */
function isAheadOf(a: MediaItem, b: MediaItem): boolean {
const oa = episodeOrder(a);
const ob = episodeOrder(b);
if (!oa || !ob) return false;
// Mixed season-numbering (one side foldered, the other flat) is not safely
// comparable — leave the entry alone rather than hide something wrongly.
if ((oa.season == null) !== (ob.season == null)) return false;
if (oa.season != null && ob.season != null && oa.season !== ob.season) {
return oa.season > ob.season;
}
return oa.index > ob.index;
}
/**
* Drop resume entries the user has already moved past.
*
* An episode is suppressed when its series has a Next Up entry strictly later
* in series order. Movies, items without a series, and anything whose ordering
* is unknown are always kept suppression must never hide something the user
* genuinely still wants to resume.
*/
export function filterSupersededResumeItems(
resumeItems: MediaItem[],
nextUpItems: MediaItem[]
): MediaItem[] {
if (nextUpItems.length === 0) return resumeItems;
// Furthest-ahead Next Up entry per series: Next Up can carry more than one
// entry for a series, and the latest is the true watch frontier.
const frontier = new Map<string, MediaItem>();
for (const item of nextUpItems) {
if (!item.seriesId) continue;
const current = frontier.get(item.seriesId);
if (!current || isAheadOf(item, current)) {
frontier.set(item.seriesId, item);
}
}
return resumeItems.filter(item => {
if (item.kind !== "episode" || !item.seriesId) return true;
const ahead = frontier.get(item.seriesId);
if (!ahead) return true;
return !isAheadOf(ahead, item);
});
}
+7 -2
View File
@@ -1,8 +1,9 @@
// Home screen data store - featured items, continue watching, recently added
// TRACES: UR-023, UR-024, UR-034 | DR-026, DR-027, DR-038, DR-039
// TRACES: UR-023, UR-024, UR-034, UR-059 | DR-026, DR-027, DR-038, DR-039, DR-089
import { writable, derived } from "svelte/store";
import type { MediaItem } from "$lib/api/types";
import { auth } from "./auth";
import { filterSupersededResumeItems } from "./continueWatchingFilter";
interface HomeState {
heroItems: MediaItem[];
@@ -50,8 +51,12 @@ function createHomeStore() {
const valueOr = <T>(i: number, fallback: T): T =>
settled[i].status === "fulfilled" ? (settled[i] as PromiseFulfilledResult<T>).value : fallback;
const resume = valueOr(0, [] as typeof initialState.resumeItems);
const rawResume = valueOr(0, [] as typeof initialState.resumeItems);
const nextUp = valueOr(1, [] as typeof initialState.nextUpItems);
// Drop episodes the user has already moved past (their series' Next Up
// points further ahead) so Continue Watching isn't cluttered with stale
// partial positions left behind by skipping.
const resume = filterSupersededResumeItems(rawResume, nextUp);
const latest = valueOr(2, [] as typeof initialState.latestItems);
const recentAudio = valueOr(3, [] as typeof initialState.recentlyPlayedAudio);
const resumeMovies = valueOr(4, [] as typeof initialState.resumeMovies);
+10 -10
View File
@@ -5,7 +5,7 @@ import { writable, derived } from "svelte/store";
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
import type { Library, MediaItem, SearchResult, Genre } from "$lib/api/types";
import type { SearchOptions } from "$lib/api/bindings";
import { scopeItemTypes, type SearchScope } from "$lib/utils/searchScope";
import type { SearchScope } from "$lib/utils/searchScope";
import { auth } from "./auth";
/**
@@ -227,12 +227,13 @@ function createLibraryStore() {
/**
* Search the library, optionally narrowed to a scope.
*
* `scope` is additive and defaults to `all`, which sends no
* `includeItemTypes` at all see scopeItemTypes() for why that differs from
* listing every type. Both the online and offline repository paths already
* honour the filter.
* The scope is sent **opaque**; Rust expands it into Jellyfin item types
* (`SearchScope::item_types()`) before the cache and server paths diverge, so
* online and offline results filter identically. `all` resolves to no filter
* at all not the union of the other scopes, which would drop People and
* folders.
*
* TRACES: UR-049 | DR-065
* TRACES: UR-049 | DR-063, DR-065
*/
async function search(query: string, scope: SearchScope = "all") {
// Bump the request id for every call (including clears) so any in-flight
@@ -259,10 +260,9 @@ function createLibraryStore() {
// Phase 1: the command resolves with instant local-cache results. The
// merged (cache + server) union arrives later via the `search-event`
// listener above, tagged with this same requestId.
const itemTypes = scopeItemTypes(scope);
const options: SearchOptions = { limit: 10000 };
// Omit the key entirely for the `all` scope rather than sending null.
if (itemTypes) options.includeItemTypes = itemTypes;
// Send the opaque scope; Rust expands it to item types. The frontend
// never names a Jellyfin item type in connection with search.
const options: SearchOptions = { limit: 10000, scope };
const result = await Promise.race([
repo.search(query, options, requestId),
+20 -12
View File
@@ -32,35 +32,43 @@ describe("library.search scoping", () => {
library.clearSearch();
});
it("omits includeItemTypes entirely for the default (all) scope", async () => {
// The frontend sends the OPAQUE scope and never names a Jellyfin item type.
// Expansion (music → MusicAlbum/MusicArtist/Audio/Playlist) is asserted in
// Rust — see `search_scope_tests` in src-tauri/src/repository/types.rs.
// Asserting item types here would mean the frontend knows the taxonomy again,
// which is the leak docs/specs/scoped-search-boundary.md exists to prevent.
it("sends the default (all) scope and never an item-type list", async () => {
await library.search("office");
const options = searchMock.mock.calls[0][1];
expect(options.scope).toBe("all");
expect(options).not.toHaveProperty("includeItemTypes");
expect(options.limit).toBe(10000);
});
it("forwards music item types when scoped to music", async () => {
it("sends the opaque scope when scoped to music", async () => {
await library.search("office", "music");
expect(searchMock.mock.calls[0][1].includeItemTypes).toEqual([
"MusicAlbum",
"MusicArtist",
"Audio",
"Playlist",
]);
const options = searchMock.mock.calls[0][1];
expect(options.scope).toBe("music");
expect(options).not.toHaveProperty("includeItemTypes");
});
it("forwards tv item types when scoped to tv", async () => {
it("sends the opaque scope when scoped to tv", async () => {
await library.search("office", "tv");
expect(searchMock.mock.calls[0][1].includeItemTypes).toEqual(["Series", "Episode"]);
const options = searchMock.mock.calls[0][1];
expect(options.scope).toBe("tv");
expect(options).not.toHaveProperty("includeItemTypes");
});
it("forwards movie item types when scoped to movies", async () => {
it("sends the opaque scope when scoped to movies", async () => {
await library.search("office", "movies");
expect(searchMock.mock.calls[0][1].includeItemTypes).toEqual(["Movie"]);
const options = searchMock.mock.calls[0][1];
expect(options.scope).toBe("movies");
expect(options).not.toHaveProperty("includeItemTypes");
});
it("stores results and the query on success", async () => {
+43 -26
View File
@@ -42,15 +42,33 @@ describe("searchGroupOrder", () => {
it("loads a stored order", async () => {
localStorage.setItem(
STORAGE_KEY,
JSON.stringify(["tvShows", "movies", "songs", "albums", "artists"])
JSON.stringify(["episodes", "shows", "movies", "songs", "albums", "artists", "people"])
);
const { searchGroupOrder } = await import("./searchGroupOrder");
expect(get(searchGroupOrder)).toEqual([
"tvShows",
"episodes",
"shows",
"movies",
"songs",
"albums",
"artists",
"people",
]);
});
it("migrates a stored `tvShows` from before the group split", async () => {
// Upgrading must keep the user's placement of TV, not append the two new
// groups at the bottom.
localStorage.setItem(STORAGE_KEY, JSON.stringify(["tvShows", "movies"]));
const { searchGroupOrder } = await import("./searchGroupOrder");
expect(get(searchGroupOrder)).toEqual([
"shows",
"episodes",
"movies",
"songs",
"albums",
"artists",
"people",
]);
});
@@ -59,10 +77,12 @@ describe("searchGroupOrder", () => {
const { searchGroupOrder } = await import("./searchGroupOrder");
expect(get(searchGroupOrder)).toEqual([
"movies",
"shows",
"episodes",
"songs",
"albums",
"artists",
"tvShows",
"people",
]);
});
@@ -74,50 +94,45 @@ describe("searchGroupOrder", () => {
it("persists a move so the order survives a restart", async () => {
const { searchGroupOrder } = await import("./searchGroupOrder");
// Default is shows, episodes, movies, songs, … — move movies up one.
searchGroupOrder.move("movies", -1);
expect(get(searchGroupOrder)).toEqual([
const expected = [
"shows",
"movies",
"episodes",
"songs",
"albums",
"movies",
"artists",
"tvShows",
]);
expect(JSON.parse(localStorage.getItem(STORAGE_KEY)!)).toEqual([
"songs",
"albums",
"movies",
"artists",
"tvShows",
]);
"people",
];
expect(get(searchGroupOrder)).toEqual(expected);
expect(JSON.parse(localStorage.getItem(STORAGE_KEY)!)).toEqual(expected);
// Simulate a fresh app start reading the same storage.
vi.resetModules();
const reloaded = await import("./searchGroupOrder");
expect(get(reloaded.searchGroupOrder)).toEqual([
"songs",
"albums",
"movies",
"artists",
"tvShows",
]);
expect(get(reloaded.searchGroupOrder)).toEqual(expected);
});
it("persists a drag reorder", async () => {
const { searchGroupOrder } = await import("./searchGroupOrder");
// Drag "albums" (index 4) to the front.
searchGroupOrder.reorder(4, 0);
expect(get(searchGroupOrder)).toEqual([
"tvShows",
"songs",
"albums",
"artists",
"shows",
"episodes",
"movies",
"songs",
"artists",
"people",
]);
});
it("resets to the shipped default", async () => {
const { searchGroupOrder } = await import("./searchGroupOrder");
searchGroupOrder.move("tvShows", -1);
searchGroupOrder.move("movies", -1);
searchGroupOrder.reset();
expect(get(searchGroupOrder)).toEqual([...DEFAULT_GROUP_ORDER]);
});
@@ -127,10 +142,12 @@ describe("searchGroupOrder", () => {
searchGroupOrder.set(["movies", "podcasts"] as never);
expect(get(searchGroupOrder)).toEqual([
"movies",
"shows",
"episodes",
"songs",
"albums",
"artists",
"tvShows",
"people",
]);
});
});
+8 -2
View File
@@ -1,10 +1,11 @@
// TV library landing page data store.
// Powers the focused TV landing: hero + horizontal sliders.
// TRACES: UR-007, UR-023, UR-034 | DR-007, DR-038, DR-039
// TRACES: UR-007, UR-023, UR-034, UR-059 | DR-007, DR-038, DR-039, DR-089
import { writable, derived } from "svelte/store";
import type { MediaItem } from "$lib/api/types";
import { auth } from "./auth";
import { buildHeroMix } from "$lib/utils/heroMix";
import { filterSupersededResumeItems } from "./continueWatchingFilter";
/** A single "by genre" row: the genre name plus the series in it. */
export interface GenreRow {
@@ -81,7 +82,12 @@ function createTvStore() {
// Resume items are already video-only from the server, but keep episodes
// (and the occasional movie that lives in a mixed library) defensively.
const continueWatching = resume.filter(i => i.kind === "episode" || i.kind === "movie");
// Then drop episodes the user has moved past — a stale partial position
// behind the series' Next Up entry isn't something to continue.
const continueWatching = filterSupersededResumeItems(
resume.filter(i => i.kind === "episode" || i.kind === "movie"),
nextUp
);
// Mix the hero: in-progress episodes first (most personal), then next-up,
// recent additions, and random series from across the library.
+68
View File
@@ -0,0 +1,68 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { setBackgroundAudioEnabled } from "./backgroundAudio";
/**
* Bridge-reporting contract for the background-audio toggle.
*
* TRACES: UR-040 | IR-025, DR-051 | UT-062
*
* Regression guard for the "screen lock kills video audio" bug: MainActivity
* re-ran configureWebViewForMedia() on every onResume, re-calling
* addJavascriptInterface over a live page. WebView then served a stale proxy
* `window.AndroidBackgroundAudio` stayed truthy but its methods were gone, so
* `setEnabled` threw `TypeError: e.setEnabled is not a function`.
*
* The old implementation swallowed that with `bridge()?.setEnabled(...)` inside
* a try/catch returning void, so the UI showed "armed" while native never got
* the flag and onStop's `if (backgroundAudioEnabled)` guard never dispatched
* `jellytau-background`. Audio died the instant the screen locked.
*
* setBackgroundAudioEnabled must therefore REPORT whether native was actually
* reached, so a dead bridge can never masquerade as an armed toggle.
*/
describe("setBackgroundAudioEnabled", () => {
beforeEach(() => {
delete (window as unknown as Record<string, unknown>).AndroidBackgroundAudio;
vi.restoreAllMocks();
});
it("reports success when the bridge is present and the call lands", () => {
const setEnabled = vi.fn();
window.AndroidBackgroundAudio = { setEnabled };
expect(setBackgroundAudioEnabled(true)).toBe(true);
expect(setEnabled).toHaveBeenCalledWith(true);
});
it("reports failure when the bridge object is absent entirely", () => {
expect(setBackgroundAudioEnabled(true)).toBe(false);
});
it("reports failure for a stale proxy whose methods are gone", () => {
// The exact shape of the bug: object present (so `?.` passes) but the
// method is missing after re-injection over a live page.
window.AndroidBackgroundAudio = {} as unknown as typeof window.AndroidBackgroundAudio;
expect(setBackgroundAudioEnabled(true)).toBe(false);
});
it("reports failure when the bridge method throws", () => {
window.AndroidBackgroundAudio = {
setEnabled: () => {
throw new TypeError("e.setEnabled is not a function");
},
};
expect(setBackgroundAudioEnabled(true)).toBe(false);
});
it("never throws out to the caller — the toggle must not break the player", () => {
window.AndroidBackgroundAudio = {
setEnabled: () => {
throw new Error("boom");
},
};
expect(() => setBackgroundAudioEnabled(false)).not.toThrow();
});
});
+14 -13
View File
@@ -20,7 +20,6 @@
interface AndroidBackgroundAudioBridge {
setEnabled(enabled: boolean): void;
isSupported(): boolean;
}
declare global {
@@ -34,25 +33,27 @@ function bridge(): AndroidBackgroundAudioBridge | undefined {
return window.AndroidBackgroundAudio;
}
/** Whether background audio is available — used to decide if the toggle renders. */
export function isBackgroundAudioSupported(): boolean {
try {
return bridge()?.isSupported() ?? false;
} catch (err) {
console.warn("[BgAudio] isSupported check failed:", err);
return false;
}
}
/**
* Arm/disarm background-audio mode for the current video. When armed, the native
* side runs the audio handoff on background instead of entering PiP.
*/
export function setBackgroundAudioEnabled(enabled: boolean): void {
export function setBackgroundAudioEnabled(enabled: boolean): boolean {
const b = bridge();
if (!b) {
// The button is gated on platform(), not on this bridge, so it can render
// before/without the bridge existing. Silently no-oping here leaves the UI
// showing "armed" while native never learns — and the handoff then never
// fires on lock. Report it so callers can retry.
console.warn("[BgAudio] setEnabled: bridge missing, native NOT armed");
return false;
}
try {
bridge()?.setEnabled(enabled);
b.setEnabled(enabled);
console.log("[BgAudio] setEnabled ->", enabled);
return true;
} catch (err) {
console.warn("[BgAudio] Failed to set enabled:", err);
return false;
}
}
+160 -46
View File
@@ -7,7 +7,8 @@ import {
normalizeGroupOrder,
reorderGroups,
resolveSearchScope,
scopeItemTypes,
searchRouteUrl,
shouldNavigateToSearch,
type SearchGroupId,
} from "./searchScope";
@@ -60,25 +61,12 @@ describe("resolveSearchScope", () => {
});
});
describe("scopeItemTypes", () => {
it("omits the key entirely for the all scope", () => {
// `all` must send no includeItemTypes — an explicit union would silently
// drop types nobody enumerated (Person, folders).
expect(scopeItemTypes("all")).toBeUndefined();
});
it("maps each narrow scope to its item types", () => {
expect(scopeItemTypes("music")).toEqual(["MusicAlbum", "MusicArtist", "Audio", "Playlist"]);
expect(scopeItemTypes("movies")).toEqual(["Movie"]);
expect(scopeItemTypes("tv")).toEqual(["Series", "Episode"]);
});
it("returns a fresh array callers cannot mutate into the table", () => {
const first = scopeItemTypes("movies")!;
first.push("Series");
expect(scopeItemTypes("movies")).toEqual(["Movie"]);
});
});
// NOTE: the former `scopeItemTypes` suite moved to Rust — see
// `search_scope_tests` in src-tauri/src/repository/types.rs. The scope →
// item-type expansion is domain vocabulary and is no longer reachable from the
// frontend, so testing it here would mean re-introducing the leak to test it.
// The "fresh array" test is gone because `item_types()` returns an owned Vec,
// making the aliasing bug it guarded structurally impossible.
describe("normalizeGroupOrder", () => {
it("returns the default for missing or non-array input", () => {
@@ -92,9 +80,11 @@ describe("normalizeGroupOrder", () => {
expect(normalizeGroupOrder(["movies", "podcasts", "songs"])).toEqual([
"movies",
"songs",
"shows",
"episodes",
"albums",
"artists",
"tvShows",
"people",
]);
});
@@ -103,9 +93,11 @@ describe("normalizeGroupOrder", () => {
expect(normalizeGroupOrder(["movies", "songs"])).toEqual([
"movies",
"songs",
"shows",
"episodes",
"albums",
"artists",
"tvShows",
"people",
]);
});
@@ -113,34 +105,78 @@ describe("normalizeGroupOrder", () => {
expect(normalizeGroupOrder(["songs", "songs", "movies"])).toEqual([
"songs",
"movies",
"shows",
"episodes",
"albums",
"artists",
"tvShows",
"people",
]);
});
it("preserves a complete valid order unchanged", () => {
const order: SearchGroupId[] = ["tvShows", "movies", "artists", "albums", "songs"];
const order: SearchGroupId[] = [
"episodes",
"shows",
"movies",
"artists",
"albums",
"songs",
"people",
];
expect(normalizeGroupOrder(order)).toEqual(order);
});
it("expands a stored `tvShows` into shows + episodes in place", () => {
// Migration: the old combined group split, and a user who put TV first
// must still get TV first rather than appended at the bottom.
expect(normalizeGroupOrder(["tvShows", "movies"])).toEqual([
"shows",
"episodes",
"movies",
"songs",
"albums",
"artists",
"people",
]);
});
});
describe("groupsForScope", () => {
it("returns every group in saved order for the all scope", () => {
expect(groupsForScope("all", ["movies", "songs", "tvShows", "albums", "artists"])).toEqual([
expect(
groupsForScope("all", [
"movies",
"songs",
"tvShows",
"shows",
"episodes",
"albums",
"artists",
]);
"people",
])
).toEqual(["movies", "songs", "shows", "episodes", "albums", "artists", "people"]);
});
it("keeps only in-scope groups, in saved order", () => {
const order: SearchGroupId[] = ["artists", "movies", "albums", "tvShows", "songs"];
const order: SearchGroupId[] = [
"artists",
"movies",
"albums",
"episodes",
"shows",
"songs",
"people",
];
expect(groupsForScope("music", order)).toEqual(["artists", "albums", "songs"]);
expect(groupsForScope("movies", order)).toEqual(["movies"]);
expect(groupsForScope("tv", order)).toEqual(["tvShows"]);
expect(groupsForScope("tv", order)).toEqual(["episodes", "shows"]);
});
it("surfaces people only under the all scope", () => {
// Cast/crew cut across music, film and TV, so no narrow scope claims them.
expect(groupsForScope("all", DEFAULT_GROUP_ORDER)).toContain("people");
expect(groupsForScope("music", DEFAULT_GROUP_ORDER)).not.toContain("people");
expect(groupsForScope("tv", DEFAULT_GROUP_ORDER)).not.toContain("people");
expect(groupsForScope("movies", DEFAULT_GROUP_ORDER)).not.toContain("people");
});
});
@@ -156,13 +192,29 @@ describe("composeSearchGroups", () => {
it("renders groups in the configured order", () => {
const groups = composeSearchGroups(results, "all", [
"tvShows",
"shows",
"episodes",
"movies",
"songs",
"albums",
"artists",
"people",
]);
expect(groups.map((g) => g.id)).toEqual(["tvShows", "movies", "songs", "albums"]);
expect(groups.map((g) => g.id)).toEqual([
"shows",
"episodes",
"movies",
"songs",
"albums",
"people",
]);
});
it("puts shows ahead of episodes by default", () => {
// Searching a show's name should surface the show itself first, not an
// arbitrary episode of it.
const ids = composeSearchGroups(results, "tv", DEFAULT_GROUP_ORDER).map((g) => g.id);
expect(ids).toEqual(["shows", "episodes"]);
});
it("omits empty groups", () => {
@@ -177,27 +229,44 @@ describe("composeSearchGroups", () => {
"albums",
]);
expect(composeSearchGroups(results, "tv", DEFAULT_GROUP_ORDER).map((g) => g.id)).toEqual([
"tvShows",
"shows",
"episodes",
]);
});
it("groups series and episodes together under tvShows", () => {
it("separates series and episodes into their own groups", () => {
const groups = composeSearchGroups(results, "tv", DEFAULT_GROUP_ORDER);
expect(groups[0].items.map((i) => i.id)).toEqual(["4", "5"]);
expect(groups.find((g) => g.id === "shows")?.items.map((i) => i.id)).toEqual(["4"]);
expect(groups.find((g) => g.id === "episodes")?.items.map((i) => i.id)).toEqual(["5"]);
});
it("surfaces people so an actor search reaches their bio", () => {
// Person items were previously returned by the backend and silently dropped.
const all = composeSearchGroups(results, "all", DEFAULT_GROUP_ORDER);
expect(all.find((g) => g.id === "people")?.items.map((i) => i.id)).toEqual(["6"]);
});
it("ignores item types that belong to no group", () => {
const all = composeSearchGroups(results, "all", DEFAULT_GROUP_ORDER);
expect(all.flatMap((g) => g.items).map((i) => i.id)).not.toContain("6");
const withFolder = [...results, { id: "7", type: "CollectionFolder" }];
const all = composeSearchGroups(withFolder, "all", DEFAULT_GROUP_ORDER);
expect(all.flatMap((g) => g.items).map((i) => i.id)).not.toContain("7");
});
it("narrowing then widening restores the full arrangement", () => {
// Scope is a filter over the saved order, never a rewrite of it.
const order: SearchGroupId[] = ["tvShows", "songs", "movies", "albums", "artists"];
const order: SearchGroupId[] = [
"shows",
"songs",
"movies",
"albums",
"artists",
"episodes",
"people",
];
const wide = composeSearchGroups(results, "all", order).map((g) => g.id);
composeSearchGroups(results, "music", order);
expect(composeSearchGroups(results, "all", order).map((g) => g.id)).toEqual(wide);
expect(wide).toEqual(["tvShows", "songs", "movies", "albums"]);
expect(wide).toEqual(["shows", "songs", "movies", "albums", "episodes", "people"]);
});
it("survives a stored order containing an unknown id", () => {
@@ -205,7 +274,14 @@ describe("composeSearchGroups", () => {
"podcasts",
"movies",
] as unknown as SearchGroupId[]);
expect(groups.map((g) => g.id)).toEqual(["movies", "songs", "albums", "tvShows"]);
expect(groups.map((g) => g.id)).toEqual([
"movies",
"shows",
"episodes",
"songs",
"albums",
"people",
]);
});
it("handles items with a missing type", () => {
@@ -219,7 +295,7 @@ describe("composeSearchGroups", () => {
});
describe("moveGroup", () => {
const order: SearchGroupId[] = ["songs", "albums", "artists", "movies", "tvShows"];
const order: SearchGroupId[] = ["songs", "albums", "artists", "movies", "shows"];
it("moves a group up", () => {
expect(moveGroup(order, "artists", -1)).toEqual([
@@ -227,7 +303,7 @@ describe("moveGroup", () => {
"artists",
"albums",
"movies",
"tvShows",
"shows",
]);
});
@@ -237,13 +313,13 @@ describe("moveGroup", () => {
"songs",
"artists",
"movies",
"tvShows",
"shows",
]);
});
it("is a no-op at the boundaries", () => {
expect(moveGroup(order, "songs", -1)).toEqual(order);
expect(moveGroup(order, "tvShows", 1)).toEqual(order);
expect(moveGroup(order, "shows", 1)).toEqual(order);
});
it("is a no-op for an unknown id", () => {
@@ -258,18 +334,18 @@ describe("moveGroup", () => {
});
describe("reorderGroups", () => {
const order: SearchGroupId[] = ["songs", "albums", "artists", "movies", "tvShows"];
const order: SearchGroupId[] = ["songs", "albums", "artists", "movies", "shows"];
it("moves an item from one index to another", () => {
expect(reorderGroups(order, 0, 4)).toEqual([
"albums",
"artists",
"movies",
"tvShows",
"shows",
"songs",
]);
expect(reorderGroups(order, 4, 0)).toEqual([
"tvShows",
"shows",
"songs",
"albums",
"artists",
@@ -283,3 +359,41 @@ describe("reorderGroups", () => {
expect(reorderGroups(order, 0, 9)).toEqual(order);
});
});
describe("searchRouteUrl", () => {
it("encodes the query and the scope", () => {
expect(searchRouteUrl("miles davis", "music")).toBe("/search?q=miles%20davis&scope=music");
});
it("omits the scope key for the default `all` scope", () => {
expect(searchRouteUrl("dune", "all")).toBe("/search?q=dune");
});
it("targets bare /search for an empty query so the page shows its empty state", () => {
expect(searchRouteUrl("", "all")).toBe("/search");
expect(searchRouteUrl(" ", "music")).toBe("/search");
});
});
describe("shouldNavigateToSearch", () => {
it("navigates from any library page, which cannot render results itself", () => {
// The bug: the header search bar shows on every /library/** route but only
// /library rendered $library.searchResults, so typing did nothing on
// /library/music, /library/tv, /library/movies and detail pages.
expect(shouldNavigateToSearch("/library", "jazz")).toBe(true);
expect(shouldNavigateToSearch("/library/music", "jazz")).toBe(true);
expect(shouldNavigateToSearch("/library/tv", "jazz")).toBe(true);
expect(shouldNavigateToSearch("/library/movies", "jazz")).toBe(true);
expect(shouldNavigateToSearch("/library/abc123", "jazz")).toBe(true);
});
it("stays put when already on /search, so typing does not re-push history", () => {
expect(shouldNavigateToSearch("/search", "jazz")).toBe(false);
expect(shouldNavigateToSearch("/search?q=old", "jazz")).toBe(false);
});
it("does not navigate on an empty query", () => {
expect(shouldNavigateToSearch("/library/music", "")).toBe(false);
expect(shouldNavigateToSearch("/library/music", " ")).toBe(false);
});
});
+115 -38
View File
@@ -8,7 +8,11 @@
//
// TRACES: UR-049, UR-050 | DR-063, DR-066, DR-067
export type SearchScope = "all" | "music" | "movies" | "tv";
// Sourced from Rust via the generated bindings — the backend owns what a scope
// *means* (which Jellyfin item types it covers). Naming an opaque variant is
// presentation; knowing its expansion is domain vocabulary and stays in Rust.
export type { SearchScope } from "$lib/api/bindings";
import type { SearchScope } from "$lib/api/bindings";
export const SEARCH_SCOPES: readonly SearchScope[] = ["all", "music", "movies", "tv"];
@@ -19,29 +23,11 @@ export const SCOPE_LABELS: Record<SearchScope, string> = {
tv: "TV",
};
/**
* Jellyfin item types requested for each scope.
*
* `all` is deliberately absent: sending no `includeItemTypes` is *not* the same
* as sending the union of the lists below types nobody enumerated here
* (Person, folders, ) would be filtered out by an explicit list.
*/
const SCOPE_ITEM_TYPES: Record<Exclude<SearchScope, "all">, string[]> = {
music: ["MusicAlbum", "MusicArtist", "Audio", "Playlist"],
movies: ["Movie"],
tv: ["Series", "Episode"],
};
/**
* Item types to send with a scoped search, or `undefined` for the `all` scope
* so the caller omits the key entirely.
*
* TRACES: UR-049 | DR-063
*/
export function scopeItemTypes(scope: SearchScope): string[] | undefined {
if (scope === "all") return undefined;
return [...SCOPE_ITEM_TYPES[scope]];
}
// NOTE: the scope → Jellyfin item-type mapping deliberately does NOT live here.
// It is domain vocabulary and lives in Rust (`SearchScope::item_types()` in
// repository/types.rs); the frontend sends the opaque scope and the backend
// expands it. Re-introducing a `{ music: ["MusicAlbum", …] }` table in this file
// is the boundary leak documented in docs/specs/scoped-search-boundary.md.
/**
* Resolve the scope a search started from a given route should default to.
@@ -62,51 +48,136 @@ export function resolveSearchScope(pathname: string): SearchScope {
return "all";
}
/**
* The URL of the single search surface for a query + scope.
*
* `/search` is the *only* route that renders results, so every other search
* affordance (the desktop header bar) is a navigator to this URL rather than a
* second result renderer. The `all` scope is the page's own default, so it is
* omitted to keep shared/back-navigated URLs clean.
*
* TRACES: UR-049 | DR-063
*/
export function searchRouteUrl(query: string, scope: SearchScope): string {
const trimmed = query.trim();
if (!trimmed) return "/search";
const params = new URLSearchParams({ q: trimmed });
if (scope !== "all") params.set("scope", scope);
// URLSearchParams renders spaces as "+", valid in a query but noisier to
// read; %20 is equally valid and matches how the app builds other links.
return `/search?${params.toString().replace(/\+/g, "%20")}`;
}
/**
* Whether a search typed on `pathname` must navigate to `/search` to be seen.
*
* True for every route except `/search` itself: no other page renders
* `searchResults`, so a search performed there is invisible. Guarding on
* `/search` keeps typing from pushing a history entry per keystroke.
*
* TRACES: UR-049 | DR-063
*/
export function shouldNavigateToSearch(pathname: string, query: string): boolean {
if (!query.trim()) return false;
const path = pathname.split(/[?#]/)[0].replace(/\/+$/, "") || "/";
return path !== "/search";
}
// ---------------------------------------------------------------------------
// Result groups
// ---------------------------------------------------------------------------
export type SearchGroupId = "songs" | "albums" | "artists" | "movies" | "tvShows";
export type SearchGroupId =
| "shows"
| "episodes"
| "movies"
| "songs"
| "albums"
| "artists"
| "people";
/** Shipped default order, per the spec. */
/**
* Shipped default order.
*
* TRACES: UR-060 | DR-091
*
* Containers lead the kinds they contain a show above its episodes, an album
* above nothing (songs are ranked separately) which matches how people search:
* you look for the show, not an arbitrary episode of it. `people` sits last as
* a peripheral match; it exists so searching an actor's name reaches their bio
* page rather than silently dropping the result.
*/
export const DEFAULT_GROUP_ORDER: readonly SearchGroupId[] = [
"shows",
"episodes",
"movies",
"songs",
"albums",
"artists",
"movies",
"tvShows",
"people",
];
export const GROUP_LABELS: Record<SearchGroupId, string> = {
shows: "TV Shows",
episodes: "Episodes",
movies: "Movies",
songs: "Songs",
albums: "Albums",
artists: "Artists",
movies: "Movies",
tvShows: "TV Shows",
people: "People",
};
/** Which scopes each group belongs to (`all` always includes everything). */
const GROUP_SCOPE: Record<SearchGroupId, Exclude<SearchScope, "all">> = {
/**
* Which scopes each group belongs to (`all` always includes everything).
*
* `people` maps to no narrow scope: cast/crew cut across music, film and TV, so
* it surfaces only under All rather than being forced into one of them.
*/
const GROUP_SCOPE: Record<SearchGroupId, Exclude<SearchScope, "all"> | null> = {
shows: "tv",
episodes: "tv",
movies: "movies",
songs: "music",
albums: "music",
artists: "music",
movies: "movies",
tvShows: "tv",
people: null,
};
/** Item types that fall into each group. */
const GROUP_ITEM_TYPES: Record<SearchGroupId, string[]> = {
shows: ["Series"],
episodes: ["Episode"],
movies: ["Movie"],
songs: ["Audio"],
albums: ["MusicAlbum"],
artists: ["MusicArtist"],
movies: ["Movie"],
tvShows: ["Series", "Episode"],
people: ["Person"],
};
export function groupItemTypes(group: SearchGroupId): string[] {
return [...GROUP_ITEM_TYPES[group]];
}
/**
* Stored group ids that no longer exist, mapped to the ids that replaced them.
*
* `tvShows` was one group holding both Series and Episode; it split so a show
* can outrank its own episodes. Expanding in place preserves the position the
* user chose for it.
*
* TRACES: UR-060 | DR-091
*/
const RETIRED_GROUP_IDS: Record<string, SearchGroupId[]> = {
tvShows: ["shows", "episodes"],
};
/** Resolve a stored id to the live id(s) it corresponds to, or none if unknown. */
function migrateGroupId(id: string, known: Set<string>): SearchGroupId[] {
if (known.has(id)) return [id as SearchGroupId];
return RETIRED_GROUP_IDS[id] ?? [];
}
/**
* Normalise a stored order into a usable one.
*
@@ -123,13 +194,17 @@ export function normalizeGroupOrder(stored: unknown): SearchGroupId[] {
if (Array.isArray(stored)) {
for (const id of stored) {
if (typeof id !== "string" || !known.has(id)) continue;
const groupId = id as SearchGroupId;
if (typeof id !== "string") continue;
// Retired ids expand in place rather than being dropped, so a user who
// dragged the old combined "TV Shows" group to the top keeps TV at the
// top instead of having shows/episodes appended to the bottom.
for (const groupId of migrateGroupId(id, known)) {
if (seen.has(groupId)) continue;
seen.add(groupId);
order.push(groupId);
}
}
}
for (const id of DEFAULT_GROUP_ORDER) {
if (!seen.has(id)) order.push(id);
@@ -143,6 +218,8 @@ export function groupsForScope(
scope: SearchScope,
order: readonly SearchGroupId[] = DEFAULT_GROUP_ORDER
): SearchGroupId[] {
// A `null` GROUP_SCOPE (people) belongs to no narrow scope, so it survives
// only under `all` — the `=== scope` test already excludes it elsewhere.
return normalizeGroupOrder(order as SearchGroupId[]).filter(
(id) => scope === "all" || GROUP_SCOPE[id] === scope
);
+20 -14
View File
@@ -6,8 +6,12 @@
import { library } from "$lib/stores/library";
import { useScrollGuard } from "$lib/composables/useScrollGuard";
import Search from "$lib/components/Search.svelte";
import SearchScopeChips from "$lib/components/search/SearchScopeChips.svelte";
import { resolveSearchScope, type SearchScope } from "$lib/utils/searchScope";
import {
resolveSearchScope,
searchRouteUrl,
shouldNavigateToSearch,
type SearchScope,
} from "$lib/utils/searchScope";
import AppHeader from "$lib/components/AppHeader.svelte";
import BottomUi from "$lib/components/BottomUi.svelte";
import SleepTimerModal from "$lib/components/player/SleepTimerModal.svelte";
@@ -48,18 +52,22 @@
}
});
// The header bar is a *navigator*, not a second results surface: /search is
// the only route that renders searchResults, so searching here routes there
// with the query + route-derived scope in the URL. Previously this ran
// library.search() in place, which was invisible on every /library/** page
// except /library itself.
// TRACES: UR-049 | DR-063
async function handleSearch(query: string) {
if (query.trim()) {
await library.search(query, searchScope);
} else {
if (!query.trim()) {
library.clearSearch();
return;
}
}
async function handleScopeChange(next: SearchScope) {
searchScope = next;
if (searchQuery.trim()) {
await library.search(searchQuery, next);
if (shouldNavigateToSearch($page.url.pathname, query)) {
await goto(searchRouteUrl(query, searchScope));
// The query now lives in the URL; clear the header input so returning to
// a library page does not leave a stale term sitting in the box.
searchQuery = "";
}
}
</script>
@@ -74,14 +82,12 @@
<AppHeader search={librarySearch} />
{#snippet librarySearch()}
<!-- Scope chips live on /search, which owns the results. -->
<Search
bind:value={searchQuery}
placeholder="Search your library..."
onSearch={handleSearch}
/>
{#if searchQuery.trim()}
<SearchScopeChips scope={searchScope} onChange={handleScopeChange} />
{/if}
{/snippet}
<!-- Main content. The BottomUi below is an in-flow flex sibling, so this
+4 -24
View File
@@ -12,8 +12,9 @@
// Scroll guard from layout - prevents accidental taps during scrolling (Android)
const scrollGuard = getContext<ReturnType<typeof useScrollGuard>>("scrollGuard");
let searchResults = $derived($library.searchResults);
let searchQuery = $derived($library.searchQuery);
// Search results are rendered exclusively by /search — this page used to
// render them inline, which made the header search bar appear broken on every
// other /library/** route. TRACES: UR-049 | DR-063
const isMusicLibrary = $derived($currentLibrary?.collectionType === "music");
@@ -169,28 +170,7 @@
</script>
<div class="space-y-8">
{#if searchQuery}
<!-- Search results -->
<div>
<div class="flex items-center justify-between mb-4">
<h1 class="text-2xl font-bold text-white">
Search results for "{searchQuery}"
</h1>
<button
onclick={() => library.clearSearch()}
class="text-sm text-gray-400 hover:text-white"
>
Clear search
</button>
</div>
<LibraryGrid
items={searchResults}
loading={$isLibraryLoading}
onItemClick={handleItemClick}
/>
</div>
{:else if showInlineLibraryContent}
{#if showInlineLibraryContent}
<!-- Library content (live TV / channels / other inline-rendered types) -->
<div class="space-y-6">
<div class="flex items-center gap-4">
+28
View File
@@ -147,6 +147,34 @@
// Sort seasons by index number
seasonData.sort((a, b) => (a.season.indexNumber || 0) - (b.season.indexNumber || 0));
// Some series expose episodes directly as children rather than under
// season folders. In that case the season fetch above yields nothing —
// group the flat episode children by their season number so the Episode
// Focus View still has a populated `allEpisodes` (otherwise "More
// Episodes" collapses to just the current episode).
if (seasonData.every((s) => s.episodes.length === 0)) {
const flatEpisodes = $libraryItems.filter((i) => i.kind === "episode");
if (flatEpisodes.length > 0) {
const bySeason = new Map<number, MediaItem[]>();
for (const ep of flatEpisodes) {
const key = ep.parentIndexNumber ?? 1;
(bySeason.get(key) ?? bySeason.set(key, []).get(key)!).push(ep);
}
seasonData = [...bySeason.entries()]
.sort(([a], [b]) => a - b)
.map(([seasonNumber, episodes]) => ({
// Synthesize a minimal season header from the episodes we have.
season: {
...(seasons.find((s) => s.indexNumber === seasonNumber) ?? episodes[0]),
kind: "season",
indexNumber: seasonNumber,
name: `Season ${seasonNumber}`,
} as MediaItem,
episodes: episodes.sort((a, b) => (a.indexNumber || 0) - (b.indexNumber || 0)),
}));
}
}
// If we have a focused episode ID but couldn't find it in the seasons,
// fetch it directly (handles ID mismatch between APIs)
const episodeIdParam = $page.url.searchParams.get("episode");
+14 -1
View File
@@ -20,6 +20,7 @@
reportPlaybackProgress,
reportPlaybackStopped,
} from "$lib/services/playbackReporting";
import { reportSkippedEpisode, shouldSuppressStopReport } from "$lib/services/skipReporting";
import { cleanup as cleanupNextEpisode } from "$lib/services/nextEpisodeService";
import * as html5Adapter from "$lib/player/html5Adapter";
@@ -536,7 +537,11 @@
function handleReportStop(positionSeconds: number, reportId?: string) {
const id = reportId ?? itemId;
if (id) {
// A skipped episode was already recorded as fully watched. Its unmount stop
// report arrives after the skip navigation carrying the mid-episode
// position; letting it through would undo that and restore the partial
// progress bar.
if (id && !shouldSuppressStopReport(id)) {
reportPlaybackStopped(id, positionSeconds);
}
// Intentionally do NOT emit a "stopped" player state here. This runs on both
@@ -592,6 +597,14 @@
function handleSkipToNextEpisode() {
if (nextEpisode) {
// Skipping means "I'm done with this one" — record the outgoing episode as
// fully watched rather than leaving a mid-episode resume point behind. This
// also arms suppression of the VideoPlayer's unmount stop report, which
// would otherwise fire after navigation and overwrite the 100% progress
// with the partial position (see skipReporting.ts).
const skippedId = currentMedia?.id ?? itemId ?? null;
void reportSkippedEpisode(skippedId);
// Use replaceState so "close/back" returns to the library, not the previous episode.
// restart=true so advancing to the next episode always starts from the beginning,
// even if it was previously started or watched.
+29 -3
View File
@@ -5,15 +5,41 @@
import Search from "$lib/components/Search.svelte";
import SearchResults from "$lib/components/search/SearchResults.svelte";
import SearchScopeChips from "$lib/components/search/SearchScopeChips.svelte";
import { resolveSearchScope, type SearchScope } from "$lib/utils/searchScope";
import { resolveSearchScope, SEARCH_SCOPES, type SearchScope } from "$lib/utils/searchScope";
import type { MediaItem } from "$lib/api/types";
let searchQuery = $state("");
// `?q=` / `?scope=` seed the page so the desktop header search bar can hand
// a query over by navigating here — /search is the only surface that renders
// results, so every other search affordance routes into it.
// TRACES: UR-049 | DR-063
const initialQuery = $page.url.searchParams.get("q") ?? "";
const initialScope = $page.url.searchParams.get("scope");
let searchQuery = $state(initialQuery);
// Route resolves the *initial* scope only. Deriving it reactively would snap
// a user who widened to All back to the route's scope on any navigation.
// TRACES: UR-049 | DR-064
let scope = $state<SearchScope>(resolveSearchScope($page.url.pathname));
let scope = $state<SearchScope>(
SEARCH_SCOPES.includes(initialScope as SearchScope)
? (initialScope as SearchScope)
: resolveSearchScope($page.url.pathname)
);
// A query arriving in the URL must actually run — mounting with a seeded
// input alone would render the empty state with a filled box.
$effect(() => {
const q = $page.url.searchParams.get("q") ?? "";
if (!q.trim()) return;
const urlScope = $page.url.searchParams.get("scope");
const nextScope = SEARCH_SCOPES.includes(urlScope as SearchScope)
? (urlScope as SearchScope)
: "all";
if (q === $library.searchQuery && nextScope === scope) return;
searchQuery = q;
scope = nextScope;
library.search(q, nextScope);
});
async function handleSearch(query: string) {
if (query.trim()) {
+3 -1
View File
@@ -8,7 +8,9 @@ export default defineConfig({
globals: true,
environment: "jsdom",
setupFiles: ["./src/test/setup-globals.ts", "./src/test/setup.ts"],
include: ["src/**/*.{test,spec}.{js,ts}"],
// `scripts/` is included so build tooling (the traceability coverage
// engine) is covered by the normal suite rather than only by CI.
include: ["src/**/*.{test,spec}.{js,ts}", "scripts/**/*.{test,spec}.{js,ts}"],
coverage: {
provider: "v8",
reporter: ["text", "json", "html"],