docs: fold shipped specs into the architecture docs and delete them
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.
This commit is contained in:
@@ -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/<seasonId>` 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 `<html>`, 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.
|
||||
|
||||
Reference in New Issue
Block a user