Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e381d626c1 | ||
|
|
b12e99b7e1 | ||
|
|
dc8b732465 | ||
|
|
b98a530f48 | ||
|
|
b565c4ae6f | ||
|
|
79e10d7485 | ||
|
|
a2dbde5492 | ||
|
|
75cd07a5c0 | ||
|
|
64d07b8940 | ||
|
|
5b810f7fc3 | ||
|
|
1ae213ff39 | ||
|
|
98a6bca645 | ||
|
|
984e594006 | ||
|
|
f49e6e4648 | ||
|
|
105cc082ea | ||
|
|
0a3ee0791f | ||
|
|
0da0a9f16c | ||
|
|
75bae2556c | ||
|
|
48f63dd763 | ||
|
|
36ef231e2f | ||
|
|
cb79a376b3 | ||
|
|
b11188e9dd | ||
|
|
f636b6b151 |
@@ -42,30 +42,45 @@ jobs:
|
|||||||
echo "📊 Validating requirement traceability..."
|
echo "📊 Validating requirement traceability..."
|
||||||
echo ""
|
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)
|
TOTAL_TRACES=$(jq '.totalTraces' traces-report.json)
|
||||||
UR=$(jq '.byType.UR | length' traces-report.json)
|
COVERED=$(jq '.coverage.covered' traces-report.json)
|
||||||
IR=$(jq '.byType.IR | length' traces-report.json)
|
TOTAL_REQS=$(jq '.coverage.total' traces-report.json)
|
||||||
DR=$(jq '.byType.DR | length' traces-report.json)
|
COVERAGE=$(jq '.coverage.percent' traces-report.json)
|
||||||
JA=$(jq '.byType.JA | length' traces-report.json)
|
|
||||||
|
|
||||||
# Print coverage report
|
|
||||||
echo "✅ TRACES Found: $TOTAL_TRACES"
|
echo "✅ TRACES Found: $TOTAL_TRACES"
|
||||||
echo ""
|
echo ""
|
||||||
echo "📋 Coverage Summary:"
|
echo "📋 Coverage Summary (traced / defined):"
|
||||||
echo " User Requirements (UR): $UR / 39 ($(( UR * 100 / 39 ))%)"
|
for T in UR IR DR JA; do
|
||||||
echo " Integration Requirements (IR): $IR / 24 ($(( IR * 100 / 24 ))%)"
|
TRACED=$(jq --arg t "$T" '[.byType[$t][] | select(. != null)] | length' traces-report.json)
|
||||||
echo " Development Requirements (DR): $DR / 48 ($(( DR * 100 / 48 ))%)"
|
DEFINED=$(jq --arg t "$T" '.defined[$t]' traces-report.json)
|
||||||
echo " Jellyfin API Requirements (JA): $JA / 3 ($(( JA * 100 / 3 ))%)"
|
echo " $T: $TRACED / $DEFINED"
|
||||||
|
done
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
COVERED=$((UR + IR + DR + JA))
|
|
||||||
TOTAL_REQS=114
|
|
||||||
COVERAGE=$((COVERED * 100 / TOTAL_REQS))
|
|
||||||
|
|
||||||
echo "📈 Overall Coverage: $COVERED / $TOTAL_REQS ($COVERAGE%)"
|
echo "📈 Overall Coverage: $COVERED / $TOTAL_REQS ($COVERAGE%)"
|
||||||
echo ""
|
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
|
# Check minimum threshold
|
||||||
MIN_THRESHOLD=50
|
MIN_THRESHOLD=50
|
||||||
if [ "$COVERAGE" -lt "$MIN_THRESHOLD" ]; then
|
if [ "$COVERAGE" -lt "$MIN_THRESHOLD" ]; then
|
||||||
|
|||||||
@@ -6,6 +6,63 @@ Entries are grouped by the capability they change, not by commit. Requirement
|
|||||||
IDs in parentheses point at [docs/requirements.md](docs/requirements.md); the
|
IDs in parentheses point at [docs/requirements.md](docs/requirements.md); the
|
||||||
generated trace matrix lives in [docs/traceability.md](docs/traceability.md).
|
generated trace matrix lives in [docs/traceability.md](docs/traceability.md).
|
||||||
|
|
||||||
|
## v0.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.3–v0.1.5 have no entries here. Their changes are in the git log
|
||||||
|
and docs/traceability.md.
|
||||||
|
-->
|
||||||
|
|
||||||
## v0.1.2
|
## v0.1.2
|
||||||
|
|
||||||
### ✨ Features
|
### ✨ Features
|
||||||
|
|||||||
@@ -183,8 +183,14 @@ and [docs/build-release.md](docs/build-release.md).
|
|||||||
backend expand it. Single-type presentation (`itemType: "Movie"`, "this page
|
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.
|
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
|
`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)
|
assignment. The canonical example lives in Rust:
|
||||||
for the incident this rule came from.
|
`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
|
## Writing specs
|
||||||
|
|
||||||
|
|||||||
+7
-3
@@ -117,9 +117,13 @@ RUN cd src-tauri && cargo fetch && cd .. && \
|
|||||||
|
|
||||||
# Desktop packaging stages build FROM the unified registry builder image (see the
|
# Desktop packaging stages build FROM the unified registry builder image (see the
|
||||||
# BUILDER_IMAGE ARG at the top), which already carries every packaging tool
|
# 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
|
# (rpm/file for Linux, cargo-xwin + nsis + the x86_64-pc-windows-msvc rust
|
||||||
# for Windows). ONE source of dependency truth, shared with CI — no per-stage
|
# target for Windows). ONE source of dependency truth, shared with CI — no
|
||||||
# apt/rustup here.
|
# 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).
|
# Linux desktop packaging environment (deb + rpm; Arch is Dockerfile.arch).
|
||||||
# Thin layer over the builder — the actual build runs at container-run time on
|
# Thin layer over the builder — the actual build runs at container-run time on
|
||||||
|
|||||||
+40
-19
@@ -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-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-029 | Toggle between grid and list view in library | Medium | Done |
|
||||||
| UR-030 | Quick genre browsing and filtering | 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-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-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 |
|
| UR-034 | Rich home screen with hero banners, carousels, and personalized sections | High | Done |
|
||||||
@@ -71,7 +71,7 @@ For a narrative overview of the system design, see
|
|||||||
| 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-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-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-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. Because a double tap starts as a single tap, the single-tap play/pause is held back until the double-tap window has passed, so skipping never also pauses the video; the skip lands relative to the position the player actually reports, and repeated double taps accumulate rather than all skipping from the same spot | Medium | Done |
|
| UR-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 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -193,7 +193,7 @@ Internal architecture, components, and application logic.
|
|||||||
| DR-031 | Clickable artist/album links in now playing view | UI | UR-028 | Done |
|
| DR-031 | Clickable artist/album links in now playing view | UI | UR-028 | Done |
|
||||||
| DR-032 | List view option for library browsing (albums, artists) | UI | UR-029 | Done |
|
| DR-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-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-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-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 |
|
| DR-037 | Remote session browser and control UI | UI | UR-010 | Done |
|
||||||
@@ -220,7 +220,7 @@ 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-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-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-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-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-065 | Thread `SearchOptions.includeItemTypes` through `library.search()` so the global/header search honours scope (backend online + offline paths already support it) | UI | UR-049 | Implemented |
|
||||||
| DR-066 | Persisted search result group order with a drag-and-drop settings list, keyboard-accessible reordering, a shipped default (see DR-091 for the current group set and order), and empty-group omission | Settings | UR-050 | Implemented |
|
| DR-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 |
|
||||||
@@ -246,7 +246,13 @@ Internal architecture, components, and application logic.
|
|||||||
| 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-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-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-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` returns `pending` for a first tap — the component defers `togglePlayPause` behind a `DOUBLE_TAP_WINDOW_MS` (300 ms) timer that a second tap cancels — or `seek` (+30 s right / −10 s left) for a second tap inside the window; a consumed second tap resets the state so a third tap starts fresh, and a swipe cancels the pending tap. The compatibility `click` the browser synthesizes after a touch tap is filtered in `handleVideoClick` so it cannot bypass the deferral. `resolveSeekTarget` converts the delta to the absolute position the facade requires, clamped to `[0, duration]` 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-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 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -406,10 +412,11 @@ Internal architecture, components, and application logic.
|
|||||||
| UT-082 | EQ fields serialize as camelCase (`equalizerEnabled`/`equalizerBands`) and round-trip | DR-030 | Done |
|
| UT-082 | EQ fields serialize as camelCase (`equalizerEnabled`/`equalizerBands`) and round-trip | DR-030 | Done |
|
||||||
| UT-083 | EQ filter entries are empty when disabled or when the curve is flat (clears the `af` filter) | IR-020 | Done |
|
| UT-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-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 `pending`, not an immediate play/pause, and becomes `togglePlayPause` only once the double-tap window has elapsed | DR-092 | 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 clears the deferred play/pause so a double tap never pauses | DR-092 | 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 a third tap after a consumed double tap, each start a fresh pending tap; repeated double taps keep seeking; `cancel()` drops a pending tap so a swipe cannot pause | DR-092 | 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 to `[0, duration]`, chains off an in-flight pending target so rapid skips accumulate, and ignores that target once the player reports past it | DR-092 | 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
|
### Integration Tests
|
||||||
|
|
||||||
@@ -499,22 +506,36 @@ The `PlayerBackend` trait defines optional audio settings methods with default e
|
|||||||
| Basic playback | ✅ | ✅ | Parity |
|
| Basic playback | ✅ | ✅ | Parity |
|
||||||
| Volume control | ✅ | ✅ | Parity |
|
| Volume control | ✅ | ✅ | Parity |
|
||||||
| Seek | ✅ | ✅ | Parity |
|
| Seek | ✅ | ✅ | Parity |
|
||||||
| Crossfade | ✅ | ❌ | Gap |
|
| Crossfade | ❌ | ❌ | Not implemented (blocked on MPV) |
|
||||||
| Gapless playback | ✅ | ❌ | Gap |
|
| Gapless playback | ✅ | ⚠️ | Implemented, pending on-device verification |
|
||||||
| Volume normalization | ✅ | ❌ | Gap |
|
| 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 |
|
| Position updates | 250ms | On-demand | Inconsistent |
|
||||||
|
|
||||||
**Future Fix**:
|
**Status** (see docs/specs/android-audio-settings-parity.md):
|
||||||
1. Implement `set_audio_settings()` in `ExoPlayerBackend`
|
1. ✅ `set_audio_settings()` implemented in `ExoPlayerBackend` (JSON over JNI)
|
||||||
2. Add Kotlin-side ExoPlayer configuration for crossfade (using `ConcatenatingMediaSource` or `DefaultMediaSourceFactory`)
|
2. ✅ Gapless via ExoPlayer's `pauseAtEndOfMediaItems`
|
||||||
3. Implement gapless via ExoPlayer's built-in gapless support
|
3. ✅ Volume normalization via `LoudnessEnhancer`
|
||||||
4. Add volume normalization via ExoPlayer's `LoudnessEnhancer` or audio processor
|
4. ✅ Equalizer via `android.media.audiofx.Equalizer`, canonical 10 bands
|
||||||
5. Standardize position update frequency across platforms
|
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**:
|
**Impact**:
|
||||||
- Medium - Android users lack audio enhancement features advertised in requirements
|
- Medium - Android users lack audio enhancement features advertised in requirements
|
||||||
- User experience differs between platforms
|
- 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
|
**Traces To**: IR-004, UR-031, UR-032, UR-033, DR-034, DR-035, DR-036
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
**Requirements:** UR-027 → DR-030 (EQ UI), IR-020 (MPV EQ integration).
|
**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).
|
**UX spec:** n/a (extends the Settings › Audio section, ux-flows §8.1 instant-apply).
|
||||||
**Supersedes / revises:** —
|
**Supersedes / revises:** —
|
||||||
|
**Revised by:** [android-audio-settings-parity.md](android-audio-settings-parity.md) — lifts the "Android is a no-op" limitation below.
|
||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
@@ -115,6 +116,10 @@ fields ride along. `NullBackend`/Android inherit the trait default (no-op).
|
|||||||
## Out of scope
|
## Out of scope
|
||||||
|
|
||||||
- Android/ExoPlayer EQ (parity gap tracked with crossfade/gapless/normalize).
|
- 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.
|
- Per-track or per-library EQ profiles — one global profile only.
|
||||||
- Automatic loudness/room correction; only manual bands + presets.
|
- Automatic loudness/room correction; only manual bands + presets.
|
||||||
- Changing the crossfade/normalize TODOs in `set_audio_settings` beyond wiring
|
- Changing the crossfade/normalize TODOs in `set_audio_settings` beyond wiring
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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 3–5 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.
|
||||||
@@ -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.
|
||||||
@@ -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 0–12s, 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.
|
||||||
@@ -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) | 1–2 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.
|
||||||
@@ -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.
|
||||||
@@ -8,8 +8,14 @@
|
|||||||
> is being moved into Rust. The **user-facing behaviour and UX in this spec are
|
> 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
|
> unchanged**; only where the scope→item-type mapping and result bucketing live
|
||||||
> changes. Read the boundary spec before touching search code.
|
> 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.)*
|
**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
|
**Requirements:** UR-049 → DR-063, DR-064, DR-065; UR-050 → DR-066, DR-067
|
||||||
(see [requirements.md](../requirements.md)).
|
(see [requirements.md](../requirements.md)).
|
||||||
|
|||||||
@@ -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
|
||||||
|
46–76) 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 78–126), 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.
|
||||||
@@ -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); ~26–30 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
@@ -43,14 +43,26 @@ Extracts all TRACES comments from:
|
|||||||
|
|
||||||
### 2. Coverage Thresholds
|
### 2. Coverage Thresholds
|
||||||
The workflow checks:
|
The workflow checks:
|
||||||
- **Minimum overall coverage:** 50% (57+ requirements traced)
|
- **Minimum overall coverage:** 50%
|
||||||
- **Requirements by type:**
|
|
||||||
- UR (User): 23+ of 39
|
|
||||||
- IR (Integration): 5+ of 24
|
|
||||||
- DR (Development): 28+ of 48
|
|
||||||
- JA (Jellyfin API): 0+ of 3
|
|
||||||
|
|
||||||
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
|
### 3. Modified File Checking
|
||||||
On pull requests, the workflow:
|
On pull requests, the workflow:
|
||||||
@@ -153,11 +165,13 @@ cat docs/traceability.md
|
|||||||
## Coverage Goals
|
## Coverage Goals
|
||||||
|
|
||||||
### Current Status
|
### Current Status
|
||||||
- Overall: 51% (56/114)
|
|
||||||
- UR: 59% (23/39)
|
Run `bun run traces:coverage` — it prints the live figure and exits non-zero
|
||||||
- IR: 21% (5/24)
|
below threshold. Numbers are deliberately not pinned here; the previous snapshot
|
||||||
- DR: 58% (28/48)
|
in this section (51%, 56/114) was stale by roughly 100 requirements and was what
|
||||||
- JA: 0% (0/3)
|
made the broken CI arithmetic look plausible for so long.
|
||||||
|
|
||||||
|
As of July 2026 overall coverage is ~86% (182/212).
|
||||||
|
|
||||||
### Targets
|
### Targets
|
||||||
- **Short term** (Sprint): Maintain ≥50% overall
|
- **Short term** (Sprint): Maintain ≥50% overall
|
||||||
|
|||||||
+801
-694
File diff suppressed because it is too large
Load Diff
+4
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "jellytau",
|
"name": "jellytau",
|
||||||
"version": "0.1.5",
|
"version": "0.2.7",
|
||||||
"description": "",
|
"description": "",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"packageManager": "bun@1.3.5",
|
"packageManager": "bun@1.3.5",
|
||||||
@@ -20,6 +20,8 @@
|
|||||||
"check:boundary": "bash scripts/check-frontend-boundary.sh",
|
"check:boundary": "bash scripts/check-frontend-boundary.sh",
|
||||||
"android:build": "./scripts/build-android.sh",
|
"android:build": "./scripts/build-android.sh",
|
||||||
"android:build:release": "./scripts/build-android.sh release",
|
"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: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:deploy": "./scripts/deploy-android.sh",
|
||||||
"android:dev": "./scripts/build-and-deploy.sh",
|
"android:dev": "./scripts/build-and-deploy.sh",
|
||||||
@@ -36,6 +38,7 @@
|
|||||||
"traces": "bun run scripts/extract-traces.ts",
|
"traces": "bun run scripts/extract-traces.ts",
|
||||||
"traces:json": "bun run scripts/extract-traces.ts --format json",
|
"traces:json": "bun run scripts/extract-traces.ts --format json",
|
||||||
"traces:markdown": "bun run scripts/extract-traces.ts --format markdown > docs/traceability.md",
|
"traces:markdown": "bun run scripts/extract-traces.ts --format markdown > docs/traceability.md",
|
||||||
|
"traces:coverage": "bun run scripts/extract-traces.ts --format coverage",
|
||||||
"release:notes": "bun run scripts/release-notes.ts"
|
"release:notes": "bun run scripts/release-notes.ts"
|
||||||
},
|
},
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
+17
-1
@@ -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 # Generate markdown report
|
||||||
bun run traces:json # Generate JSON report
|
bun run traces:json # Generate JSON report
|
||||||
bun run traces:markdown # Save to docs/traceability.md
|
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
|
- Which code files implement which requirements
|
||||||
- Line numbers and code context
|
- Line numbers and code context
|
||||||
- Coverage summary by requirement type (UR, IR, DR, JA)
|
- 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:
|
Example TRACES comment in code:
|
||||||
```typescript
|
```typescript
|
||||||
// TRACES: UR-005, UR-026 | DR-029
|
// TRACES: UR-005, UR-026 | DR-029
|
||||||
|
|||||||
@@ -18,15 +18,50 @@ echo ""
|
|||||||
# Parse args: build type (debug/release) and optional --clean flag.
|
# Parse args: build type (debug/release) and optional --clean flag.
|
||||||
# By default the build is INCREMENTAL — Cargo and Vite reuse their caches.
|
# 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.
|
# 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"
|
BUILD_TYPE="debug"
|
||||||
CLEAN="${CLEAN:-0}"
|
CLEAN="${CLEAN:-0}"
|
||||||
|
ABI="${ABI:-}"
|
||||||
|
next_is_abi=0
|
||||||
for arg in "$@"; do
|
for arg in "$@"; do
|
||||||
|
if [ "$next_is_abi" = "1" ]; then
|
||||||
|
ABI="$arg"
|
||||||
|
next_is_abi=0
|
||||||
|
continue
|
||||||
|
fi
|
||||||
case "$arg" in
|
case "$arg" in
|
||||||
--clean) CLEAN=1 ;;
|
--clean) CLEAN=1 ;;
|
||||||
|
--abi) next_is_abi=1 ;;
|
||||||
|
--device) ABI="device" ;;
|
||||||
debug|release) BUILD_TYPE="$arg" ;;
|
debug|release) BUILD_TYPE="$arg" ;;
|
||||||
esac
|
esac
|
||||||
done
|
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.
|
# Step 0: Optionally clear build caches for a fully fresh build.
|
||||||
if [ "$CLEAN" = "1" ]; then
|
if [ "$CLEAN" = "1" ]; then
|
||||||
echo "🧹 Clearing build caches (clean build)..."
|
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.
|
# after sync-android-sources.sh, since gen/android is (re)generated there.
|
||||||
./scripts/write-keystore-properties.sh
|
./scripts/write-keystore-properties.sh
|
||||||
echo "📦 Building release APK..."
|
echo "📦 Building release APK..."
|
||||||
bun run tauri android build --apk true
|
bun run tauri android build --apk true "${TARGET_ARGS[@]}"
|
||||||
else
|
else
|
||||||
echo "📦 Building debug APK..."
|
echo "📦 Building debug APK..."
|
||||||
bun run tauri android build --apk true --debug
|
bun run tauri android build --apk true --debug "${TARGET_ARGS[@]}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Boundary tripwire: flag domain-taxonomy leaks in the Svelte frontend.
|
# 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 project rule (CLAUDE.md, docs/architecture/02-svelte-frontend.md) is that
|
||||||
# the frontend is presentation-only and the Rust backend owns domain logic —
|
# 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
|
# 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
|
# ⚠️ 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
|
# (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
|
# targets the machine-detectable signature of the leak class and defers
|
||||||
# names a multi-type category — and defers everything subtler to the human
|
# everything subtler to the human spec-review checklist
|
||||||
# spec-review checklist (docs/specs/SPEC-REVIEW-CHECKLIST.md). A clean run here
|
# (docs/specs/SPEC-REVIEW-CHECKLIST.md). A clean run here does not mean the
|
||||||
# does not mean the boundary is respected; it means the crudest violation isn't
|
# boundary is respected; it means the crudest violation isn't present.
|
||||||
# present.
|
|
||||||
#
|
#
|
||||||
# What it flags: an `includeItemTypes: [ ... , ... ]` array literal with two or
|
# What it flags: an array literal naming two or more Jellyfin item types,
|
||||||
# more types — i.e. the frontend deciding that a *category* maps to a *set* of
|
# ANYWHERE in src/ — i.e. the frontend deciding that a *category* maps to a *set*
|
||||||
# Jellyfin types, which is domain knowledge the backend should own. Single-type
|
# of Jellyfin types, which is domain knowledge the backend should own.
|
||||||
# query arrays (`includeItemTypes: ["Movie"]`) are a page saying "I show movies"
|
# Single-type arrays (`includeItemTypes: ["Movie"]`) are a page saying "I show
|
||||||
# and are allowed. Type *inspection* (`item.type === "Audio"`) is display logic
|
# movies" and are allowed. Type *inspection* (`item.type === "Audio"`) is display
|
||||||
# and is not matched.
|
# 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.
|
# Escaping a genuine exception: add the file+reason to the ALLOWLIST below.
|
||||||
|
|
||||||
@@ -28,7 +41,7 @@ set -euo pipefail
|
|||||||
|
|
||||||
cd "$(dirname "$0")/.."
|
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
|
# 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.
|
# signal to push taxonomy into Rust, not to keep appending here.
|
||||||
ALLOWLIST=(
|
ALLOWLIST=(
|
||||||
@@ -36,8 +49,31 @@ ALLOWLIST=(
|
|||||||
# two-type filmography query with no category-configuration behind it. Tracked
|
# two-type filmography query with no category-configuration behind it. Tracked
|
||||||
# as acceptable pending any person-scope work; revisit if it grows.
|
# as acceptable pending any person-scope work; revisit if it grows.
|
||||||
"src/lib/components/library/PersonDetailView.svelte"
|
"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() {
|
is_allowed() {
|
||||||
local file="$1"
|
local file="$1"
|
||||||
for allowed in "${ALLOWLIST[@]}"; do
|
for allowed in "${ALLOWLIST[@]}"; do
|
||||||
@@ -46,11 +82,28 @@ is_allowed() {
|
|||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
# Multi-element includeItemTypes array: `includeItemTypes: [ <x> , <y> ... ]`.
|
# Two or more adjacent Jellyfin item-type string literals inside a bracket.
|
||||||
# The comma inside the brackets is what makes it multi-type.
|
#
|
||||||
PATTERN='includeItemTypes:[[:space:]]*\[[^]]*,[^]]*\]'
|
# 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.
|
# Collect hits, excluding tests and the allowlist.
|
||||||
violations=""
|
violations=""
|
||||||
@@ -69,9 +122,11 @@ done < <(grep -rInE "$PATTERN" src/ 2>/dev/null || true)
|
|||||||
|
|
||||||
if [[ -n "$violations" ]]; then
|
if [[ -n "$violations" ]]; then
|
||||||
echo ""
|
echo ""
|
||||||
echo "❌ Frontend boundary violation: a multi-type includeItemTypes query defines"
|
echo "❌ Frontend boundary violation: an item-type array literal defines a"
|
||||||
echo " a category in the presentation layer. That taxonomy belongs in Rust —"
|
echo " 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 " 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 " See docs/specs/scoped-search-boundary.md and CLAUDE.md."
|
||||||
echo ""
|
echo ""
|
||||||
echo "$violations" | sed 's/^/ /'
|
echo "$violations" | sed 's/^/ /'
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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 ""
|
|
||||||
@@ -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
@@ -34,11 +34,20 @@ interface TracesData {
|
|||||||
DR: string[];
|
DR: string[];
|
||||||
JA: 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).
|
// 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.
|
// 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 TRACES_PATTERN = /TRACES:\s*([^\n]+)/gi;
|
||||||
const REQ_ID_PATTERN = /([A-Z]{2})-(\d{3})/g;
|
const REQ_ID_PATTERN = /([A-Z]{2})-(\d{3})/g;
|
||||||
@@ -50,7 +59,10 @@ function extractRequirementIds(tracesString: string): string[] {
|
|||||||
|
|
||||||
function getAllSourceFiles(): string[] {
|
function getAllSourceFiles(): string[] {
|
||||||
const baseDir = BASE_DIR;
|
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[] = [];
|
const files: string[] = [];
|
||||||
|
|
||||||
function walkDir(dir: 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 {
|
function generateMarkdown(data: TracesData): string {
|
||||||
let md = `# Code Traceability Matrix
|
let md = `# Code Traceability Matrix
|
||||||
|
|
||||||
@@ -265,21 +380,83 @@ function generateJson(data: TracesData): string {
|
|||||||
return JSON.stringify(data, null, 2);
|
return JSON.stringify(data, null, 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Main
|
/**
|
||||||
const args = Bun.argv.slice(2);
|
* Human-readable coverage report; exits non-zero below the threshold so this is
|
||||||
const format = args.includes("--format")
|
* 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]
|
? args[args.indexOf("--format") + 1]
|
||||||
: "markdown";
|
: "markdown";
|
||||||
|
|
||||||
console.error("🔍 Extracting TRACES from codebase...");
|
console.error("🔍 Extracting TRACES from codebase...");
|
||||||
const data = extractTraces();
|
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));
|
console.log(generateJson(data));
|
||||||
} else {
|
} else if (format === "coverage") {
|
||||||
|
process.exit(reportCoverage(data, 50));
|
||||||
|
} else {
|
||||||
console.log(generateMarkdown(data));
|
console.log(generateMarkdown(data));
|
||||||
}
|
}
|
||||||
|
|
||||||
console.error(
|
console.error(
|
||||||
`\n✅ Complete! Found ${data.totalTraces} TRACES across ${data.totalFiles} files`
|
`\n✅ Complete! Found ${data.totalTraces} TRACES across ${data.totalFiles} files`
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -7,7 +7,7 @@ echo "🧪 Running all tests..."
|
|||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
echo "📦 Running frontend tests..."
|
echo "📦 Running frontend tests..."
|
||||||
bun run test
|
bun run test --run
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "🦀 Running Rust tests..."
|
echo "🦀 Running Rust tests..."
|
||||||
@@ -15,5 +15,12 @@ cd src-tauri
|
|||||||
cargo test
|
cargo test
|
||||||
cd ..
|
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 ""
|
||||||
echo "✅ All tests passed!"
|
echo "✅ All tests passed!"
|
||||||
|
|||||||
Generated
+1
-1
@@ -1994,7 +1994,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "jellytau"
|
name = "jellytau"
|
||||||
version = "0.1.5"
|
version = "0.2.7"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "jellytau"
|
name = "jellytau"
|
||||||
version = "0.1.5"
|
version = "0.2.7"
|
||||||
description = "A Tauri App"
|
description = "A Tauri App"
|
||||||
authors = ["you"]
|
authors = ["you"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|||||||
@@ -36,6 +36,53 @@ class JellyTauPlayer(private val appContext: Context) {
|
|||||||
/** Position update interval in milliseconds */
|
/** Position update interval in milliseconds */
|
||||||
private const val POSITION_UPDATE_INTERVAL_MS = 250L
|
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 */
|
/** Singleton instance for JNI access */
|
||||||
@Volatile
|
@Volatile
|
||||||
private var instance: JellyTauPlayer? = null
|
private var instance: JellyTauPlayer? = null
|
||||||
@@ -135,6 +182,18 @@ class JellyTauPlayer(private val appContext: Context) {
|
|||||||
private val coroutineScope = CoroutineScope(Dispatchers.Main + SupervisorJob())
|
private val coroutineScope = CoroutineScope(Dispatchers.Main + SupervisorJob())
|
||||||
private var positionUpdateJob: Job? = null
|
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 */
|
/** Current media ID being played */
|
||||||
private var currentMediaId: String? = null
|
private var currentMediaId: String? = null
|
||||||
|
|
||||||
@@ -334,6 +393,11 @@ class JellyTauPlayer(private val appContext: Context) {
|
|||||||
|
|
||||||
override fun onAudioSessionIdChanged(audioSessionId: Int) {
|
override fun onAudioSessionIdChanged(audioSessionId: Int) {
|
||||||
android.util.Log.d("JellyTauPlayer", "▶▶▶ AUDIO SESSION ID CHANGED: $audioSessionId")
|
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.
|
* Get the current playback position in seconds.
|
||||||
*/
|
*/
|
||||||
@@ -756,6 +947,7 @@ class JellyTauPlayer(private val appContext: Context) {
|
|||||||
mainHandler.post {
|
mainHandler.post {
|
||||||
stopPositionUpdates()
|
stopPositionUpdates()
|
||||||
coroutineScope.cancel()
|
coroutineScope.cancel()
|
||||||
|
releaseAudioEffects()
|
||||||
exoPlayer.release()
|
exoPlayer.release()
|
||||||
instance = null
|
instance = null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -395,7 +395,12 @@ pub struct SearchUpdateEvent {
|
|||||||
pub result: SearchResult,
|
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]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
pub async fn repository_search(
|
pub async fn repository_search(
|
||||||
@@ -408,6 +413,16 @@ pub async fn repository_search(
|
|||||||
) -> Result<SearchResult, String> {
|
) -> Result<SearchResult, String> {
|
||||||
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
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
|
// Phase 1: instant local results from the cache (downloaded content) so the
|
||||||
// UI can render immediately while the server is still being queried.
|
// UI can render immediately while the server is still being queried.
|
||||||
let mut cache_result = repo
|
let mut cache_result = repo
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ use super::events::{PlayerStatusEvent, SharedEventEmitter};
|
|||||||
use super::media::{MediaItem, MediaType};
|
use super::media::{MediaItem, MediaType};
|
||||||
use super::state::PlayerState;
|
use super::state::PlayerState;
|
||||||
use crate::playback_reporting::{EventThrottler, PlaybackOperation, PlaybackReporter};
|
use crate::playback_reporting::{EventThrottler, PlaybackOperation, PlaybackReporter};
|
||||||
|
use crate::settings::{audio_settings_jni_payload, AudioSettings};
|
||||||
use crate::utils::conversions::seconds_to_ticks;
|
use crate::utils::conversions::seconds_to_ticks;
|
||||||
|
|
||||||
/// Global reference to the JavaVM for JNI callbacks
|
/// Global reference to the JavaVM for JNI callbacks
|
||||||
@@ -148,6 +149,10 @@ struct ExoPlayerState {
|
|||||||
volume: f32,
|
volume: f32,
|
||||||
is_loaded: bool,
|
is_loaded: bool,
|
||||||
current_media: Option<MediaItem>,
|
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 {
|
impl ExoPlayerState {
|
||||||
@@ -159,6 +164,7 @@ impl ExoPlayerState {
|
|||||||
volume: 1.0,
|
volume: 1.0,
|
||||||
is_loaded: false,
|
is_loaded: false,
|
||||||
current_media: None,
|
current_media: None,
|
||||||
|
audio_settings: AudioSettings::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -529,6 +535,56 @@ impl PlayerBackend for ExoPlayerBackend {
|
|||||||
self.shared_state.lock_safe().volume
|
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> {
|
fn set_audio_track(&mut self, stream_index: i32) -> Result<(), PlayerError> {
|
||||||
let vm = JAVA_VM
|
let vm = JAVA_VM
|
||||||
.get()
|
.get()
|
||||||
|
|||||||
+231
-1
@@ -152,6 +152,17 @@ pub struct PlayerController {
|
|||||||
|
|
||||||
// Auto-play episode counter (session-based, resets on manual play)
|
// Auto-play episode counter (session-based, resets on manual play)
|
||||||
autoplay_episode_count: Arc<Mutex<u32>>,
|
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 {
|
impl PlayerController {
|
||||||
@@ -174,6 +185,7 @@ impl PlayerController {
|
|||||||
position_throttler,
|
position_throttler,
|
||||||
end_reason: Arc::new(Mutex::new(None)),
|
end_reason: Arc::new(Mutex::new(None)),
|
||||||
autoplay_episode_count: Arc::new(Mutex::new(0)),
|
autoplay_episode_count: Arc::new(Mutex::new(0)),
|
||||||
|
html5_playing: Arc::new(Mutex::new(None)),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Start background timer thread for sleep timer countdown
|
// Start background timer thread for sleep timer countdown
|
||||||
@@ -476,21 +488,72 @@ impl PlayerController {
|
|||||||
Ok(())
|
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
|
/// Play/resume playback
|
||||||
pub fn play(&self) -> Result<(), PlayerError> {
|
pub fn play(&self) -> Result<(), PlayerError> {
|
||||||
debug!("[PlayerController] play");
|
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();
|
let mut backend = self.backend.lock_safe();
|
||||||
backend.play()
|
backend.play()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pause playback
|
/// Pause playback
|
||||||
pub fn pause(&self) -> Result<(), PlayerError> {
|
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();
|
let mut backend = self.backend.lock_safe();
|
||||||
backend.pause()
|
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> {
|
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();
|
let mut backend = self.backend.lock_safe();
|
||||||
if backend.state().is_playing() {
|
if backend.state().is_playing() {
|
||||||
backend.pause()
|
backend.pause()
|
||||||
@@ -890,6 +953,23 @@ impl PlayerController {
|
|||||||
/// Re-emits a `StateChanged` event identical to what MpvBackend/ExoPlayer
|
/// Re-emits a `StateChanged` event identical to what MpvBackend/ExoPlayer
|
||||||
/// would emit, so `playerEvents.ts` needs no HTML5-specific branch.
|
/// would emit, so `playerEvents.ts` needs no HTML5-specific branch.
|
||||||
pub fn report_html5_state(&self, state: String, media_id: Option<String>) {
|
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() {
|
if let Some(emitter) = self.event_emitter.lock_safe().as_ref() {
|
||||||
emitter.emit(PlayerStatusEvent::StateChanged { state, media_id });
|
emitter.emit(PlayerStatusEvent::StateChanged { state, media_id });
|
||||||
}
|
}
|
||||||
@@ -1489,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]
|
#[test]
|
||||||
fn test_controller_volume_default() {
|
fn test_controller_volume_default() {
|
||||||
let controller = PlayerController::default();
|
let controller = PlayerController::default();
|
||||||
|
|||||||
@@ -294,6 +294,53 @@ pub struct GetItemsOptions {
|
|||||||
pub genres: Option<Vec<String>>,
|
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
|
/// Options for search queries
|
||||||
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize, Default)]
|
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
@@ -304,6 +351,28 @@ pub struct SearchOptions {
|
|||||||
pub include_item_types: Option<Vec<String>>,
|
pub include_item_types: Option<Vec<String>>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub search_term: Option<String>,
|
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
|
/// 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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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]
|
#[test]
|
||||||
fn test_default_settings() {
|
fn test_default_settings() {
|
||||||
let settings = AudioSettings::default();
|
let settings = AudioSettings::default();
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "jellytau",
|
"productName": "jellytau",
|
||||||
"version": "0.1.5",
|
"version": "0.2.7",
|
||||||
"identifier": "com.dtourolle.jellytau",
|
"identifier": "com.dtourolle.jellytau",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "bun run dev",
|
"beforeDevCommand": "bun run dev",
|
||||||
|
|||||||
+25
-2
@@ -1290,7 +1290,12 @@ async repositoryGetGenres(handle: string, parentId: string | null) : Promise<Gen
|
|||||||
return await TAURI_INVOKE("repository_get_genres", { handle, parentId });
|
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> {
|
async repositorySearch(handle: string, query: string, options: SearchOptions | null, requestId: number) : Promise<SearchResult> {
|
||||||
return await TAURI_INVOKE("repository_search", { handle, query, options, requestId });
|
return await TAURI_INVOKE("repository_search", { handle, query, options, requestId });
|
||||||
@@ -2517,11 +2522,29 @@ failed: number }
|
|||||||
/**
|
/**
|
||||||
* Options for search queries
|
* 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
|
* Search result with pagination
|
||||||
*/
|
*/
|
||||||
export type SearchResult = { items: MediaItem[]; totalRecordCount: number }
|
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
|
* Security status info
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -24,6 +24,9 @@
|
|||||||
createTapGestureState,
|
createTapGestureState,
|
||||||
registerTap,
|
registerTap,
|
||||||
resolveSeekTarget,
|
resolveSeekTarget,
|
||||||
|
clampSeekTarget,
|
||||||
|
isSynthesizedTouchClick,
|
||||||
|
isControlSurfaceTouch,
|
||||||
SEEK_FORWARD_SECONDS,
|
SEEK_FORWARD_SECONDS,
|
||||||
SEEK_BACKWARD_SECONDS,
|
SEEK_BACKWARD_SECONDS,
|
||||||
type TapFeedback,
|
type TapFeedback,
|
||||||
@@ -111,7 +114,9 @@
|
|||||||
let touchStartY = $state(0);
|
let touchStartY = $state(0);
|
||||||
let touchStartTime = $state(0);
|
let touchStartTime = $state(0);
|
||||||
let tapGestures = createTapGestureState();
|
let tapGestures = createTapGestureState();
|
||||||
let tapTimeout: ReturnType<typeof setTimeout> | null = null;
|
// 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 brightness = $state(1); // 0-2, default 1
|
||||||
let showDoubleTapFeedback = $state<TapFeedback | null>(null);
|
let showDoubleTapFeedback = $state<TapFeedback | null>(null);
|
||||||
let doubleTapFeedbackTimeout: ReturnType<typeof setTimeout> | null = null;
|
let doubleTapFeedbackTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
@@ -685,15 +690,19 @@
|
|||||||
bufferedRanges.push(`[${buffered.start(i).toFixed(1)} - ${buffered.end(i).toFixed(1)}]`);
|
bufferedRanges.push(`[${buffered.start(i).toFixed(1)} - ${buffered.end(i).toFixed(1)}]`);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("[VideoPlayer Debug]", {
|
// Flattened to a single string on purpose: the Android WebView console
|
||||||
currentTime: videoElement.currentTime.toFixed(2),
|
// bridge stringifies objects as "[object Object]" in logcat, which made
|
||||||
displayTime: currentTime.toFixed(2),
|
// this whole payload useless when diagnosing over adb.
|
||||||
buffered: bufferedRanges.join(", "),
|
console.log(
|
||||||
readyState: videoElement.readyState,
|
`[VideoPlayer Debug] t=${videoElement.currentTime.toFixed(2)}` +
|
||||||
paused: videoElement.paused,
|
` display=${currentTime.toFixed(2)}` +
|
||||||
seeking: videoElement.seeking,
|
` readyState=${videoElement.readyState}` +
|
||||||
playbackRate: videoElement.playbackRate,
|
` networkState=${videoElement.networkState}` +
|
||||||
});
|
` paused=${videoElement.paused}` +
|
||||||
|
` seeking=${videoElement.seeking}` +
|
||||||
|
` rate=${videoElement.playbackRate}` +
|
||||||
|
` buffered=${bufferedRanges.join(", ")}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}, 1000);
|
}, 1000);
|
||||||
});
|
});
|
||||||
@@ -714,11 +723,6 @@
|
|||||||
if (debugLogInterval) {
|
if (debugLogInterval) {
|
||||||
clearInterval(debugLogInterval);
|
clearInterval(debugLogInterval);
|
||||||
}
|
}
|
||||||
// A deferred single tap must not fire play/pause after teardown.
|
|
||||||
if (tapTimeout) {
|
|
||||||
clearTimeout(tapTimeout);
|
|
||||||
tapTimeout = null;
|
|
||||||
}
|
|
||||||
tapGestures.cancel();
|
tapGestures.cancel();
|
||||||
if (doubleTapFeedbackTimeout) {
|
if (doubleTapFeedbackTimeout) {
|
||||||
clearTimeout(doubleTapFeedbackTimeout);
|
clearTimeout(doubleTapFeedbackTimeout);
|
||||||
@@ -1100,6 +1104,21 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handlePause() {
|
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;
|
isPlaying = false;
|
||||||
stopTimeUpdates(); // Stop RAF loop when paused
|
stopTimeUpdates(); // Stop RAF loop when paused
|
||||||
html5Adapter.reportState("paused", reportMediaId ?? null);
|
html5Adapter.reportState("paused", reportMediaId ?? null);
|
||||||
@@ -1147,7 +1166,10 @@
|
|||||||
|
|
||||||
async function handleSeekBarChange(e: Event) {
|
async function handleSeekBarChange(e: Event) {
|
||||||
const input = e.target as HTMLInputElement;
|
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
|
// Set isSeeking immediately to prevent timeupdate from interfering
|
||||||
isSeeking = true;
|
isSeeking = true;
|
||||||
@@ -1421,8 +1443,38 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
// Touch gesture handlers
|
||||||
function handleTouchStart(e: TouchEvent) {
|
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];
|
const touch = e.touches[0];
|
||||||
touchStartX = touch.clientX;
|
touchStartX = touch.clientX;
|
||||||
touchStartY = touch.clientY;
|
touchStartY = touch.clientY;
|
||||||
@@ -1434,26 +1486,23 @@
|
|||||||
now: Date.now(),
|
now: Date.now(),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (tapTimeout) {
|
// Suppress the compatibility click this touch will synthesize.
|
||||||
clearTimeout(tapTimeout);
|
lastTouchTapAt = Date.now();
|
||||||
tapTimeout = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (outcome.action === "seek") {
|
if (outcome.action === "seek") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
handleDoubleTap(outcome.seekSeconds, outcome.feedback);
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Single tap so far: defer play/pause until the double-tap window closes,
|
// First tap: act now. Nothing is deferred, so there is no timer to race the
|
||||||
// so a double tap seeks without also toggling pause.
|
// compatibility click Android synthesizes after a touch tap (see DR-098).
|
||||||
tapTimeout = setTimeout(() => {
|
|
||||||
tapTimeout = null;
|
|
||||||
if (tapGestures.resolvePending(Date.now())) {
|
|
||||||
togglePlayPause();
|
togglePlayPause();
|
||||||
}
|
}
|
||||||
}, outcome.pendingAfterMs);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleTouchMove(e: TouchEvent) {
|
function handleTouchMove(e: TouchEvent) {
|
||||||
if (!e.touches[0]) return;
|
if (!e.touches[0]) return;
|
||||||
@@ -1465,14 +1514,16 @@
|
|||||||
|
|
||||||
// Minimum movement to register as swipe (50px)
|
// Minimum movement to register as swipe (50px)
|
||||||
if (Math.abs(deltaY) > 50 && timeDelta > 50) {
|
if (Math.abs(deltaY) > 50 && timeDelta > 50) {
|
||||||
swipeGestureActive = true;
|
// 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.
|
||||||
// This is a swipe, not a tap — drop the deferred play/pause.
|
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();
|
tapGestures.cancel();
|
||||||
if (tapTimeout) {
|
|
||||||
clearTimeout(tapTimeout);
|
|
||||||
tapTimeout = null;
|
|
||||||
}
|
}
|
||||||
|
swipeGestureActive = true;
|
||||||
|
|
||||||
// Brightness control on vertical swipe
|
// Brightness control on vertical swipe
|
||||||
swipeType = "brightness";
|
swipeType = "brightness";
|
||||||
@@ -1491,14 +1542,16 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mouse clicks toggle play/pause immediately. Touch taps are already handled
|
* Mouse clicks toggle play/pause immediately. Touch taps are handled fully by
|
||||||
* by `handleTouchStart` (which defers play/pause past the double-tap window),
|
* `handleTouchStart`, so the compatibility click the browser synthesizes after
|
||||||
* so the compatibility click that follows a tap must be ignored here —
|
* a tap must be ignored or every tap toggles twice.
|
||||||
* otherwise it pauses on the first tap of a double tap.
|
*
|
||||||
|
* 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 handleVideoClick(e: MouseEvent) {
|
function handleSurfaceClick(e: MouseEvent) {
|
||||||
// A click synthesized from a touch reports no pointer movement detail.
|
if (isSynthesizedTouchClick(e.detail, Date.now(), lastTouchTapAt)) return;
|
||||||
if (e.detail === 0 || tapTimeout !== null) return;
|
|
||||||
togglePlayPause();
|
togglePlayPause();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1666,7 +1719,7 @@
|
|||||||
onwaiting={handleWaiting}
|
onwaiting={handleWaiting}
|
||||||
onplaying={handlePlaying}
|
onplaying={handlePlaying}
|
||||||
onloadstart={handleLoadStart}
|
onloadstart={handleLoadStart}
|
||||||
onclick={handleVideoClick}
|
onclick={handleSurfaceClick}
|
||||||
>
|
>
|
||||||
<!-- Temporarily disabled to debug playback issues
|
<!-- Temporarily disabled to debug playback issues
|
||||||
{#each subtitleTracks() as track}
|
{#each subtitleTracks() as track}
|
||||||
@@ -1759,10 +1812,17 @@
|
|||||||
<div class="w-12 h-12 border-4 border-white border-t-transparent rounded-full animate-spin"></div>
|
<div class="w-12 h-12 border-4 border-white border-t-transparent rounded-full animate-spin"></div>
|
||||||
</div>
|
</div>
|
||||||
{:else if !isPlaying}
|
{: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
|
<button
|
||||||
|
data-player-surface
|
||||||
class="absolute inset-0 flex items-center justify-center bg-black/30"
|
class="absolute inset-0 flex items-center justify-center bg-black/30"
|
||||||
onclick={togglePlayPause}
|
onclick={handleSurfaceClick}
|
||||||
aria-label="Play"
|
aria-label="Play"
|
||||||
>
|
>
|
||||||
<svg class="w-20 h-20 text-white" fill="currentColor" viewBox="0 0 24 24">
|
<svg class="w-20 h-20 text-white" fill="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -1810,8 +1870,10 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Controls -->
|
<!-- Controls. `data-player-controls` marks this subtree as interactive so
|
||||||
|
container-level tap gestures ignore touches here (see DR-098). -->
|
||||||
<div
|
<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="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:opacity-0={!showControls}
|
||||||
class:pointer-events-none={!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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,6 +6,11 @@ import {
|
|||||||
createTapGestureState,
|
createTapGestureState,
|
||||||
registerTap,
|
registerTap,
|
||||||
resolveSeekTarget,
|
resolveSeekTarget,
|
||||||
|
clampSeekTarget,
|
||||||
|
END_SEEK_MARGIN_SECONDS,
|
||||||
|
isSynthesizedTouchClick,
|
||||||
|
isControlSurfaceTouch,
|
||||||
|
TOUCH_CLICK_SUPPRESS_MS,
|
||||||
} from "./tapGestures";
|
} from "./tapGestures";
|
||||||
|
|
||||||
const SCREEN_WIDTH = 1000;
|
const SCREEN_WIDTH = 1000;
|
||||||
@@ -25,24 +30,22 @@ function asSeek(outcome: ReturnType<typeof tap>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("tap gesture resolution", () => {
|
describe("tap gesture resolution", () => {
|
||||||
it("defers the single-tap action until the double-tap window has elapsed", () => {
|
// Every tap acts IMMEDIATELY — there is no deferral and no timer.
|
||||||
const state = createTapGestureState();
|
//
|
||||||
const first = tap(state, RIGHT, 1000);
|
// 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.
|
||||||
|
|
||||||
// The first tap must NOT immediately toggle play/pause — it may still
|
it("toggles play/pause immediately on the first tap", () => {
|
||||||
// become a double tap.
|
const state = createTapGestureState();
|
||||||
expect(first).toEqual({ action: "pending", pendingAfterMs: DOUBLE_TAP_WINDOW_MS });
|
expect(tap(state, RIGHT, 1000)).toEqual({ action: "togglePlayPause" });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("resolves an isolated tap to togglePlayPause once the window expires", () => {
|
it("seeks forward 30s AND toggles again on a second right-side tap", () => {
|
||||||
const state = createTapGestureState();
|
|
||||||
tap(state, RIGHT, 1000);
|
|
||||||
|
|
||||||
const resolved = state.resolvePending(1000 + DOUBLE_TAP_WINDOW_MS);
|
|
||||||
expect(resolved).toEqual({ action: "togglePlayPause" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("seeks forward 30s on a double tap on the right half and never pauses", () => {
|
|
||||||
const state = createTapGestureState();
|
const state = createTapGestureState();
|
||||||
tap(state, RIGHT, 1000);
|
tap(state, RIGHT, 1000);
|
||||||
const second = asSeek(tap(state, RIGHT, 1150));
|
const second = asSeek(tap(state, RIGHT, 1150));
|
||||||
@@ -50,12 +53,11 @@ describe("tap gesture resolution", () => {
|
|||||||
expect(second.seekSeconds).toBe(SEEK_FORWARD_SECONDS);
|
expect(second.seekSeconds).toBe(SEEK_FORWARD_SECONDS);
|
||||||
expect(second.seekSeconds).toBe(30);
|
expect(second.seekSeconds).toBe(30);
|
||||||
expect(second.feedback).toBe("right");
|
expect(second.feedback).toBe("right");
|
||||||
|
// The re-toggle is what preserves the play state across a double tap.
|
||||||
// The deferred single-tap pause must have been cancelled.
|
expect(second.togglePlayPause).toBe(true);
|
||||||
expect(state.resolvePending(1150 + DOUBLE_TAP_WINDOW_MS)).toBeNull();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("seeks back 10s on a double tap on the left half", () => {
|
it("seeks back 10s AND toggles again on a second left-side tap", () => {
|
||||||
const state = createTapGestureState();
|
const state = createTapGestureState();
|
||||||
tap(state, LEFT, 1000);
|
tap(state, LEFT, 1000);
|
||||||
const second = asSeek(tap(state, LEFT, 1100));
|
const second = asSeek(tap(state, LEFT, 1100));
|
||||||
@@ -63,24 +65,44 @@ describe("tap gesture resolution", () => {
|
|||||||
expect(second.seekSeconds).toBe(SEEK_BACKWARD_SECONDS);
|
expect(second.seekSeconds).toBe(SEEK_BACKWARD_SECONDS);
|
||||||
expect(second.seekSeconds).toBe(-10);
|
expect(second.seekSeconds).toBe(-10);
|
||||||
expect(second.feedback).toBe("left");
|
expect(second.feedback).toBe("left");
|
||||||
|
expect(second.togglePlayPause).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("treats a second tap after the window as a new pending single tap", () => {
|
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();
|
const state = createTapGestureState();
|
||||||
tap(state, RIGHT, 1000);
|
tap(state, RIGHT, 1000);
|
||||||
const late = tap(state, RIGHT, 1000 + DOUBLE_TAP_WINDOW_MS + 1);
|
const late = tap(state, RIGHT, 1000 + DOUBLE_TAP_WINDOW_MS + 1);
|
||||||
|
|
||||||
expect(late.action).toBe("pending");
|
expect(late.action).toBe("togglePlayPause");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not treat a third tap as another double tap", () => {
|
it("only ever has first and second taps — the tap after a pair is a fresh toggle", () => {
|
||||||
const state = createTapGestureState();
|
const state = createTapGestureState();
|
||||||
tap(state, RIGHT, 1000);
|
tap(state, RIGHT, 1000);
|
||||||
expect(tap(state, RIGHT, 1100).action).toBe("seek");
|
expect(tap(state, RIGHT, 1100).action).toBe("seek");
|
||||||
|
|
||||||
// Triple tap: the third tap starts a fresh pending tap rather than
|
// The pair is consumed. The next tap is a FIRST tap again, so it toggles
|
||||||
// seeking again off the consumed second tap.
|
// play/pause — there is no "third tap" concept.
|
||||||
expect(tap(state, RIGHT, 1200).action).toBe("pending");
|
expect(tap(state, RIGHT, 1200).action).toBe("togglePlayPause");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("accumulates repeated double taps on the same side", () => {
|
it("accumulates repeated double taps on the same side", () => {
|
||||||
@@ -103,12 +125,13 @@ describe("tap gesture resolution", () => {
|
|||||||
expect(second.feedback).toBe("right");
|
expect(second.feedback).toBe("right");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("cancel() drops a pending tap so an interpreted swipe cannot pause", () => {
|
it("cancel() makes the next tap a fresh first tap (swipe interrupted the pair)", () => {
|
||||||
const state = createTapGestureState();
|
const state = createTapGestureState();
|
||||||
tap(state, RIGHT, 1000);
|
tap(state, RIGHT, 1000);
|
||||||
state.cancel();
|
state.cancel();
|
||||||
|
|
||||||
expect(state.resolvePending(1000 + DOUBLE_TAP_WINDOW_MS)).toBeNull();
|
// Without cancel() this would have been the seeking second tap.
|
||||||
|
expect(tap(state, RIGHT, 1100).action).toBe("togglePlayPause");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -123,8 +146,28 @@ describe("seek target resolution", () => {
|
|||||||
expect(resolveSeekTarget({ delta: -10, reportedPosition: 4, duration: DURATION })).toBe(0);
|
expect(resolveSeekTarget({ delta: -10, reportedPosition: 4, duration: DURATION })).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("clamps to the duration when skipping past the end", () => {
|
it("clamps short of the duration when skipping past the end", () => {
|
||||||
expect(resolveSeekTarget({ delta: 30, reportedPosition: 590, duration: DURATION })).toBe(DURATION);
|
// 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", () => {
|
it("chains off a pending target so rapid taps do not compound off a stale position", () => {
|
||||||
@@ -156,3 +199,78 @@ describe("seek target resolution", () => {
|
|||||||
expect(resolveSeekTarget({ delta: 30, reportedPosition: 100, duration: 0 })).toBe(130);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,18 +1,86 @@
|
|||||||
/**
|
/**
|
||||||
* Tap-gesture interpretation for the video player surface.
|
* Tap-gesture interpretation for the video player surface.
|
||||||
*
|
*
|
||||||
* Pulled out of `VideoPlayer.svelte` so the timing rules are unit-testable:
|
* Every tap acts IMMEDIATELY — there are only first and second taps, and no
|
||||||
* a tap cannot be classified at the moment it lands, because it may still turn
|
* deferral:
|
||||||
* out to be the first half of a double tap. Play/pause is therefore *deferred*
|
|
||||||
* until the double-tap window closes, and cancelled outright if a second tap
|
|
||||||
* arrives — otherwise a double tap both toggles pause and seeks.
|
|
||||||
*
|
*
|
||||||
* TRACES: UR-005, UR-061 | DR-092 | UT-085, UT-086, UT-087, UT-088
|
* 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 makes a double tap. */
|
/** A second tap within this window pairs with the previous one (seek + re-toggle). */
|
||||||
export const DOUBLE_TAP_WINDOW_MS = 300;
|
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. */
|
/** Double tap on the right half: skip forward. */
|
||||||
export const SEEK_FORWARD_SECONDS = 30;
|
export const SEEK_FORWARD_SECONDS = 30;
|
||||||
|
|
||||||
@@ -22,9 +90,18 @@ export const SEEK_BACKWARD_SECONDS = -10;
|
|||||||
export type TapFeedback = "left" | "right";
|
export type TapFeedback = "left" | "right";
|
||||||
|
|
||||||
export type TapOutcome =
|
export type TapOutcome =
|
||||||
/** Deferred: play/pause fires only if no second tap lands within the window. */
|
/** First tap: toggle play/pause right now. */
|
||||||
| { action: "pending"; pendingAfterMs: number }
|
| { action: "togglePlayPause" }
|
||||||
| { action: "seek"; seekSeconds: number; feedback: TapFeedback };
|
/**
|
||||||
|
* 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 {
|
export interface TapInput {
|
||||||
/** Tap x position, viewport pixels. */
|
/** Tap x position, viewport pixels. */
|
||||||
@@ -35,32 +112,20 @@ export interface TapInput {
|
|||||||
|
|
||||||
export interface TapGestureState {
|
export interface TapGestureState {
|
||||||
/**
|
/**
|
||||||
* Resolve a still-pending single tap. Returns the play/pause action once the
|
* Forget the previous tap, so the next one is treated as a first tap. Used
|
||||||
* double-tap window has elapsed, or null if there is nothing pending (the tap
|
* when the gesture turns out to be a swipe.
|
||||||
* became a double tap, or was cancelled).
|
|
||||||
*/
|
*/
|
||||||
resolvePending(now: number): { action: "togglePlayPause" } | null;
|
|
||||||
/** Drop any pending tap — used when the gesture turns into a swipe. */
|
|
||||||
cancel(): void;
|
cancel(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface InternalState extends TapGestureState {
|
interface InternalState extends TapGestureState {
|
||||||
lastTapTime: number;
|
lastTapTime: number;
|
||||||
pendingSince: number | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createTapGestureState(): TapGestureState {
|
export function createTapGestureState(): TapGestureState {
|
||||||
const state: InternalState = {
|
const state: InternalState = {
|
||||||
lastTapTime: 0,
|
lastTapTime: 0,
|
||||||
pendingSince: null,
|
|
||||||
resolvePending(now: number) {
|
|
||||||
if (state.pendingSince === null) return null;
|
|
||||||
if (now - state.pendingSince < DOUBLE_TAP_WINDOW_MS) return null;
|
|
||||||
state.pendingSince = null;
|
|
||||||
return { action: "togglePlayPause" };
|
|
||||||
},
|
|
||||||
cancel() {
|
cancel() {
|
||||||
state.pendingSince = null;
|
|
||||||
state.lastTapTime = 0;
|
state.lastTapTime = 0;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -68,27 +133,64 @@ export function createTapGestureState(): TapGestureState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Classify a tap. The first tap of a potential pair returns `pending` — the
|
* Classify a tap and return the action to perform *now*.
|
||||||
* caller schedules `resolvePending` after `pendingAfterMs`. A second tap inside
|
*
|
||||||
* the window returns the seek and clears the pending play/pause.
|
* 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 {
|
export function registerTap(state: TapGestureState, input: TapInput): TapOutcome {
|
||||||
const s = state as InternalState;
|
const s = state as InternalState;
|
||||||
const sinceLastTap = input.now - s.lastTapTime;
|
const sinceLastTap = input.now - s.lastTapTime;
|
||||||
|
|
||||||
if (s.lastTapTime > 0 && sinceLastTap > 0 && sinceLastTap < DOUBLE_TAP_WINDOW_MS) {
|
if (s.lastTapTime > 0 && sinceLastTap > 0 && sinceLastTap < DOUBLE_TAP_WINDOW_MS) {
|
||||||
// Second tap: cancel the deferred play/pause and seek instead.
|
s.lastTapTime = 0; // pair consumed; the next tap is a first tap again
|
||||||
s.pendingSince = null;
|
|
||||||
s.lastTapTime = 0; // consumed, so a third tap starts fresh
|
|
||||||
const isLeftSide = input.x < input.screenWidth / 2;
|
const isLeftSide = input.x < input.screenWidth / 2;
|
||||||
return isLeftSide
|
return isLeftSide
|
||||||
? { action: "seek", seekSeconds: SEEK_BACKWARD_SECONDS, feedback: "left" }
|
? {
|
||||||
: { action: "seek", seekSeconds: SEEK_FORWARD_SECONDS, feedback: "right" };
|
action: "seek",
|
||||||
|
seekSeconds: SEEK_BACKWARD_SECONDS,
|
||||||
|
feedback: "left",
|
||||||
|
togglePlayPause: true,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
action: "seek",
|
||||||
|
seekSeconds: SEEK_FORWARD_SECONDS,
|
||||||
|
feedback: "right",
|
||||||
|
togglePlayPause: true,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
s.lastTapTime = input.now;
|
s.lastTapTime = input.now;
|
||||||
s.pendingSince = input.now;
|
return { action: "togglePlayPause" };
|
||||||
return { action: "pending", pendingAfterMs: DOUBLE_TAP_WINDOW_MS };
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 {
|
export interface SeekTargetInput {
|
||||||
@@ -123,6 +225,10 @@ export function resolveSeekTarget(input: SeekTargetInput): number {
|
|||||||
|
|
||||||
const target = base + delta;
|
const target = base + delta;
|
||||||
if (target < 0) return 0;
|
if (target < 0) return 0;
|
||||||
if (duration > 0 && target > duration) return duration;
|
// 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;
|
return target;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,6 +95,63 @@ describe("Html5PlayerAdapter", () => {
|
|||||||
expect(video.play).toHaveBeenCalledTimes(1);
|
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 () => {
|
it("pause() calls element.pause()", async () => {
|
||||||
video.paused = false;
|
video.paused = false;
|
||||||
await adapter.pause();
|
await adapter.pause();
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
* intents flowing through the PlayerAdapter interface while preserving the
|
* intents flowing through the PlayerAdapter interface while preserving the
|
||||||
* hard-won element behavior verbatim.
|
* 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";
|
import type { AdapterHost, PlayerAdapter, PlayerLoadOptions } from "./types";
|
||||||
@@ -41,10 +41,24 @@ export interface Html5ElementBridge {
|
|||||||
getMediaSourceId(): string | null;
|
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 {
|
export class Html5PlayerAdapter implements PlayerAdapter {
|
||||||
readonly kind = "html5" as const;
|
readonly kind = "html5" as const;
|
||||||
|
|
||||||
private attachedElement: HTMLVideoElement | null = null;
|
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 host: AdapterHost;
|
||||||
private bridge: Html5ElementBridge;
|
private bridge: Html5ElementBridge;
|
||||||
|
|
||||||
@@ -81,12 +95,31 @@ export class Html5PlayerAdapter implements PlayerAdapter {
|
|||||||
async play(): Promise<void> {
|
async play(): Promise<void> {
|
||||||
const el = this.element;
|
const el = this.element;
|
||||||
if (!el) return;
|
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 {
|
try {
|
||||||
await el.play();
|
await el.play();
|
||||||
// handlePlay on the element reports "playing"; no double-report here.
|
// handlePlay on the element reports "playing"; no double-report here.
|
||||||
} catch (err) {
|
} 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}`);
|
this.host.onError(`play() failed: ${err}`);
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
this.pendingPlay = null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return this.pendingPlay;
|
||||||
}
|
}
|
||||||
|
|
||||||
async pause(): Promise<void> {
|
async pause(): Promise<void> {
|
||||||
|
|||||||
@@ -7,11 +7,19 @@
|
|||||||
* hls.js. State reporting is unnecessary here because the native backend emits
|
* hls.js. State reporting is unnecessary here because the native backend emits
|
||||||
* events directly — the adapter's job is only to forward control intents.
|
* events directly — the adapter's job is only to forward control intents.
|
||||||
*
|
*
|
||||||
* NOTE: On current Tauri, native Android video rendering is blocked upstream
|
* NOTE: This adapter is currently unreachable — `createAdapter()` hardcodes the
|
||||||
* (transparent webview / SurfaceView compositing — tauri#10152), so video on
|
* HTML5 kind, so Android video runs through Html5PlayerAdapter.
|
||||||
* 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
|
* That override was introduced citing tauri#10152 as an upstream blocker. That
|
||||||
* upstream limitation is resolved.
|
* 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
|
* TRACES: UR-003, UR-005 | DR-004, DR-028
|
||||||
*/
|
*/
|
||||||
|
|||||||
+15
-4
@@ -12,7 +12,7 @@
|
|||||||
* derived + merged (remote-session-aware) stores so UI can import state and
|
* derived + merged (remote-session-aware) stores so UI can import state and
|
||||||
* actions from one place, in both local and remote modes.
|
* 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";
|
import { get } from "svelte/store";
|
||||||
@@ -83,18 +83,29 @@ function requireHandle(): string {
|
|||||||
// Transport controls (no repository handle required)
|
// 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() {
|
async function play() {
|
||||||
if (activeAdapter) return void (await activeAdapter.play());
|
|
||||||
await commands.playerPlay();
|
await commands.playerPlay();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function pause() {
|
async function pause() {
|
||||||
if (activeAdapter) return void (await activeAdapter.pause());
|
|
||||||
await commands.playerPause();
|
await commands.playerPause();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function toggle() {
|
async function toggle() {
|
||||||
if (activeAdapter) return void (await activeAdapter.toggle());
|
|
||||||
await commands.playerToggle();
|
await commands.playerToggle();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
* frontend stores accordingly. This enables push-based updates instead
|
* frontend stores accordingly. This enables push-based updates instead
|
||||||
* of polling.
|
* 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";
|
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
|
* Route a backend-originated control command to the active player adapter, so a
|
||||||
* backend intent (lockscreen/remote/sleep) can drive the webview <video> element
|
* backend intent can drive the webview <video>/<audio> element that Rust cannot
|
||||||
* that Rust cannot reach directly. No-op when no video adapter is active (audio
|
* reach directly. No-op when no adapter is active (native playback is already
|
||||||
* playback is already fully backend-driven).
|
* 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 {
|
function handleControlCommand(action: string, position: number | null): void {
|
||||||
const adapter = playerController.getActiveAdapter();
|
const adapter = playerController.getActiveAdapter();
|
||||||
|
|||||||
+10
-10
@@ -5,7 +5,7 @@ import { writable, derived } from "svelte/store";
|
|||||||
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
|
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
|
||||||
import type { Library, MediaItem, SearchResult, Genre } from "$lib/api/types";
|
import type { Library, MediaItem, SearchResult, Genre } from "$lib/api/types";
|
||||||
import type { SearchOptions } from "$lib/api/bindings";
|
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";
|
import { auth } from "./auth";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -227,12 +227,13 @@ function createLibraryStore() {
|
|||||||
/**
|
/**
|
||||||
* Search the library, optionally narrowed to a scope.
|
* Search the library, optionally narrowed to a scope.
|
||||||
*
|
*
|
||||||
* `scope` is additive and defaults to `all`, which sends no
|
* The scope is sent **opaque**; Rust expands it into Jellyfin item types
|
||||||
* `includeItemTypes` at all — see scopeItemTypes() for why that differs from
|
* (`SearchScope::item_types()`) before the cache and server paths diverge, so
|
||||||
* listing every type. Both the online and offline repository paths already
|
* online and offline results filter identically. `all` resolves to no filter
|
||||||
* honour the 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") {
|
async function search(query: string, scope: SearchScope = "all") {
|
||||||
// Bump the request id for every call (including clears) so any in-flight
|
// 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
|
// Phase 1: the command resolves with instant local-cache results. The
|
||||||
// merged (cache + server) union arrives later via the `search-event`
|
// merged (cache + server) union arrives later via the `search-event`
|
||||||
// listener above, tagged with this same requestId.
|
// listener above, tagged with this same requestId.
|
||||||
const itemTypes = scopeItemTypes(scope);
|
// Send the opaque scope; Rust expands it to item types. The frontend
|
||||||
const options: SearchOptions = { limit: 10000 };
|
// never names a Jellyfin item type in connection with search.
|
||||||
// Omit the key entirely for the `all` scope rather than sending null.
|
const options: SearchOptions = { limit: 10000, scope };
|
||||||
if (itemTypes) options.includeItemTypes = itemTypes;
|
|
||||||
|
|
||||||
const result = await Promise.race([
|
const result = await Promise.race([
|
||||||
repo.search(query, options, requestId),
|
repo.search(query, options, requestId),
|
||||||
|
|||||||
@@ -32,35 +32,43 @@ describe("library.search scoping", () => {
|
|||||||
library.clearSearch();
|
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");
|
await library.search("office");
|
||||||
|
|
||||||
const options = searchMock.mock.calls[0][1];
|
const options = searchMock.mock.calls[0][1];
|
||||||
|
expect(options.scope).toBe("all");
|
||||||
expect(options).not.toHaveProperty("includeItemTypes");
|
expect(options).not.toHaveProperty("includeItemTypes");
|
||||||
expect(options.limit).toBe(10000);
|
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");
|
await library.search("office", "music");
|
||||||
|
|
||||||
expect(searchMock.mock.calls[0][1].includeItemTypes).toEqual([
|
const options = searchMock.mock.calls[0][1];
|
||||||
"MusicAlbum",
|
expect(options.scope).toBe("music");
|
||||||
"MusicArtist",
|
expect(options).not.toHaveProperty("includeItemTypes");
|
||||||
"Audio",
|
|
||||||
"Playlist",
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
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");
|
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");
|
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 () => {
|
it("stores results and the query on success", async () => {
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import {
|
|||||||
resolveSearchScope,
|
resolveSearchScope,
|
||||||
searchRouteUrl,
|
searchRouteUrl,
|
||||||
shouldNavigateToSearch,
|
shouldNavigateToSearch,
|
||||||
scopeItemTypes,
|
|
||||||
type SearchGroupId,
|
type SearchGroupId,
|
||||||
} from "./searchScope";
|
} from "./searchScope";
|
||||||
|
|
||||||
@@ -62,25 +61,12 @@ describe("resolveSearchScope", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("scopeItemTypes", () => {
|
// NOTE: the former `scopeItemTypes` suite moved to Rust — see
|
||||||
it("omits the key entirely for the all scope", () => {
|
// `search_scope_tests` in src-tauri/src/repository/types.rs. The scope →
|
||||||
// `all` must send no includeItemTypes — an explicit union would silently
|
// item-type expansion is domain vocabulary and is no longer reachable from the
|
||||||
// drop types nobody enumerated (Person, folders).
|
// frontend, so testing it here would mean re-introducing the leak to test it.
|
||||||
expect(scopeItemTypes("all")).toBeUndefined();
|
// The "fresh array" test is gone because `item_types()` returns an owned Vec,
|
||||||
});
|
// making the aliasing bug it guarded structurally impossible.
|
||||||
|
|
||||||
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"]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("normalizeGroupOrder", () => {
|
describe("normalizeGroupOrder", () => {
|
||||||
it("returns the default for missing or non-array input", () => {
|
it("returns the default for missing or non-array input", () => {
|
||||||
|
|||||||
@@ -8,7 +8,11 @@
|
|||||||
//
|
//
|
||||||
// TRACES: UR-049, UR-050 | DR-063, DR-066, DR-067
|
// 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"];
|
export const SEARCH_SCOPES: readonly SearchScope[] = ["all", "music", "movies", "tv"];
|
||||||
|
|
||||||
@@ -19,29 +23,11 @@ export const SCOPE_LABELS: Record<SearchScope, string> = {
|
|||||||
tv: "TV",
|
tv: "TV",
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
// NOTE: the scope → Jellyfin item-type mapping deliberately does NOT live here.
|
||||||
* Jellyfin item types requested for each scope.
|
// It is domain vocabulary and lives in Rust (`SearchScope::item_types()` in
|
||||||
*
|
// repository/types.rs); the frontend sends the opaque scope and the backend
|
||||||
* `all` is deliberately absent: sending no `includeItemTypes` is *not* the same
|
// expands it. Re-introducing a `{ music: ["MusicAlbum", …] }` table in this file
|
||||||
* as sending the union of the lists below — types nobody enumerated here
|
// is the boundary leak documented in docs/specs/scoped-search-boundary.md.
|
||||||
* (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]];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve the scope a search started from a given route should default to.
|
* Resolve the scope a search started from a given route should default to.
|
||||||
|
|||||||
+3
-1
@@ -8,7 +8,9 @@ export default defineConfig({
|
|||||||
globals: true,
|
globals: true,
|
||||||
environment: "jsdom",
|
environment: "jsdom",
|
||||||
setupFiles: ["./src/test/setup-globals.ts", "./src/test/setup.ts"],
|
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: {
|
coverage: {
|
||||||
provider: "v8",
|
provider: "v8",
|
||||||
reporter: ["text", "json", "html"],
|
reporter: ["text", "json", "html"],
|
||||||
|
|||||||
Reference in New Issue
Block a user