From 32043a2152f1a6a2a094b961f277a802fc4bbff6 Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Fri, 21 Aug 2026 18:15:58 +0200 Subject: [PATCH] docs: fold shipped specs into the architecture docs and delete them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A spec was a promise; sixteen of them had become descriptions of code that already shipped, sitting beside four that describe work still outstanding, with nothing in the file telling the two apart. Half the statuses were also wrong — audio-equalizer read "Accepted" with the EQ live on both platforms, the native video spec said the flag stays off after the default was flipped on. The shipped designs move into docs/architecture, which is the maintained description of the build, and the spec files go. Git history keeps the originals; what a future change still needs is carried across: - 01-rust-backend: favourites rewritten (the old section named a file that no longer exists and called shipped buttons "planned"), domain vocabulary owned by Rust (SearchScope, exclusions, the bitrate ladder), background workers - 02-svelte-frontend: app shell and chrome, library mosaic, series/episode navigation, downloaded browse, safe-area insets, native-video store, logging - 03-data-flow: locally-indexed search - 05-platform-backends: audio settings on ExoPlayer, the equalizer's band vocabulary, native video compositing, the background-audio handoff - 06-downloads-and-offline: one storage model, offline catalog visibility - 09-security: path confinement and input binding docs/specs/README.md now says what the directory is for and where each shipped design went. Deferred work the specs recorded is kept beside the code it concerns rather than lost: season-bounded autoplay, the two dead search commands, why indexing is a full crawl. requirements.md had fourteen stale statuses — Android audio parity still read "Linux only", DR-150 still said the native-video default was off, DR-190 was Proposed after DR-196 implemented it, and five tooling requirements were Proposed after landing. Three unbuilt specs suggested requirement ids that have since been allocated to other work; each now carries a warning. --- .gitea/workflows/traceability-check.yml | 2 +- docs-site/SUMMARY.md | 31 +- docs/architecture/01-rust-backend.md | 209 +++++++-- docs/architecture/02-svelte-frontend.md | 185 ++++++++ docs/architecture/03-data-flow.md | 55 +++ docs/architecture/05-platform-backends.md | 178 ++++++++ docs/architecture/06-downloads-and-offline.md | 54 ++- docs/architecture/09-security.md | 35 ++ docs/architecture/README.md | 46 +- docs/requirements.md | 38 +- docs/specs/README.md | 79 ++++ docs/specs/account-menu.md | 164 ------- docs/specs/android-audio-settings-parity.md | 209 --------- docs/specs/android-native-video-spike.md | 306 ------------- docs/specs/audio-equalizer.md | 176 -------- docs/specs/boundary-tripwire-hardening.md | 215 ---------- docs/specs/build-provenance.md | 10 +- docs/specs/catalog-index-search.md | 334 --------------- docs/specs/downloads-as-offline-library.md | 166 -------- docs/specs/favorites-browsing.md | 403 ------------------ docs/specs/libmpv2-migration.md | 4 +- docs/specs/library-mosaic.md | 125 ------ docs/specs/offline-downloaded-only-filter.md | 235 ---------- docs/specs/playback-backend-unification.md | 9 +- docs/specs/playback-docs-corrections.md | 198 --------- docs/specs/player-facade-enforcement.md | 9 +- docs/specs/read-through-media-cache.md | 18 +- docs/specs/req-coverage-script-removal.md | 161 ------- .../scoped-search-boundary-implementation.md | 8 +- docs/specs/scoped-search-boundary.md | 9 +- .../series-current-episode-navigation.md | 239 ----------- docs/specs/streaming-bitrate-cap.md | 146 ------- docs/specs/traceability-gate-repair.md | 238 ----------- docs/specs/video-background-audio.md | 233 ---------- docs/specs/windows-native-audio-backend.md | 8 +- docs/traceability-ci.md | 2 +- docs/ux-flows.md | 2 +- scripts/README.md | 3 +- scripts/check-frontend-boundary.sh | 5 +- scripts/extract-traces.ts | 2 +- src-tauri/src/commands/catalog.rs | 3 +- src-tauri/src/player/mpv_backend.rs | 2 +- src-tauri/src/settings.rs | 7 +- src/lib/api/bindings.ts | 3 +- src/lib/player/adapters/nativeAdapter.ts | 3 +- src/lib/services/downloadedCatalog.ts | 3 +- src/lib/services/offlineCatalog.test.ts | 3 +- src/lib/stores/connectivity.test.ts | 3 +- 48 files changed, 890 insertions(+), 3686 deletions(-) create mode 100644 docs/specs/README.md delete mode 100644 docs/specs/account-menu.md delete mode 100644 docs/specs/android-audio-settings-parity.md delete mode 100644 docs/specs/android-native-video-spike.md delete mode 100644 docs/specs/audio-equalizer.md delete mode 100644 docs/specs/boundary-tripwire-hardening.md delete mode 100644 docs/specs/catalog-index-search.md delete mode 100644 docs/specs/downloads-as-offline-library.md delete mode 100644 docs/specs/favorites-browsing.md delete mode 100644 docs/specs/library-mosaic.md delete mode 100644 docs/specs/offline-downloaded-only-filter.md delete mode 100644 docs/specs/playback-docs-corrections.md delete mode 100644 docs/specs/req-coverage-script-removal.md delete mode 100644 docs/specs/series-current-episode-navigation.md delete mode 100644 docs/specs/streaming-bitrate-cap.md delete mode 100644 docs/specs/traceability-gate-repair.md delete mode 100644 docs/specs/video-background-audio.md diff --git a/.gitea/workflows/traceability-check.yml b/.gitea/workflows/traceability-check.yml index 52043f0d..ed5f6d7b 100644 --- a/.gitea/workflows/traceability-check.yml +++ b/.gitea/workflows/traceability-check.yml @@ -46,7 +46,7 @@ jobs: # 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. + # below could never trip. See docs/traceability-ci.md. TOTAL_TRACES=$(jq '.totalTraces' traces-report.json) COVERED=$(jq '.coverage.covered' traces-report.json) TOTAL_REQS=$(jq '.coverage.total' traces-report.json) diff --git a/docs-site/SUMMARY.md b/docs-site/SUMMARY.md index 855a495e..c8733acd 100644 --- a/docs-site/SUMMARY.md +++ b/docs-site/SUMMARY.md @@ -26,47 +26,20 @@ - [UX Flows](ux-flows.md) -# Specs — Writing One +# Specs — Pending Work +- [Specs Index](specs/README.md) - [Spec Template](specs/SPEC-TEMPLATE.md) - [Spec Review Checklist](specs/SPEC-REVIEW-CHECKLIST.md) - -# Specs — Playback & Player - - [Playback Backend Unification](specs/playback-backend-unification.md) - [Player Facade Enforcement](specs/player-facade-enforcement.md) -- [Playback Documentation Corrections](specs/playback-docs-corrections.md) -- [Video Background Audio](specs/video-background-audio.md) -- [Android Native Video Spike](specs/android-native-video-spike.md) -- [Android Audio Settings Parity](specs/android-audio-settings-parity.md) -- [Audio Equalizer](specs/audio-equalizer.md) - [Windows Native Audio Backend](specs/windows-native-audio-backend.md) - [libmpv2 Migration](specs/libmpv2-migration.md) -- [Streaming Bitrate Cap](specs/streaming-bitrate-cap.md) - [Read-Through Media Cache](specs/read-through-media-cache.md) - -# Specs — Library & Browsing - - [Scoped Search](specs/scoped-search.md) - [Scoped Search Boundary](specs/scoped-search-boundary.md) - [Scoped Search Boundary — Implementation](specs/scoped-search-boundary-implementation.md) -- [Locally-Indexed Search](specs/catalog-index-search.md) -- [Favourites Browsing](specs/favorites-browsing.md) -- [Library Mosaic](specs/library-mosaic.md) -- [Series Current-Episode Navigation](specs/series-current-episode-navigation.md) -- [Account Menu](specs/account-menu.md) - [Frontend Domain Model](specs/frontend-domain-model.md) - -# Specs — Downloads & Offline - -- [Downloads as an Offline Library](specs/downloads-as-offline-library.md) -- [Offline Downloaded-Only Filter](specs/offline-downloaded-only-filter.md) - -# Specs — Tooling & Build - -- [Traceability Gate Repair](specs/traceability-gate-repair.md) -- [Boundary Tripwire Hardening](specs/boundary-tripwire-hardening.md) -- [Requirement-Coverage Script Removal](specs/req-coverage-script-removal.md) - [Build Provenance](specs/build-provenance.md) # Build & Release diff --git a/docs/architecture/01-rust-backend.md b/docs/architecture/01-rust-backend.md index 9044af49..8486aeb0 100644 --- a/docs/architecture/01-rust-backend.md +++ b/docs/architecture/01-rust-backend.md @@ -376,57 +376,77 @@ flowchart TB ## Favorites System **Location**: -- Service: `src/lib/services/favorites.ts` -- Component: `src/lib/components/FavoriteButton.svelte` -- Backend: `src-tauri/src/commands/storage.rs` +- Commands: `src-tauri/src/commands/favorites.rs` (offline drain), + `src-tauri/src/commands/repository.rs` (query + toggle), + `src-tauri/src/commands/storage/` (local `user_data` writes) +- Repository: `get_favorites` on the trait, implemented by `online.rs`, + `offline.rs` and `hybrid.rs` +- Frontend: `src/lib/services/favorites.ts`, + `src/lib/components/FavoriteButton.svelte`, `/library/favorites` -The favorites system implements optimistic updates with server synchronization: +Favouriting has two halves that are easy to confuse: **marking** an item, which +has existed since UR-017, and **browsing** what was marked, which arrived with +UR-067…069 (DR-113 … DR-120). Both go through the repository, not around it. + +### Marking + +Optimistic local write, then server sync: ```mermaid flowchart TB UI[FavoriteButton] -->|Click| Service[toggleFavorite] - Service -->|1. Optimistic| LocalDB[(SQLite user_data)] - Service -->|2. Sync| JellyfinAPI[Jellyfin API] - Service -->|3. Mark Synced| LocalDB - - JellyfinAPI -->|POST| MarkFav["/Users/{id}/FavoriteItems/{itemId}"] - JellyfinAPI -->|DELETE| UnmarkFav["/Users/{id}/FavoriteItems/{itemId}"] - - LocalDB -->|is_favorite
pending_sync| UserData[user_data table] + Service -->|"1. Optimistic"| LocalDB[("SQLite user_data
is_favorite, pending_sync")] + Service -->|"2. Sync"| Repo[Repository] + Repo -->|POST / DELETE| JellyfinAPI["/Users/{id}/FavoriteItems/{itemId}"] + Service -->|"3. Mark synced"| LocalDB + Drain["spawn_favorites_drain
(background task)"] -->|"pending_sync = 1"| Repo ``` -**Flow**: -1. User clicks heart button in UI (MiniPlayer, AudioPlayer, or detail pages) -2. `toggleFavorite()` service function handles the logic: - - Updates local SQLite database immediately (optimistic update) - - Attempts to sync with Jellyfin server - - Marks as synced if successful, otherwise leaves `pending_sync = 1` -3. UI reflects the change immediately without waiting for server response +1. The local row is updated immediately, so the heart fills without a round trip. +2. The repository is asked to mark or unmark on the server. +3. On success `pending_sync` is cleared; on failure the row stays pending. +4. A **background drain** (`spawn_favorites_drain`, started in `lib.rs` setup) + retries pending rows, so a favourite marked offline still reaches the server + (DR-120). This is the same pattern as the sync-queue drain — see + [Background workers](#background-workers). -**Components**: +### Browsing -- **FavoriteButton.svelte**: Reusable heart button component - - Configurable size (sm/md/lg) - - Red when favorited, gray when not - - Loading state during toggle - - Bindable `isFavorite` prop for two-way binding +`get_favorites(scope, options)` answers "what did this user favourite", across +libraries, with the **scope owned by Rust** — the frontend sends a +[`SearchScope`](#search-scope-and-the-taxonomy-boundary) variant and never names +an item type. `HybridRepository` splits it the same way it splits every query: -- **Integration Points**: - - MiniPlayer: Shows favorite button for audio tracks (hidden on small screens) - - Full AudioPlayer: Shows favorite button (planned) - - Album/Artist detail pages: Shows favorite button (planned) +| Method | Used for | +|--------|----------| +| `get_favorites_cache_only` | The instant leg — the local `user_data` join | +| `get_favorites_server_only` | The reconciliation leg | +| `get_favorites` | Cache-first with server merge, per the repository's usual policy | -**Database Schema**: -- `user_data.is_favorite`: Boolean flag (stored as INTEGER 0/1) -- `user_data.pending_sync`: Indicates if local changes need syncing +`GetItemsOptions.favorites_only` is the other entry point: it filters an +*existing* library listing rather than starting a cross-library query (DR-116), +which is what a library page's favourites filter uses. -**Tauri Commands**: -- `storage_toggle_favorite`: Updates favorite status in local database -- `storage_mark_synced`: Clears pending_sync flag after successful sync +Server favourite state is mirrored into the local `user_data` table on catalog +sync (DR-113/DR-114), so a favourite marked in another Jellyfin client shows up +here — before this, `MediaItem.user_data` was left empty and no query anywhere +asked for favourites. -**API Methods**: -- `LibraryApi.markFavorite(itemId)`: POST to Jellyfin -- `LibraryApi.unmarkFavorite(itemId)`: DELETE from Jellyfin +**Tauri commands**: + +| Command | Description | +|---------|-------------| +| `repository_get_favorites` | Cross-library favourites for a scope | +| `repository_mark_favorite` / `repository_unmark_favorite` | Toggle on the server, through the repository | +| `storage_toggle_favorite` | Local optimistic write (`is_favorite`, `pending_sync`) | +| `storage_mark_synced` | Clear `pending_sync` after a successful server write | + +**Frontend surfaces** (DR-117 … DR-119): the `/library/favorites` page with a +scope selector, favourite rows on home (`favoriteMovies` / `favoriteShows` / +`favoriteMusic` in `stores/home.ts`), a favourites tile per category in the +library mosaic, and `FavoriteButton` mounted wherever a whole item is shown — +movie, series, episode, album, artist and playlist detail views as well as the +mini player. ## Player Backend Trait @@ -569,3 +589,116 @@ async fn move_playlist_item(&self, playlist_id: &str, item_id: &str, new_index: | `player_set_autoplay_settings` | `settings: AutoplaySettings` | `AutoplaySettings` | | `player_get_autoplay_settings` | - | `AutoplaySettings` | | `player_on_playback_ended` | - | `()` | + +## Domain Vocabulary Owned by Rust + +The frontend is presentation-only and must not encode Jellyfin's *taxonomy* — the +rule in [CLAUDE.md](../../CLAUDE.md) and +[scoped-search-boundary.md](../specs/scoped-search-boundary.md). These are the +places where that vocabulary actually lives. + +### Search scope and the taxonomy boundary + +**Location**: `src-tauri/src/repository/types.rs` + +`SearchScope` is the canonical example the boundary rule is taught from. The +frontend sends an opaque variant; Rust expands it into Jellyfin item types: + +```rust +pub enum SearchScope { All, Music, Movies, Tv } + +impl SearchScope { + /// The Jellyfin item types this scope requests, or `None` for `All`. + pub fn item_types(self) -> Option> { … } + + /// The scope a library of this Jellyfin `CollectionType` belongs to. + pub fn for_collection_type(collection_type: &str) -> Option { … } +} +``` + +Two details that are load-bearing: + +- `All` returns `None`, **not** the union of every listed type. 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`. +- `for_collection_type` maps a Jellyfin `CollectionType` to a favourites + category (DR-175). It changes when *Jellyfin* renames a collection type, not + when the library page is redesigned — which is the test for whether something + belongs on this side of the boundary. + +⚠️ **The result side has not moved yet.** `GROUP_ITEM_TYPES` in +`src/lib/utils/searchScope.ts` still maps result groups to item types in the +frontend, and `check:boundary` does not match its shape. Tracked as Stage 2 of +[scoped-search-boundary-implementation.md](../specs/scoped-search-boundary-implementation.md). + +### Library exclusions + +**Location**: `src-tauri/src/repository/exclusions.rs` (TRACES: UR-076 | DR-209) + +Folders the user has chosen to keep out of music browsing — a "Podcasts" folder +inside a music library being the canonical case. Excluded **by item id**, not by +name, in a process-wide `RwLock>` restored from the database at +startup, and applied by the repository layer to every music query (libraries, +artists, albums, genres, search, home rows). + +The id is normalised (`trim`, strip `-`, lowercase) because Jellyfin writes the +same GUID both dashed and undashed depending on the endpoint. The predecessor was +a frontend filter matching the English string "Podcasts" — wrong in three ways at +once, and the reason this lives in the repository. + +The set is process-wide rather than a field on a repository for the same reason +as `online::STREAMING_QUALITY`: it is a preference about *this user's browsing*, +not about a server session, so it must survive a repository being rebuilt on +re-login. + +### Streaming quality ladder + +**Location**: `src-tauri/src/settings.rs` (TRACES: UR-074 | DR-162) + +`StreamingQuality` is a bandwidth ladder (`Original`, 20/10/8/4/2/1 Mbps, +720 kbps), not a resolution picker: it exists to fit a connection, and the +resolution cap is chosen *from* the bitrate so the encoder does not spend a small +budget on pixels it cannot afford. + +| Method | Answers | +|--------|---------| +| `max_bitrate()` | Total bits/s (video + audio), `None` for `Original` | +| `audio_bitrate()` | The audio share — shrinks down the ladder, so 384 kbps is not a third of the budget at the bottom | +| `video_bitrate()` | Total minus audio, so the two together honour the ceiling | +| `max_height()` | Resolution ceiling that suits the bitrate | + +The ceiling goes to `PlaybackInfo` as `MaxStreamingBitrate` **and** into the +device profile. Sending it there — not just on the transcode URL — is what makes +the cap real: a stream the server decides to *direct play* is served at the +source file's own bitrate, and no URL parameter afterwards can reduce it. + +The frontend names a variant and nothing else; the labels the picker shows are +served over IPC by `player_get_streaming_qualities`. + +## Background workers + +Three long-lived tasks are spawned from the Tauri `setup` hook in `lib.rs`. All +three exist because *when* something happens is a backend policy, not something +a page load should decide. + +| Worker | Location | Responsibility | +|--------|----------|----------------| +| `spawn_catalog_indexer` | `commands/catalog.rs` | Keeps the local FTS5 catalog fresh (DR-109, IR-030) | +| `spawn_favorites_drain` | `commands/favorites.rs` | Retries favourite toggles made while offline (DR-120) | +| `spawn_sync_queue_drain` | `commands/sync_drain.rs` | Drains the offline mutation queue (DR-131) | + +### Catalog indexer + +Replaces the frontend's startup-only `syncCatalog()` call. It ticks on +`CATALOG_INDEX_TICK` and runs a pass when three things hold: a repository exists, +the server is reachable, and the index is due per `index_is_due`. A tick is +nearly free — one indexed `app_settings` lookup — which is what makes it +responsive to events it cannot subscribe to, such as signing in: a fresh install +would otherwise sit unindexed until the next scheduled pass. + +`index_is_due` treats both "never indexed" and an unparseable stored timestamp as +due; a corrupt timestamp should trigger a re-index, not silently freeze the +catalog. A failed pass is never fatal — it leaves the existing index in place and +warns. Progress is emitted on `CATALOG_INDEX_EVENT` for the staleness hint in the +UI. diff --git a/docs/architecture/02-svelte-frontend.md b/docs/architecture/02-svelte-frontend.md index a423eb54..539253bb 100644 --- a/docs/architecture/02-svelte-frontend.md +++ b/docs/architecture/02-svelte-frontend.md @@ -538,6 +538,14 @@ sequenceDiagram ## Auto-Play Episode Limit +> ⚠️ **Autoplay is season-bounded.** `player/mod.rs:fetch_next_episode_for_item` +> does not cross a season boundary, so autoplay stops at the end of a season even +> though the "More Episodes" strip runs past it. Fixing it should reuse +> `repository_get_series_episodes`, but it touches the playback state machine and +> the Android JNI advance path (see the `AutoplayDecision` deadlock note in +> [CLAUDE.md](../../CLAUDE.md)) — its own change, not a drive-by. + + **Location**: `src-tauri/src/player/mod.rs`, `src-tauri/src/player/autoplay.rs`, `src-tauri/src/settings.rs` **TRACES**: UR-023 | DR-049 @@ -657,3 +665,180 @@ The playlist UI provides full CRUD operations for Jellyfin playlists with offlin All playlist mutations are queued for offline sync: - `queuePlaylistCreate`, `queuePlaylistDelete`, `queuePlaylistRename` - `queuePlaylistAddItems`, `queuePlaylistRemoveItems`, `queuePlaylistReorderItem` + +## App Shell and Chrome + +**Location**: `src/lib/utils/layoutShell.ts` (pure rules), +`src/lib/components/AppHeader.svelte`, +`src/lib/components/account/AccountMenu.svelte`, `BottomUi.svelte` +**TRACES**: UR-054 | DR-075, DR-076, DR-077 + +Account actions used to be reachable **only from `/library/*`** — the header +that hosted them belonged to the library layout, the bottom nav offered Home / +Search / Library, and the desktop username was inert text. From `/`, `/search` +or `/downloads` there was no route to Settings or Sign out at all. The header is +now shared and rendered from the root layout. + +### Visibility rules + +All four rules are pure functions in `layoutShell.ts`, so the contract is +unit-testable rather than a scattering of `$derived` booleans that drift per +route and platform (which is what they were): + +| Function | Rule | +|----------|------| +| `showBottomNav` | Every authenticated route except `/player/*` and `/login` | +| `showGlobalMiniPlayer` | Everything except `/player/*`, `/login`, `/settings`. **Not** gated on platform or `/library` — the root owns the mini player everywhere, so the library route must never render a second one | +| `routeOwnsLayout` | `/library`, `/player/`, `/login` render their own full-height flex column; everything else renders into the root scroller | +| `showGlobalHeader` | Authenticated, not a layout-owning route, not `/settings` (the user is already there) | + +### The structural fix worth not undoing + +The "last row hidden behind the nav" bug is solved **structurally, not by +measurement**: the bottom UI is an in-flow flex child *below* the scroller +(`BottomUi.svelte`), so the scroller is physically bounded above it and cannot +render behind it. There is no measurement and no reserved padding. If you +restructure the shell, preserve the scroll containment — reintroducing padding +math reintroduces the bug. + +### AccountMenu + +One component for both breakpoints, anchored to the username/avatar (a real +button with `aria-expanded`, not a bare three-dot icon). Fixed item order: +identity block (user + server) → Downloads, Settings, Display → divider → Sign +out, destructive and last. Dismissal is backdrop click, `Escape`, and focus +return to the trigger. + +The identity block falls back to the bare host of the server URL when the server +has no human-readable name, so it always shows *something* server-identifying. + +Settings' Display section and the library page-header toggle are two views onto +the **same** persisted `viewMode` store (`jellytau-view-mode`) — no second state, +no migration, and they stay in sync for free. + +## Library Mosaic + +**Location**: `src/lib/components/library/libraryMosaic.ts` (pure), +`MosaicGrid.svelte`, `MosaicTile.svelte` +**TRACES**: UR-075, UR-067 | DR-174, DR-175 + +The library overview and the home "Your Libraries" strip are a **mosaic**, not a +grid: rows share one height and each tile is as wide as its own artwork is, so a +square music cover, a 16:9 library backdrop and a 2:3 poster sit in the same row +at their own proportions instead of all three being cropped into whichever box a +grid picked. + +`libraryMosaic.ts` is deliberately pure — it takes the libraries and returns the +tiles to draw, so ordering and de-duplication are unit-testable rather than +buried in markup. Tiles start at an *assumed* aspect (square, 16:9) and a +measured image overrides it in `MosaicGrid`. + +Note what this file does **not** decide: which favourites category a library +belongs to. That is Jellyfin vocabulary and arrives on the library itself as +`favoritesScope`, from `SearchScope::for_collection_type` in Rust (see +[01-rust-backend.md](01-rust-backend.md#search-scope-and-the-taxonomy-boundary)). +The frontend only decides what to *call* it and where to put it. + +## Series and Episode Navigation + +**Location**: `src/lib/components/library/` — `SeasonSection.svelte`, +`EpisodeFocusView.svelte`, `episodeStrip.ts` (pure) +**TRACES**: UR-062 … UR-064 | DR-101 … DR-107 + +Opening a series lands the viewer where they actually are in it. **"Where is this +viewer in this series" is resolved in Rust** (DR-101), not by the page: the +series detail page asks the repository and anchors on the answer — the current +season expanded, the current episode highlighted and scrolled into view, and a +hero button labelled `Resume S2E4` / `Play S1E1`. + +A season is not a destination: `/library/` redirects to its series +(DR-103). Video library routes collapse to one per library (DR-105). + +`episodeStrip.ts` holds the pure logic for the "More Episodes" strip, extracted +from the component because it had three distinct bugs that markup made +untestable: the strip collapsing to just the current episode while real siblings +existed, number-less episodes all matching as "current" (`undefined === +undefined`), and the window dead-ending at a season boundary instead of running +past it. It matches by id first and only falls back to season+episode number when +both numbers are known on both sides. + +## Downloaded Browse + +**Location**: `src/lib/services/downloadedCatalog.ts`, +`src/lib/components/downloads/DownloadedBrowse.svelte` +**TRACES**: UR-055, UR-056 | DR-081 … DR-085 + +`/downloads` is two views: **Downloaded** (the default) — the library filtered to +what is on the device, reusing the same grids, cards and detail pages as online +browsing — and **Transfers**, the in-flight progress rows demoted to a secondary +tab. + +`downloadedCatalog` reads the **offline-only** browse path on the repository, +never the hybrid merge. That is the point: an empty result means "nothing +downloaded here", never "server unreachable", so the view is authoritative +regardless of connectivity. It also owns disk usage — a per-item/container byte +map plus the device total, aggregated by the backend from `downloads.file_size` +(DR-085). + +## Safe-area Insets + +**Location**: `src/app.css`, `WindowInsetsBridge.kt` +**TRACES**: UR-066 | DR-112, IR-031 + +The Android WebView does not reliably report system-bar insets through +`env(safe-area-inset-*)`. Native `WindowInsets` (`systemBars() | +displayCutout()`) are therefore pushed in as CSS custom properties, and every +edge takes the larger of the two sources: + +```css +--safe-top: max(env(safe-area-inset-top, 0px), var(--jt-inset-top, 0px)); +``` + +Two rules keep this from going wrong: **one owner per edge** (two components both +padding the top edge double-pads it), and **no nested `h-screen`** — a full-height +child inside a full-height parent that has already consumed the inset overflows +by exactly the inset. + +Unlike `addJavascriptInterface`, the inset push only writes CSS properties, so it +can safely be re-sent on resume. + +## Native Video Store + +**Location**: `src/lib/stores/nativeVideo.ts` +**TRACES**: UR-003, UR-004 | DR-188 + +Two separate concerns live here, deliberately: + +- `experimentalNativeVideo` — the user-facing opt-in flag, **defaulting to on**. + Rust already decides *which backend this platform has* (`useHtml5Element` from + `player_play_item`); this flag only *suppresses* that decision. It never turns + native on where Rust says HTML5. An explicit stored choice wins in both + directions, so someone who opted out is not re-enabled by a default flip — + hence the `null` check rather than a bare `=== "true"`. +- `nativeVideoActive` — whether a native surface is on screen *right now*. + Setting it toggles `data-native-video` on ``, which is what the CSS in + `app.css` keys off to clear the app's opaque backgrounds. It is deliberately + **not** derived from the flag: the backgrounds must come back the moment the + player unmounts. + +See [05-platform-backends.md](05-platform-backends.md#native-video-compositing-android) +for what is behind the WebView. + +## Logging + +**Location**: `src/lib/utils/logger.ts` +**TRACES**: DR-204 + +The frontend's equivalent of the Rust `log` crate: four levels +(`debug < info < warn < error`), a compile-environment default (dev → `debug`, +production → `warn`), and a runtime override that is the moral equivalent of +`RUST_LOG`. Scoped loggers carry the subsystem in the message, so a filtered +console stays usable while a player, a download worker and a store are all +talking. + +Production deliberately keeps **warn and error**: this is a client talking to a +server that may or may not be there, and a silent failure is worse to support +than a noisy console. Only the chatter is suppressed. + +`no-console` is an ESLint **error**, with the sink module itself the only +exception, so a raw `console.*` cannot re-appear. diff --git a/docs/architecture/03-data-flow.md b/docs/architecture/03-data-flow.md index 4e07663c..140099cd 100644 --- a/docs/architecture/03-data-flow.md +++ b/docs/architecture/03-data-flow.md @@ -49,6 +49,61 @@ sequenceDiagram - Background cache updates (planned) - **Connectivity side-effect**: each server request feeds the `ConnectivityMonitor`, which is the source of truth for the offline/online banner (see [07-connectivity.md](07-connectivity.md)). A server-answered error (401/404/5xx) still counts as *reachable* — only network failures, sustained past a debounce window, flip the app to offline. +## Search Flow (Locally Indexed) + +**TRACES**: UR-065 | DR-108 … DR-111, IR-030 + +Search does not depend on a per-keystroke round trip to Jellyfin. The instant leg +reads the **local SQLite catalog**, which is already synced and already +FTS5-indexed, so results appear as fast as SQLite can answer — online or offline. +The server query stays, demoted to a background reconciliation that merges in +late results. + +```mermaid +sequenceDiagram + participant UI as Search UI + participant Rust as repository_search + participant Cache as Local catalog (FTS5) + participant Server as Jellyfin + participant Indexer as spawn_catalog_indexer + + UI->>Rust: search(query, scope) + Rust->>Cache: FTS5 query, scope expanded by SearchScope::item_types() + Cache-->>UI: instant results + Rust->>Server: reconciliation query (background) + Server-->>UI: search-event with late/merged results + Note over Indexer,Cache: Independent of any query:
scheduled crawl keeps the index fresh,
prunes items deleted on the server +``` + +**Key points:** + +- The **scope is opaque on the wire**. The frontend sends a `SearchScope` + variant; Rust expands it to item types + ([01-rust-backend.md](01-rust-backend.md#search-scope-and-the-taxonomy-boundary)). +- **Index freshness is a Rust policy**, not a frontend startup call — a scheduled + background pass, not "whatever was synced when the app last launched" + (DR-109). See + [Background workers](01-rust-backend.md#background-workers). +- **Index hygiene matters as much as freshness**: the catalog save path uses + `INSERT OR REPLACE` and the crawl prunes rows for content deleted on the + server, or search keeps returning items that no longer exist (DR-110). +- The index covers **exactly the types the result groups render** (DR-111) — + including Artists, which the crawl must reach or the Artists group is silently + always empty. + +**Deliberately not done, with reasons:** + +- **Incremental indexing** (Jellyfin's `MinDateLastSaved`). A *full* crawl is + what makes the deletion sweep sound — it yields the authoritative id set per + library, and an incremental pass cannot detect deletions. Worth revisiting if + full crawls prove slow on large libraries; measure first. +- **Removing the server leg.** The reconciliation query stays. + +> ⚠️ Two dead search implementations still exist: `storage_search_items` +> (`commands/storage/mod.rs`) and `offline_search` (`commands/offline.rs`). Both +> are registered in `lib.rs` and exported to `bindings.ts`; neither is called +> from the frontend. Deleting them is correct and unclaimed. + ## Playback Initiation Flow ```mermaid diff --git a/docs/architecture/05-platform-backends.md b/docs/architecture/05-platform-backends.md index 6429b1da..bcee2598 100644 --- a/docs/architecture/05-platform-backends.md +++ b/docs/architecture/05-platform-backends.md @@ -247,6 +247,184 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO } ``` +### Audio settings on ExoPlayer + +**TRACES**: UR-027, UR-032, UR-033 | DR-030, DR-035, DR-036 + +`PlayerBackend` declares `set_audio_settings` with a default `Ok(())` body. For a +long time `ExoPlayerBackend` took that default, so Settings › Audio rendered +controls that silently did nothing on Android — the parity gap recorded in +[requirements.md](../requirements.md#platform-playback-backend-parity-linux-vs-android), +now closed. + +The settings cross to Kotlin as **JSON over JNI**, not as a wide signature, so new +fields do not change the method signature — the same approach `load()` uses for +subtitles: + +```rust +fn set_audio_settings(&mut self, settings: &AudioSettings) -> Result<(), PlayerError> { + let json = audio_settings_jni_payload(settings)?; + env.call_method(&self.player_ref, "setAudioSettings", "(Ljava/lang/String;)V", …)?; + // Store the sanitised form, so audio_settings() reflects what was applied. + self.shared_state.lock_safe().audio_settings = + settings.clone().with_crossfade_clamped().with_equalizer_normalised(); +} +``` + +Kotlin owns the *mechanics* — attaching `AudioEffect`s to the audio session — while +the canonical band layout and preset curves stay in Rust: + +| Feature | Android mechanism | Notes | +|---------|-------------------|-------| +| Gapless | `pauseAtEndOfMediaItems` | | +| Volume normalization | `LoudnessEnhancer` | A gain stage — approximate next to MPV's `dynaudnorm` | +| Equalizer | `android.media.audiofx.Equalizer` | The canonical 10 bands are resampled onto the device's own band centres | +| Crossfade | — | Unimplemented on **every** platform (DR-034), architecturally blocked on MPV. Building it on Android alone would invert the parity gap | + +Two things are deliberately still open: the effects are **not yet verified on a +physical device** (`AudioEffect` availability and band layouts are device-specific), +and the trait default is still a silent `Ok(())` rather than an error, so a backend +that omits the method still reports success. Flipping that default waits on the +device verification. + +### The equalizer, and where its vocabulary lives + +**TRACES**: UR-027 | DR-030, IR-020 + +The canonical band layout (`EQ_BANDS`) and the preset curves live in +`settings.rs`, **not** in either backend and not in the UI: a preset *is* a gain +curve defined by the band layout, and the layout is a property of the audio +engine rather than of the picker that renders it. Presets are Flat, Rock, Pop, +Jazz, Classical, Bass Boost, Treble Boost and Vocal, all conservative (within +±8 dB) so they stack safely with volume normalization. + +| Platform | Mechanism | +|----------|-----------| +| Linux | One ffmpeg two-pole peaking `equalizer` filter per band, composed by `build_af_filter` into MPV's `af` property alongside the normalization filter: `equalizer=f=31:width_type=o:width=1:g=5` | +| Android | `android.media.audiofx.Equalizer`, with the canonical 10 bands **resampled onto whatever band centres the device actually has** | + +Gains are normalised (`with_equalizer_normalised`) before use, and bands beyond +`EQ_BANDS` are ignored, so a malformed settings payload cannot produce a filter +chain of unbounded length. + +## Background Audio Handoff (Android) + +**TRACES**: UR-040 | IR-025, DR-051, DR-052, DR-178 … DR-180, DR-196, DR-203 + +Keeping a video's **audio** alive when the app is backgrounded or the screen +locks, while video decode stops. Two verified facts drive the whole design: + +1. An Android WebView `